Skip to main content

armature_openapi/
swagger.rs

1//! Swagger UI integration
2
3use crate::spec::OpenApiSpec;
4use armature_core::{Error, HttpResponse};
5
6/// Swagger UI configuration
7#[derive(Debug, Clone)]
8pub struct SwaggerConfig {
9    /// Path where Swagger UI will be served (e.g., "/api-docs")
10    pub path: String,
11    /// Title for the Swagger UI page
12    pub title: String,
13    /// OpenAPI specification
14    pub spec: OpenApiSpec,
15}
16
17impl SwaggerConfig {
18    /// Create a new Swagger configuration
19    pub fn new(path: impl Into<String>, spec: OpenApiSpec) -> Self {
20        Self {
21            path: path.into(),
22            title: "API Documentation".to_string(),
23            spec,
24        }
25    }
26
27    /// Set the title
28    pub fn with_title(mut self, title: impl Into<String>) -> Self {
29        self.title = title.into();
30        self
31    }
32
33    /// Get the OpenAPI spec as JSON
34    pub fn spec_json(&self) -> Result<String, Error> {
35        serde_json::to_string_pretty(&self.spec)
36            .map_err(|e| Error::Internal(format!("Failed to serialize spec: {}", e)))
37    }
38
39    /// Get the OpenAPI spec as YAML
40    pub fn spec_yaml(&self) -> Result<String, Error> {
41        serde_yaml::to_string(&self.spec)
42            .map_err(|e| Error::Internal(format!("Failed to serialize spec: {}", e)))
43    }
44}
45
46/// Generate a Swagger UI HTML response.
47///
48/// The returned page loads the `swagger-ui-dist` JS/CSS bundle from the
49/// jsdelivr CDN (`cdn.jsdelivr.net`) rather than bundling it — it is not
50/// self-contained. It will fail to render fully offline or under a strict
51/// Content-Security-Policy that blocks that origin.
52pub fn swagger_ui_response(config: &SwaggerConfig) -> Result<HttpResponse, Error> {
53    let spec_json = config.spec_json()?;
54    // JSON is already valid JS object-literal syntax, so it can be injected
55    // as-is. The only hazard is a literal `</script>` (or any `</`) inside a
56    // string value breaking out of the enclosing <script> block, since serde
57    // does not escape `/`. Escaping `</` to `<\/` is a JSON/JS-safe transform
58    // that renders identically. We must NOT double backslashes: that would
59    // corrupt data (e.g. a `\d+` pattern would render as `\\d+`).
60    let spec_json_safe = spec_json.replace("</", "<\\/");
61
62    let html = format!(
63        r#"<!DOCTYPE html>
64<html lang="en">
65<head>
66    <meta charset="UTF-8">
67    <meta name="viewport" content="width=device-width, initial-scale=1.0">
68    <title>{title}</title>
69    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.10.0/swagger-ui.css">
70    <style>
71        body {{
72            margin: 0;
73            padding: 0;
74        }}
75    </style>
76</head>
77<body>
78    <div id="swagger-ui"></div>
79    <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.10.0/swagger-ui-bundle.js"></script>
80    <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.10.0/swagger-ui-standalone-preset.js"></script>
81    <script>
82        window.onload = function() {{
83            const spec = {spec};
84            SwaggerUIBundle({{
85                spec: spec,
86                dom_id: '#swagger-ui',
87                deepLinking: true,
88                presets: [
89                    SwaggerUIBundle.presets.apis,
90                    SwaggerUIStandalonePreset
91                ],
92                plugins: [
93                    SwaggerUIBundle.plugins.DownloadUrl
94                ],
95                layout: "StandaloneLayout"
96            }});
97        }};
98    </script>
99</body>
100</html>"#,
101        title = config.title,
102        spec = spec_json_safe
103    );
104
105    Ok(HttpResponse::ok()
106        .with_header("content-type".to_string(), "text/html".to_string())
107        .with_body(html.into_bytes()))
108}
109
110/// Generate a response for the OpenAPI spec JSON endpoint
111pub fn spec_json_response(config: &SwaggerConfig) -> Result<HttpResponse, Error> {
112    let spec_json = config.spec_json()?;
113
114    Ok(HttpResponse::ok()
115        .with_header("content-type".to_string(), "application/json".to_string())
116        .with_body(spec_json.into_bytes()))
117}
118
119/// Generate a response for the OpenAPI spec YAML endpoint
120pub fn spec_yaml_response(config: &SwaggerConfig) -> Result<HttpResponse, Error> {
121    let spec_yaml = config.spec_yaml()?;
122
123    Ok(HttpResponse::ok()
124        .with_header("content-type".to_string(), "application/x-yaml".to_string())
125        .with_body(spec_yaml.into_bytes()))
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::builder::OpenApiBuilder;
132
133    #[test]
134    fn test_swagger_config() {
135        let spec = OpenApiBuilder::new("Test API", "1.0.0").build();
136        let config = SwaggerConfig::new("/api-docs", spec).with_title("Test Docs");
137
138        assert_eq!(config.path, "/api-docs");
139        assert_eq!(config.title, "Test Docs");
140    }
141
142    #[test]
143    fn test_spec_json() {
144        let spec = OpenApiBuilder::new("Test API", "1.0.0")
145            .description("A test API")
146            .build();
147        let config = SwaggerConfig::new("/api-docs", spec);
148
149        let json = config.spec_json().unwrap();
150        assert!(json.contains("Test API"));
151        assert!(json.contains("1.0.0"));
152    }
153
154    #[test]
155    fn test_spec_yaml() {
156        let spec = OpenApiBuilder::new("Test API", "1.0.0")
157            .description("A test API")
158            .build();
159        let config = SwaggerConfig::new("/api-docs", spec);
160
161        let yaml = config.spec_yaml().unwrap();
162        assert!(yaml.contains("Test API"));
163        assert!(yaml.contains("1.0.0"));
164    }
165}