Further centralize state, return result from serial methods

This commit is contained in:
Juno Takano 2026-01-17 04:01:03 -03:00
commit c23d35217d
15 changed files with 471 additions and 244 deletions

View file

@ -1,18 +1,23 @@
use axum::{
body::Body,
extract::State,
http::{Response, StatusCode, header},
};
use crate::{graph::Graph, router::handlers};
use crate::{
graph::Graph,
router::{GlobalState, handlers},
};
pub(in crate::router::handlers) fn by_code(
code: Option<u16>,
message: Option<&str>,
graph: &Graph,
) -> Response<Body> {
let out_code = code.unwrap_or(500);
let out_message = &message.unwrap_or("Unknown error");
let body = make_body(Some(out_code), Some(out_message));
let body = make_body(Some(out_code), Some(out_message), graph);
handlers::raw::make_response(
&body,
@ -21,10 +26,13 @@ pub(in crate::router::handlers) fn by_code(
)
}
fn make_body(code: Option<u16>, message: Option<&str>) -> String {
fn make_body(
code: Option<u16>,
message: Option<&str>,
graph: &Graph,
) -> String {
let mut context = tera::Context::default();
let graph = Graph::load();
let out_code = code.unwrap_or(500);
let out_message = &message.unwrap_or("Unknown error");
@ -35,12 +43,12 @@ fn make_body(code: Option<u16>, message: Option<&str>) -> String {
.to_string(),
);
context.insert("graph", &graph);
context.insert("graph", graph);
context.insert("message", out_message);
context.insert("status_code", &out_code.to_string());
handlers::template::render(
"error.html",
"error",
&context,
Some(&format!(
"Failed to render template for Error {out_code}: {out_message}"
@ -50,10 +58,11 @@ fn make_body(code: Option<u16>, message: Option<&str>) -> String {
.0
}
pub async fn not_found() -> Response<Body> {
pub async fn not_found(State(state): State<GlobalState>) -> Response<Body> {
by_code(
Some(404),
Some("The page you tried to access could not be found."),
&state.graph,
)
}
@ -61,27 +70,32 @@ pub async fn not_found() -> Response<Body> {
mod tests {
use axum::{
http::{StatusCode},
extract::State,
};
use super::*;
#[tokio::test]
async fn not_found() {
let response = super::not_found().await;
let state = State(GlobalState {
graph: Graph::load(),
});
let response = super::not_found(state).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn internal_error() {
assert!(by_code(Some(201), None).status() == 201);
assert!(by_code(Some(304), None).status() == 304);
assert!(by_code(Some(418), None).status() == 418);
assert!(by_code(Some(505), None).status() == 505);
let graph = Graph::load();
assert!(by_code(Some(201), None, &graph).status() == 201);
assert!(by_code(Some(304), None, &graph).status() == 304);
assert!(by_code(Some(418), None, &graph).status() == 418);
assert!(by_code(Some(505), None, &graph).status() == 505);
}
#[test]
fn custom_message() {
let pattern = "sibPtt0mvHPWS9HQ0YBQfGu8cUs954LZ";
let body = make_body(Some(501), Some(pattern));
let body = make_body(Some(501), Some(pattern), &Graph::load());
assert!(body.contains(pattern));
assert!(!body.contains(&pattern.chars().rev().collect::<String>()));
}

View file

@ -1,12 +1,13 @@
use axum::{
body::Body,
http::{Response, StatusCode, header, HeaderValue},
extract::{Path, State},
http::{HeaderValue, Response, StatusCode, header},
};
use crate::prelude::*;
use crate::{
router::handlers,
graph::{Graph, Format},
graph::{Format, Graph, SerialErrorCause},
router::{GlobalState, handlers},
};
/// # Panics
@ -41,22 +42,68 @@ pub async fn file(file_path: &str, content_type: &str) -> Response<Body> {
response
}
#[expect(clippy::unused_async)]
pub async fn serial(format: &Format) -> Response<Body> {
let graph = Graph::load();
let body = Graph::to_serial(&graph, format);
pub async fn serial(
Path(format): Path<String>,
State(state): State<GlobalState>,
) -> Response<Body> {
let config = &state.graph.meta.config;
match *format {
Format::TOML => handlers::raw::make_response(
&body,
200,
&[(header::CONTENT_TYPE, "text/plain")],
),
Format::JSON => handlers::raw::make_response(
&body,
200,
&[(header::CONTENT_TYPE, "application/json")],
),
let make_error = |code: u16, message: &str| -> Response<Body> {
handlers::error::by_code(
Some(code),
Some(
format!(
"<p>{message}</p>\n\
<p>Check the <a href=/data>data</a> \n\
page for the available formats.</p>"
)
.as_str(),
),
&state.graph,
)
};
let forbidden_response =
make_error(403, "This graph format is not available.");
let unsupported_response =
make_error(400, "This graph format is not supported.");
let parse_failure = make_error(505, "The graph has failed to parse.");
let body =
match Graph::to_serial(&state.graph, &Format::from(format.as_str())) {
Ok(serial) => serial,
Err(error) => match error.cause {
SerialErrorCause::MalformedInput => return parse_failure,
SerialErrorCause::UnsupportedFormat => {
return unsupported_response;
},
},
};
match Format::from(format.as_str()) {
Format::TOML => {
if config.raw && config.raw_toml {
handlers::raw::make_response(
&body,
200,
&[(header::CONTENT_TYPE, "text/plain")],
)
} else {
forbidden_response
}
},
Format::JSON => {
if config.raw && config.raw_json {
handlers::raw::make_response(
&body,
200,
&[(header::CONTENT_TYPE, "application/json")],
)
} else {
forbidden_response
}
},
Format::Unsupported => unsupported_response,
}
}
@ -64,15 +111,28 @@ pub async fn serial(format: &Format) -> Response<Body> {
mod tests {
use super::*;
async fn wrap_serial(format: &str) -> Response<Body> {
let state = GlobalState {
graph: Graph::load(),
};
serial(Path(format.to_string()), State(state)).await
}
#[tokio::test]
async fn serial_toml() {
let response = serial(&Format::TOML).await;
let response = wrap_serial("toml").await;
assert!(response.status() == 200);
}
#[tokio::test]
async fn serial_json() {
let response = wrap_serial("json").await;
assert!(response.status() == 200);
}
#[tokio::test]
async fn serial_toml_content_type() {
let response = serial(&Format::TOML).await;
let response = wrap_serial("TOML").await;
assert!(
response.headers().get(header::CONTENT_TYPE).unwrap()
== "text/plain"
@ -81,7 +141,7 @@ mod tests {
#[tokio::test]
async fn serial_json_content_type() {
let response = serial(&Format::JSON).await;
let response = wrap_serial("json").await;
assert!(
response.headers().get(header::CONTENT_TYPE).unwrap()
== "application/json"

View file

@ -1,12 +1,21 @@
use axum::response::IntoResponse as _;
use axum::{body::Body, extract::Path, http::Response, response::Redirect};
use axum::{
extract::State,
response::IntoResponse as _,
{body::Body, extract::Path, http::Response, response::Redirect},
};
use crate::{prelude::*, graph::Graph, router::handlers, graph::Node};
use crate::{
graph::Node,
prelude::*,
router::{GlobalState, handlers},
};
pub async fn node(Path(id): Path<String>) -> Response<Body> {
pub async fn node(
Path(id): Path<String>,
State(state): State<GlobalState>,
) -> Response<Body> {
let instant = now();
let graph = Graph::load();
let result = graph.find_node(&id);
let result = state.graph.find_node(&id);
let found = result.node.is_some();
let node = result
.node
@ -25,20 +34,19 @@ pub async fn node(Path(id): Path<String>) -> Response<Body> {
}
let mut context = tera::Context::default();
context.insert("graph", &graph);
context.insert("graph", &state.graph);
context.insert("node", &node);
context.insert("incoming", &graph.incoming.get(&id));
context.insert("incoming", &state.graph.incoming.get(&id));
tlog!(&instant, "Assembled response for node {}", node.id);
handlers::template::by_filename(
"node.html",
handlers::template::with_context(
"node",
&context,
if found { 500 } else { 404 },
Some(
format!(
"Failed to generate page for node {} (ID {}).\n\
Node struct: <pre>{:#?}</pre>",
node.title, id, node
"Failed to generate page for node {} (ID {}).",
node.title, id
)
.to_owned(),
),
@ -52,17 +60,26 @@ mod tests {
http::{HeaderName, StatusCode},
};
use crate::graph::Graph;
use super::*;
async fn wrap_node(query: &str) -> Response<Body> {
let state = GlobalState {
graph: Graph::load(),
};
node(Path(query.to_string()), axum::extract::State(state)).await
}
#[tokio::test]
async fn syntax() {
let response = node(Path("Syntax".to_string())).await;
let response = wrap_node("Syntax").await;
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn syntax_content_type() {
let response = node(Path("Syntax".to_string())).await;
let response = wrap_node("Syntax").await;
assert!(
response
.headers()
@ -76,19 +93,19 @@ mod tests {
#[tokio::test]
async fn not_found() {
let response = node(Path("InexistentNode".to_string())).await;
let response = wrap_node("InexistentNode").await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn redirect() {
let response = node(Path("syntax".to_string())).await;
let response = wrap_node("syntax").await;
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
}
#[tokio::test]
async fn docs_redirect() {
let response = node(Path("docs".to_string())).await;
let response = wrap_node("docs").await;
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
}
}

View file

@ -1,67 +1,45 @@
use axum::{
body::Body,
http::{Response},
response::Redirect,
Form,
};
use axum::{Form, body::Body, extract::State, http::Response, response::Redirect};
use crate::{
prelude::*,
graph::{Graph, Node},
router::handlers,
router::{GlobalState, handlers},
};
#[expect(clippy::unused_async)]
pub async fn page(template: &str) -> Response<Body> {
let instant = now();
let mut context = tera::Context::default();
let graph = Graph::load();
context.insert("graph", &graph);
tlog!(&instant, "Assembled response for template {template}");
handlers::template::by_filename(template, &context, 500, None, false)
pub async fn index(State(state): State<GlobalState>) -> Response<Body> {
handlers::template::with_graph("index", state).await
}
pub async fn tree() -> Response<Body> {
let instant = now();
let mut context = tera::Context::default();
let mut graph = Graph::load();
pub async fn about(State(state): State<GlobalState>) -> Response<Body> {
handlers::template::with_graph("about", state).await
}
context.insert("graph", &graph);
if let Some(root_node) = graph.get_root() {
graph.nodes.remove(&root_node.id);
pub async fn tree(State(state): State<GlobalState>) -> Response<Body> {
let instant = now();
let mut context = tera::Context::default();
context.insert("graph", &state.graph);
if let Some(root_node) = state.graph.get_root() {
context.insert("root_node", &root_node);
context.insert(
"nodes",
&graph.nodes.values().cloned().collect::<Vec<Node>>(),
);
} else {
context.insert(
"nodes",
&graph.nodes.values().cloned().collect::<Vec<Node>>(),
);
}
tlog!(&instant, "Assembled response for tree endpoint");
handlers::template::by_filename("tree.html", &context, 500, None, false)
handlers::template::with_context("tree", &context, 500, None, false)
}
pub async fn data() -> Response<Body> {
pub async fn data(State(state): State<GlobalState>) -> Response<Body> {
let instant = now();
let mut context = tera::Context::default();
let graph = Graph::load();
let mut detached_pairs: Vec<(String, u32)> =
graph.stats.detached.clone().into_iter().collect();
state.graph.stats.detached.clone().into_iter().collect();
detached_pairs.sort_by(|a, b| b.1.cmp(&a.1));
context.insert("graph", &graph);
context.insert("detached_count", &graph.stats.detached.len());
let mut context = tera::Context::default();
context.insert("graph", &state.graph);
context.insert("detached_count", &state.graph.stats.detached.len());
context.insert("detached_pairs", &detached_pairs);
tlog!(&instant, "Assembled response for data endpoint");
handlers::template::by_filename("data.html", &context, 500, None, false)
handlers::template::with_context("data", &context, 500, None, false)
}
pub async fn search(Form(query): Form<Query>) -> Redirect {
@ -79,11 +57,18 @@ pub struct Query {
#[cfg(test)]
mod tests {
use axum::{
http::{StatusCode},
};
use axum::http::StatusCode;
use crate::graph::Graph;
use super::*;
async fn wrap_page(path: &str) -> Response<Body> {
let state = GlobalState {
graph: Graph::load(),
};
handlers::template::with_graph(path, state).await
}
#[tokio::test]
async fn search_redirect() {
let query = Form(Query {
@ -95,19 +80,19 @@ mod tests {
#[tokio::test]
async fn about_page_ok() {
let response = page("about.html").await;
let response = wrap_page("about").await;
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn tree_page_ok() {
let response = page("tree.html").await;
let response = wrap_page("tree").await;
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn inexistent_page_error() {
let response = page("HBvcwqT8wLk6hxk1GdvNcEzJ6IiZ2Fod").await;
let response = wrap_page("HBvcwqT8wLk6hxk1GdvNcEzJ6IiZ2Fod").await;
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}

View file

@ -3,9 +3,28 @@ use axum::{
http::{header, Response, StatusCode},
};
use crate::{prelude::*, router::handlers::raw::make_response};
use crate::{
prelude::*,
router::{GlobalState, handlers::raw::make_response},
};
pub(in crate::router::handlers) fn by_filename(
/// Assembles a response containing the graph as its only context
///
/// The template name **must not** contain the extension.
#[expect(clippy::unused_async)]
pub async fn with_graph(template: &str, state: GlobalState) -> Response<Body> {
let instant = now();
let mut context = tera::Context::default();
context.insert("graph", &state.graph);
tlog!(&instant, "Assembled response for template {template}");
with_context(template, &context, 500, None, false)
}
/// Assembles a response with a custom context.
///
/// The template name **must not** contain the extension.
pub(in crate::router::handlers) fn with_context(
name: &str,
context: &tera::Context,
error_code: u16,
@ -19,8 +38,11 @@ pub(in crate::router::handlers) fn by_filename(
make_response(&body, status_code, &[(header::CONTENT_TYPE, "text/html")])
}
/// Renderes a template into a String and error code
///
/// The template name **must not** contain the extension (e.g. `.html`).
pub(in crate::router::handlers) fn render(
name: &str,
template: &str,
// TODO take Option, skip context if None,
// then template_handler can replace static_template_handler
context: &tera::Context,
@ -35,29 +57,35 @@ pub(in crate::router::handlers) fn render(
},
};
match tera.render(name, context) {
match tera.render(format!("{template}.html").as_str(), context) {
Ok(t) => {
tlog!(&instant, "Rendered template {name}");
tlog!(&instant, "Rendered template {template}");
(t, 200)
},
Err(e) => {
let mut error_context = tera::Context::default();
let out_error_message = match error_message {
Some(s) => &format!(
let mut out_error_message = match error_message {
Some(s) => format!(
"Template render failed.\n\
User message: {s},
Engine message:\n<pre>{e:#?}</pre>\n\
Context:\n<pre>{context:#?}</pre>"
Engine message:\n<pre>{e:#?}</pre>"
),
None => &format!(
None => format!(
"Template render failed.\n\
Engine message:\n<pre>{e:#?}</pre>\n\
Context:\n<pre>{context:#?}</pre>"
Engine message:\n<pre>{e:#?}</pre>"
),
};
error_context.insert("message", out_error_message);
if log::env_level() >= VERBOSE {
out_error_message = format!(
"{out_error_message}\n\
Context:\n<pre>{context:#?}</pre>"
);
}
log!(ERROR, "{out_error_message}");
error_context.insert("message", &out_error_message);
error_context.insert(
"title",
&StatusCode::INTERNAL_SERVER_ERROR.to_string(),
@ -113,31 +141,21 @@ mod tests {
#[test]
fn by_filename_forced_error() {
let response = by_filename(
"index.html",
&tera::Context::default(),
418,
None,
true,
);
let response =
with_context("index", &tera::Context::default(), 418, None, true);
assert_eq!(response.status(), 418);
}
#[test]
fn by_filename_index() {
let response = by_filename(
"index.html",
&tera::Context::default(),
418,
None,
false,
);
let response =
with_context("index", &tera::Context::default(), 418, None, false);
assert_eq!(response.status(), 200);
}
#[test]
fn by_filename_file_not_found() {
let response = by_filename(
let response = with_context(
"bwbl3BnWsluIgbO2NV9t3vtihwcjuF6t",
&tera::Context::default(),
418,
@ -150,7 +168,7 @@ mod tests {
#[test]
fn by_filename_empty() {
let response =
by_filename("", &tera::Context::default(), 418, None, false);
with_context("", &tera::Context::default(), 418, None, false);
assert_eq!(response.status(), 500);
}
@ -163,7 +181,7 @@ mod tests {
context.insert("node", &node);
context.insert("graph", &graph);
context.insert("incoming", &graph.incoming.get(&node.id));
let (body, status) = render("node.html", &context, None);
let (body, status) = render("node", &context, None);
assert_eq!(status, 200);
assert!(body.matches(payload).count() == 1);
}
@ -203,8 +221,7 @@ mod tests {
#[test]
fn render_bad_context() {
let (body, status) =
render("node.html", &tera::Context::default(), None);
let (body, status) = render("node", &tera::Context::default(), None);
assert!(body.matches("Template render failed.").count() > 0);
assert_eq!(status, 500);
}