docs: auto-generate API route documentation
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Id0d1f9769b7ccdbf83d5fa78adef62e46a6a6964
This commit is contained in:
parent
9d58927cb4
commit
934691c0f9
40 changed files with 17444 additions and 1 deletions
17
xtask/Cargo.toml
Normal file
17
xtask/Cargo.toml
Normal 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
215
xtask/src/docs.rs
Normal 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
19
xtask/src/main.rs
Normal 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);
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue