Skip to main content

nestforge_openapi/
lib.rs

1use axum::{response::Html, routing::get, Json, Router};
2use nestforge_core::RouteDocumentation;
3use serde::Serialize;
4use serde_json::{json, Value};
5
6#[derive(Debug, Clone, Serialize)]
7pub struct OpenApiRoute {
8    pub method: String,
9    pub path: String,
10    pub summary: Option<String>,
11    pub description: Option<String>,
12    pub tags: Vec<String>,
13    pub requires_auth: bool,
14    pub required_roles: Vec<String>,
15    pub responses: Vec<OpenApiResponse>,
16}
17
18#[derive(Debug, Clone, Serialize)]
19pub struct OpenApiResponse {
20    pub status: u16,
21    pub description: String,
22}
23
24#[derive(Debug, Clone, Serialize)]
25pub struct OpenApiDoc {
26    pub title: String,
27    pub version: String,
28    pub routes: Vec<OpenApiRoute>,
29}
30
31impl OpenApiDoc {
32    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
33        Self {
34            title: title.into(),
35            version: version.into(),
36            routes: Vec::new(),
37        }
38    }
39
40    pub fn add_route(mut self, method: impl Into<String>, path: impl Into<String>) -> Self {
41        self.routes.push(OpenApiRoute {
42            method: method.into(),
43            path: path.into(),
44            summary: None,
45            description: None,
46            tags: Vec::new(),
47            requires_auth: false,
48            required_roles: Vec::new(),
49            responses: vec![OpenApiResponse {
50                status: 200,
51                description: "OK".to_string(),
52            }],
53        });
54        self
55    }
56
57    pub fn from_routes(
58        title: impl Into<String>,
59        version: impl Into<String>,
60        routes: Vec<RouteDocumentation>,
61    ) -> Self {
62        Self {
63            title: title.into(),
64            version: version.into(),
65            routes: routes
66                .into_iter()
67                .map(|route| OpenApiRoute {
68                    method: route.method,
69                    path: route.path,
70                    summary: route.summary,
71                    description: route.description,
72                    tags: route.tags,
73                    requires_auth: route.requires_auth,
74                    required_roles: route.required_roles,
75                    responses: route
76                        .responses
77                        .into_iter()
78                        .map(|response| OpenApiResponse {
79                            status: response.status,
80                            description: response.description,
81                        })
82                        .collect(),
83                })
84                .collect(),
85        }
86    }
87
88    pub fn to_openapi_json(&self) -> Value {
89        let mut paths = serde_json::Map::new();
90        for route in &self.routes {
91            let method = route.method.to_lowercase();
92            let entry = paths.entry(route.path.clone()).or_insert_with(|| json!({}));
93            let obj = entry.as_object_mut().expect("path entry object");
94            let responses = route
95                .responses
96                .iter()
97                .map(|response| {
98                    (
99                        response.status.to_string(),
100                        json!({ "description": response.description }),
101                    )
102                })
103                .collect::<serde_json::Map<String, Value>>();
104            obj.insert(
105                method,
106                json!({
107                    "summary": route.summary,
108                    "description": route.description,
109                    "tags": route.tags,
110                    "responses": responses,
111                    "x-required-roles": route.required_roles,
112                    "security": if route.requires_auth { json!([{"bearerAuth": []}]) } else { json!([]) }
113                }),
114            );
115        }
116
117        json!({
118            "openapi": "3.1.0",
119            "info": {
120                "title": self.title,
121                "version": self.version
122            },
123            "components": {
124                "securitySchemes": {
125                    "bearerAuth": {
126                        "type": "http",
127                        "scheme": "bearer",
128                        "bearerFormat": "JWT"
129                    }
130                }
131            },
132            "paths": paths
133        })
134    }
135}
136
137pub fn docs_router<S>(doc: OpenApiDoc) -> Router<S>
138where
139    S: Clone + Send + Sync + 'static,
140{
141    let openapi = doc.to_openapi_json();
142    let routes_html = doc
143        .routes
144        .iter()
145        .map(|route| {
146            let summary = route.summary.clone().unwrap_or_else(|| "No summary".to_string());
147            format!(
148                "<li><strong>{}</strong> <code>{}</code> - {}</li>",
149                route.method, route.path, summary
150            )
151        })
152        .collect::<Vec<_>>()
153        .join("");
154    let docs_html = format!(
155        r#"<!doctype html>
156<html>
157<head><meta charset="utf-8"><title>NestForge API Docs</title></head>
158<body>
159  <h1>NestForge API Docs</h1>
160  <p>OpenAPI JSON is available at <code>/openapi.json</code>.</p>
161  <ul>{routes_html}</ul>
162</body>
163</html>"#
164    );
165
166    Router::<S>::new()
167        .route(
168            "/openapi.json",
169            get({
170                let payload = openapi.clone();
171                move || async move { Json(payload.clone()) }
172            }),
173        )
174        .route("/docs", get(move || async move { Html(docs_html.clone()) }))
175}