Skip to main content

volga_open_api/
ui.rs

1//! Helpers for OpenAPI UI.
2
3use super::config::{DEFAULT_SPEC_PATH, OpenApiSpec};
4
5const SWAGGER_UI_CDN_BASE: &str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.31.2";
6
7/// Generates OpenAPI UI HTML.
8pub fn ui_html(specs: &[OpenApiSpec], ui_title: &str) -> String {
9    let config_js = if specs.len() <= 1 {
10        let url = specs
11            .first()
12            .map(|s| s.spec_path.as_str())
13            .unwrap_or(DEFAULT_SPEC_PATH);
14
15        format!(r#"url: {},"#, js_string(url))
16    } else {
17        // urls: [{url, name}, ...] + primaryName
18        let urls = specs
19            .iter()
20            .map(|s| {
21                format!(
22                    r#"{{ url: {}, name: {} }}"#,
23                    js_string(&s.spec_path),
24                    js_string(&s.name),
25                )
26            })
27            .collect::<Vec<_>>()
28            .join(",\n          ");
29
30        let primary = js_string(&specs[0].name);
31
32        format!(
33            r#"urls: [
34            {urls}
35        ],
36        "urls.primaryName": {primary},"#
37        )
38    };
39
40    format!(
41        r##"<!doctype html>
42<html lang="en">
43  <head>
44    <meta charset="utf-8" />
45    <meta name="viewport" content="width=device-width, initial-scale=1" />
46    <title>{}</title>
47    <link rel="stylesheet" href="{}/swagger-ui.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
48  </head>
49  <body>
50    <div id="swagger-ui"></div>
51
52    <script src="{}/swagger-ui-bundle.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
53    <script src="{}/swagger-ui-standalone-preset.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
54
55    <script>
56      window.onload = function() {{
57        SwaggerUIBundle({{
58          {config_js}
59          dom_id: "#swagger-ui",
60          presets: [
61            SwaggerUIBundle.presets.apis,
62            SwaggerUIStandalonePreset
63          ],
64          layout: "StandaloneLayout"
65        }});
66      }};
67    </script>
68  </body>
69</html>"##,
70        html_escape(ui_title),
71        SWAGGER_UI_CDN_BASE,
72        SWAGGER_UI_CDN_BASE,
73        SWAGGER_UI_CDN_BASE,
74    )
75}
76
77#[inline]
78fn js_string(s: &str) -> String {
79    // returns a valid JS string literal: "\"...\"" with escapes
80    serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string())
81}
82
83#[inline]
84fn html_escape(s: &str) -> String {
85    let mut out = String::with_capacity(s.len());
86    for ch in s.chars() {
87        match ch {
88            '&' => out.push_str("&amp;"),
89            '<' => out.push_str("&lt;"),
90            '>' => out.push_str("&gt;"),
91            '"' => out.push_str("&quot;"),
92            '\'' => out.push_str("&#39;"),
93            _ => out.push(ch),
94        }
95    }
96    out
97}
98
99#[cfg(test)]
100mod tests {
101    use super::{SWAGGER_UI_CDN_BASE, ui_html};
102    use crate::config::OpenApiSpec;
103
104    #[test]
105    fn ui_html_uses_single_url_config_for_one_spec() {
106        let html = ui_html(&[OpenApiSpec::new("v1")], "Docs");
107
108        assert!(html.contains("url: \"/v1/openapi.json\""));
109        assert!(!html.contains("urls.primaryName"));
110        assert!(html.contains("<title>Docs</title>"));
111        assert!(html.contains(SWAGGER_UI_CDN_BASE));
112        assert!(html.contains("crossorigin=\"anonymous\""));
113        assert!(html.contains("referrerpolicy=\"no-referrer\""));
114    }
115
116    #[test]
117    fn ui_html_uses_urls_config_for_multiple_specs() {
118        let html = ui_html(&[OpenApiSpec::new("v1"), OpenApiSpec::new("admin")], "Docs");
119
120        assert!(html.contains("urls: ["));
121        assert!(html.contains("{ url: \"/v1/openapi.json\", name: \"v1\" }"));
122        assert!(html.contains("{ url: \"/admin/openapi.json\", name: \"admin\" }"));
123        assert!(html.contains("\"urls.primaryName\": \"v1\""));
124    }
125
126    #[test]
127    fn ui_html_falls_back_to_default_path_for_empty_specs() {
128        let html = ui_html(&[], "Docs");
129        assert!(html.contains("url: \"/openapi.json\""));
130    }
131
132    #[test]
133    fn ui_html_escapes_title_html() {
134        let html = ui_html(
135            &[OpenApiSpec::new("v1")],
136            r#"</title><script>alert(1)</script>"#,
137        );
138
139        assert!(
140            html.contains("<title>&lt;/title&gt;&lt;script&gt;alert(1)&lt;/script&gt;</title>")
141        );
142        assert!(!html.contains("</title><script>alert(1)</script>"));
143    }
144
145    #[test]
146    fn ui_html_pins_exact_swagger_ui_version() {
147        let html = ui_html(&[OpenApiSpec::new("v1")], "Docs");
148
149        assert!(html.contains("/npm/swagger-ui-dist@5.31.2/swagger-ui.css"));
150        assert!(html.contains("/npm/swagger-ui-dist@5.31.2/swagger-ui-bundle.js"));
151        assert!(html.contains("/npm/swagger-ui-dist@5.31.2/swagger-ui-standalone-preset.js"));
152        assert!(!html.contains("unpkg.com/swagger-ui-dist@5/"));
153    }
154}