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 = 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::() + 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", } }