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#[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}