elif_openapi/
swagger.rs

1/*!
2Swagger UI integration for interactive API documentation.
3
4This module provides functionality to serve interactive Swagger UI documentation
5for OpenAPI specifications.
6*/
7
8use 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/// Application state for the Swagger UI server
24#[derive(Clone)]
25pub struct SwaggerState {
26    /// OpenAPI specification
27    pub spec: Arc<OpenApiSpec>,
28    /// Configuration
29    pub config: SwaggerConfig,
30}
31
32/// Swagger UI server for serving interactive API documentation
33pub struct SwaggerUi {
34    /// OpenAPI specification
35    spec: Arc<OpenApiSpec>,
36    /// Configuration
37    config: SwaggerConfig,
38}
39
40/// Configuration for Swagger UI
41#[derive(Debug, Clone)]
42pub struct SwaggerConfig {
43    /// Server host
44    pub host: String,
45    /// Server port
46    pub port: u16,
47    /// Page title
48    pub title: String,
49    /// Custom CSS
50    pub custom_css: Option<String>,
51    /// Custom JavaScript
52    pub custom_js: Option<String>,
53    /// OAuth configuration
54    pub oauth: Option<OAuthConfig>,
55}
56
57/// OAuth configuration for Swagger UI
58#[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    /// Create new Swagger UI server
69    pub fn new(spec: OpenApiSpec, config: SwaggerConfig) -> Self {
70        Self {
71            spec: Arc::new(spec),
72            config,
73        }
74    }
75
76    /// Get the specification
77    pub fn specification(&self) -> Option<&OpenApiSpec> {
78        Some(&self.spec)
79    }
80
81    /// Get the configuration  
82    pub fn config(&self) -> &SwaggerConfig {
83        &self.config
84    }
85
86    /// Start the Swagger UI server using axum
87    pub async fn serve(&self) -> OpenApiResult<()> {
88        let state = SwaggerState {
89            spec: Arc::clone(&self.spec),
90            config: self.config.clone(),
91        };
92
93        // Build the router
94        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        // Bind to the configured address
102        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        // Start the server
111        axum::serve(listener, app)
112            .await
113            .map_err(|e| OpenApiError::generic(format!("Server error: {}", e)))?;
114
115        Ok(())
116    }
117}
118
119// Axum handlers for Swagger UI routes
120
121/// Serve the main Swagger UI index page
122async fn serve_index(State(state): State<SwaggerState>) -> Html<String> {
123    let html = SwaggerUi::generate_index_html(&state.config);
124    Html(html)
125}
126
127/// Serve the OpenAPI specification JSON
128async fn serve_spec(
129    State(state): State<SwaggerState>,
130) -> Result<Json<OpenApiSpec>, (StatusCode, String)> {
131    Ok(Json((*state.spec).clone()))
132}
133
134/// Serve static assets (CSS, JS)
135async 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    /// Generate the main HTML page
170    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    /// Generate static Swagger UI HTML file
270    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    /// Create new configuration
393    pub fn new() -> Self {
394        Self::default()
395    }
396
397    /// Set server host and port
398    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    /// Set page title
405    pub fn with_title(mut self, title: &str) -> Self {
406        self.title = title.to_string();
407        self
408    }
409
410    /// Add custom CSS
411    pub fn with_custom_css(mut self, css: &str) -> Self {
412        self.custom_css = Some(css.to_string());
413        self
414    }
415
416    /// Add custom JavaScript
417    pub fn with_custom_js(mut self, js: &str) -> Self {
418        self.custom_js = Some(js.to_string());
419        self
420    }
421
422    /// Configure OAuth
423    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}