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}