#[cfg(test)]
mod tests;
use crate::router::RouteInfo;
use crate::schema::JsonSchema;
#[derive(Clone, Debug)]
pub struct OpenApiConfig {
pub title: String,
pub version: String,
pub description: Option<String>,
pub operations: Vec<(String, String, Operation)>,
}
impl OpenApiConfig {
pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
OpenApiConfig { title: title.into(), version: version.into(), description: None, operations: Vec::new() }
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn operation(mut self, method: &str, pattern: &str, operation: Operation) -> Self {
let method = method.to_ascii_uppercase();
self.operations.retain(|(m, p, _)| !(*m == method && to_openapi_path(p) == to_openapi_path(pattern)));
self.operations.push((method, pattern.to_string(), operation));
self
}
}
#[derive(Clone, Debug, Default)]
pub struct Operation {
summary: String,
description: Option<String>,
operation_id: Option<String>,
tags: Vec<String>,
deprecated: bool,
path_params: Vec<(String, String, String)>,
query: Vec<(String, String, bool)>,
body: Option<(String, String, bool)>,
responses: Vec<(u16, String, Option<(String, String)>)>,
}
impl Operation {
pub fn new(summary: impl Into<String>) -> Self {
Operation { summary: summary.into(), ..Operation::default() }
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn operation_id(mut self, id: impl Into<String>) -> Self {
self.operation_id = Some(id.into());
self
}
pub fn tag(mut self, tag: impl Into<String>) -> Self {
self.tags.push(tag.into());
self
}
pub fn deprecated(mut self) -> Self {
self.deprecated = true;
self
}
pub fn path_param<T: JsonSchema + ?Sized>(self, name: &str) -> Self {
self.path_param_schema(name, &T::json_schema(), "")
}
pub fn path_param_schema(mut self, name: &str, schema: &str, description: &str) -> Self {
self.path_params.retain(|(n, _, _)| n != name);
self.path_params.push((name.to_string(), schema.to_string(), description.to_string()));
self
}
pub fn query<T: JsonSchema + ?Sized>(mut self) -> Self {
self.query.extend(object_properties(&T::json_schema()));
self
}
pub fn query_param(mut self, name: &str, schema: &str, required: bool) -> Self {
self.query.push((name.to_string(), schema.to_string(), required));
self
}
pub fn body<T: JsonSchema + ?Sized>(self) -> Self {
self.body_schema("application/json", &T::json_schema(), true)
}
pub fn body_schema(mut self, content_type: &str, schema: &str, required: bool) -> Self {
self.body = Some((content_type.to_string(), schema.to_string(), required));
self
}
pub fn response<T: JsonSchema + ?Sized>(self, status: u16, description: &str) -> Self {
self.response_schema(status, description, "application/json", &T::json_schema())
}
pub fn response_schema(mut self, status: u16, description: &str, content_type: &str, schema: &str) -> Self {
self.responses.retain(|(s, _, _)| *s != status);
self.responses.push((status, description.to_string(), Some((content_type.to_string(), schema.to_string()))));
self
}
pub fn status(mut self, status: u16, description: &str) -> Self {
self.responses.retain(|(s, _, _)| *s != status);
self.responses.push((status, description.to_string(), None));
self
}
}
fn object_properties(schema: &str) -> Vec<(String, String, bool)> {
use crate::mcp::json_rpc::object_members;
let Some(members) = object_members(schema) else { return Vec::new() };
let required: Vec<String> = members
.iter()
.find(|(k, _)| k == "required")
.map(|(_, v)| {
v.trim().trim_start_matches('[').trim_end_matches(']').split(',').map(|n| n.trim().trim_matches('"').to_string()).collect()
})
.unwrap_or_default();
let Some(properties) = members.iter().find(|(k, _)| k == "properties").and_then(|(_, v)| object_members(v)) else { return Vec::new() };
properties.into_iter().map(|(name, schema)| { let required = required.contains(&name); (name, schema, required) }).collect()
}
pub fn build_spec(config: &OpenApiConfig, routes: &[RouteInfo]) -> String {
let mut all: Vec<(String, String)> = routes.iter().map(|r| (r.method.to_ascii_uppercase(), r.pattern.clone())).collect();
for (method, pattern, _) in &config.operations {
if !all.iter().any(|(m, p)| m == method && to_openapi_path(p) == to_openapi_path(pattern)) {
all.push((method.clone(), pattern.clone()));
}
}
let mut paths: Vec<(String, Vec<(String, String)>)> = Vec::new();
for (method, pattern) in all {
let openapi_path = to_openapi_path(&pattern);
match paths.iter_mut().find(|(p, _)| *p == openapi_path) {
Some(entry) => entry.1.push((method, pattern)),
None => paths.push((openapi_path, vec![(method, pattern)])),
}
}
let paths_json: Vec<String> = paths
.iter()
.map(|(path, methods)| {
let operations: Vec<String> = methods
.iter()
.map(|(method, pattern)| {
let described = config.operations.iter().find(|(m, p, _)| m == method && to_openapi_path(p) == *path).map(|(_, _, o)| o);
build_operation_json(method, pattern, described)
})
.collect();
format!(r#""{}":{{{}}}"#, json_escape(path), operations.join(","))
})
.collect();
let description_json = match &config.description {
Some(d) => format!(r#","description":"{}""#, json_escape(d)),
None => String::new(),
};
format!(
r#"{{"openapi":"3.1.0","info":{{"title":"{}","version":"{}"{}}},"paths":{{{}}}}}"#,
json_escape(&config.title),
json_escape(&config.version),
description_json,
paths_json.join(",")
)
}
fn build_operation_json(method: &str, pattern: &str, operation: Option<&Operation>) -> String {
let default = Operation::new(format!("{method} {pattern}"));
let operation = operation.unwrap_or(&default);
let mut fields: Vec<String> = Vec::new();
let summary = if operation.summary.is_empty() { format!("{method} {pattern}") } else { operation.summary.clone() };
fields.push(format!(r#""summary":"{}""#, json_escape(&summary)));
if let Some(description) = &operation.description {
fields.push(format!(r#""description":"{}""#, json_escape(description)));
}
if let Some(id) = &operation.operation_id {
fields.push(format!(r#""operationId":"{}""#, json_escape(id)));
}
if !operation.tags.is_empty() {
let tags: Vec<String> = operation.tags.iter().map(|t| format!(r#""{}""#, json_escape(t))).collect();
fields.push(format!(r#""tags":[{}]"#, tags.join(",")));
}
if operation.deprecated {
fields.push(r#""deprecated":true"#.to_string());
}
let mut parameters: Vec<String> = path_param_names(pattern)
.iter()
.map(|name| {
let (schema, description) = operation
.path_params
.iter()
.find(|(n, _, _)| n == name)
.map(|(_, schema, description)| (schema.as_str(), description.as_str()))
.unwrap_or((r#"{"type":"string"}"#, ""));
let description = if description.is_empty() { String::new() } else { format!(r#","description":"{}""#, json_escape(description)) };
format!(r#"{{"name":"{}","in":"path","required":true{description},"schema":{schema}}}"#, json_escape(name))
})
.collect();
parameters.extend(operation.query.iter().map(|(name, schema, required)| {
format!(r#"{{"name":"{}","in":"query","required":{required},"schema":{schema}}}"#, json_escape(name))
}));
if !parameters.is_empty() {
fields.push(format!(r#""parameters":[{}]"#, parameters.join(",")));
}
if let Some((content_type, schema, required)) = &operation.body {
fields.push(format!(
r#""requestBody":{{"required":{required},"content":{{"{}":{{"schema":{schema}}}}}}}"#,
json_escape(content_type)
));
}
let responses: Vec<String> = if operation.responses.is_empty() {
vec![r#""200":{"description":"OK"}"#.to_string()]
} else {
operation
.responses
.iter()
.map(|(status, description, content)| {
let content = match content {
Some((content_type, schema)) => format!(r#","content":{{"{}":{{"schema":{schema}}}}}"#, json_escape(content_type)),
None => String::new(),
};
format!(r#""{status}":{{"description":"{}"{content}}}"#, json_escape(description))
})
.collect()
};
fields.push(format!(r#""responses":{{{}}}"#, responses.join(",")));
format!(r#""{}":{{{}}}"#, method.to_lowercase(), fields.join(","))
}
fn to_openapi_path(pattern: &str) -> String {
pattern
.split('/')
.map(|seg| {
match param_name(seg) {
Some(name) => format!("{{{}}}", name),
None => seg.replace("{{", "{").replace("}}", "}"),
}
})
.collect::<Vec<_>>()
.join("/")
}
fn param_name(segment: &str) -> Option<&str> {
segment
.strip_prefix(':')
.or_else(|| segment.strip_prefix('*'))
.or_else(|| crate::router::matcher::braced(segment).map(|inner| inner.trim_start_matches('*')))
}
fn path_param_names(pattern: &str) -> Vec<String> {
pattern
.split('/')
.filter_map(param_name)
.map(|s| s.to_string())
.collect()
}
fn json_escape(s: &str) -> String {
crate::mcp::json_escape(s)
}
pub fn swagger_ui_html(spec_url: &str) -> String {
format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.onload = function() {{
window.ui = SwaggerUIBundle({{
url: '{}',
dom_id: '#swagger-ui',
}});
}};
</script>
</body>
</html>"#,
spec_url
)
}