Skip to main content

nestforge_openapi/
lib.rs

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/**
12 * OpenApiRoute
13 *
14 * Represents a single route in the OpenAPI documentation.
15 * Contains all metadata needed to generate OpenAPI specification.
16 */
17#[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/**
31 * OpenApiResponse
32 *
33 * Represents a single response in the OpenAPI documentation.
34 */
35#[derive(Debug, Clone, Serialize)]
36pub struct OpenApiResponse {
37    pub status: u16,
38    pub description: String,
39    pub schema: Option<Value>,
40}
41
42/**
43 * OpenApiDoc
44 *
45 * The complete OpenAPI documentation for an application.
46 * Contains title, version, routes, and schema components.
47 */
48#[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/**
57 * OpenApiUi
58 *
59 * Available UI options for viewing OpenAPI documentation.
60 */
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum OpenApiUi {
63    /** A minimal plain text UI */
64    Simple,
65    /** Swagger UI - interactive API documentation */
66    SwaggerUi,
67    /** Redoc - three-panel API documentation */
68    Redoc,
69}
70
71/**
72 * OpenApiConfig
73 *
74 * Configuration options for OpenAPI documentation generation.
75 *
76 * # Defaults
77 * - JSON path: "/openapi.json"
78 * - YAML path: "/openapi.yaml"
79 * - Docs path: "/docs"
80 * - Swagger UI path: "/swagger-ui"
81 * - Redoc path: "/redoc"
82 * - Default UI: SwaggerUi
83 */
84#[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    /**
109     * Creates a new OpenApiConfig with default values.
110     */
111    pub fn new() -> Self {
112        Self::default()
113    }
114
115    /**
116     * Sets the path for the JSON OpenAPI specification.
117     */
118    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    /**
124     * Sets the path for the YAML OpenAPI specification.
125     */
126    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}