Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ia355e5626b5db7760c8dbb571cb552c46a6a6964
215 lines
5.8 KiB
Rust
215 lines
5.8 KiB
Rust
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
|
|
&& !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",
|
|
}
|
|
}
|