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                    (
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}