apsis-book/src/api/mod.rs

1153 lines
44 KiB
Rust

//! 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<AppState>,
/// 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<AppState>) -> 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-web",
"version" => env!("CARGO_PKG_VERSION"),
"documents" => self.state.docs.len(),
"layers" => self
.state
.docs
.groups()
.iter()
.map(|group| group.sections.len())
.sum::<usize>(),
"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::<usize>().ok())
.unwrap_or(20)
.min(100);
let hits = self.search.search(&text, limit);
let hits: Vec<Value> = 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::<Vec<Value>>(),
});
}
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::<Vec<Value>>(),
"references" => document.references().iter().map(|id| Value::from(id.as_str())).collect::<Vec<Value>>(),
"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<Value> = 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<Value> = 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::<Vec<Value>>(),
"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<Value> = state
.docs
.groups()
.iter()
.map(|group| {
let sections: Vec<Value> = group
.sections
.iter()
.map(|section| {
let documents = |ids: &[String]| -> Vec<Value> {
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<Value> = 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(&section.title_zh, &section.title),
"summary" => optional_bilingual(&section.summary_zh, &section.summary),
"documents" => documents(&section.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<String>) -> 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<String>) -> 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<Value> = docs
.groups()
.iter()
.flat_map(|group| group.sections.iter())
.map(|section| {
json_object! {
"id" => section.id.clone(),
"title" => optional_bilingual(&section.title_zh, &section.title),
"summary" => optional_bilingual(&section.summary_zh, &section.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<String>, 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<Value> = 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::<Vec<Value>>(),
"zh" => document.tags_zh.iter().map(|tag| Value::from(tag.as_str())).collect::<Vec<Value>>(),
}
}
/// 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(&section.title_zh, &section.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<Value> = 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<Value> = 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::<Vec<Value>>(),
"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<String>,
) -> Value {
let items: Vec<Value> = 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");
}
}