1use crate::{
9 error::{OpenApiError, OpenApiResult},
10 specification::OpenApiSpec,
11};
12use axum::{
13 body::Body,
14 extract::{Path, State},
15 http::{header, StatusCode},
16 response::{Html, Json, Response},
17 routing::get,
18 Router,
19};
20use std::sync::Arc;
21use tower_http::cors::CorsLayer;
22
23#[derive(Clone)]
25pub struct SwaggerState {
26 pub spec: Arc<OpenApiSpec>,
28 pub config: SwaggerConfig,
30}
31
32pub struct SwaggerUi {
34 spec: Arc<OpenApiSpec>,
36 config: SwaggerConfig,
38}
39
40#[derive(Debug, Clone)]
42pub struct SwaggerConfig {
43 pub host: String,
45 pub port: u16,
47 pub title: String,
49 pub custom_css: Option<String>,
51 pub custom_js: Option<String>,
53 pub oauth: Option<OAuthConfig>,
55}
56
57#[derive(Debug, Clone)]
59pub struct OAuthConfig {
60 pub client_id: String,
61 pub client_secret: Option<String>,
62 pub realm: Option<String>,
63 pub app_name: String,
64 pub scopes: Vec<String>,
65}
66
67impl SwaggerUi {
68 pub fn new(spec: OpenApiSpec, config: SwaggerConfig) -> Self {
70 Self {
71 spec: Arc::new(spec),
72 config,
73 }
74 }
75
76 pub fn specification(&self) -> Option<&OpenApiSpec> {
78 Some(&self.spec)
79 }
80
81 pub fn config(&self) -> &SwaggerConfig {
83 &self.config
84 }
85
86 pub async fn serve(&self) -> OpenApiResult<()> {
88 let state = SwaggerState {
89 spec: Arc::clone(&self.spec),
90 config: self.config.clone(),
91 };
92
93 let app = Router::new()
95 .route("/", get(serve_index))
96 .route("/api-spec.json", get(serve_spec))
97 .route("/static/*path", get(serve_static))
98 .layer(CorsLayer::permissive())
99 .with_state(state);
100
101 let addr = format!("{}:{}", self.config.host, self.config.port);
103 let listener = tokio::net::TcpListener::bind(&addr)
104 .await
105 .map_err(|e| OpenApiError::generic(format!("Failed to bind to {}: {}", addr, e)))?;
106
107 println!("🚀 Swagger UI server running at http://{}", addr);
108 println!("📖 API documentation available at http://{}/", addr);
109
110 axum::serve(listener, app)
112 .await
113 .map_err(|e| OpenApiError::generic(format!("Server error: {}", e)))?;
114
115 Ok(())
116 }
117}
118
119async fn serve_index(State(state): State<SwaggerState>) -> Html<String> {
123 let html = SwaggerUi::generate_index_html(&state.config);
124 Html(html)
125}
126
127async fn serve_spec(
129 State(state): State<SwaggerState>,
130) -> Result<Json<OpenApiSpec>, (StatusCode, String)> {
131 Ok(Json((*state.spec).clone()))
132}
133
134async fn serve_static(
136 Path(path): Path<String>,
137) -> Result<Response<Body>, (StatusCode, &'static str)> {
138 let (content_type, body) = match path.as_str() {
139 "swagger-ui-bundle.js" => (
140 "application/javascript",
141 "// Swagger UI Bundle - placeholder for CDN content",
142 ),
143 "swagger-ui-standalone-preset.js" => (
144 "application/javascript",
145 "// Swagger UI Preset - placeholder for CDN content",
146 ),
147 "swagger-ui.css" => (
148 "text/css",
149 "/* Swagger UI CSS - placeholder for CDN content */",
150 ),
151 _ => return Err((StatusCode::NOT_FOUND, "Not Found")),
152 };
153
154 let response = Response::builder()
155 .status(StatusCode::OK)
156 .header(header::CONTENT_TYPE, content_type)
157 .body(Body::from(body))
158 .map_err(|_| {
159 (
160 StatusCode::INTERNAL_SERVER_ERROR,
161 "Failed to build response",
162 )
163 })?;
164
165 Ok(response)
166}
167
168impl SwaggerUi {
169 fn generate_index_html(config: &SwaggerConfig) -> String {
171 let oauth_config = if let Some(oauth) = &config.oauth {
172 format!(
173 r#"
174 ui.initOAuth({{
175 clientId: "{}",
176 realm: "{}",
177 appName: "{}",
178 scopes: [{}]
179 }});
180 "#,
181 oauth.client_id,
182 oauth.realm.as_deref().unwrap_or(""),
183 oauth.app_name,
184 oauth
185 .scopes
186 .iter()
187 .map(|s| format!(r#""{}""#, s))
188 .collect::<Vec<_>>()
189 .join(", ")
190 )
191 } else {
192 String::new()
193 };
194
195 let custom_css = config.custom_css.as_deref().unwrap_or("");
196 let custom_js = config.custom_js.as_deref().unwrap_or("");
197
198 format!(
199 r#"<!DOCTYPE html>
200<html lang="en">
201<head>
202 <meta charset="UTF-8">
203 <meta name="viewport" content="width=device-width, initial-scale=1.0">
204 <title>{}</title>
205 <link rel="stylesheet" type="text/css" href="/static/swagger-ui.css" />
206 <style>
207 html {{
208 box-sizing: border-box;
209 overflow: -moz-scrollbars-vertical;
210 overflow-y: scroll;
211 }}
212
213 *, *:before, *:after {{
214 box-sizing: inherit;
215 }}
216
217 body {{
218 margin:0;
219 background: #fafafa;
220 }}
221
222 {}
223 </style>
224</head>
225<body>
226 <div id="swagger-ui"></div>
227
228 <script src="/static/swagger-ui-bundle.js"></script>
229 <script src="/static/swagger-ui-standalone-preset.js"></script>
230 <script>
231 window.onload = function() {{
232 const ui = SwaggerUIBundle({{
233 url: '/api-spec.json',
234 dom_id: '#swagger-ui',
235 deepLinking: true,
236 presets: [
237 SwaggerUIBundle.presets.apis,
238 SwaggerUIStandalonePreset
239 ],
240 plugins: [
241 SwaggerUIBundle.plugins.DownloadUrl
242 ],
243 layout: "StandaloneLayout",
244 validatorUrl: null,
245 tryItOutEnabled: true,
246 filter: true,
247 supportedSubmitMethods: ['get', 'post', 'put', 'delete', 'patch'],
248 onComplete: function() {{
249 console.log("Swagger UI loaded successfully");
250 }},
251 onFailure: function(error) {{
252 console.error("Swagger UI failed to load:", error);
253 }}
254 }});
255
256 {}
257
258 window.ui = ui;
259 }};
260
261 {}
262 </script>
263</body>
264</html>"#,
265 config.title, custom_css, oauth_config, custom_js
266 )
267 }
268
269 pub fn generate_static_html(
271 spec: &OpenApiSpec,
272 config: &SwaggerConfig,
273 ) -> OpenApiResult<String> {
274 let spec_json = serde_json::to_string(spec)
275 .map_err(|e| OpenApiError::export_error(format!("Failed to serialize spec: {}", e)))?;
276
277 let oauth_config = if let Some(oauth) = &config.oauth {
278 format!(
279 r#"
280 ui.initOAuth({{
281 clientId: "{}",
282 realm: "{}",
283 appName: "{}",
284 scopes: [{}]
285 }});
286 "#,
287 oauth.client_id,
288 oauth.realm.as_deref().unwrap_or(""),
289 oauth.app_name,
290 oauth
291 .scopes
292 .iter()
293 .map(|s| format!(r#""{}""#, s))
294 .collect::<Vec<_>>()
295 .join(", ")
296 )
297 } else {
298 String::new()
299 };
300
301 let custom_css = config.custom_css.as_deref().unwrap_or("");
302 let custom_js = config.custom_js.as_deref().unwrap_or("");
303
304 Ok(format!(
305 r#"<!DOCTYPE html>
306<html lang="en">
307<head>
308 <meta charset="UTF-8">
309 <meta name="viewport" content="width=device-width, initial-scale=1.0">
310 <title>{}</title>
311 <link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui.css" />
312 <style>
313 html {{
314 box-sizing: border-box;
315 overflow: -moz-scrollbars-vertical;
316 overflow-y: scroll;
317 }}
318
319 *, *:before, *:after {{
320 box-sizing: inherit;
321 }}
322
323 body {{
324 margin:0;
325 background: #fafafa;
326 }}
327
328 {}
329 </style>
330</head>
331<body>
332 <div id="swagger-ui"></div>
333
334 <script src="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui-bundle.js"></script>
335 <script src="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui-standalone-preset.js"></script>
336 <script>
337 const spec = {};
338
339 window.onload = function() {{
340 const ui = SwaggerUIBundle({{
341 spec: spec,
342 dom_id: '#swagger-ui',
343 deepLinking: true,
344 presets: [
345 SwaggerUIBundle.presets.apis,
346 SwaggerUIStandalonePreset
347 ],
348 plugins: [
349 SwaggerUIBundle.plugins.DownloadUrl
350 ],
351 layout: "StandaloneLayout",
352 validatorUrl: null,
353 tryItOutEnabled: true,
354 filter: true,
355 supportedSubmitMethods: ['get', 'post', 'put', 'delete', 'patch'],
356 onComplete: function() {{
357 console.log("Swagger UI loaded successfully");
358 }},
359 onFailure: function(error) {{
360 console.error("Swagger UI failed to load:", error);
361 }}
362 }});
363
364 {}
365
366 window.ui = ui;
367 }};
368
369 {}
370 </script>
371</body>
372</html>"#,
373 config.title, custom_css, spec_json, oauth_config, custom_js
374 ))
375 }
376}
377
378impl Default for SwaggerConfig {
379 fn default() -> Self {
380 Self {
381 host: "127.0.0.1".to_string(),
382 port: 8080,
383 title: "API Documentation".to_string(),
384 custom_css: None,
385 custom_js: None,
386 oauth: None,
387 }
388 }
389}
390
391impl SwaggerConfig {
392 pub fn new() -> Self {
394 Self::default()
395 }
396
397 pub fn with_server(mut self, host: &str, port: u16) -> Self {
399 self.host = host.to_string();
400 self.port = port;
401 self
402 }
403
404 pub fn with_title(mut self, title: &str) -> Self {
406 self.title = title.to_string();
407 self
408 }
409
410 pub fn with_custom_css(mut self, css: &str) -> Self {
412 self.custom_css = Some(css.to_string());
413 self
414 }
415
416 pub fn with_custom_js(mut self, js: &str) -> Self {
418 self.custom_js = Some(js.to_string());
419 self
420 }
421
422 pub fn with_oauth(mut self, oauth: OAuthConfig) -> Self {
424 self.oauth = Some(oauth);
425 self
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use crate::specification::OpenApiSpec;
433
434 #[test]
435 fn test_swagger_config_creation() {
436 let config = SwaggerConfig::new()
437 .with_server("localhost", 3000)
438 .with_title("My API");
439
440 assert_eq!(config.host, "localhost");
441 assert_eq!(config.port, 3000);
442 assert_eq!(config.title, "My API");
443 }
444
445 #[test]
446 fn test_static_html_generation() {
447 let spec = OpenApiSpec::new("Test API", "1.0.0");
448 let config = SwaggerConfig::new().with_title("Test API Documentation");
449
450 let html = SwaggerUi::generate_static_html(&spec, &config).unwrap();
451
452 assert!(html.contains("Test API Documentation"));
453 assert!(html.contains("swagger-ui"));
454 assert!(html.contains("SwaggerUIBundle"));
455 }
456
457 #[test]
458 fn test_swagger_ui_creation() {
459 let spec = OpenApiSpec::new("Test API", "1.0.0");
460 let config = SwaggerConfig::default();
461
462 let swagger_ui = SwaggerUi::new(spec, config);
463 assert_eq!(swagger_ui.config.host, "127.0.0.1");
464 assert_eq!(swagger_ui.config.port, 8080);
465 }
466}