From ab6e90b6b811d6153ea65aa94550a0fdfb34b765 Mon Sep 17 00:00:00 2001 From: jutty Date: Fri, 12 Dec 2025 04:13:32 -0300 Subject: [PATCH] Heavy refactor and extraction of most code to handler submodules --- README.md | 4 +- src/handlers.rs | 6 + src/handlers/error.rs | 53 +++++++ src/handlers/fixed.rs | 54 +++++++ src/handlers/graph.rs | 38 +++++ src/handlers/navigation.rs | 29 ++++ src/handlers/raw.rs | 36 +++++ src/handlers/template.rs | 77 ++++++++++ src/main.rs | 293 +++---------------------------------- 9 files changed, 313 insertions(+), 277 deletions(-) create mode 100644 src/handlers.rs create mode 100644 src/handlers/error.rs create mode 100644 src/handlers/fixed.rs create mode 100644 src/handlers/graph.rs create mode 100644 src/handlers/navigation.rs create mode 100644 src/handlers/raw.rs create mode 100644 src/handlers/template.rs diff --git a/README.md b/README.md index 5c012b5..5795ff2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ It works by ingesting a TOML file containing your node specification and serving ## Roadmap -- [ ] Automatic anchors +- [ ] Anchor rendering + - [ ] Automatic anchors +- [ ] Connection kinds - [ ] Reduce O(n) calls in the formats module - [ ] Add tests - [x] Array syntax for lightweight connections diff --git a/src/handlers.rs b/src/handlers.rs new file mode 100644 index 0000000..3757eae --- /dev/null +++ b/src/handlers.rs @@ -0,0 +1,6 @@ +pub mod graph; +pub mod template; +pub mod raw; +pub mod navigation; +pub mod fixed; +pub mod error; diff --git a/src/handlers/error.rs b/src/handlers/error.rs new file mode 100644 index 0000000..0aaa543 --- /dev/null +++ b/src/handlers/error.rs @@ -0,0 +1,53 @@ +use axum::{ + body::Body, + http::{Response, StatusCode, header}, +}; + +use crate::handlers; + +pub fn by_code(code: Option, message: Option<&str>) -> Response { + 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)); + + handlers::raw::make_response( + &body, + out_code, + &[(header::CONTENT_TYPE, "text/html")], + ) +} + +fn make_body(code: Option, message: Option<&str>) -> String { + let mut context = tera::Context::new(); + + let out_code = code.unwrap_or(500); + let out_message = &message.unwrap_or("Unknown error"); + + context.insert( + "title", + &StatusCode::from_u16(out_code) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) + .to_string(), + ); + + context.insert("message", out_message); + context.insert("status_code", &out_code.to_string()); + + handlers::template::render( + "error.html", + &context, + Some(&format!( + "Failed to render template for Error {out_code}: {out_message}" + )) + .cloned(), + ) + .0 +} + +pub async fn not_found() -> Response { + by_code( + Some(404), + Some("The page you tried to access could not be found."), + ) +} diff --git a/src/handlers/fixed.rs b/src/handlers/fixed.rs new file mode 100644 index 0000000..59d7eb3 --- /dev/null +++ b/src/handlers/fixed.rs @@ -0,0 +1,54 @@ +use axum::{ + body::Body, + http::{Response, StatusCode, header, HeaderValue}, +}; + +use crate::formats::{Format, populate_graph, serialize_graph}; +use crate::handlers; + +pub async fn file(file_path: &str, content_type: &str) -> Response { + let content = match std::fs::read(file_path) { + Ok(s) => s, + Err(e) => { + panic!("[file_handler] Failed to read file contents: {e}") + }, + }; + + let mut response = Response::new(Body::from(content)); + *response.status_mut() = StatusCode::OK; + let header = header::CONTENT_TYPE; + + if let Ok(header_value) = HeaderValue::from_str(content_type) { + if let Some(h) = response.headers_mut().insert(header, header_value) { + eprintln!( + "[file_handler] Overwrote existing header {h:?} \ + because a header for the same key existed" + ); + } + } else { + eprintln!( + "[file_handler] Failed to create content type \ + header value from {content_type}" + ); + } + + response +} + +pub async fn serial(format: &Format) -> Response { + let graph = populate_graph(); + let body = serialize_graph(format, &graph); + + 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")], + ), + } +} diff --git a/src/handlers/graph.rs b/src/handlers/graph.rs new file mode 100644 index 0000000..dfabf82 --- /dev/null +++ b/src/handlers/graph.rs @@ -0,0 +1,38 @@ +use axum::{body::Body, extract::Path, http::Response}; + +use crate::{formats::populate_graph, types::Node, handlers}; + +pub async fn node(Path(id): Path) -> Response { + let mut context = tera::Context::new(); + + let graph = populate_graph(); + let nodes = graph.nodes; + let empty_node = + Node::new(Some(format!("Could not find node with ID {id}."))); + + let node: &Node = nodes.get(&id).unwrap_or(&empty_node); + + context.insert("id", &id); + context.insert("title", &node.title); + context.insert("text", &node.text); + context.insert("connections", &node.connections.clone()); + context.insert("incoming", &graph.incoming.get(&id)); + + let not_found = node.clone() == empty_node; + let template_name = "node.html".to_string(); + + handlers::template::by_filename( + &template_name, + &context, + if not_found { 404 } else { 500 }, + Some( + format!( + "Failed to generate page for node {} (ID {}).\n\ + Node struct:
{:#?}
", + node.title, id, node + ) + .to_owned(), + ), + not_found, + ) +} diff --git a/src/handlers/navigation.rs b/src/handlers/navigation.rs new file mode 100644 index 0000000..323c4cc --- /dev/null +++ b/src/handlers/navigation.rs @@ -0,0 +1,29 @@ +use axum::{ + body::Body, + http::{Response}, + response::Redirect, + Form, +}; + +use crate::{formats::populate_graph, types::Node, handlers}; + +pub async fn nexus(template: &str) -> Response { + let mut context = tera::Context::new(); + let graph = populate_graph(); + let root_node = graph.get_root().unwrap_or_default(); + let nodes: Vec = graph.nodes.into_values().collect(); + + context.insert("nodes", &nodes); + context.insert("root_node", &root_node); + + handlers::template::by_filename(template, &context, 500, None, false) +} + +pub async fn search(Form(query): Form) -> Redirect { + Redirect::permanent(format!("/node/{}", query.node).as_str()) +} + +#[derive(serde::Deserialize)] +pub struct Query { + node: String, +} diff --git a/src/handlers/raw.rs b/src/handlers/raw.rs new file mode 100644 index 0000000..9049268 --- /dev/null +++ b/src/handlers/raw.rs @@ -0,0 +1,36 @@ +use axum::{ + body::Body, + http::{header, HeaderValue, Response, StatusCode}, +}; + +pub fn make_response( + body: &str, + status_code: u16, + headers: &[(header::HeaderName, &str)], +) -> Response { + let mut response = Response::new(Body::from(body.to_owned())); + + *response.status_mut() = StatusCode::from_u16(status_code) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + + for header in headers { + if let Ok(wrapped) = HeaderValue::from_str(header.1) { + if let Some(overwritten) = + response.headers_mut().insert(header.0.clone(), wrapped) + { + eprintln!( + "[make_response] Overwrote header {overwritten:?} \ + because another for key {} already existed", + header.0 + ); + } + } else { + eprintln!( + "[make_response] Failed to wrap header value {}", + header.1 + ); + } + } + + response +} diff --git a/src/handlers/template.rs b/src/handlers/template.rs new file mode 100644 index 0000000..b8812b9 --- /dev/null +++ b/src/handlers/template.rs @@ -0,0 +1,77 @@ +use axum::{ + body::Body, + http::{header, Response, StatusCode}, +}; + +use crate::handlers::raw::make_response; + +pub(super) fn by_filename( + name: &str, + context: &tera::Context, + error_code: u16, + error_message: Option, + is_error: bool, +) -> Response { + let (body, render_status) = render(name, context, error_message); + + let status_code = if is_error { error_code } else { render_status }; + + make_response(&body, status_code, &[(header::CONTENT_TYPE, "text/html")]) +} + +pub async fn static_template_handler(name: &str) -> Response { + by_filename(name, &tera::Context::new(), 500, None, false) +} + +pub(super) fn render( + name: &str, + // TODO take Option, skip context if None, + // then template_handler can replace static_template_handler + context: &tera::Context, + error_message: Option, +) -> (String, u16) { + // TODO just return an Option here + let tera = match tera::Tera::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/templates/**/*" + )) { + Ok(t) => t, + Err(e) => { + println!("Tera parsing error: {e:#?}"); + panic!("{e}") + }, + }; + + match tera.render(name, context) { + Ok(t) => (t, 200), + Err(e) => { + let mut error_context = tera::Context::new(); + + let out_error_message = match error_message { + Some(s) => &format!( + "Template render failed.\n\ + User message: {s}, + Engine message:\n
{e:#?}
\n\ + Context:\n
{context:#?}
" + ), + None => &format!( + "Template render failed.\n\ + Engine message:\n
{e:#?}
\n\ + Context:\n
{context:#?}
" + ), + }; + + error_context.insert("message", out_error_message); + error_context.insert( + "title", + &StatusCode::INTERNAL_SERVER_ERROR.to_string(), + ); + + ( + tera.render("error.html", &error_context) + .unwrap_or(out_error_message.clone()), + 500, + ) + }, + } +} diff --git a/src/main.rs b/src/main.rs index 9ac4e19..ed87fc8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,17 @@ -use axum::{ - body::Body, - extract::Path, - http::{header, HeaderValue, Response, StatusCode}, - response::{Redirect}, - routing::get, - Form, Router, -}; +use axum::{routing::get, Router}; -use formats::{populate_graph, serialize_graph, Format}; -use types::Node; +use handlers::{ + graph::node, + navigation::{nexus, search}, + fixed::{file, serial}, + template::static_template_handler, + error::not_found, +}; +use formats::Format; mod formats; mod types; +mod handlers; static ONSET: std::sync::LazyLock = std::sync::LazyLock::new(std::time::Instant::now); @@ -32,19 +32,19 @@ async fn main() { })); let app = Router::new() - .route("/", get(index).post(query)) - .route("/graph/toml", get(toml_graph)) - .route("/graph/json", get(json_graph)) + .route("/", get(|| nexus("index.html")).post(search)) + .route("/graph/toml", get(|| serial(&Format::Toml))) + .route("/graph/json", get(|| serial(&Format::Json))) .route( "/static/style.css", - get(|| file_handler("./static/style.css", "text/css")), + get(|| file("./static/style.css", "text/css")), ) .route( "/static/favicon.svg", - get(|| file_handler("./static/favicon.svg", "image/svg+xml")), + get(|| file("./static/favicon.svg", "image/svg+xml")), ) - .route("/node/{node_id}", get(node_view).post(node_view)) - .route("/tree", get(tree)) + .route("/node/{node_id}", get(node).post(node)) + .route("/tree", get(|| nexus("tree.html"))) .route("/about", get(|| static_template_handler("about.html"))) .route( "/acknowledgments", @@ -67,262 +67,3 @@ async fn main() { } } } - -fn make_body( - name: &str, - context: &tera::Context, - error_message: Option, -) -> (String, u16) { - let tera = match tera::Tera::new(concat!( - env!("CARGO_MANIFEST_DIR"), - "/templates/**/*" - )) { - Ok(t) => t, - Err(e) => { - println!("Tera parsing error: {e:#?}"); - panic!("{e}") - }, - }; - - match tera.render(name, context) { - Ok(t) => (t, 200), - Err(e) => { - let mut error_context = tera::Context::new(); - - let out_error_message = match error_message { - Some(s) => &format!( - "Template render failed.\n\ - User message: {s}, - Engine message:\n
{e:#?}
\n\ - Context:\n
{context:#?}
" - ), - None => &format!( - "Template render failed.\n\ - Engine message:\n
{e:#?}
\n\ - Context:\n
{context:#?}
" - ), - }; - - error_context.insert("message", out_error_message); - error_context.insert( - "title", - &StatusCode::INTERNAL_SERVER_ERROR.to_string(), - ); - - ( - tera.render("error.html", &error_context) - .unwrap_or(out_error_message.clone()), - 500, - ) - }, - } -} - -fn make_response( - body: &str, - status_code: u16, - headers: &[(header::HeaderName, &str)], -) -> Response { - let mut response = Response::new(Body::from(body.to_owned())); - - *response.status_mut() = StatusCode::from_u16(status_code) - .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); - - for header in headers { - if let Ok(wrapped) = HeaderValue::from_str(header.1) { - if let Some(overwritten) = - response.headers_mut().insert(header.0.clone(), wrapped) - { - eprintln!( - "[make_response] Overwrote header {overwritten:?} \ - because another for key {} already existed", - header.0 - ); - } - } else { - eprintln!( - "[make_response] Failed to wrap header value {}", - header.1 - ); - } - } - - response -} - -fn template_handler( - name: &str, - context: &tera::Context, - error_code: u16, - error_message: Option, - is_error: bool, -) -> Response { - let (body, render_status) = make_body(name, context, error_message); - - let status_code = if is_error { error_code } else { render_status }; - - make_response(&body, status_code, &[(header::CONTENT_TYPE, "text/html")]) -} - -async fn node_view(Path(id): Path) -> Response { - let mut context = tera::Context::new(); - - let graph = populate_graph(); - let nodes = graph.nodes; - let empty_node = - Node::new(Some(format!("Could not find node with ID {id}."))); - - let node: &Node = nodes.get(&id).unwrap_or(&empty_node); - - context.insert("id", &id); - context.insert("title", &node.title); - context.insert("text", &node.text); - context.insert("connections", &node.connections.clone()); - context.insert("incoming", &graph.incoming.get(&id)); - - let not_found = node.clone() == empty_node; - let template_name = "node.html".to_string(); - - template_handler( - &template_name, - &context, - if not_found { 404 } else { 500 }, - Some( - format!( - "Failed to generate page for node {} (ID {}).\n\ - Node struct:
{:#?}
", - node.title, id, node - ) - .to_owned(), - ), - not_found, - ) -} - -async fn index() -> Response { - let mut context = tera::Context::new(); - let graph = populate_graph(); - let root_node = graph.get_root().unwrap_or_default(); - let nodes: Vec = graph.nodes.into_values().collect(); - - context.insert("nodes", &nodes); - context.insert("root_node", &root_node); - - template_handler("index.html", &context, 500, None, false) -} - -async fn tree() -> Response { - let mut context = tera::Context::new(); - let graph = populate_graph(); - let root_node = graph.get_root().unwrap_or_default(); - let nodes: Vec = graph.nodes.into_values().collect(); - - context.insert("nodes", &nodes); - context.insert("root_node", &root_node); - - template_handler("tree.html", &context, 500, None, false) -} - -#[expect(clippy::unused_async)] -async fn static_template_handler(name: &str) -> Response { - template_handler(name, &tera::Context::new(), 500, None, false) -} - -#[expect(clippy::unused_async)] -async fn file_handler(file_path: &str, content_type: &str) -> Response { - let content = match std::fs::read(file_path) { - Ok(s) => s, - Err(e) => { - panic!("[static_file_handler] Failed to read file contents: {e}") - }, - }; - - let mut response = Response::new(Body::from(content)); - *response.status_mut() = StatusCode::OK; - let header = header::CONTENT_TYPE; - - if let Ok(header_value) = HeaderValue::from_str(content_type) { - if let Some(h) = response.headers_mut().insert(header, header_value) { - eprintln!( - "[static_file_handler] Overwrote existing header {h:?} \ - because a header for the same key existed" - ); - } - } else { - eprintln!( - "[static_file_handler] Failed to create content type \ - header value from {content_type}" - ); - } - - response -} - -#[derive(serde::Deserialize)] -struct Query { - node: String, -} - -async fn query(Form(query): Form) -> Redirect { - Redirect::permanent(format!("/node/{}", query.node).as_str()) -} - -async fn json_graph() -> Response { - let graph = populate_graph(); - let body = serialize_graph(&Format::Json, &graph); - - make_response(&body, 200, &[(header::CONTENT_TYPE, "application/json")]) -} - -async fn toml_graph() -> Response { - let graph = populate_graph(); - let body = serialize_graph(&Format::Toml, &graph); - - make_response(&body, 200, &[(header::CONTENT_TYPE, "text/plain")]) -} - -fn make_error_body(code: Option, message: Option<&str>) -> String { - let mut context = tera::Context::new(); - - let out_code = code.unwrap_or(500); - let out_message = &message.unwrap_or("Unknown error"); - - context.insert( - "title", - &StatusCode::from_u16(out_code) - .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) - .to_string(), - ); - - context.insert("message", out_message); - context.insert("status_code", &out_code.to_string()); - - make_body( - "error.html", - &context, - Some(&format!( - "Failed to render template for Error {out_code}: {out_message}" - )) - .cloned(), - ) - .0 -} - -fn make_error_response( - code: Option, - message: Option<&str>, -) -> Response { - let out_code = code.unwrap_or(500); - let out_message = &message.unwrap_or("Unknown error"); - - let body = make_error_body(Some(out_code), Some(out_message)); - - make_response(&body, out_code, &[(header::CONTENT_TYPE, "text/html")]) -} - -async fn not_found() -> Response { - make_error_response( - Some(404), - Some("The page you tried to access could not be found."), - ) -}