docs: auto-generate API route documentation

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Id0d1f9769b7ccdbf83d5fa78adef62e46a6a6964
This commit is contained in:
raf 2026-03-21 02:18:48 +03:00
commit 934691c0f9
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
40 changed files with 17444 additions and 1 deletions

17
xtask/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "xtask"
version = "0.1.0"
edition = "2024"
publish = false
[[bin]]
name = "xtask"
path = "src/main.rs"
[dependencies]
pinakes-server = { workspace = true }
utoipa = { workspace = true }
serde_json = { workspace = true }
[lints]
workspace = true

215
xtask/src/docs.rs Normal file
View file

@ -0,0 +1,215 @@
use std::{collections::BTreeMap, fmt::Write as _};
use pinakes_server::api_doc::ApiDoc;
use utoipa::{
OpenApi,
openapi::{RefOr, Required, path::ParameterIn},
};
#[expect(
clippy::expect_used,
clippy::print_stdout,
reason = "Panics are acceptable here."
)]
pub fn run() {
let api = ApiDoc::openapi();
let out_dir = std::path::Path::new("docs/api");
std::fs::create_dir_all(out_dir).expect("create docs/api dir");
let json = serde_json::to_string_pretty(&api).expect("serialize openapi");
std::fs::write(out_dir.join("openapi.json"), &json)
.expect("write openapi.json");
println!("Written docs/api/openapi.json");
// Collect all operations grouped by tag.
let mut tag_ops: BTreeMap<
String,
Vec<(String, String, &utoipa::openapi::path::Operation)>,
> = BTreeMap::new();
for (path, item) in &api.paths.paths {
let method_ops: &[(&str, Option<&utoipa::openapi::path::Operation>)] = &[
("GET", item.get.as_ref()),
("POST", item.post.as_ref()),
("PUT", item.put.as_ref()),
("PATCH", item.patch.as_ref()),
("DELETE", item.delete.as_ref()),
];
for (method, maybe_op) in method_ops {
let Some(op) = maybe_op else { continue };
let tags = op.tags.as_deref().unwrap_or(&[]);
if tags.is_empty() {
tag_ops.entry("_untagged".to_owned()).or_default().push((
(*method).to_owned(),
path.clone(),
op,
));
} else {
for tag in tags {
tag_ops.entry(tag.clone()).or_default().push((
(*method).to_owned(),
path.clone(),
op,
));
}
}
}
}
// Build a lookup from tag name to description.
let tag_descriptions: BTreeMap<String, String> = api
.tags
.as_deref()
.unwrap_or(&[])
.iter()
.map(|t| {
let desc = t.description.as_deref().unwrap_or("").to_owned();
(t.name.clone(), desc)
})
.collect();
let mut files_written = 0usize;
for (tag_name, ops) in &tag_ops {
let description = tag_descriptions.get(tag_name).map_or("", String::as_str);
let mut md = String::new();
write!(md, "# {}\n\n", title_case(tag_name)).expect("write to String");
if !description.is_empty() {
write!(md, "{description}\n\n").expect("write to String");
}
md.push_str("## Endpoints\n\n");
for (method, path, op) in ops {
write_operation(&mut md, method, path, op);
}
let file_name = format!("{}.md", tag_name.replace('/', "_"));
let dest = out_dir.join(&file_name);
std::fs::write(&dest, &md).expect("write markdown file");
println!("Written docs/api/{file_name}");
files_written += 1;
}
println!(
"Done: wrote docs/api/openapi.json and {files_written} markdown files."
);
}
fn title_case(s: &str) -> String {
let mut chars = s.chars();
chars.next().map_or_else(String::new, |c| {
c.to_uppercase().collect::<String>() + chars.as_str()
})
}
#[expect(
clippy::expect_used,
reason = "write! on String is infallible, but clippy still warns on expect()"
)]
fn write_operation(
md: &mut String,
method: &str,
path: &str,
op: &utoipa::openapi::path::Operation,
) {
let summary = op.summary.as_deref().unwrap_or("");
let description = op.description.as_deref().unwrap_or("");
write!(md, "### {method} {path}\n\n").expect("write to String");
if !summary.is_empty() {
md.push_str(summary);
md.push('\n');
if !description.is_empty() {
md.push('\n');
md.push_str(description);
md.push('\n');
}
md.push('\n');
} else if !description.is_empty() {
write!(md, "{description}\n\n").expect("write to String");
}
// Authentication
let needs_auth = op.security.as_ref().is_none_or(|s| !s.is_empty());
if needs_auth {
md.push_str("**Authentication:** Required (Bearer JWT)\n\n");
} else {
md.push_str("**Authentication:** Not required\n\n");
}
// Parameters
if let Some(params) = &op.parameters {
if !params.is_empty() {
md.push_str("#### Parameters\n\n");
md.push_str("| Name | In | Required | Description |\n");
md.push_str("|------|----|----------|-------------|\n");
for p in params {
let location = param_in_str(&p.parameter_in);
let required = match p.required {
Required::True => "Yes",
Required::False => "No",
};
let desc = p
.description
.as_deref()
.unwrap_or("")
.replace('|', "\\|")
.replace('\n', " ");
writeln!(
md,
"| `{}` | {} | {} | {} |",
p.name, location, required, desc
)
.expect("write to String");
}
md.push('\n');
}
}
// Request body
if let Some(rb) = &op.request_body {
md.push_str("#### Request Body\n\n");
if let Some(desc) = &rb.description {
writeln!(md, "{desc}").expect("write to String");
}
for content_type in rb.content.keys() {
write!(md, "`Content-Type: {content_type}`\n\n")
.expect("write to String");
md.push_str("See `docs/api/openapi.json` for the full schema.\n\n");
}
}
// Responses
let responses = &op.responses;
if !responses.responses.is_empty() {
md.push_str("#### Responses\n\n");
md.push_str("| Status | Description |\n");
md.push_str("|--------|-------------|\n");
for (status, resp) in &responses.responses {
let raw = match resp {
RefOr::T(r) => r.description.as_str(),
RefOr::Ref(_) => "See schema",
};
let desc = raw.replace('|', "\\|").replace('\n', " ");
writeln!(md, "| {status} | {desc} |").expect("write to String");
}
md.push('\n');
}
md.push_str("---\n\n");
}
const fn param_in_str(pin: &ParameterIn) -> &'static str {
match pin {
ParameterIn::Path => "path",
ParameterIn::Query => "query",
ParameterIn::Header => "header",
ParameterIn::Cookie => "cookie",
}
}

19
xtask/src/main.rs Normal file
View file

@ -0,0 +1,19 @@
mod docs;
#[expect(clippy::print_stderr)]
fn main() {
let args: Vec<String> = std::env::args().collect();
match args.get(1).map(String::as_str) {
Some("docs") => docs::run(),
Some(cmd) => {
eprintln!("Unknown command: {cmd}");
std::process::exit(1);
},
None => {
eprintln!("Usage: cargo xtask <command>");
eprintln!("Commands:");
eprintln!(" docs Generate API documentation");
std::process::exit(1);
},
}
}