1use axum::{
2 http::header,
3 response::{Html, IntoResponse, Response},
4 routing::get,
5 Json, Router,
6};
7use nestforge_core::{OpenApiSchemaComponent, RouteDocumentation};
8use serde::Serialize;
9use serde_json::{json, Value};
10
11#[derive(Debug, Clone, Serialize)]
12pub struct OpenApiRoute {
13 pub method: String,
14 pub path: String,
15 pub summary: Option<String>,
16 pub description: Option<String>,
17 pub tags: Vec<String>,
18 pub requires_auth: bool,
19 pub required_roles: Vec<String>,
20 pub request_body: Option<Value>,
21 pub responses: Vec<OpenApiResponse>,
22}
23
24#[derive(Debug, Clone, Serialize)]
25pub struct OpenApiResponse {
26 pub status: u16,
27 pub description: String,
28 pub schema: Option<Value>,
29}
30
31#[derive(Debug, Clone, Serialize)]
32pub struct OpenApiDoc {
33 pub title: String,
34 pub version: String,
35 pub routes: Vec<OpenApiRoute>,
36 pub components: Vec<OpenApiSchemaComponent>,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum OpenApiUi {
41 Simple,
42 SwaggerUi,
43 Redoc,
44}
45
46#[derive(Debug, Clone)]
47pub struct OpenApiConfig {
48 pub json_path: String,
49 pub yaml_path: String,
50 pub docs_path: String,
51 pub swagger_ui_path: Option<String>,
52 pub redoc_path: Option<String>,
53 pub default_ui: OpenApiUi,
54}
55
56impl Default for OpenApiConfig {
57 fn default() -> Self {
58 Self {
59 json_path: "/openapi.json".to_string(),
60 yaml_path: "/openapi.yaml".to_string(),
61 docs_path: "/docs".to_string(),
62 swagger_ui_path: Some("/swagger-ui".to_string()),
63 redoc_path: Some("/redoc".to_string()),
64 default_ui: OpenApiUi::SwaggerUi,
65 }
66 }
67}
68
69impl OpenApiConfig {
70 pub fn new() -> Self {
71 Self::default()
72 }
73
74 pub fn with_json_path(mut self, path: impl Into<String>) -> Self {
75 self.json_path = normalize_path(path.into(), "/openapi.json");
76 self
77 }
78
79 pub fn with_yaml_path(mut self, path: impl Into<String>) -> Self {
80 self.yaml_path = normalize_path(path.into(), "/openapi.yaml");
81 self
82 }
83
84 pub fn with_docs_path(mut self, path: impl Into<String>) -> Self {
85 self.docs_path = normalize_path(path.into(), "/docs");
86 self
87 }
88
89 pub fn with_swagger_ui_path(mut self, path: impl Into<String>) -> Self {
90 self.swagger_ui_path = Some(normalize_path(path.into(), "/swagger-ui"));
91 self
92 }
93
94 pub fn without_swagger_ui(mut self) -> Self {
95 self.swagger_ui_path = None;
96 self
97 }
98
99 pub fn with_redoc_path(mut self, path: impl Into<String>) -> Self {
100 self.redoc_path = Some(normalize_path(path.into(), "/redoc"));
101 self
102 }
103
104 pub fn without_redoc(mut self) -> Self {
105 self.redoc_path = None;
106 self
107 }
108
109 pub fn with_default_ui(mut self, ui: OpenApiUi) -> Self {
110 self.default_ui = ui;
111 self
112 }
113}
114
115impl OpenApiDoc {
116 pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
117 Self {
118 title: title.into(),
119 version: version.into(),
120 routes: Vec::new(),
121 components: Vec::new(),
122 }
123 }
124
125 pub fn add_route(mut self, method: impl Into<String>, path: impl Into<String>) -> Self {
126 self.routes.push(OpenApiRoute {
127 method: method.into(),
128 path: path.into(),
129 summary: None,
130 description: None,
131 tags: Vec::new(),
132 requires_auth: false,
133 required_roles: Vec::new(),
134 responses: vec![OpenApiResponse {
135 status: 200,
136 description: "OK".to_string(),
137 schema: None,
138 }],
139 request_body: None,
140 });
141 self
142 }
143
144 pub fn from_routes(
145 title: impl Into<String>,
146 version: impl Into<String>,
147 routes: Vec<RouteDocumentation>,
148 ) -> Self {
149 Self {
150 title: title.into(),
151 version: version.into(),
152 routes: routes
153 .iter()
154 .map(|route| OpenApiRoute {
155 method: route.method.clone(),
156 path: route.path.clone(),
157 summary: route.summary.clone(),
158 description: route.description.clone(),
159 tags: route.tags.clone(),
160 requires_auth: route.requires_auth,
161 required_roles: route.required_roles.clone(),
162 request_body: route.request_body.clone(),
163 responses: route
164 .responses
165 .iter()
166 .map(|response| OpenApiResponse {
167 status: response.status,
168 description: response.description.clone(),
169 schema: response.schema.clone(),
170 })
171 .collect(),
172 })
173 .collect(),
174 components: collect_schema_components(&routes),
175 }
176 }
177
178 pub fn to_openapi_json(&self) -> Value {
179 let mut paths = serde_json::Map::new();
180 for route in &self.routes {
181 let method = route.method.to_lowercase();
182 let entry = paths.entry(route.path.clone()).or_insert_with(|| json!({}));
183 let obj = entry.as_object_mut().expect("path entry object");
184 let responses = route
185 .responses
186 .iter()
187 .map(|response| {
188 let mut body = json!({ "description": response.description });
189 if let Some(schema) = &response.schema {
190 body["content"] = json!({
191 "application/json": {
192 "schema": schema
193 }
194 });
195 }
196
197 (
198 response.status.to_string(),
199 body,
200 )
201 })
202 .collect::<serde_json::Map<String, Value>>();
203 let mut operation = json!({
204 "summary": route.summary,
205 "description": route.description,
206 "tags": route.tags,
207 "responses": responses,
208 "x-required-roles": route.required_roles,
209 "security": if route.requires_auth { json!([{"bearerAuth": []}]) } else { json!([]) }
210 });
211
212 if let Some(request_body) = &route.request_body {
213 operation["requestBody"] = json!({
214 "required": true,
215 "content": {
216 "application/json": {
217 "schema": request_body
218 }
219 }
220 });
221 }
222 obj.insert(
223 method,
224 operation,
225 );
226 }
227
228 let schemas = self
229 .components
230 .iter()
231 .map(|component| (component.name.clone(), component.schema.clone()))
232 .collect::<serde_json::Map<String, Value>>();
233
234 json!({
235 "openapi": "3.1.0",
236 "info": {
237 "title": self.title,
238 "version": self.version
239 },
240 "components": {
241 "securitySchemes": {
242 "bearerAuth": {
243 "type": "http",
244 "scheme": "bearer",
245 "bearerFormat": "JWT"
246 }
247 },
248 "schemas": schemas
249 },
250 "paths": paths
251 })
252 }
253
254 pub fn to_openapi_yaml(&self) -> String {
255 json_value_to_yaml(&self.to_openapi_json(), 0)
256 }
257}
258
259pub fn docs_router<S>(doc: OpenApiDoc) -> Router<S>
260where
261 S: Clone + Send + Sync + 'static,
262{
263 docs_router_with_config(doc, OpenApiConfig::default())
264}
265
266pub fn docs_router_with_config<S>(doc: OpenApiDoc, config: OpenApiConfig) -> Router<S>
267where
268 S: Clone + Send + Sync + 'static,
269{
270 let openapi_json = doc.to_openapi_json();
271 let openapi_yaml = doc.to_openapi_yaml();
272 let simple_docs = render_simple_docs(&doc, &config);
273 let swagger_docs = render_swagger_ui(&doc.title, &config.json_path);
274 let redoc_docs = render_redoc_ui(&doc.title, &config.json_path);
275
276 let primary_docs = match config.default_ui {
277 OpenApiUi::Simple => simple_docs.clone(),
278 OpenApiUi::SwaggerUi => swagger_docs.clone(),
279 OpenApiUi::Redoc => redoc_docs.clone(),
280 };
281
282 let mut router = Router::<S>::new()
283 .route(
284 &config.json_path,
285 get({
286 let payload = openapi_json.clone();
287 move || async move { Json(payload.clone()) }
288 }),
289 )
290 .route(
291 &config.yaml_path,
292 get({
293 let payload = openapi_yaml.clone();
294 move || async move { yaml_response(payload.clone()) }
295 }),
296 )
297 .route(&config.docs_path, get(move || async move { Html(primary_docs.clone()) }));
298
299 if let Some(path) = &config.swagger_ui_path {
300 router = router.route(
301 path,
302 get({
303 let html = swagger_docs.clone();
304 move || async move { Html(html.clone()) }
305 }),
306 );
307 }
308
309 if let Some(path) = &config.redoc_path {
310 router = router.route(
311 path,
312 get({
313 let html = redoc_docs.clone();
314 move || async move { Html(html.clone()) }
315 }),
316 );
317 }
318
319 router
320}
321
322fn render_simple_docs(doc: &OpenApiDoc, config: &OpenApiConfig) -> String {
323 let routes_html = doc
324 .routes
325 .iter()
326 .map(|route| {
327 let summary = route
328 .summary
329 .clone()
330 .unwrap_or_else(|| "No summary".to_string());
331 format!(
332 "<li><strong>{}</strong> <code>{}</code> - {}</li>",
333 route.method, route.path, summary
334 )
335 })
336 .collect::<Vec<_>>()
337 .join("");
338
339 let swagger_link = config
340 .swagger_ui_path
341 .as_ref()
342 .map(|path| format!(r#"<li><a href="{path}">Swagger UI</a></li>"#))
343 .unwrap_or_default();
344 let redoc_link = config
345 .redoc_path
346 .as_ref()
347 .map(|path| format!(r#"<li><a href="{path}">Redoc</a></li>"#))
348 .unwrap_or_default();
349
350 format!(
351 r#"<!doctype html>
352<html>
353<head><meta charset="utf-8"><title>{title}</title></head>
354<body>
355 <h1>{title}</h1>
356 <p>OpenAPI JSON is available at <code>{json_path}</code>.</p>
357 <p>OpenAPI YAML is available at <code>{yaml_path}</code>.</p>
358 <ul>
359 {swagger_link}
360 {redoc_link}
361 </ul>
362 <ul>{routes_html}</ul>
363</body>
364</html>"#,
365 title = doc.title,
366 json_path = config.json_path,
367 yaml_path = config.yaml_path,
368 )
369}
370
371fn collect_schema_components(routes: &[RouteDocumentation]) -> Vec<OpenApiSchemaComponent> {
372 let mut components = Vec::new();
373
374 for route in routes {
375 for component in &route.schema_components {
376 if !components
377 .iter()
378 .any(|existing: &OpenApiSchemaComponent| existing.name == component.name)
379 {
380 components.push(component.clone());
381 }
382 }
383 }
384
385 components
386}
387
388fn render_swagger_ui(title: &str, json_path: &str) -> String {
389 format!(
390 r##"<!doctype html>
391<html>
392<head>
393 <meta charset="utf-8">
394 <title>{title} - Swagger UI</title>
395 <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
396</head>
397<body>
398 <div id="swagger-ui"></div>
399 <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
400 <script>
401 window.ui = SwaggerUIBundle({{
402 url: "{json_path}",
403 dom_id: "#swagger-ui",
404 deepLinking: true,
405 presets: [SwaggerUIBundle.presets.apis],
406 }});
407 </script>
408</body>
409</html>"##
410 )
411}
412
413fn render_redoc_ui(title: &str, json_path: &str) -> String {
414 format!(
415 r##"<!doctype html>
416<html>
417<head>
418 <meta charset="utf-8">
419 <title>{title} - Redoc</title>
420 <script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
421</head>
422<body>
423 <redoc spec-url="{json_path}"></redoc>
424</body>
425</html>"##
426 )
427}
428
429fn yaml_response(payload: String) -> Response {
430 (
431 [(header::CONTENT_TYPE, "application/yaml; charset=utf-8")],
432 payload,
433 )
434 .into_response()
435}
436
437fn normalize_path(path: String, default_path: &str) -> String {
438 let trimmed = path.trim();
439 if trimmed.is_empty() || trimmed == "/" {
440 return default_path.to_string();
441 }
442
443 if trimmed.starts_with('/') {
444 trimmed.to_string()
445 } else {
446 format!("/{trimmed}")
447 }
448}
449
450fn json_value_to_yaml(value: &Value, indent: usize) -> String {
451 match value {
452 Value::Null => "null".to_string(),
453 Value::Bool(boolean) => boolean.to_string(),
454 Value::Number(number) => number.to_string(),
455 Value::String(string) => format!("\"{}\"", escape_yaml_string(string)),
456 Value::Array(items) => {
457 if items.is_empty() {
458 return "[]".to_string();
459 }
460
461 let indent_str = " ".repeat(indent);
462 items
463 .iter()
464 .map(|item| match item {
465 Value::Object(_) | Value::Array(_) => format!(
466 "{indent_str}-\n{}",
467 indent_multiline(&json_value_to_yaml(item, indent + 2), indent + 2)
468 ),
469 _ => format!("{indent_str}- {}", json_value_to_yaml(item, indent + 2)),
470 })
471 .collect::<Vec<_>>()
472 .join("\n")
473 }
474 Value::Object(map) => {
475 if map.is_empty() {
476 return "{}".to_string();
477 }
478
479 let indent_str = " ".repeat(indent);
480 map.iter()
481 .map(|(key, value)| match value {
482 Value::Object(_) | Value::Array(_) => format!(
483 "{indent_str}{key}:\n{}",
484 indent_multiline(&json_value_to_yaml(value, indent + 2), indent + 2)
485 ),
486 _ => format!(
487 "{indent_str}{key}: {}",
488 json_value_to_yaml(value, indent + 2)
489 ),
490 })
491 .collect::<Vec<_>>()
492 .join("\n")
493 }
494 }
495}
496
497fn indent_multiline(value: &str, indent: usize) -> String {
498 let indent_str = " ".repeat(indent);
499 value
500 .lines()
501 .map(|line| format!("{indent_str}{line}"))
502 .collect::<Vec<_>>()
503 .join("\n")
504}
505
506fn escape_yaml_string(value: &str) -> String {
507 value.replace('\\', "\\\\").replace('"', "\\\"")
508}
509
510#[cfg(test)]
511mod tests {
512 use tower::util::ServiceExt;
513
514 use super::{docs_router_with_config, OpenApiConfig, OpenApiDoc, OpenApiUi};
515
516 #[test]
517 fn openapi_doc_exports_yaml() {
518 let yaml = OpenApiDoc::new("Test API", "1.0.0")
519 .add_route("GET", "/users")
520 .to_openapi_yaml();
521
522 assert!(yaml.contains("openapi: \"3.1.0\""));
523 assert!(yaml.contains("title: \"Test API\""));
524 assert!(yaml.contains("/users:"));
525 }
526
527 #[tokio::test]
528 async fn docs_router_serves_swagger_and_yaml_endpoints() {
529 let doc = OpenApiDoc::new("Test API", "1.0.0").add_route("GET", "/users");
530 let app: axum::Router = docs_router_with_config(
531 doc,
532 OpenApiConfig::new()
533 .with_docs_path("/api/docs")
534 .with_default_ui(OpenApiUi::SwaggerUi),
535 );
536
537 let docs_response = app
538 .clone()
539 .oneshot(
540 axum::http::Request::builder()
541 .uri("/api/docs")
542 .body(axum::body::Body::empty())
543 .expect("request should build"),
544 )
545 .await
546 .expect("docs request should succeed");
547
548 assert_eq!(docs_response.status(), axum::http::StatusCode::OK);
549
550 let yaml_response = app
551 .oneshot(
552 axum::http::Request::builder()
553 .uri("/openapi.yaml")
554 .body(axum::body::Body::empty())
555 .expect("request should build"),
556 )
557 .await
558 .expect("yaml request should succeed");
559
560 assert_eq!(yaml_response.status(), axum::http::StatusCode::OK);
561 }
562}