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