1#[cfg(test)]
45mod tests;
46
47use crate::router::RouteInfo;
48use crate::schema::JsonSchema;
49
50#[derive(Clone, Debug)]
52pub struct OpenApiConfig {
53 pub title: String,
54 pub version: String,
55 pub description: Option<String>,
56 pub operations: Vec<(String, String, Operation)>,
58}
59
60impl OpenApiConfig {
61 pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
63 OpenApiConfig { title: title.into(), version: version.into(), description: None, operations: Vec::new() }
64 }
65
66 pub fn description(mut self, description: impl Into<String>) -> Self {
68 self.description = Some(description.into());
69 self
70 }
71
72 pub fn operation(mut self, method: &str, pattern: &str, operation: Operation) -> Self {
76 let method = method.to_ascii_uppercase();
77 self.operations.retain(|(m, p, _)| !(*m == method && to_openapi_path(p) == to_openapi_path(pattern)));
78 self.operations.push((method, pattern.to_string(), operation));
79 self
80 }
81}
82
83#[derive(Clone, Debug, Default)]
86pub struct Operation {
87 summary: String,
88 description: Option<String>,
89 operation_id: Option<String>,
90 tags: Vec<String>,
91 deprecated: bool,
92 path_params: Vec<(String, String, String)>,
94 query: Vec<(String, String, bool)>,
96 body: Option<(String, String, bool)>,
98 responses: Vec<(u16, String, Option<(String, String)>)>,
100}
101
102impl Operation {
103 pub fn new(summary: impl Into<String>) -> Self {
105 Operation { summary: summary.into(), ..Operation::default() }
106 }
107
108 pub fn description(mut self, description: impl Into<String>) -> Self {
110 self.description = Some(description.into());
111 self
112 }
113
114 pub fn operation_id(mut self, id: impl Into<String>) -> Self {
116 self.operation_id = Some(id.into());
117 self
118 }
119
120 pub fn tag(mut self, tag: impl Into<String>) -> Self {
122 self.tags.push(tag.into());
123 self
124 }
125
126 pub fn deprecated(mut self) -> Self {
128 self.deprecated = true;
129 self
130 }
131
132 pub fn path_param<T: JsonSchema + ?Sized>(self, name: &str) -> Self {
134 self.path_param_schema(name, &T::json_schema(), "")
135 }
136
137 pub fn path_param_schema(mut self, name: &str, schema: &str, description: &str) -> Self {
139 self.path_params.retain(|(n, _, _)| n != name);
140 self.path_params.push((name.to_string(), schema.to_string(), description.to_string()));
141 self
142 }
143
144 pub fn query<T: JsonSchema + ?Sized>(mut self) -> Self {
147 self.query.extend(object_properties(&T::json_schema()));
148 self
149 }
150
151 pub fn query_param(mut self, name: &str, schema: &str, required: bool) -> Self {
153 self.query.push((name.to_string(), schema.to_string(), required));
154 self
155 }
156
157 pub fn body<T: JsonSchema + ?Sized>(self) -> Self {
159 self.body_schema("application/json", &T::json_schema(), true)
160 }
161
162 pub fn body_schema(mut self, content_type: &str, schema: &str, required: bool) -> Self {
164 self.body = Some((content_type.to_string(), schema.to_string(), required));
165 self
166 }
167
168 pub fn response<T: JsonSchema + ?Sized>(self, status: u16, description: &str) -> Self {
170 self.response_schema(status, description, "application/json", &T::json_schema())
171 }
172
173 pub fn response_schema(mut self, status: u16, description: &str, content_type: &str, schema: &str) -> Self {
175 self.responses.retain(|(s, _, _)| *s != status);
176 self.responses.push((status, description.to_string(), Some((content_type.to_string(), schema.to_string()))));
177 self
178 }
179
180 pub fn status(mut self, status: u16, description: &str) -> Self {
182 self.responses.retain(|(s, _, _)| *s != status);
183 self.responses.push((status, description.to_string(), None));
184 self
185 }
186}
187
188fn object_properties(schema: &str) -> Vec<(String, String, bool)> {
190 use crate::mcp::json_rpc::object_members;
191 let Some(members) = object_members(schema) else { return Vec::new() };
192 let required: Vec<String> = members
193 .iter()
194 .find(|(k, _)| k == "required")
195 .map(|(_, v)| {
196 v.trim().trim_start_matches('[').trim_end_matches(']').split(',').map(|n| n.trim().trim_matches('"').to_string()).collect()
197 })
198 .unwrap_or_default();
199 let Some(properties) = members.iter().find(|(k, _)| k == "properties").and_then(|(_, v)| object_members(v)) else { return Vec::new() };
200 properties.into_iter().map(|(name, schema)| { let required = required.contains(&name); (name, schema, required) }).collect()
201}
202
203pub fn build_spec(config: &OpenApiConfig, routes: &[RouteInfo]) -> String {
211 let mut all: Vec<(String, String)> = routes.iter().map(|r| (r.method.to_ascii_uppercase(), r.pattern.clone())).collect();
213 for (method, pattern, _) in &config.operations {
214 if !all.iter().any(|(m, p)| m == method && to_openapi_path(p) == to_openapi_path(pattern)) {
215 all.push((method.clone(), pattern.clone()));
216 }
217 }
218 let mut paths: Vec<(String, Vec<(String, String)>)> = Vec::new();
219 for (method, pattern) in all {
220 let openapi_path = to_openapi_path(&pattern);
221 match paths.iter_mut().find(|(p, _)| *p == openapi_path) {
222 Some(entry) => entry.1.push((method, pattern)),
223 None => paths.push((openapi_path, vec![(method, pattern)])),
224 }
225 }
226
227 let paths_json: Vec<String> = paths
228 .iter()
229 .map(|(path, methods)| {
230 let operations: Vec<String> = methods
231 .iter()
232 .map(|(method, pattern)| {
233 let described = config.operations.iter().find(|(m, p, _)| m == method && to_openapi_path(p) == *path).map(|(_, _, o)| o);
234 build_operation_json(method, pattern, described)
235 })
236 .collect();
237 format!(r#""{}":{{{}}}"#, json_escape(path), operations.join(","))
238 })
239 .collect();
240
241 let description_json = match &config.description {
242 Some(d) => format!(r#","description":"{}""#, json_escape(d)),
243 None => String::new(),
244 };
245
246 format!(
247 r#"{{"openapi":"3.1.0","info":{{"title":"{}","version":"{}"{}}},"paths":{{{}}}}}"#,
248 json_escape(&config.title),
249 json_escape(&config.version),
250 description_json,
251 paths_json.join(",")
252 )
253}
254
255fn build_operation_json(method: &str, pattern: &str, operation: Option<&Operation>) -> String {
256 let default = Operation::new(format!("{method} {pattern}"));
257 let operation = operation.unwrap_or(&default);
258 let mut fields: Vec<String> = Vec::new();
259 let summary = if operation.summary.is_empty() { format!("{method} {pattern}") } else { operation.summary.clone() };
260 fields.push(format!(r#""summary":"{}""#, json_escape(&summary)));
261 if let Some(description) = &operation.description {
262 fields.push(format!(r#""description":"{}""#, json_escape(description)));
263 }
264 if let Some(id) = &operation.operation_id {
265 fields.push(format!(r#""operationId":"{}""#, json_escape(id)));
266 }
267 if !operation.tags.is_empty() {
268 let tags: Vec<String> = operation.tags.iter().map(|t| format!(r#""{}""#, json_escape(t))).collect();
269 fields.push(format!(r#""tags":[{}]"#, tags.join(",")));
270 }
271 if operation.deprecated {
272 fields.push(r#""deprecated":true"#.to_string());
273 }
274
275 let mut parameters: Vec<String> = path_param_names(pattern)
276 .iter()
277 .map(|name| {
278 let (schema, description) = operation
279 .path_params
280 .iter()
281 .find(|(n, _, _)| n == name)
282 .map(|(_, schema, description)| (schema.as_str(), description.as_str()))
283 .unwrap_or((r#"{"type":"string"}"#, ""));
284 let description = if description.is_empty() { String::new() } else { format!(r#","description":"{}""#, json_escape(description)) };
285 format!(r#"{{"name":"{}","in":"path","required":true{description},"schema":{schema}}}"#, json_escape(name))
286 })
287 .collect();
288 parameters.extend(operation.query.iter().map(|(name, schema, required)| {
289 format!(r#"{{"name":"{}","in":"query","required":{required},"schema":{schema}}}"#, json_escape(name))
290 }));
291 if !parameters.is_empty() {
292 fields.push(format!(r#""parameters":[{}]"#, parameters.join(",")));
293 }
294
295 if let Some((content_type, schema, required)) = &operation.body {
296 fields.push(format!(
297 r#""requestBody":{{"required":{required},"content":{{"{}":{{"schema":{schema}}}}}}}"#,
298 json_escape(content_type)
299 ));
300 }
301
302 let responses: Vec<String> = if operation.responses.is_empty() {
303 vec![r#""200":{"description":"OK"}"#.to_string()]
304 } else {
305 operation
306 .responses
307 .iter()
308 .map(|(status, description, content)| {
309 let content = match content {
310 Some((content_type, schema)) => format!(r#","content":{{"{}":{{"schema":{schema}}}}}"#, json_escape(content_type)),
311 None => String::new(),
312 };
313 format!(r#""{status}":{{"description":"{}"{content}}}"#, json_escape(description))
314 })
315 .collect()
316 };
317 fields.push(format!(r#""responses":{{{}}}"#, responses.join(",")));
318
319 format!(r#""{}":{{{}}}"#, method.to_lowercase(), fields.join(","))
320}
321
322fn to_openapi_path(pattern: &str) -> String {
325 pattern
326 .split('/')
327 .map(|seg| {
328 match param_name(seg) {
329 Some(name) => format!("{{{}}}", name),
330 None => seg.replace("{{", "{").replace("}}", "}"),
331 }
332 })
333 .collect::<Vec<_>>()
334 .join("/")
335}
336
337fn param_name(segment: &str) -> Option<&str> {
339 segment
340 .strip_prefix(':')
341 .or_else(|| segment.strip_prefix('*'))
342 .or_else(|| crate::router::matcher::braced(segment).map(|inner| inner.trim_start_matches('*')))
343}
344
345fn path_param_names(pattern: &str) -> Vec<String> {
346 pattern
347 .split('/')
348 .filter_map(param_name)
349 .map(|s| s.to_string())
350 .collect()
351}
352
353fn json_escape(s: &str) -> String {
354 crate::core::json::escape(s)
355}
356
357pub fn swagger_ui_html(spec_url: &str) -> String {
360 format!(
361 r#"<!DOCTYPE html>
362<html>
363<head>
364 <meta charset="utf-8">
365 <title>API Docs</title>
366 <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
367</head>
368<body>
369 <div id="swagger-ui"></div>
370 <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
371 <script>
372 window.onload = function() {{
373 window.ui = SwaggerUIBundle({{
374 url: '{}',
375 dom_id: '#swagger-ui',
376 }});
377 }};
378 </script>
379</body>
380</html>"#,
381 spec_url
382 )
383}