use crate::spec::OpenApiSpec;
use armature_core::{Error, HttpResponse};
#[derive(Debug, Clone)]
pub struct SwaggerConfig {
pub path: String,
pub title: String,
pub spec: OpenApiSpec,
}
impl SwaggerConfig {
pub fn new(path: impl Into<String>, spec: OpenApiSpec) -> Self {
Self {
path: path.into(),
title: "API Documentation".to_string(),
spec,
}
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
pub fn spec_json(&self) -> Result<String, Error> {
serde_json::to_string_pretty(&self.spec)
.map_err(|e| Error::Internal(format!("Failed to serialize spec: {}", e)))
}
pub fn spec_yaml(&self) -> Result<String, Error> {
serde_norway::to_string(&self.spec)
.map_err(|e| Error::Internal(format!("Failed to serialize spec: {}", e)))
}
}
pub const SWAGGER_UI_VERSION: &str = "5.33.1";
const SWAGGER_UI_CSS_URL: &str =
"https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui.css";
const SWAGGER_UI_CSS_SRI: &str =
"sha384-Ov4/wv3j2bmct8cDc5X4ngJZohVPzEmc6uDPH8WeljUxO5vtoykvMEfbu9Vh6RaW";
const SWAGGER_UI_BUNDLE_URL: &str =
"https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui-bundle.js";
const SWAGGER_UI_BUNDLE_SRI: &str =
"sha384-ZPehFMQommnnuaZ4rpxgkgTT2DKFVp4hZC/7pLit+9Lek9T1YGSo23eHFbvNkXkw";
const SWAGGER_UI_PRESET_URL: &str =
"https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui-standalone-preset.js";
const SWAGGER_UI_PRESET_SRI: &str =
"sha384-My2aDM4r2Mbm3ybHcubKm9O9U8FEjvF/O5nGvE9YK5dzqOTbWEKa79RPJ1krdMaF";
fn escape_html(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for c in input.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
pub fn swagger_ui_response(config: &SwaggerConfig) -> Result<HttpResponse, Error> {
let spec_json = config.spec_json()?;
let spec_json_safe = spec_json.replace("</", "<\\/");
let html = format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<link rel="stylesheet" href="{css_url}" integrity="{css_sri}" crossorigin="anonymous">
<style>
body {{
margin: 0;
padding: 0;
}}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="{bundle_url}" integrity="{bundle_sri}" crossorigin="anonymous"></script>
<script src="{preset_url}" integrity="{preset_sri}" crossorigin="anonymous"></script>
<script>
window.onload = function() {{
const spec = {spec};
SwaggerUIBundle({{
spec: spec,
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
}});
}};
</script>
</body>
</html>"#,
title = escape_html(&config.title),
spec = spec_json_safe,
css_url = SWAGGER_UI_CSS_URL,
css_sri = SWAGGER_UI_CSS_SRI,
bundle_url = SWAGGER_UI_BUNDLE_URL,
bundle_sri = SWAGGER_UI_BUNDLE_SRI,
preset_url = SWAGGER_UI_PRESET_URL,
preset_sri = SWAGGER_UI_PRESET_SRI,
);
Ok(HttpResponse::ok()
.with_header("content-type".to_string(), "text/html".to_string())
.with_body(html.into_bytes()))
}
pub fn spec_json_response(config: &SwaggerConfig) -> Result<HttpResponse, Error> {
let spec_json = config.spec_json()?;
Ok(HttpResponse::ok()
.with_header("content-type".to_string(), "application/json".to_string())
.with_body(spec_json.into_bytes()))
}
pub fn spec_yaml_response(config: &SwaggerConfig) -> Result<HttpResponse, Error> {
let spec_yaml = config.spec_yaml()?;
Ok(HttpResponse::ok()
.with_header("content-type".to_string(), "application/x-yaml".to_string())
.with_body(spec_yaml.into_bytes()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::builder::OpenApiBuilder;
#[test]
fn test_swagger_config() {
let spec = OpenApiBuilder::new("Test API", "1.0.0").build();
let config = SwaggerConfig::new("/api-docs", spec).with_title("Test Docs");
assert_eq!(config.path, "/api-docs");
assert_eq!(config.title, "Test Docs");
}
#[test]
fn test_spec_json() {
let spec = OpenApiBuilder::new("Test API", "1.0.0")
.description("A test API")
.build();
let config = SwaggerConfig::new("/api-docs", spec);
let json = config.spec_json().unwrap();
assert!(json.contains("Test API"));
assert!(json.contains("1.0.0"));
}
#[test]
fn test_spec_yaml() {
let spec = OpenApiBuilder::new("Test API", "1.0.0")
.description("A test API")
.build();
let config = SwaggerConfig::new("/api-docs", spec);
let yaml = config.spec_yaml().unwrap();
assert!(yaml.contains("Test API"));
assert!(yaml.contains("1.0.0"));
}
fn swagger_html(title: &str) -> String {
let spec = OpenApiBuilder::new("Test API", "1.0.0").build();
let config = SwaggerConfig::new("/api-docs", spec).with_title(title);
let response = swagger_ui_response(&config).unwrap();
String::from_utf8(response.body.to_vec()).unwrap()
}
#[test]
fn test_swagger_ui_assets_pinned_with_sri() {
let html = swagger_html("Docs");
let tags: Vec<&str> = html
.split('<')
.filter(|t| t.starts_with("script src") || t.starts_with("link rel=\"stylesheet\""))
.collect();
assert_eq!(tags.len(), 3, "expected css + 2 scripts: {tags:?}");
for tag in tags {
assert!(tag.contains("integrity=\"sha384-"), "missing SRI: <{tag}");
assert!(
tag.contains("crossorigin=\"anonymous\""),
"missing crossorigin: <{tag}"
);
assert!(
tag.contains(&format!("swagger-ui-dist@{SWAGGER_UI_VERSION}/")),
"asset must be pinned to the exact version: <{tag}"
);
}
}
#[test]
fn test_swagger_ui_title_is_escaped() {
let html = swagger_html("</title><script>alert(1)</script>");
assert!(!html.contains("<script>alert(1)"));
assert!(html.contains("</title>"));
}
#[test]
fn test_spec_yaml_round_trips() {
let spec = OpenApiBuilder::new("Test API", "1.0.0").build();
let yaml = SwaggerConfig::new("/api-docs", spec).spec_yaml().unwrap();
let value: serde_json::Value = serde_norway::from_str(&yaml).unwrap();
assert_eq!(value["info"]["title"], "Test API");
}
}