armature_openapi/
swagger.rs1use crate::spec::OpenApiSpec;
4use armature_core::{Error, HttpResponse};
5
6#[derive(Debug, Clone)]
8pub struct SwaggerConfig {
9 pub path: String,
11 pub title: String,
13 pub spec: OpenApiSpec,
15}
16
17impl SwaggerConfig {
18 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 pub fn with_title(mut self, title: impl Into<String>) -> Self {
29 self.title = title.into();
30 self
31 }
32
33 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 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
46pub const SWAGGER_UI_VERSION: &str = "5.33.1";
48
49const 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
66fn 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("&"),
72 '<' => out.push_str("<"),
73 '>' => out.push_str(">"),
74 '"' => out.push_str("""),
75 '\'' => out.push_str("'"),
76 _ => out.push(c),
77 }
78 }
79 out
80}
81
82pub fn swagger_ui_response(config: &SwaggerConfig) -> Result<HttpResponse, Error> {
91 let spec_json = config.spec_json()?;
92 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
154pub 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
163pub 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("</title>"));
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}