use std::collections::{HashMap, HashSet};
use std::io::Write;
use anyhow::Result;
use crate::models::{
ApiDocumentation, DetailLevel, DocConfig, Endpoint, GroupBy, Response, Schema,
};
use crate::utils::{clean_for_id, extract_content_type};
pub fn generate_markdown<W: Write>(
writer: &mut W,
doc: &ApiDocumentation,
config: &DocConfig,
) -> Result<()> {
if config.detail_level == DetailLevel::Summary {
generate_summary(writer, doc, config)
} else {
match config.group_by {
GroupBy::Service => generate_by_service(writer, doc, config),
GroupBy::Method => generate_by_method(writer, doc, config),
GroupBy::Flat => generate_flat(writer, doc, config),
}
}
}
fn generate_summary<W: Write>(
writer: &mut W,
doc: &ApiDocumentation,
config: &DocConfig,
) -> Result<()> {
writeln!(writer, "# {}", doc.title)?;
if let Some(description) = &doc.description {
writeln!(writer, "\n{}\n", description)?;
}
writeln!(writer, "API Version: {}\n", doc.version)?;
if !doc.servers.is_empty() && config.include_auth {
writeln!(writer, "## Server URLs")?;
for server in &doc.servers {
writeln!(writer, "* {}", server)?;
}
writeln!(writer)?;
}
if !doc.security_schemes.is_empty() && config.include_auth {
writeln!(writer, "## Authentication")?;
for (name, desc) in &doc.security_schemes {
writeln!(writer, "* **{}**: {}", name, desc)?;
}
writeln!(writer)?;
}
let services = if let Some(filter) = &config.service_filter {
let filter_set: HashSet<_> = filter.iter().collect();
doc.services
.iter()
.filter(|s| filter_set.contains(&s.name))
.collect::<Vec<_>>()
} else {
doc.services.iter().collect()
};
let mut service_endpoints: HashMap<&str, Vec<&Endpoint>> = HashMap::new();
for endpoint in &doc.endpoints {
if config.exclude_deprecated && endpoint.deprecated {
continue;
}
if let Some(methods) = &config.method_filter {
if !methods.contains(&endpoint.method) {
continue;
}
}
if let Some(path_pattern) = &config.path_filter {
if !endpoint.path.contains(path_pattern) {
continue;
}
}
for service_name in &endpoint.services {
service_endpoints
.entry(service_name)
.or_default()
.push(endpoint);
}
}
writeln!(writer, "## Services")?;
for service in &services {
writeln!(writer, "- {}", service.name)?;
if let Some(endpoints) = service_endpoints.get(&service.name as &str) {
let mut sorted_ops = endpoints.clone();
match config.sort_method {
crate::models::SortMethod::Alphabetical => {
sorted_ops.sort_by(|a, b| {
a.operation_id
.clone()
.unwrap_or_default()
.cmp(&b.operation_id.clone().unwrap_or_default())
});
}
crate::models::SortMethod::PathLength => {
sorted_ops.sort_by_key(|a| a.path.len());
}
crate::models::SortMethod::None => {}
}
for endpoint in sorted_ops {
let op_name = if let Some(operation_id) = &endpoint.operation_id {
if operation_id.starts_with(&format!("{}_", service.name)) {
operation_id.replacen(&format!("{}_", service.name), "", 1)
} else {
operation_id.clone()
}
} else {
format!("{} {}", endpoint.method, endpoint.path)
};
writeln!(writer, " * {}", op_name)?;
}
}
}
Ok(())
}
fn generate_by_service<W: Write>(
writer: &mut W,
doc: &ApiDocumentation,
config: &DocConfig,
) -> Result<()> {
writeln!(writer, "# {}", doc.title)?;
if let Some(description) = &doc.description {
writeln!(writer, "\n{}\n", description)?;
}
writeln!(writer, "API Version: {}\n", doc.version)?;
if !doc.servers.is_empty() && config.include_auth {
writeln!(writer, "## Server URLs")?;
for server in &doc.servers {
writeln!(writer, "* {}", server)?;
}
writeln!(writer)?;
}
if !doc.security_schemes.is_empty() && config.include_auth {
writeln!(writer, "## Authentication")?;
for (name, desc) in &doc.security_schemes {
writeln!(writer, "* **{}**: {}", name, desc)?;
}
writeln!(writer)?;
}
let services = if let Some(filter) = &config.service_filter {
let filter_set: HashSet<_> = filter.iter().collect();
doc.services
.iter()
.filter(|s| filter_set.contains(&s.name))
.collect::<Vec<_>>()
} else {
doc.services.iter().collect()
};
let mut service_endpoints: HashMap<&str, Vec<&Endpoint>> = HashMap::new();
for endpoint in &doc.endpoints {
if config.exclude_deprecated && endpoint.deprecated {
continue;
}
if let Some(methods) = &config.method_filter {
if !methods.contains(&endpoint.method) {
continue;
}
}
if let Some(path_pattern) = &config.path_filter {
if !endpoint.path.contains(path_pattern) {
continue;
}
}
for service_name in &endpoint.services {
service_endpoints
.entry(service_name)
.or_default()
.push(endpoint);
}
}
if config.include_toc {
writeln!(writer, "## Services\n")?;
for service in &services {
let anchor = clean_for_id(&service.name);
writeln!(writer, "- [{}](#{anchor})", service.name)?;
if let Some(endpoints) = service_endpoints.get(&service.name as &str) {
let mut sorted_ops = endpoints.clone();
match config.sort_method {
crate::models::SortMethod::Alphabetical => {
sorted_ops.sort_by(|a, b| {
a.summary
.clone()
.unwrap_or_default()
.cmp(&b.summary.clone().unwrap_or_default())
});
}
crate::models::SortMethod::PathLength => {
sorted_ops.sort_by_key(|a| a.path.len());
}
crate::models::SortMethod::None => {}
}
for endpoint in sorted_ops {
let op_title = get_short_title(endpoint);
let op_anchor = clean_for_id(&op_title);
writeln!(writer, " * [{}](#{op_anchor})", op_title)?;
}
}
}
writeln!(writer)?;
}
for service in &services {
let anchor = clean_for_id(&service.name);
writeln!(writer, "## {} {{#{}}}", service.name, anchor)?;
if let Some(description) = &service.description {
writeln!(writer, "\n{}", description)?;
}
if let Some(endpoints) = service_endpoints.get(&service.name as &str) {
let mut sorted_endpoints = endpoints.clone();
match config.sort_method {
crate::models::SortMethod::Alphabetical => {
sorted_endpoints.sort_by(|a, b| a.path.cmp(&b.path));
}
crate::models::SortMethod::PathLength => {
sorted_endpoints.sort_by_key(|a| a.path.len());
}
crate::models::SortMethod::None => {}
}
for endpoint in sorted_endpoints {
write_endpoint(writer, endpoint, doc, config, true)?;
}
} else {
writeln!(writer, "\nNo endpoints found for this service.\n")?;
}
}
Ok(())
}
fn generate_by_method<W: Write>(
writer: &mut W,
doc: &ApiDocumentation,
config: &DocConfig,
) -> Result<()> {
writeln!(writer, "# {}", doc.title)?;
if let Some(description) = &doc.description {
writeln!(writer, "\n{}\n", description)?;
}
writeln!(writer, "API Version: {}\n", doc.version)?;
if !doc.servers.is_empty() && config.include_auth {
writeln!(writer, "## Server URLs")?;
for server in &doc.servers {
writeln!(writer, "* {}", server)?;
}
writeln!(writer)?;
}
if !doc.security_schemes.is_empty() && config.include_auth {
writeln!(writer, "## Authentication")?;
for (name, desc) in &doc.security_schemes {
writeln!(writer, "* **{}**: {}", name, desc)?;
}
writeln!(writer)?;
}
let mut method_endpoints: HashMap<&str, Vec<&Endpoint>> = HashMap::new();
for endpoint in &doc.endpoints {
if config.exclude_deprecated && endpoint.deprecated {
continue;
}
if let Some(services) = &config.service_filter {
if !endpoint.services.iter().any(|s| services.contains(s)) {
continue;
}
}
if let Some(path_pattern) = &config.path_filter {
if !endpoint.path.contains(path_pattern) {
continue;
}
}
if let Some(methods) = &config.method_filter {
if !methods.contains(&endpoint.method) {
continue;
}
}
method_endpoints
.entry(&endpoint.method)
.or_default()
.push(endpoint);
}
if config.include_toc {
writeln!(writer, "## HTTP Methods\n")?;
for method in [
"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD", "TRACE",
] {
if let Some(endpoints) = method_endpoints.get(method) {
if !endpoints.is_empty() {
let anchor = clean_for_id(method);
writeln!(writer, "- [{}](#{anchor})", method)?;
}
}
}
writeln!(writer)?;
}
for method in [
"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD", "TRACE",
] {
if let Some(endpoints) = method_endpoints.get(method) {
if !endpoints.is_empty() {
let anchor = clean_for_id(method);
writeln!(writer, "## {} {{#{}}}", method, anchor)?;
let mut sorted_endpoints = endpoints.clone();
match config.sort_method {
crate::models::SortMethod::Alphabetical => {
sorted_endpoints.sort_by(|a, b| a.path.cmp(&b.path));
}
crate::models::SortMethod::PathLength => {
sorted_endpoints.sort_by_key(|a| a.path.len());
}
crate::models::SortMethod::None => {}
}
for endpoint in sorted_endpoints {
write_endpoint(writer, endpoint, doc, config, true)?;
}
}
}
}
Ok(())
}
fn generate_flat<W: Write>(
writer: &mut W,
doc: &ApiDocumentation,
config: &DocConfig,
) -> Result<()> {
writeln!(writer, "# {}", doc.title)?;
if let Some(description) = &doc.description {
writeln!(writer, "\n{}\n", description)?;
}
writeln!(writer, "API Version: {}\n", doc.version)?;
if !doc.servers.is_empty() && config.include_auth {
writeln!(writer, "## Server URLs")?;
for server in &doc.servers {
writeln!(writer, "* {}", server)?;
}
writeln!(writer)?;
}
if !doc.security_schemes.is_empty() && config.include_auth {
writeln!(writer, "## Authentication")?;
for (name, desc) in &doc.security_schemes {
writeln!(writer, "* **{}**: {}", name, desc)?;
}
writeln!(writer)?;
}
let mut endpoints: Vec<&Endpoint> = doc
.endpoints
.iter()
.filter(|endpoint| {
if config.exclude_deprecated && endpoint.deprecated {
return false;
}
if let Some(services) = &config.service_filter {
if !endpoint.services.iter().any(|s| services.contains(s)) {
return false;
}
}
if let Some(methods) = &config.method_filter {
if !methods.contains(&endpoint.method) {
return false;
}
}
if let Some(path_pattern) = &config.path_filter {
if !endpoint.path.contains(path_pattern) {
return false;
}
}
true
})
.collect();
match config.sort_method {
crate::models::SortMethod::Alphabetical => {
endpoints.sort_by(|a, b| a.path.cmp(&b.path).then(a.method.cmp(&b.method)));
}
crate::models::SortMethod::PathLength => {
endpoints.sort_by_key(|a| a.path.len());
}
crate::models::SortMethod::None => {}
}
writeln!(writer, "## Endpoints\n")?;
for endpoint in endpoints {
write_endpoint(writer, endpoint, doc, config, true)?;
}
Ok(())
}
fn write_endpoint<W: Write>(
writer: &mut W,
endpoint: &Endpoint,
doc: &ApiDocumentation,
config: &DocConfig,
include_heading: bool,
) -> Result<()> {
let title = get_short_title(endpoint);
if include_heading {
let anchor = clean_for_id(&title);
writeln!(writer, "### {} {{#{}}}", title, anchor)?;
} else {
writeln!(writer, "**{}**", title)?;
}
writeln!(
writer,
"**Operation:** {} {}",
endpoint.method, endpoint.path
)?;
if let Some(description) = &endpoint.description {
writeln!(writer, "**Description:** {}", description)?;
} else if let Some(summary) = &endpoint.summary {
writeln!(writer, "**Description:** {}", summary)?;
}
if endpoint.deprecated {
writeln!(writer, "\n> **Deprecated**: This endpoint is deprecated.")?;
}
if let Some(operation_id) = &endpoint.operation_id {
writeln!(writer, "**Operation ID:** `{}`", operation_id)?;
}
if config.detail_level != DetailLevel::Basic {
if !endpoint.parameters.is_empty() {
writeln!(writer, "\n#### Parameters")?;
writeln!(writer, "| Name | In | Required | Description |")?;
writeln!(writer, "|------|----|---------:|-------------|")?;
for param in &endpoint.parameters {
if let Some(required) = param.required {
if !required && config.required_only {
continue;
}
}
let required_str = if let Some(req) = param.required {
if req {
"Yes"
} else {
"No"
}
} else {
"No"
};
let desc = param.description.as_deref().unwrap_or("-");
writeln!(
writer,
"| `{}` | {} | {} | {} |",
param.name, param.parameter_in, required_str, desc
)?;
}
}
writeln!(writer, "\n#### Responses")?;
writeln!(writer, "| Code | Type | Description |")?;
writeln!(writer, "|------|------|-------------|")?;
for (code, response) in &endpoint.responses {
let desc = response.description.as_deref().unwrap_or("-");
let content_type = extract_content_type(response).unwrap_or_default();
writeln!(writer, "| {} | {} | {} |", code, content_type, desc)?;
}
if config.include_schemas && config.detail_level == DetailLevel::Full {
writeln!(writer, "\n#### Request Schema")?;
let body_param = endpoint
.parameters
.iter()
.find(|p| p.parameter_in == "body" && p.schema.is_some());
if let Some(param) = body_param {
if let Some(schema) = ¶m.schema {
write_schema_table(writer, schema, doc, "request")?;
} else {
writeln!(writer, "*No request schema available*")?;
}
} else {
writeln!(writer, "*No request schema available*")?;
}
writeln!(writer, "\n#### Response Schema")?;
if let Some((_, response)) = endpoint
.responses
.iter()
.find(|(code, _)| code.starts_with('2'))
{
if let Some(schema) = response_schema(response) {
write_schema_table(writer, schema, doc, "response")?;
} else {
writeln!(writer, "*No response schema available*")?;
}
} else {
writeln!(writer, "*No success response schema available*")?;
}
}
if config.include_examples && config.detail_level == DetailLevel::Full {
writeln!(writer, "\n#### Examples")?;
writeln!(writer, "*Examples would be included here if available*")?;
}
}
writeln!(writer)?; Ok(())
}
fn get_short_title(endpoint: &Endpoint) -> String {
if let Some(operation_id) = &endpoint.operation_id {
return operation_id.clone();
} else if let Some(summary) = &endpoint.summary {
if let Some(first_word) = summary.split_whitespace().next() {
if first_word.chars().any(|c| c.is_uppercase()) {
return first_word.to_string();
}
}
return summary.clone();
}
format!("{} {}", endpoint.method, endpoint.path)
}
#[derive(Debug)]
struct SchemaRow {
field: String,
type_name: String,
required: String,
description: String,
}
fn response_schema(response: &Response) -> Option<&Schema> {
if let Some(schema) = &response.schema {
return Some(schema);
}
response
.content
.as_ref()
.and_then(|content| content.values().find_map(|media| media.schema.as_ref()))
}
fn write_schema_table<W: Write>(
writer: &mut W,
schema: &Schema,
doc: &ApiDocumentation,
root_label: &str,
) -> Result<()> {
let mut rows = Vec::new();
let mut ref_stack = Vec::new();
collect_schema_rows(schema, doc, root_label, None, &mut rows, &mut ref_stack, 0);
if rows.is_empty() {
writeln!(writer, "*No schema fields available*")?;
return Ok(());
}
writeln!(writer, "| Field | Type | Required | Description |")?;
writeln!(writer, "|------|------|---------:|-------------|")?;
for row in rows {
writeln!(
writer,
"| `{}` | {} | {} | {} |",
row.field,
escape_table_cell(&row.type_name),
row.required,
escape_table_cell(&row.description)
)?;
}
Ok(())
}
fn collect_schema_rows(
schema: &Schema,
doc: &ApiDocumentation,
field: &str,
required: Option<bool>,
rows: &mut Vec<SchemaRow>,
ref_stack: &mut Vec<String>,
depth: usize,
) {
const MAX_DEPTH: usize = 24;
if depth >= MAX_DEPTH {
rows.push(SchemaRow {
field: field.to_string(),
type_name: "truncated".to_string(),
required: required_to_string(required).to_string(),
description: "Maximum schema depth reached; nested expansion stopped".to_string(),
});
return;
}
if let Some(reference) = &schema.reference {
if ref_stack.contains(reference) {
rows.push(SchemaRow {
field: field.to_string(),
type_name: format!("ref {}", short_schema_reference(reference)),
required: required_to_string(required).to_string(),
description: "Cycle detected while expanding schema reference".to_string(),
});
return;
}
if let Some(resolved) = resolve_schema_reference(reference, doc) {
ref_stack.push(reference.clone());
collect_schema_rows(resolved, doc, field, required, rows, ref_stack, depth + 1);
ref_stack.pop();
return;
}
rows.push(SchemaRow {
field: field.to_string(),
type_name: format!("ref {}", short_schema_reference(reference)),
required: required_to_string(required).to_string(),
description: format!("Unresolved schema reference: {}", reference),
});
return;
}
let description = schema.description.as_deref().unwrap_or("-");
rows.push(SchemaRow {
field: field.to_string(),
type_name: schema_type_label(schema),
required: required_to_string(required).to_string(),
description: description.to_string(),
});
if let Some(properties) = &schema.properties {
let required_fields: HashSet<&str> = schema
.required
.as_ref()
.map(|items| items.iter().map(String::as_str).collect())
.unwrap_or_default();
for (name, child_schema) in properties {
let child_field = format_field(field, name);
collect_schema_rows(
child_schema,
doc,
&child_field,
Some(required_fields.contains(name.as_str())),
rows,
ref_stack,
depth + 1,
);
}
}
if let Some(items) = &schema.items {
let item_field = format!("{}[]", field);
collect_schema_rows(items, doc, &item_field, None, rows, ref_stack, depth + 1);
}
if let Some(all_of) = &schema.all_of {
for (index, variant) in all_of.iter().enumerate() {
let variant_field = format!("{}.allOf[{}]", field, index + 1);
collect_schema_rows(
variant,
doc,
&variant_field,
required,
rows,
ref_stack,
depth + 1,
);
}
}
if let Some(one_of) = &schema.one_of {
for (index, variant) in one_of.iter().enumerate() {
let variant_field = format!("{}.oneOf[{}]", field, index + 1);
collect_schema_rows(
variant,
doc,
&variant_field,
required,
rows,
ref_stack,
depth + 1,
);
}
}
if let Some(any_of) = &schema.any_of {
for (index, variant) in any_of.iter().enumerate() {
let variant_field = format!("{}.anyOf[{}]", field, index + 1);
collect_schema_rows(
variant,
doc,
&variant_field,
required,
rows,
ref_stack,
depth + 1,
);
}
}
}
fn resolve_schema_reference<'a>(reference: &str, doc: &'a ApiDocumentation) -> Option<&'a Schema> {
if let Some(name) = reference.strip_prefix("#/components/schemas/") {
return doc.schemas.get(&decode_json_pointer_token(name));
}
if let Some(name) = reference.strip_prefix("#/definitions/") {
return doc.schemas.get(&decode_json_pointer_token(name));
}
None
}
fn schema_type_label(schema: &Schema) -> String {
let mut label = if let Some(schema_type) = &schema.schema_type {
if schema_type == "array" {
let item_label = schema
.items
.as_deref()
.map(schema_type_hint)
.unwrap_or_else(|| "unknown".to_string());
format!("array<{}>", item_label)
} else if let Some(format) = &schema.format {
format!("{}({})", schema_type, format)
} else {
schema_type.clone()
}
} else if schema.properties.is_some() {
"object".to_string()
} else if schema.items.is_some() {
let item_label = schema
.items
.as_deref()
.map(schema_type_hint)
.unwrap_or_else(|| "unknown".to_string());
format!("array<{}>", item_label)
} else if schema.all_of.as_ref().is_some_and(|v| !v.is_empty()) {
"allOf".to_string()
} else if schema.one_of.as_ref().is_some_and(|v| !v.is_empty()) {
"oneOf".to_string()
} else if schema.any_of.as_ref().is_some_and(|v| !v.is_empty()) {
"anyOf".to_string()
} else if let Some(enum_values) = &schema.enum_values {
format!("enum[{}]", enum_values.len())
} else {
"unknown".to_string()
};
if schema.nullable.unwrap_or(false) {
label.push_str(" | null");
}
label
}
fn schema_type_hint(schema: &Schema) -> String {
if let Some(reference) = &schema.reference {
return format!("ref {}", short_schema_reference(reference));
}
if let Some(schema_type) = &schema.schema_type {
return schema_type.clone();
}
if schema.properties.is_some() {
return "object".to_string();
}
if schema.items.is_some() {
return "array".to_string();
}
"unknown".to_string()
}
fn format_field(parent: &str, child: &str) -> String {
if parent.is_empty() {
return child.to_string();
}
format!("{}.{}", parent, child)
}
fn required_to_string(required: Option<bool>) -> &'static str {
match required {
Some(true) => "Yes",
Some(false) => "No",
None => "-",
}
}
fn short_schema_reference(reference: &str) -> String {
reference
.rsplit('/')
.next()
.map(decode_json_pointer_token)
.unwrap_or_else(|| reference.to_string())
}
fn decode_json_pointer_token(token: &str) -> String {
token.replace("~1", "/").replace("~0", "~")
}
fn escape_table_cell(value: &str) -> String {
value.replace('|', "\\|").replace('\n', "<br/>")
}