//! Request routing. //! //! The router turns a [`Request`] into a [`Response`]. It owns the route //! table, the JSON encoding of each payload, and the mapping from an error //! to a status code. Every route is a plain function so that the table reads //! as a list of what the service offers. //! //! The routes fall into four groups: //! //! - `/health` and `/api/health` report that the process is serving. //! - `/api/docs`, `/api/graph`, and `/api/api` expose the content of tasks //! one and two. //! - `/api/tools` exposes the tool registry of task three and runs a tool. //! - Every other path is served from the embedded frontend, with directory //! requests falling back to `index.html` so that client side routing //! works on a reload. use std::net::SocketAddr; use std::sync::Arc; use crate::docs::{ApiCatalog, Language}; use crate::embedded; use crate::graph::Graph; use crate::http::{Handler, Method, Request, Response, Status}; use crate::json::{self, Value}; use crate::json_object; use crate::state::AppState; use crate::tools; /// The request router. pub struct Router { state: Arc, /// Pre-encoded document listing, built once at startup. docs_index: String, /// Pre-encoded search index, built once at startup. search: crate::docs::SearchIndex, /// Pre-encoded concept graph. concept_graph: String, /// Pre-encoded list of the flow graphs. flow_index: String, /// Pre-encoded crate structure graph. crate_graph: String, /// Pre-encoded tool registry. tool_registry: String, } impl Router { /// Builds the router over the parsed content. pub fn new(state: Arc) -> Self { let docs_index = json::to_string(&document_index(&state)); let search = state.docs.search_index(); let concept_graph = json::to_string(&graph_json(&state.graph.concepts)); let flow_index = json::to_string(&flow_list_json(&state.graph.flows)); let crate_graph = json::to_string(&graph_json(&crate::graph::crate_structure(&state.api))); let tool_registry = json::to_string(&tools::registry_json(&state.tools)); Self { state, docs_index, search, concept_graph, flow_index, crate_graph, tool_registry, } } } impl Handler for Router { fn handle(&self, request: &Request, _peer: SocketAddr) -> Response { let path = request.path.as_str(); match (request.method.clone(), path) { (Method::Get | Method::Head, "/health") | (Method::Get | Method::Head, "/api/health") => self.health(), (Method::Get | Method::Head, "/api/docs") => { Response::json(self.docs_index.clone()).with_cache_control("no-cache") } (Method::Get | Method::Head, "/api/docs/search") => self.search_documents(request), (Method::Get | Method::Head, "/api/graph") => { Response::json(self.concept_graph.clone()).with_cache_control("no-cache") } (Method::Get | Method::Head, "/api/graph/flows") => { Response::json(self.flow_index.clone()).with_cache_control("no-cache") } (Method::Get | Method::Head, "/api/graph/crates") => { Response::json(self.crate_graph.clone()).with_cache_control("no-cache") } (Method::Get | Method::Head, "/api/api") => self.api_index(), (Method::Get | Method::Head, "/api/tools") => { Response::json(self.tool_registry.clone()).with_cache_control("no-cache") } (Method::Get, _) if path.starts_with("/api/docs/") => self.document(path), (Method::Get, _) if path.starts_with("/api/graph/") => self.flow(path), (Method::Get, _) if path.starts_with("/api/api/crates/") => self.api_crate(path), (Method::Get, _) if path.starts_with("/api/api/items/") => self.api_item(path), (Method::Get, _) if path.starts_with("/api/tools/") && path.ends_with("/run") => { Response::error(Status::MethodNotAllowed, "this route accepts POST") } (Method::Get, _) if path.starts_with("/api/tools/") => self.tool(path), (Method::Post, _) if path.starts_with("/api/tools/") && path.ends_with("/run") => { self.run_tool(request, path) } (Method::Post, _) if path.starts_with("/api/") => { Response::error(Status::MethodNotAllowed, "this route does not accept POST") } (Method::Get | Method::Head, _) => self.static_asset(path), (Method::Options, _) => { Response::empty(Status::NoContent).with_header("Allow", "GET, HEAD, POST, OPTIONS") } _ => Response::error(Status::MethodNotAllowed, "unsupported method"), } } } impl Router { /// Reports service health and the state of the embedded content. fn health(&self) -> Response { let body = json_object! { "status" => "ok", "service" => "apsis-book", "version" => env!("CARGO_PKG_VERSION"), "documents" => self.state.docs.len(), "layers" => self .state .docs .groups() .iter() .map(|group| group.sections.len()) .sum::(), "graph_nodes" => self.state.graph.concepts.nodes.len(), "api_crates" => self.state.api.crates.len(), "api_items" => self.state.api.item_count(), "tools" => self.state.tools.entries().len(), "frontend_embedded" => embedded::is_populated(), }; Response::json(json::to_string(&body)).with_cache_control("no-store") } /// Searches the document index. fn search_documents(&self, request: &Request) -> Response { let query = request.query_pairs(); let text = query.get("q").cloned().unwrap_or_default(); let limit = query .get("limit") .and_then(|value| value.parse::().ok()) .unwrap_or(20) .min(100); let hits = self.search.search(&text, limit); let hits: Vec = hits .iter() .map(|hit| { json_object! { "id" => hit.id.clone(), "title" => hit.title.clone(), "summary" => hit.summary.clone(), "language" => hit.language.tag(), "chapter" => hit.chapter.clone(), "chapter_title" => chapter_title(&self.state, &hit.chapter), "score" => hit.score, } }) .collect(); let body = json_object! { "query" => text, "count" => hits.len(), "indexed" => self.search.len(), "hits" => hits, }; Response::json(json::to_string(&body)).with_cache_control("no-store") } /// Returns one document with its rendered HTML. /// /// Both language forms are returned when both are embedded, so the /// reader can switch the language without another request. A language /// without a translation is omitted and the frontend falls back to the /// English form. fn document(&self, path: &str) -> Response { let id = path.trim_start_matches("/api/docs/"); if id.is_empty() || id.contains('/') { return not_found("document"); } match self.state.document(id) { Some(document) => { let mut forms = Vec::new(); for language in [Language::Chinese, Language::English] { if !document.has(language) { continue; } let variant = document.variant(language); forms.push(json_object! { "language" => language.tag(), "title" => variant.title.clone(), "summary" => variant.summary.clone(), "html" => variant.html.clone(), "markdown" => variant.markdown.clone(), "headings" => variant .headings .iter() .map(|heading| { json_object! { "anchor" => heading.anchor.clone(), "text" => heading.text.clone(), "level" => heading.level as f64, } }) .collect::>(), }); } let body = json_object! { "id" => document.id.clone(), "chapter" => document.chapter.clone(), "tags" => tag_lists(document), "prerequisites" => document.prerequisites.iter().map(|id| Value::from(id.as_str())).collect::>(), "references" => document.references().iter().map(|id| Value::from(id.as_str())).collect::>(), "forms" => forms, }; Response::json(json::to_string(&body)).with_cache_control("no-cache") } None => not_found("document"), } } /// Returns one flow graph by identifier. fn flow(&self, path: &str) -> Response { let id = path.trim_start_matches("/api/graph/"); if id == "flows" { return Response::json(self.flow_index.clone()).with_cache_control("no-cache"); } if id == "crates" { return Response::json(self.crate_graph.clone()).with_cache_control("no-cache"); } if id == "concepts" { return Response::json(self.concept_graph.clone()).with_cache_control("no-cache"); } match self.state.graph.flow(id) { Some(graph) => { Response::json(json::to_string(&graph_json(graph))).with_cache_control("no-cache") } None => not_found("graph"), } } /// Returns the interface catalog summary. fn api_index(&self) -> Response { let catalog = &self.state.api; let item_names = &catalog.item_names; let crates: Vec = catalog .crates .iter() .map(|krate| { json_object! { "name" => krate.name.clone(), "layer" => krate.layer.clone(), "summary" => crate_texts(krate, item_names), "items" => krate.items.len(), } }) .collect(); let items: Vec = catalog .crates .iter() .flat_map(|krate| { krate.items.iter().map(move |item| { json_object! { "name" => item.name.clone(), "kind" => item.kind.clone(), "crate" => krate.name.clone(), } }) }) .collect(); let body = json_object! { "generated_from" => catalog.generated_from.iter().map(|path| Value::from(path.as_str())).collect::>(), "crate_count" => catalog.crates.len(), "item_count" => catalog.item_count(), // The layers the crates belong to, in reading order, so that the // index is organized the same way as the concept documentation // rather than as one flat list. "layers" => api_layers(&self.state.docs), "crates" => crates, "items" => items, }; Response::json(json::to_string(&body)).with_cache_control("no-cache") } /// Returns one crate of the interface catalog. fn api_crate(&self, path: &str) -> Response { let name = path.trim_start_matches("/api/api/crates/"); match self.state.api.krate(name) { Some(krate) => Response::json(json::to_string(&crate_json( krate, &self.state.api.item_names, ))) .with_cache_control("no-cache"), None => not_found("crate"), } } /// Returns one interface item by name. fn api_item(&self, path: &str) -> Response { let name = path.trim_start_matches("/api/api/items/"); match self.state.api.find_item(name) { Some((krate, item)) => { let body = json_object! { "crate" => krate.name.clone(), "layer" => krate.layer.clone(), "kind" => item.kind.clone(), "name" => item.name.clone(), "signature" => item.signature.clone(), "doc" => doc_texts(item, &self.state.api.item_names), "associated" => item.associated, }; Response::json(json::to_string(&body)).with_cache_control("no-cache") } None => not_found("interface item"), } } /// Returns one tool description. fn tool(&self, path: &str) -> Response { let id = path.trim_start_matches("/api/tools/"); match self.state.tools.get(id) { Some(tool) => { let body = tools::registry_json(&crate::tools::Registry::new()); let _ = body; let single = tool_json(tool); Response::json(json::to_string(&single)).with_cache_control("no-cache") } None => not_found("tool"), } } /// Runs a tool. /// /// The body must be a JSON object of parameters. A body that is not /// valid JSON, or a parameter that fails validation, produces a bad /// request status with a message naming the parameter. fn run_tool(&self, request: &Request, path: &str) -> Response { let id = path .trim_start_matches("/api/tools/") .trim_end_matches("/run"); if self.state.tools.get(id).is_none() { return not_found("tool"); } let Some(text) = request.body_text() else { return Response::error(Status::BadRequest, "the request body must be JSON text"); }; if text.trim().is_empty() { return Response::error(Status::BadRequest, "the request body must be a JSON object"); } let values = match json::parse(text) { Ok(values) => values, Err(error) => { return Response::json(json::to_string(&json_object! { "ok" => false, "error" => json_object! { "message" => format!("the request body is not valid JSON: {error}"), }, })); } }; match tools::execute(&self.state.tools, id, &values) { Ok(result) => Response::json(json::to_string(&json_object! { "ok" => true, "tool" => id, "result" => result, })) .with_cache_control("no-store"), Err(error) => Response::json(json::to_string(&json_object! { "ok" => false, "tool" => id, "error" => json_object! { "parameter" => error.parameter.map(Value::from).unwrap_or(Value::Null), "message" => error.message.clone(), }, })) .with_cache_control("no-store"), } } /// Serves an embedded frontend asset. /// /// A request that names a file is answered with that file. Any other /// path falls back to `index.html` so that a client side route survives /// a reload. When no frontend is embedded the placeholder page is /// served, which keeps the JSON interfaces reachable from a browser. fn static_asset(&self, path: &str) -> Response { let relative = path.trim_start_matches('/'); if !embedded::is_populated() { if relative.is_empty() || !relative.contains('.') { return Response::new( Status::Ok, "text/html; charset=utf-8", embedded::placeholder().to_vec(), ) .with_cache_control("no-store"); } return not_found("asset"); } if !relative.is_empty() && !relative.ends_with('/') { if let Some(bytes) = embedded::get(relative) { return Response::new(Status::Ok, embedded::content_type(relative), bytes.to_vec()) .with_cache_control(cache_policy(relative)); } } // A directory request, or an unknown path that looks like a route, // returns the single page entry point, which names the current // bundle and is therefore never served from a cache. match embedded::get("index.html") { Some(bytes) => Response::new(Status::Ok, "text/html; charset=utf-8", bytes.to_vec()) .with_cache_control(ENTRY_CACHE_POLICY), None => not_found("asset"), } } } /// The cache policy for the entry point. /// /// The entry point names the current bundle, so it is never reused from a /// cache without asking. `must-revalidate` states that requirement to /// intermediaries as well as to the browser. const ENTRY_CACHE_POLICY: &str = "no-cache, must-revalidate"; /// The cache policy for a file whose name carries a content hash. /// /// A build that changes a file also changes its name, so the URL of the new /// file has never been requested before and cannot be in a cache. Marking it /// immutable is therefore both safe and free: the browser keeps it until it /// is no longer named by any entry point. const HASHED_CACHE_POLICY: &str = "public, max-age=31536000, immutable"; /// The cache policy for a file whose name carries no content hash. /// /// Such a file is a name that a future build reuses, so it must be /// revalidated rather than reused from a cache. const UNHASHED_CACHE_POLICY: &str = "no-cache, must-revalidate"; /// Returns the cache policy for an asset path. /// /// The decision is made from the file name alone, which is the same signal /// the build uses to decide whether the contents changed. Nothing about the /// contents is read, so the policy is correct for a file that was embedded by /// an earlier build as well as for one embedded by the current one. fn cache_policy(path: &str) -> &'static str { if is_content_hashed(path) { HASHED_CACHE_POLICY } else { UNHASHED_CACHE_POLICY } } /// Returns `true` when a file name ends with a content hash. /// /// The build appends the hash before the extension. The alphabet is the one /// the bundler emits, which is base64 and therefore mixes cases, so the /// check accepts both and only rejects characters that cannot appear in a /// hash. A name without one is a name a later build reuses. fn is_content_hashed(path: &str) -> bool { let Some((stem, _)) = path.rsplit_once('.') else { return false; }; let Some((_, hash)) = stem.rsplit_once('-') else { return false; }; hash.len() >= 8 && hash .chars() .all(|character| character.is_ascii_alphanumeric()) } /// Builds the document listing payload. /// /// The listing is grouped so that the distinction between the concepts and /// the implementation survives to the reader: the concepts are the larger /// superset, and the implementation is what one particular library does. fn document_index(state: &AppState) -> Value { let groups: Vec = state .docs .groups() .iter() .map(|group| { let sections: Vec = group .sections .iter() .map(|section| { let documents = |ids: &[String]| -> Vec { ids.iter() .filter_map(|id| state.docs.document(id)) .map(|document| { json_object! { "id" => document.id.clone(), "title" => bilingual("title", document), "summary" => bilingual("summary", document), "tags" => tag_lists(document), } }) .collect() }; // A layer that covers two unrelated subjects carries a // subject for each, rather than one list of documents // that have nothing to do with one another. let subsections: Vec = section .subsections .iter() .map(|subject| { json_object! { "id" => subject.id.clone(), "title" => optional_bilingual(&subject.title_zh, &subject.title), "summary" => optional_bilingual(&subject.summary_zh, &subject.summary), "documents" => documents(&subject.documents), } }) .collect(); json_object! { "id" => section.id.clone(), "title" => optional_bilingual(§ion.title_zh, §ion.title), "summary" => optional_bilingual(§ion.summary_zh, §ion.summary), "documents" => documents(§ion.documents), "subsections" => subsections, } }) .collect(); json_object! { "id" => group.id.clone(), "title" => optional_bilingual(&group.title_zh, &group.title), "sections" => sections, } }) .collect(); json_object! { "document_count" => state.docs.len(), "groups" => groups, } } /// Encodes a documentation text and its rendering in both languages. /// /// The documentation comment is rendered to HTML here, by the same renderer /// the concept documents use, so a comment that contains a list, a table, /// or a code block is drawn as one. The two `plain` members carry the text /// with the markup removed, which the index uses for a one line summary. fn doc_texts(item: &crate::docs::ApiItem, items: &std::collections::HashSet) -> Value { let zh = item.doc_zh.as_deref().unwrap_or(&item.doc); json_object! { "zh" => zh.to_string(), "en" => item.doc.clone(), "html" => json_object! { "zh" => crate::docs::markdown::render_with(zh, Some(items)), "en" => crate::docs::markdown::render_with(&item.doc, Some(items)), }, "plain" => json_object! { "zh" => crate::docs::markdown::to_plain_text(zh), "en" => crate::docs::markdown::to_plain_text(&item.doc), }, } } /// Encodes a crate summary in both languages, with the plain text. fn crate_texts(krate: &crate::docs::ApiCrate, items: &std::collections::HashSet) -> Value { let zh = krate.summary_zh.as_deref().unwrap_or(&krate.summary); json_object! { "zh" => zh.to_string(), "en" => krate.summary.clone(), "plain" => json_object! { "zh" => crate_summary_plain(zh), "en" => crate_summary_plain(&krate.summary), }, // The index card takes the opening sentence; the crate page shows the // whole summary under `html`. "brief" => json_object! { "zh" => crate_summary_brief(zh), "en" => crate_summary_brief(&krate.summary), }, "html" => json_object! { "zh" => crate::docs::markdown::render_with(zh, Some(items)), "en" => crate::docs::markdown::render_with(&krate.summary, Some(items)), }, } } /// Returns the plain text of a crate summary. /// /// The Markdown renderer is applied first so that a summary that opens with /// a list or a heading is reduced to its prose. fn crate_summary_plain(summary: &str) -> String { crate::docs::markdown::to_plain_text(summary) } /// Returns the first sentence of a summary, for a card that links onward. /// /// Returns the layers of the concept documentation, in reading order. /// /// The interface catalog records which layer each crate belongs to. The index /// groups the crates by that layer, so it needs the layer's title and its /// place in the reading order, both of which the concept documentation /// already declares. fn api_layers(docs: &crate::docs::Tree) -> Value { let layers: Vec = docs .groups() .iter() .flat_map(|group| group.sections.iter()) .map(|section| { json_object! { "id" => section.id.clone(), "title" => optional_bilingual(§ion.title_zh, §ion.title), "summary" => optional_bilingual(§ion.summary_zh, §ion.summary), } }) .collect(); Value::Array(layers) } /// Returns the first sentence of a summary, for a card that links onward. /// /// The crate summary is the whole of the crate documentation, which runs to /// several paragraphs and a list of what the crate holds. A card on the index /// shows what a crate is, and the crate page shows the rest, so the card takes /// the opening sentence only. Without this the index cards are as tall as the /// longest crate document and the page reads as a wall. fn crate_summary_brief(summary: &str) -> String { let text = crate_summary_plain(summary); let text = text.trim(); // A sentence ends at the first full stop that is not part of a number or // an abbreviation, which keeps `1.5` and `e.g.` from cutting a sentence // in half. let mut end = None; for (index, character) in text.char_indices() { // A Chinese sentence ends at its own full stop and is not followed by // a space, so those three end a sentence wherever they fall. if matches!(character, '\u{3002}' | '\u{ff01}' | '\u{ff1f}') { end = Some(index + character.len_utf8()); break; } if character == '.' { let before = text[..index].chars().next_back(); let after = text[index + 1..].chars().next(); let ends_number = before.is_some_and(|c| c.is_ascii_digit()) && after.is_some_and(|c| c.is_ascii_digit()); let next_is_space = after.is_none_or(|c| c.is_whitespace()); if !ends_number && next_is_space { end = Some(index + 1); break; } } } let first = match end { Some(index) => &text[..index], None => text, }; let first = first.trim(); // A summary with no full stop would otherwise put a whole paragraph on a // card, so the length is capped as well. if first.chars().count() <= 160 { return first.to_string(); } let mut cut: String = first.chars().take(157).collect(); cut.push('…'); cut } /// Encodes a document title or summary in both languages. /// /// The two names select the field of a document; an unknown name yields an /// empty pair rather than a panic. fn bilingual(field: &str, document: &crate::docs::Document) -> Value { let (zh, en) = match field { "title" => ( document.title(Language::Chinese), document.title(Language::English), ), "summary" => ( document.summary(Language::Chinese), document.summary(Language::English), ), _ => ("", ""), }; json_object! { "zh" => zh.to_string(), "en" => en.to_string() } } /// Pairs an optional translation with the form it translates. /// /// A field without a translation is written with the same text in both /// members, which keeps the payload uniform and lets the frontend read one /// shape. fn optional_bilingual(translation: &Option, fallback: &str) -> Value { match translation { Some(zh) => json_object! { "zh" => zh.clone(), "en" => fallback.to_string() }, None => json_object! { "zh" => fallback.to_string(), "en" => fallback.to_string() }, } } /// Encodes the list of flow graphs. /// /// The list is part of the payload so that the frontend does not carry its /// own copy of the flow identifiers; adding a flow to the content is enough /// for it to appear in the navigation. fn flow_list_json(flows: &[Graph]) -> Value { let items: Vec = flows .iter() .map(|graph| { json_object! { "id" => graph.id.clone(), "title" => optional_bilingual(&graph.title_zh, &graph.title), "summary" => optional_bilingual(&graph.summary_zh, &graph.summary), "nodes" => graph.nodes.len(), "edges" => graph.edges.len(), } }) .collect(); json_object! { "flows" => items } } /// Encodes the tag lists of a document, one per language. fn tag_lists(document: &crate::docs::Document) -> Value { json_object! { "en" => document.tags.iter().map(|tag| Value::from(tag.as_str())).collect::>(), "zh" => document.tags_zh.iter().map(|tag| Value::from(tag.as_str())).collect::>(), } } /// Encodes the title of a chapter, in both languages. fn chapter_title(state: &AppState, chapter_id: &str) -> Value { let found = state .docs .groups() .iter() .flat_map(|group| group.sections.iter()) .find(|section| section.id == chapter_id); match found { Some(section) => optional_bilingual(§ion.title_zh, §ion.title), None => Value::string(chapter_id), } } /// Encodes a graph as JSON. /// /// A display label and a summary are carried in both languages when the /// authored data has a translation. A node whose label is a name, such as a /// crate, carries only the one form because it is written the same way in /// both languages. fn graph_json(graph: &Graph) -> Value { let nodes: Vec = graph .nodes .iter() .map(|node| { let label = match &node.label_zh { Some(zh) => json_object! { "zh" => zh.clone(), "en" => node.label.clone() }, None => json_object! { "zh" => node.label.clone(), "en" => node.label.clone() }, }; let summary = match &node.summary_zh { Some(zh) => json_object! { "zh" => zh.clone(), "en" => node.summary.clone() }, None => json_object! { "zh" => node.summary.clone(), "en" => node.summary.clone() }, }; json_object! { "id" => node.id.clone(), "label" => label, "kind" => node.kind.name(), "group" => node.group.clone(), "summary" => summary, "document" => node.document.clone().map(Value::from).unwrap_or(Value::Null), "api" => node.api.clone().map(Value::from).unwrap_or(Value::Null), } }) .collect(); let edges: Vec = graph .edges .iter() .map(|edge| { json_object! { "source" => edge.source.clone(), "target" => edge.target.clone(), "kind" => edge.kind.clone(), "label" => optional_bilingual(&edge.label_zh, edge.label.as_deref().unwrap_or("")), } }) .collect(); let title = match &graph.title_zh { Some(zh) => json_object! { "zh" => zh.clone(), "en" => graph.title.clone() }, None => json_object! { "zh" => graph.title.clone(), "en" => graph.title.clone() }, }; let summary = match &graph.summary_zh { Some(zh) => json_object! { "zh" => zh.clone(), "en" => graph.summary.clone() }, None => json_object! { "zh" => graph.summary.clone(), "en" => graph.summary.clone() }, }; json_object! { "id" => graph.id.clone(), "title" => title, "summary" => summary, "groups" => graph.groups().into_iter().map(Value::from).collect::>(), "nodes" => nodes, "edges" => edges, } } /// Encodes a crate of the interface catalog as JSON. fn crate_json( krate: &crate::docs::ApiCrate, item_names: &std::collections::HashSet, ) -> Value { let items: Vec = krate .items .iter() .map(|item| { json_object! { "kind" => item.kind.clone(), "name" => item.name.clone(), "signature" => item.signature.clone(), "doc" => doc_texts(item, item_names), "associated" => item.associated, } }) .collect(); json_object! { "name" => krate.name.clone(), "layer" => krate.layer.clone(), "summary" => crate_texts(krate, item_names), "items" => items, } } /// Encodes one tool description as JSON. fn tool_json(tool: &crate::tools::ToolSpec) -> Value { // The registry encoder is the single source of the tool shape, so the // listing of all tools and the description of one tool cannot drift. let registry = tools::registry_json(&crate::tools::Registry::new()); let listed = registry .get("tools") .map(Value::as_array) .unwrap_or_default() .iter() .find(|entry| entry.get("id").and_then(Value::as_str) == Some(tool.id)) .cloned(); match listed { Some(value) => value, None => json_object! { "id" => tool.id }, } } /// Returns the catalog summary for the interface index, used by tests. #[allow(dead_code)] fn catalog_summary(catalog: &ApiCatalog) -> Value { json_object! { "crates" => catalog.crates.len(), "items" => catalog.item_count(), } } /// Builds a not found response naming the kind of resource. /// /// The status is a real `404` so that a client can distinguish a missing /// resource from a server failure, and the body carries the same message in /// the shape the other routes use. fn not_found(kind: &str) -> Response { Response::new( Status::NotFound, "application/json; charset=utf-8", json::to_string(&json_object! { "ok" => false, "error" => json_object! { "message" => format!("no such {kind}") }, }) .into_bytes(), ) .with_cache_control("no-store") } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; fn router() -> Router { Router::new(Arc::new(AppState::new())) } fn get(router: &Router, path: &str) -> Response { // The request parser splits the target, so a test that wants a query // supplies it in the path and it is split the same way here. let (path, query) = match path.split_once('?') { Some((path, query)) => (path.to_string(), Some(query.to_string())), None => (path.to_string(), None), }; let request = Request { method: Method::Get, path, query, headers: HashMap::new(), body: Vec::new(), }; router.handle(&request, "127.0.0.1:1".parse().unwrap()) } fn post(router: &Router, path: &str, body: &str) -> Response { let request = Request { method: Method::Post, path: path.to_string(), query: None, headers: HashMap::new(), body: body.as_bytes().to_vec(), }; router.handle(&request, "127.0.0.1:1".parse().unwrap()) } fn parse(response: &Response) -> Value { json::parse(std::str::from_utf8(&response.body).unwrap()).unwrap() } #[test] fn health_reports_the_content_counts() { let response = get(&router(), "/health"); assert_eq!(response.status, Status::Ok); let body = parse(&response); assert_eq!(body.get("status").unwrap().as_str(), Some("ok")); assert!(body.get("documents").unwrap().as_f64().unwrap() > 0.0); } #[test] fn document_routes_serve_content() { let router = router(); let index = parse(&get(&router, "/api/docs")); let groups = index.get("groups").unwrap().as_array(); assert_eq!(groups.len(), 2, "the concepts and the implementation"); let first = groups[0] .get("sections") .unwrap() .as_array() .first() .unwrap() .get("documents") .unwrap() .as_array() .first() .unwrap() .get("id") .unwrap() .as_str() .unwrap() .to_string(); let document = parse(&get(&router, &format!("/api/docs/{first}"))); let forms = document.get("forms").unwrap().as_array(); assert!(!forms.is_empty()); assert!( forms[0] .get("html") .unwrap() .as_str() .unwrap() .contains('<') ); } #[test] fn documents_carry_both_languages() { let router = router(); let document = parse(&get(&router, "/api/docs/sgp4")); let forms = document.get("forms").unwrap().as_array(); assert_eq!(forms.len(), 2, "the sample document has a translation"); let languages: Vec<&str> = forms .iter() .map(|form| form.get("language").unwrap().as_str().unwrap()) .collect(); assert!(languages.contains(&"zh")); assert!(languages.contains(&"en")); } #[test] fn document_listing_carries_bilingual_titles() { let router = router(); let index = parse(&get(&router, "/api/docs")); let groups = index.get("groups").unwrap().as_array(); let title = groups[0].get("title").unwrap(); assert!(title.get("zh").unwrap().as_str().is_some()); assert!(title.get("en").unwrap().as_str().is_some()); let section = groups[0] .get("sections") .unwrap() .as_array() .first() .unwrap(); assert!( section .get("title") .unwrap() .get("zh") .unwrap() .as_str() .is_some() ); } #[test] fn search_returns_ranked_hits() { let response = get(&router(), "/api/docs/search?q=propagation"); let body = parse(&response); assert!(body.get("count").unwrap().as_f64().unwrap() > 0.0); } #[test] fn graph_routes_serve_the_concept_and_crate_graphs() { let router = router(); let concepts = parse(&get(&router, "/api/graph")); assert!(!concepts.get("nodes").unwrap().as_array().is_empty()); let crates = parse(&get(&router, "/api/graph/crates")); assert!(!crates.get("nodes").unwrap().as_array().is_empty()); } #[test] fn api_catalog_routes_serve_items() { let router = router(); let index = parse(&get(&router, "/api/api")); assert!(!index.get("crates").unwrap().as_array().is_empty()); let items = index.get("items").unwrap().as_array(); let name = items .first() .unwrap() .get("name") .unwrap() .as_str() .unwrap(); let item = parse(&get(&router, &format!("/api/api/items/{name}"))); assert!(item.get("signature").is_some()); } #[test] fn tool_registry_and_execution() { let router = router(); let registry = parse(&get(&router, "/api/tools")); assert!(registry.get("tools").unwrap().as_array().len() >= 7); let response = post(&router, "/api/tools/sgp4-propagate/run", r#"{"minutes":0}"#); let body = parse(&response); assert_eq!(body.get("ok").unwrap().as_bool(), Some(true)); } #[test] fn tool_failures_are_reported_with_the_parameter() { let router = router(); let response = post( &router, "/api/tools/sgp4-propagate/run", r#"{"minutes":100000000}"#, ); let body = parse(&response); assert_eq!(body.get("ok").unwrap().as_bool(), Some(false)); assert_eq!( body.get("error") .unwrap() .get("parameter") .unwrap() .as_str(), Some("minutes") ); } #[test] fn malformed_bodies_are_rejected() { let router = router(); let response = post(&router, "/api/tools/sgp4-propagate/run", "{"); let body = parse(&response); assert_eq!(body.get("ok").unwrap().as_bool(), Some(false)); } #[test] fn a_hashed_asset_is_immutable_and_a_plain_one_is_not() { // A content hash in the name means the name changes with the // contents, so the browser can keep it for as long as it likes. assert_eq!( cache_policy("assets/index-Vou83oaV.js"), "public, max-age=31536000, immutable" ); assert_eq!( cache_policy("assets/style-CEH25aoZ.css"), "public, max-age=31536000, immutable" ); // A name without one is reused by the next build. assert_eq!(cache_policy("index.html"), UNHASHED_CACHE_POLICY); assert_eq!(cache_policy("assets/app.js"), UNHASHED_CACHE_POLICY); assert_eq!(cache_policy("favicon.ico"), UNHASHED_CACHE_POLICY); // A name that merely ends in a dash is not hashed. assert_eq!(cache_policy("assets/app-.js"), UNHASHED_CACHE_POLICY); assert_eq!(cache_policy("assets/no-extension"), UNHASHED_CACHE_POLICY); } #[test] fn a_hash_is_recognised_by_its_shape() { // The bundler emits a mixed case base64 hash. assert!(is_content_hashed("index-Vou83oaV.js")); assert!(is_content_hashed("style-CEH25aoZ.css")); assert!(is_content_hashed("app-deadbeef.js")); assert!(!is_content_hashed("app-abc.js"), "too few digits"); assert!(!is_content_hashed("app-na-me.js"), "a dash is not a hash"); assert!(!is_content_hashed("app.js"), "no hash segment"); assert!(!is_content_hashed("noextension")); } #[test] fn unknown_routes_fall_back_to_the_entry_point() { let router = router(); let response = get(&router, "/some/client/route"); assert_eq!(response.status, Status::Ok); assert!(response.content_type.starts_with("text/html")); } #[test] fn unknown_api_resources_report_not_found() { let router = router(); let response = get(&router, "/api/docs/not-a-document"); assert_eq!(response.status, Status::NotFound); let body = parse(&response); assert_eq!(body.get("ok").unwrap().as_bool(), Some(false)); } #[test] fn post_to_a_get_route_is_refused() { let router = router(); assert_eq!( post(&router, "/api/docs", "{}").status, Status::MethodNotAllowed ); } #[test] fn a_crate_card_takes_the_first_sentence_only() { // The index shows what a crate is; the crate page shows the rest. assert_eq!( crate_summary_brief( "ApsisRS \u{7684}\u{5171}\u{4eab}\u{57fa}\u{7840}\u{7c7b}\u{578b}\u{3002}\u{672c} crate \u{4f4d}\u{4e8e}\u{4f9d}\u{8d56}\u{56fe}\u{7684}\u{5e95}\u{5c42}\u{3002}" ), "ApsisRS \u{7684}\u{5171}\u{4eab}\u{57fa}\u{7840}\u{7c7b}\u{578b}\u{3002}" ); } #[test] fn a_number_is_not_a_sentence_end() { // A version or a measurement must not cut a sentence in half. assert_eq!( crate_summary_brief("Reads version 1.5 of the format. Then it checksums."), "Reads version 1.5 of the format." ); } #[test] fn a_summary_without_a_full_stop_is_capped() { let long = "word ".repeat(80); let brief = crate_summary_brief(&long); assert!(brief.chars().count() <= 158, "the brief is too long"); assert!(brief.ends_with('\u{2026}'), "it should be marked as cut"); } }