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_norway::to_string(&self.spec)
42            .map_err(|e| Error::Internal(format!("Failed to serialize spec: {}", e)))
43    }
44}
45
46/// Pinned `swagger-ui-dist` release loaded by [`swagger_ui_response`].
47pub const SWAGGER_UI_VERSION: &str = "5.33.1";
48
49// Subresource Integrity hashes for the exact pinned files below, computed with
50// `curl -sfL <url> | openssl dgst -sha384 -binary | base64` (and cross-checked
51// against the same files on unpkg). Recompute them whenever
52// `SWAGGER_UI_VERSION` changes.
53const SWAGGER_UI_CSS_URL: &str =
54    "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui.css";
55const SWAGGER_UI_CSS_SRI: &str =
56    "sha384-Ov4/wv3j2bmct8cDc5X4ngJZohVPzEmc6uDPH8WeljUxO5vtoykvMEfbu9Vh6RaW";
57const SWAGGER_UI_BUNDLE_URL: &str =
58    "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui-bundle.js";
59const SWAGGER_UI_BUNDLE_SRI: &str =
60    "sha384-ZPehFMQommnnuaZ4rpxgkgTT2DKFVp4hZC/7pLit+9Lek9T1YGSo23eHFbvNkXkw";
61const SWAGGER_UI_PRESET_URL: &str =
62    "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui-standalone-preset.js";
63const SWAGGER_UI_PRESET_SRI: &str =
64    "sha384-My2aDM4r2Mbm3ybHcubKm9O9U8FEjvF/O5nGvE9YK5dzqOTbWEKa79RPJ1krdMaF";
65
66/// Escape text for an HTML text node / attribute value.
67fn escape_html(input: &str) -> String {
68    let mut out = String::with_capacity(input.len());
69    for c in input.chars() {
70        match c {
71            '&' => out.push_str("&amp;"),
72            '<' => out.push_str("&lt;"),
73            '>' => out.push_str("&gt;"),
74            '"' => out.push_str("&quot;"),
75            '\'' => out.push_str("&#x27;"),
76            _ => out.push(c),
77        }
78    }
79    out
80}
81
82/// Generate a Swagger UI HTML response.
83///
84/// The returned page loads a pinned `swagger-ui-dist` release
85/// ([`SWAGGER_UI_VERSION`]) from the jsdelivr CDN (`cdn.jsdelivr.net`) with
86/// Subresource Integrity (`integrity="sha384-..."` + `crossorigin`), so the
87/// browser refuses a modified or substituted file. It is not self-contained:
88/// it will fail to render offline or under a Content-Security-Policy that
89/// blocks that origin.
90pub fn swagger_ui_response(config: &SwaggerConfig) -> Result<HttpResponse, Error> {
91    let spec_json = config.spec_json()?;
92    // JSON is already valid JS object-literal syntax, so it can be injected
93    // as-is. The only hazard is a literal `</script>` (or any `</`) inside a
94    // string value breaking out of the enclosing <script> block, since serde
95    // does not escape `/`. Escaping `</` to `<\/` is a JSON/JS-safe transform
96    // that renders identically. We must NOT double backslashes: that would
97    // corrupt data (e.g. a `\d+` pattern would render as `\\d+`).
98    let spec_json_safe = spec_json.replace("</", "<\\/");
99
100    let html = format!(
101        r#"<!DOCTYPE html>
102<html lang="en">
103<head>
104    <meta charset="UTF-8">
105    <meta name="viewport" content="width=device-width, initial-scale=1.0">
106    <title>{title}</title>
107    <link rel="stylesheet" href="{css_url}" integrity="{css_sri}" crossorigin="anonymous">
108    <style>
109        body {{
110            margin: 0;
111            padding: 0;
112        }}
113    </style>
114</head>
115<body>
116    <div id="swagger-ui"></div>
117    <script src="{bundle_url}" integrity="{bundle_sri}" crossorigin="anonymous"></script>
118    <script src="{preset_url}" integrity="{preset_sri}" crossorigin="anonymous"></script>
119    <script>
120        window.onload = function() {{
121            const spec = {spec};
122            SwaggerUIBundle({{
123                spec: spec,
124                dom_id: '#swagger-ui',
125                deepLinking: true,
126                presets: [
127                    SwaggerUIBundle.presets.apis,
128                    SwaggerUIStandalonePreset
129                ],
130                plugins: [
131                    SwaggerUIBundle.plugins.DownloadUrl
132                ],
133                layout: "StandaloneLayout"
134            }});
135        }};
136    </script>
137</body>
138</html>"#,
139        title = escape_html(&config.title),
140        spec = spec_json_safe,
141        css_url = SWAGGER_UI_CSS_URL,
142        css_sri = SWAGGER_UI_CSS_SRI,
143        bundle_url = SWAGGER_UI_BUNDLE_URL,
144        bundle_sri = SWAGGER_UI_BUNDLE_SRI,
145        preset_url = SWAGGER_UI_PRESET_URL,
146        preset_sri = SWAGGER_UI_PRESET_SRI,
147    );
148
149    Ok(HttpResponse::ok()
150        .with_header("content-type".to_string(), "text/html".to_string())
151        .with_body(html.into_bytes()))
152}
153
154/// Generate a response for the OpenAPI spec JSON endpoint
155pub fn spec_json_response(config: &SwaggerConfig) -> Result<HttpResponse, Error> {
156    let spec_json = config.spec_json()?;
157
158    Ok(HttpResponse::ok()
159        .with_header("content-type".to_string(), "application/json".to_string())
160        .with_body(spec_json.into_bytes()))
161}
162
163/// Generate a response for the OpenAPI spec YAML endpoint
164pub fn spec_yaml_response(config: &SwaggerConfig) -> Result<HttpResponse, Error> {
165    let spec_yaml = config.spec_yaml()?;
166
167    Ok(HttpResponse::ok()
168        .with_header("content-type".to_string(), "application/x-yaml".to_string())
169        .with_body(spec_yaml.into_bytes()))
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::builder::OpenApiBuilder;
176
177    #[test]
178    fn test_swagger_config() {
179        let spec = OpenApiBuilder::new("Test API", "1.0.0").build();
180        let config = SwaggerConfig::new("/api-docs", spec).with_title("Test Docs");
181
182        assert_eq!(config.path, "/api-docs");
183        assert_eq!(config.title, "Test Docs");
184    }
185
186    #[test]
187    fn test_spec_json() {
188        let spec = OpenApiBuilder::new("Test API", "1.0.0")
189            .description("A test API")
190            .build();
191        let config = SwaggerConfig::new("/api-docs", spec);
192
193        let json = config.spec_json().unwrap();
194        assert!(json.contains("Test API"));
195        assert!(json.contains("1.0.0"));
196    }
197
198    #[test]
199    fn test_spec_yaml() {
200        let spec = OpenApiBuilder::new("Test API", "1.0.0")
201            .description("A test API")
202            .build();
203        let config = SwaggerConfig::new("/api-docs", spec);
204
205        let yaml = config.spec_yaml().unwrap();
206        assert!(yaml.contains("Test API"));
207        assert!(yaml.contains("1.0.0"));
208    }
209
210    fn swagger_html(title: &str) -> String {
211        let spec = OpenApiBuilder::new("Test API", "1.0.0").build();
212        let config = SwaggerConfig::new("/api-docs", spec).with_title(title);
213        let response = swagger_ui_response(&config).unwrap();
214        String::from_utf8(response.body.to_vec()).unwrap()
215    }
216
217    #[test]
218    fn test_swagger_ui_assets_pinned_with_sri() {
219        let html = swagger_html("Docs");
220        let tags: Vec<&str> = html
221            .split('<')
222            .filter(|t| t.starts_with("script src") || t.starts_with("link rel=\"stylesheet\""))
223            .collect();
224        assert_eq!(tags.len(), 3, "expected css + 2 scripts: {tags:?}");
225        for tag in tags {
226            assert!(tag.contains("integrity=\"sha384-"), "missing SRI: <{tag}");
227            assert!(
228                tag.contains("crossorigin=\"anonymous\""),
229                "missing crossorigin: <{tag}"
230            );
231            assert!(
232                tag.contains(&format!("swagger-ui-dist@{SWAGGER_UI_VERSION}/")),
233                "asset must be pinned to the exact version: <{tag}"
234            );
235        }
236    }
237
238    #[test]
239    fn test_swagger_ui_title_is_escaped() {
240        let html = swagger_html("</title><script>alert(1)</script>");
241        assert!(!html.contains("<script>alert(1)"));
242        assert!(html.contains("&lt;/title&gt;"));
243    }
244
245    #[test]
246    fn test_spec_yaml_round_trips() {
247        let spec = OpenApiBuilder::new("Test API", "1.0.0").build();
248        let yaml = SwaggerConfig::new("/api-docs", spec).spec_yaml().unwrap();
249        let value: serde_json::Value = serde_norway::from_str(&yaml).unwrap();
250        assert_eq!(value["info"]["title"], "Test API");
251    }
252}