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 (response.status.to_string(), body)
198 })
199 .collect::<serde_json::Map<String, Value>>();
200 let mut operation = json!({
201 "summary": route.summary,
202 "description": route.description,
203 "tags": route.tags,
204 "responses": responses,
205 "x-required-roles": route.required_roles,
206 "security": if route.requires_auth { json!([{"bearerAuth": []}]) } else { json!([]) }
207 });
208
209 if let Some(request_body) = &route.request_body {
210 operation["requestBody"] = json!({
211 "required": true,
212 "content": {
213 "application/json": {
214 "schema": request_body
215 }
216 }
217 });
218 }
219 obj.insert(method, operation);
220 }
221
222 let schemas = self
223 .components
224 .iter()
225 .map(|component| (component.name.clone(), component.schema.clone()))
226 .collect::<serde_json::Map<String, Value>>();
227
228 json!({
229 "openapi": "3.1.0",
230 "info": {
231 "title": self.title,
232 "version": self.version
233 },
234 "components": {
235 "securitySchemes": {
236 "bearerAuth": {
237 "type": "http",
238 "scheme": "bearer",
239 "bearerFormat": "JWT"
240 }
241 },
242 "schemas": schemas
243 },
244 "paths": paths
245 })
246 }
247
248 pub fn to_openapi_yaml(&self) -> String {
249 json_value_to_yaml(&self.to_openapi_json(), 0)
250 }
251}
252
253pub fn docs_router<S>(doc: OpenApiDoc) -> Router<S>
254where
255 S: Clone + Send + Sync + 'static,
256{
257 docs_router_with_config(doc, OpenApiConfig::default())
258}
259
260pub fn docs_router_with_config<S>(doc: OpenApiDoc, config: OpenApiConfig) -> Router<S>
261where
262 S: Clone + Send + Sync + 'static,
263{
264 let openapi_json = doc.to_openapi_json();
265 let openapi_yaml = doc.to_openapi_yaml();
266 let simple_docs = render_simple_docs(&doc, &config);
267 let primary_docs = match config.default_ui {
268 OpenApiUi::Simple => simple_docs.clone(),
269 OpenApiUi::SwaggerUi => render_swagger_ui(
270 &doc.title,
271 &relative_browser_path(&config.docs_path, &config.json_path),
272 ),
273 OpenApiUi::Redoc => render_redoc_ui(
274 &doc.title,
275 &relative_browser_path(&config.docs_path, &config.json_path),
276 ),
277 };
278
279 let mut router = Router::<S>::new()
280 .route(
281 &config.json_path,
282 get({
283 let payload = openapi_json.clone();
284 move || async move { Json(payload.clone()) }
285 }),
286 )
287 .route(
288 &config.yaml_path,
289 get({
290 let payload = openapi_yaml.clone();
291 move || async move { yaml_response(payload.clone()) }
292 }),
293 )
294 .route(
295 &config.docs_path,
296 get(move || async move { Html(primary_docs.clone()) }),
297 );
298
299 if let Some(path) = &config.swagger_ui_path {
300 let swagger_docs =
301 render_swagger_ui(&doc.title, &relative_browser_path(path, &config.json_path));
302 router = router.route(
303 path,
304 get({
305 let html = swagger_docs.clone();
306 move || async move { Html(html.clone()) }
307 }),
308 );
309 }
310
311 if let Some(path) = &config.redoc_path {
312 let redoc_docs =
313 render_redoc_ui(&doc.title, &relative_browser_path(path, &config.json_path));
314 router = router.route(
315 path,
316 get({
317 let html = redoc_docs.clone();
318 move || async move { Html(html.clone()) }
319 }),
320 );
321 }
322
323 router
324}
325
326fn render_simple_docs(doc: &OpenApiDoc, config: &OpenApiConfig) -> String {
327 let routes_html = doc
328 .routes
329 .iter()
330 .map(|route| {
331 let summary = route
332 .summary
333 .clone()
334 .unwrap_or_else(|| "No summary".to_string());
335 format!(
336 "<li><strong>{}</strong> <code>{}</code> - {}</li>",
337 route.method, route.path, summary
338 )
339 })
340 .collect::<Vec<_>>()
341 .join("");
342
343 let swagger_link = config
344 .swagger_ui_path
345 .as_ref()
346 .map(|path| {
347 format!(
348 r#"<li><a href="{}">Swagger UI</a></li>"#,
349 relative_browser_path(&config.docs_path, path)
350 )
351 })
352 .unwrap_or_default();
353 let redoc_link = config
354 .redoc_path
355 .as_ref()
356 .map(|path| {
357 format!(
358 r#"<li><a href="{}">Redoc</a></li>"#,
359 relative_browser_path(&config.docs_path, path)
360 )
361 })
362 .unwrap_or_default();
363
364 format!(
365 r#"<!doctype html>
366<html>
367<head><meta charset="utf-8"><title>{title}</title></head>
368<body>
369 <h1>{title}</h1>
370 <p>OpenAPI JSON is available at <code>{json_path}</code>.</p>
371 <p>OpenAPI YAML is available at <code>{yaml_path}</code>.</p>
372 <ul>
373 {swagger_link}
374 {redoc_link}
375 </ul>
376 <ul>{routes_html}</ul>
377</body>
378</html>"#,
379 title = doc.title,
380 json_path = relative_browser_path(&config.docs_path, &config.json_path),
381 yaml_path = relative_browser_path(&config.docs_path, &config.yaml_path),
382 )
383}
384
385fn collect_schema_components(routes: &[RouteDocumentation]) -> Vec<OpenApiSchemaComponent> {
386 let mut components = Vec::new();
387
388 for route in routes {
389 for component in &route.schema_components {
390 if !components
391 .iter()
392 .any(|existing: &OpenApiSchemaComponent| existing.name == component.name)
393 {
394 components.push(component.clone());
395 }
396 }
397 }
398
399 components
400}
401
402fn render_swagger_ui(title: &str, json_path: &str) -> String {
403 format!(
404 r##"<!doctype html>
405<html>
406<head>
407 <meta charset="utf-8">
408 <title>{title} - Swagger UI</title>
409 <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
410</head>
411<body>
412 <div id="swagger-ui"></div>
413 <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
414 <script>
415 window.ui = SwaggerUIBundle({{
416 url: "{json_path}",
417 dom_id: "#swagger-ui",
418 deepLinking: true,
419 presets: [SwaggerUIBundle.presets.apis],
420 }});
421 </script>
422</body>
423</html>"##
424 )
425}
426
427fn render_redoc_ui(title: &str, json_path: &str) -> String {
428 format!(
429 r##"<!doctype html>
430<html>
431<head>
432 <meta charset="utf-8">
433 <title>{title} - Redoc</title>
434 <script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
435</head>
436<body>
437 <redoc spec-url="{json_path}"></redoc>
438</body>
439</html>"##
440 )
441}
442
443fn yaml_response(payload: String) -> Response {
444 (
445 [(header::CONTENT_TYPE, "application/yaml; charset=utf-8")],
446 payload,
447 )
448 .into_response()
449}
450
451fn normalize_path(path: String, default_path: &str) -> String {
452 let trimmed = path.trim();
453 if trimmed.is_empty() || trimmed == "/" {
454 return default_path.to_string();
455 }
456
457 if trimmed.starts_with('/') {
458 trimmed.to_string()
459 } else {
460 format!("/{trimmed}")
461 }
462}
463
464fn relative_browser_path(from_path: &str, to_path: &str) -> String {
465 let from_segments = path_segments(from_path);
466 let to_segments = path_segments(to_path);
467
468 let from_dir_len = from_segments.len().saturating_sub(1);
469 let common_len = from_segments[..from_dir_len]
470 .iter()
471 .zip(to_segments.iter())
472 .take_while(|(left, right)| left == right)
473 .count();
474
475 let mut parts = Vec::new();
476 for _ in common_len..from_dir_len {
477 parts.push("..".to_string());
478 }
479 for segment in &to_segments[common_len..] {
480 parts.push(segment.clone());
481 }
482
483 if parts.is_empty() {
484 ".".to_string()
485 } else {
486 parts.join("/")
487 }
488}
489
490fn path_segments(path: &str) -> Vec<String> {
491 path.trim_matches('/')
492 .split('/')
493 .filter(|segment| !segment.is_empty())
494 .map(|segment| segment.to_string())
495 .collect()
496}
497
498fn json_value_to_yaml(value: &Value, indent: usize) -> String {
499 match value {
500 Value::Null => "null".to_string(),
501 Value::Bool(boolean) => boolean.to_string(),
502 Value::Number(number) => number.to_string(),
503 Value::String(string) => format!("\"{}\"", escape_yaml_string(string)),
504 Value::Array(items) => {
505 if items.is_empty() {
506 return "[]".to_string();
507 }
508
509 let indent_str = " ".repeat(indent);
510 items
511 .iter()
512 .map(|item| match item {
513 Value::Object(_) | Value::Array(_) => format!(
514 "{indent_str}-\n{}",
515 indent_multiline(&json_value_to_yaml(item, indent + 2), indent + 2)
516 ),
517 _ => format!("{indent_str}- {}", json_value_to_yaml(item, indent + 2)),
518 })
519 .collect::<Vec<_>>()
520 .join("\n")
521 }
522 Value::Object(map) => {
523 if map.is_empty() {
524 return "{}".to_string();
525 }
526
527 let indent_str = " ".repeat(indent);
528 map.iter()
529 .map(|(key, value)| match value {
530 Value::Object(_) | Value::Array(_) => format!(
531 "{indent_str}{key}:\n{}",
532 indent_multiline(&json_value_to_yaml(value, indent + 2), indent + 2)
533 ),
534 _ => format!(
535 "{indent_str}{key}: {}",
536 json_value_to_yaml(value, indent + 2)
537 ),
538 })
539 .collect::<Vec<_>>()
540 .join("\n")
541 }
542 }
543}
544
545fn indent_multiline(value: &str, indent: usize) -> String {
546 let indent_str = " ".repeat(indent);
547 value
548 .lines()
549 .map(|line| format!("{indent_str}{line}"))
550 .collect::<Vec<_>>()
551 .join("\n")
552}
553
554fn escape_yaml_string(value: &str) -> String {
555 value.replace('\\', "\\\\").replace('"', "\\\"")
556}
557
558#[cfg(test)]
559mod tests {
560 use tower::util::ServiceExt;
561
562 use super::{docs_router_with_config, OpenApiConfig, OpenApiDoc, OpenApiUi};
563
564 #[test]
565 fn openapi_doc_exports_yaml() {
566 let yaml = OpenApiDoc::new("Test API", "1.0.0")
567 .add_route("GET", "/users")
568 .to_openapi_yaml();
569
570 assert!(yaml.contains("openapi: \"3.1.0\""));
571 assert!(yaml.contains("title: \"Test API\""));
572 assert!(yaml.contains("/users:"));
573 }
574
575 #[tokio::test]
576 async fn docs_router_serves_swagger_and_yaml_endpoints() {
577 let doc = OpenApiDoc::new("Test API", "1.0.0").add_route("GET", "/users");
578 let app: axum::Router = docs_router_with_config(
579 doc,
580 OpenApiConfig::new()
581 .with_docs_path("/api/docs")
582 .with_default_ui(OpenApiUi::SwaggerUi),
583 );
584
585 let docs_response = app
586 .clone()
587 .oneshot(
588 axum::http::Request::builder()
589 .uri("/api/docs")
590 .body(axum::body::Body::empty())
591 .expect("request should build"),
592 )
593 .await
594 .expect("docs request should succeed");
595
596 assert_eq!(docs_response.status(), axum::http::StatusCode::OK);
597
598 let yaml_response = app
599 .oneshot(
600 axum::http::Request::builder()
601 .uri("/openapi.yaml")
602 .body(axum::body::Body::empty())
603 .expect("request should build"),
604 )
605 .await
606 .expect("yaml request should succeed");
607
608 assert_eq!(yaml_response.status(), axum::http::StatusCode::OK);
609 }
610
611 #[test]
612 fn relative_browser_path_handles_prefixed_docs_routes() {
613 assert_eq!(
614 super::relative_browser_path("/docs", "/openapi.json"),
615 "openapi.json"
616 );
617 assert_eq!(
618 super::relative_browser_path("/api/docs", "/openapi.json"),
619 "../openapi.json"
620 );
621 assert_eq!(
622 super::relative_browser_path("/api/v1/docs", "/api/v1/openapi.json"),
623 "openapi.json"
624 );
625 }
626}