use crate::web::api_docs;
use crate::web::templates::{ApiSection, EndpointDoc};
const DEFAULT_BASE_URL: &str = "http://localhost:9080/api";
pub fn render() -> String {
let mut out = String::with_capacity(64 * 1024);
let preamble = api_docs::preamble();
let sections = api_docs::sections();
out.push_str("# Server API\n\n");
out.push_str(&preamble.intro);
out.push_str("\n\n");
out.push_str(
"Start the server with [`cook server`](server.md); every endpoint below is served by \
it. The same reference is available from a running server at `/api-docs`, where the \
base URL reflects the host you reached it on.\n\n",
);
out.push_str("## Before you start\n\n");
out.push_str(&format!("- **Base URL:** `{DEFAULT_BASE_URL}`\n"));
for n in &preamble.notes {
out.push_str(&format!("- **{}:** {}\n", n.label, n.text));
}
out.push('\n');
out.push_str("## Errors\n\n");
out.push_str(&preamble.error_intro);
out.push_str("\n\n```json\n");
out.push_str(&preamble.error_example);
out.push_str("\n```\n\n");
for c in &preamble.error_codes {
out.push_str(&format!("- `{}` — {}\n", c.label, c.text));
}
out.push('\n');
out.push_str("## Contents\n\n");
for s in §ions {
out.push_str(&format!("- [{}](#{})\n", s.title, anchor(&s.title)));
}
out.push('\n');
for section in §ions {
render_section(&mut out, section);
}
out.push_str(
"---\n\nThis file is generated from `src/web/api_docs.rs`. Edit that, then run \
`UPDATE_API_DOCS=1 cargo test --test api_docs_md_test` to regenerate.\n",
);
out
}
fn render_section(out: &mut String, section: &ApiSection) {
out.push_str(&format!("## {}\n\n", section.title));
out.push_str(§ion.description);
out.push_str("\n\n");
for endpoint in §ion.endpoints {
render_endpoint(out, endpoint);
}
}
fn render_endpoint(out: &mut String, ep: &EndpointDoc) {
out.push_str(&format!("### `{} {}`\n\n", ep.method, ep.path));
out.push_str(&ep.summary);
if let Some(feature) = &ep.feature {
out.push_str(&format!(
" *(requires a build with the `{feature}` feature)*"
));
}
out.push_str("\n\n");
if !ep.description.is_empty() {
out.push_str(&ep.description);
out.push_str("\n\n");
}
if !ep.params.is_empty() {
out.push_str("| Name | In | Type | Required | Description |\n");
out.push_str("|------|----|------|----------|-------------|\n");
for p in &ep.params {
out.push_str(&format!(
"| `{}` | {} | `{}` | {} | {} |\n",
p.name,
p.kind,
p.type_name,
if p.required { "yes" } else { "no" },
escape_cell(&p.description),
));
}
out.push('\n');
}
if let Some(request) = &ep.request_example {
out.push_str("Request body:\n\n");
push_fence(out, request);
}
if let Some(response) = &ep.response_example {
out.push_str("Response:\n\n");
push_fence(out, response);
}
}
fn push_fence(out: &mut String, example: &str) {
let lang = match example.trim_start().chars().next() {
Some('{') | Some('[') => "json",
_ => "text",
};
out.push_str(&format!("```{lang}\n{}\n```\n\n", example.trim_end()));
}
fn escape_cell(text: &str) -> String {
text.replace('|', "\\|").replace('\n', " ")
}
fn anchor(title: &str) -> String {
title
.chars()
.filter_map(|c| match c {
' ' => Some('-'),
c if c.is_alphanumeric() => Some(c.to_ascii_lowercase()),
'-' | '_' => Some(c),
_ => None,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anchor_matches_github_slugging() {
assert_eq!(anchor("Search & Stats"), "search--stats");
assert_eq!(anchor("Shopping List"), "shopping-list");
assert_eq!(anchor("Recipes"), "recipes");
}
#[test]
fn fences_are_tagged_by_content() {
let mut out = String::new();
push_fence(&mut out, "{ \"a\": 1 }");
assert!(out.starts_with("```json\n"));
let mut out = String::new();
push_fence(&mut out, "event: change\ndata: {}");
assert!(out.starts_with("```text\n"));
}
#[test]
fn table_cells_escape_pipes() {
assert_eq!(escape_cell("a | b"), "a \\| b");
}
#[test]
fn fences_are_balanced() {
let md = render();
assert_eq!(md.matches("\n```").count() % 2, 0);
}
#[test]
fn no_example_contains_a_fence() {
for section in api_docs::sections() {
for ep in section.endpoints {
for example in [ep.request_example, ep.response_example]
.into_iter()
.flatten()
{
assert!(
!example.contains("```"),
"example for {} {} contains a code fence",
ep.method,
ep.path
);
}
}
}
}
#[test]
fn backticks_pair_up_in_prose() {
let mut prose: Vec<(String, String)> = Vec::new();
let preamble = api_docs::preamble();
for n in preamble.notes.iter().chain(preamble.error_codes.iter()) {
prose.push((format!("preamble note {}", n.label), n.text.clone()));
}
for section in api_docs::sections() {
prose.push((format!("section {}", section.title), section.description));
for ep in section.endpoints {
let at = format!("{} {}", ep.method, ep.path);
prose.push((format!("{at} summary"), ep.summary));
prose.push((format!("{at} description"), ep.description));
for p in ep.params {
prose.push((format!("{at} param {}", p.name), p.description));
}
}
}
for (at, text) in prose {
assert_eq!(
text.matches('`').count() % 2,
0,
"unbalanced backtick in {at}: {text}"
);
assert!(
!text.contains("\\`"),
"escaped backtick in {at} — neither renderer honours it: {text}"
);
}
}
#[test]
fn contents_links_resolve() {
let md = render();
for section in api_docs::sections() {
assert!(
md.contains(&format!("## {}\n", section.title)),
"no heading for section {}",
section.title
);
assert!(
md.contains(&format!("](#{})", anchor(§ion.title))),
"no contents link for section {}",
section.title
);
}
}
}