Skip to main content

salvo_oapi/
swagger_ui.rs

1//! This crate implements necessary boiler plate code to serve Swagger UI via web server. It
2//! works as a bridge for serving the OpenAPI documentation created with [`salvo`][salvo] library in the
3//! Swagger UI.
4//!
5//! [salvo]: <https://docs.rs/salvo/>
6//!
7use std::borrow::Cow;
8
9mod config;
10pub mod oauth;
11pub use config::Config;
12pub use oauth::Config as OauthConfig;
13use rust_embed::RustEmbed;
14use salvo_core::http::{HeaderValue, ResBody, StatusError, header};
15use salvo_core::routing::redirect_to_dir_url;
16use salvo_core::{Depot, Error, FlowCtrl, Handler, Request, Response, Router, async_trait};
17use serde::Serialize;
18
19use crate::html::{description_meta, escape_html, keywords_meta, script_safe_json};
20
21#[derive(RustEmbed)]
22#[folder = "src/swagger_ui/v5.32.8"]
23struct SwaggerUiDist;
24
25const INDEX_TMPL: &str = r#"
26<!DOCTYPE html>
27<html charset="UTF-8">
28  <head>
29    <meta charset="UTF-8">
30    <title>{{title}}</title>
31    {{keywords}}
32    {{description}}
33    <link rel="stylesheet" type="text/css" href="./swagger-ui.css" />
34    <style>
35    html {
36        box-sizing: border-box;
37        overflow: -moz-scrollbars-vertical;
38        overflow-y: scroll;
39    }
40    *,
41    *:before,
42    *:after {
43        box-sizing: inherit;
44    }
45    body {
46        margin: 0;
47        background: #fafafa;
48    }
49    </style>
50  </head>
51
52  <body>
53    <div id="swagger-ui"></div>
54    <script src="./swagger-ui-bundle.js" charset="UTF-8"></script>
55    <script src="./swagger-ui-standalone-preset.js" charset="UTF-8"></script>
56    <script>
57    window.onload = function() {
58        let config = {
59            dom_id: '#swagger-ui',
60            deepLinking: true,
61            presets: [
62              SwaggerUIBundle.presets.apis,
63              SwaggerUIStandalonePreset
64            ],
65            plugins: [
66              SwaggerUIBundle.plugins.DownloadUrl
67            ],
68            layout: "StandaloneLayout"
69          };
70        window.ui = SwaggerUIBundle(Object.assign(config, {{config}}));
71        //{{oauth}}
72    };
73    </script>
74  </body>
75</html>
76"#;
77
78/// Implements [`Handler`] for serving Swagger UI.
79#[derive(Clone, Debug)]
80pub struct SwaggerUi {
81    config: Config<'static>,
82    /// The title of the html page. The default title is "Swagger UI".
83    pub title: Cow<'static, str>,
84    /// The keywords of the html page.
85    pub keywords: Option<Cow<'static, str>>,
86    /// The description of the html page.
87    pub description: Option<Cow<'static, str>>,
88}
89impl SwaggerUi {
90    /// Create a new [`SwaggerUi`] for given path.
91    ///
92    /// Path argument will expose the Swagger UI to the user and should be something that
93    /// the underlying application framework / library supports.
94    ///
95    /// # Examples
96    ///
97    /// ```rust
98    /// # use salvo_oapi::swagger_ui::SwaggerUi;
99    /// let swagger = SwaggerUi::new("/swagger-ui/{_:.*}");
100    /// ```
101    pub fn new(config: impl Into<Config<'static>>) -> Self {
102        Self {
103            config: config.into(),
104            title: "Swagger UI".into(),
105            keywords: None,
106            description: None,
107        }
108    }
109
110    /// Set title of the html page. The default title is "Swagger UI".
111    #[must_use]
112    pub fn title(mut self, title: impl Into<Cow<'static, str>>) -> Self {
113        self.title = title.into();
114        self
115    }
116
117    /// Set keywords of the html page.
118    #[must_use]
119    pub fn keywords(mut self, keywords: impl Into<Cow<'static, str>>) -> Self {
120        self.keywords = Some(keywords.into());
121        self
122    }
123
124    /// Set description of the html page.
125    #[must_use]
126    pub fn description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
127        self.description = Some(description.into());
128        self
129    }
130
131    /// Add api doc [`Url`] into [`SwaggerUi`].
132    ///
133    /// Calling this again will add another url to the Swagger UI.
134    ///
135    /// # Examples
136    ///
137    /// ```rust
138    /// # use salvo_oapi::swagger_ui::SwaggerUi;
139    /// # use salvo_oapi::OpenApi;
140    ///
141    /// let swagger = SwaggerUi::new("/api-doc/openapi.json")
142    ///     .url("/api-docs/openapi2.json");
143    /// ```
144    #[must_use]
145    pub fn url<U: Into<Url<'static>>>(mut self, url: U) -> Self {
146        self.config.urls.push(url.into());
147        self
148    }
149
150    /// Add multiple [`Url`]s to Swagger UI.
151    ///
152    /// Takes one [`Vec`] argument containing tuples of [`Url`] and [OpenApi][crate::OpenApi].
153    ///
154    /// Situations where this comes handy is when there is a need or wish to separate different parts
155    /// of the api to separate api docs.
156    ///
157    /// # Examples
158    ///
159    /// Expose multiple api docs via Swagger UI.
160    /// ```rust
161    /// # use salvo_oapi::swagger_ui::{SwaggerUi, Url};
162    /// # use salvo_oapi::OpenApi;
163    ///
164    /// let swagger = SwaggerUi::new("/swagger-ui/{_:.*}")
165    ///     .urls(
166    ///       vec![
167    ///          (Url::with_primary("api doc 1", "/api-docs/openapi.json", true)),
168    ///          (Url::new("api doc 2", "/api-docs/openapi2.json"))
169    ///     ]
170    /// );
171    /// ```
172    #[must_use]
173    pub fn urls(mut self, urls: Vec<Url<'static>>) -> Self {
174        self.config.urls = urls;
175        self
176    }
177
178    /// Add OAuth [`oauth::Config`] into [`SwaggerUi`].
179    ///
180    /// Method takes one argument which exposes the [`oauth::Config`] to the user.
181    ///
182    /// # Examples
183    ///
184    /// Enable pkce with default client_id.
185    /// ```rust
186    /// # use salvo_oapi::swagger_ui::{SwaggerUi, oauth};
187    /// # use salvo_oapi::OpenApi;
188    ///
189    /// let swagger = SwaggerUi::new("/swagger-ui/{_:.*}")
190    ///     .url("/api-docs/openapi.json")
191    ///     .oauth(oauth::Config::new()
192    ///         .client_id("client-id")
193    ///         .scopes(vec![String::from("openid")])
194    ///         .use_pkce_with_authorization_code_grant(true)
195    ///     );
196    /// ```
197    #[must_use]
198    pub fn oauth(mut self, oauth: oauth::Config) -> Self {
199        self.config.oauth = Some(oauth);
200        self
201    }
202
203    /// Consumes the [`SwaggerUi`] and returns [`Router`] with the [`SwaggerUi`] as handler.
204    pub fn into_router(self, path: impl Into<String>) -> Router {
205        Router::with_path(format!("{}/{{**}}", path.into())).goal(self)
206    }
207}
208
209#[async_trait]
210impl Handler for SwaggerUi {
211    async fn handle(
212        &self,
213        req: &mut Request,
214        _depot: &mut Depot,
215        res: &mut Response,
216        _ctrl: &mut FlowCtrl,
217    ) {
218        let path = req.params().tail().unwrap_or_default();
219        // Redirect to dir url if path is empty and not end with '/'
220        if path.is_empty() && !req.uri().path().ends_with('/') {
221            redirect_to_dir_url(req.uri(), res);
222            return;
223        }
224
225        let keywords = self
226            .keywords
227            .as_ref()
228            .map(|s| keywords_meta(s))
229            .unwrap_or_default();
230        let description = self
231            .description
232            .as_ref()
233            .map(|s| description_meta(s))
234            .unwrap_or_default();
235        match serve(path, &self.title, &keywords, &description, &self.config) {
236            Ok(Some(file)) => {
237                res.headers_mut().insert(
238                    header::CONTENT_TYPE,
239                    HeaderValue::from_str(&file.content_type).expect("content type parse failed"),
240                );
241                res.body(ResBody::Once(file.bytes.to_vec().into()));
242            }
243            Ok(None) => {
244                tracing::warn!(path, "swagger ui file not found");
245                res.render(StatusError::not_found());
246            }
247            Err(e) => {
248                tracing::error!(error = ?e, path, "failed to fetch swagger ui file");
249                res.render(StatusError::internal_server_error());
250            }
251        }
252    }
253}
254
255/// Rust type for Swagger UI url configuration object.
256#[non_exhaustive]
257#[derive(Default, Serialize, Clone, Debug)]
258pub struct Url<'a> {
259    name: Cow<'a, str>,
260    url: Cow<'a, str>,
261    #[serde(skip)]
262    primary: bool,
263}
264
265impl<'a> Url<'a> {
266    /// Creates a new [`Url`].
267    ///
268    /// Name is shown in the select dropdown when there are multiple docs in Swagger UI.
269    ///
270    /// Url is path which exposes the OpenAPI doc.
271    ///
272    /// # Examples
273    ///
274    /// ```rust
275    /// # use salvo_oapi::swagger_ui::Url;
276    /// let url = Url::new("My Api", "/api-docs/openapi.json");
277    /// ```
278    #[must_use]
279    pub fn new(name: &'a str, url: &'a str) -> Self {
280        Self {
281            name: Cow::Borrowed(name),
282            url: Cow::Borrowed(url),
283            ..Default::default()
284        }
285    }
286
287    /// Creates a new [`Url`] with the primary flag.
288    ///
289    /// Primary flag allows users to override the default behavior of the Swagger UI for selecting the primary
290    /// doc to display. By default when there are multiple docs in Swagger UI the first one in the list
291    /// will be the primary.
292    ///
293    /// Name is shown in the select dropdown when there are multiple docs in Swagger UI.
294    ///
295    /// Url is path which exposes the OpenAPI doc.
296    ///
297    /// # Examples
298    ///
299    /// Set "My Api" as primary.
300    /// ```rust
301    /// # use salvo_oapi::swagger_ui::Url;
302    /// let url = Url::with_primary("My Api", "/api-docs/openapi.json", true);
303    /// ```
304    #[must_use]
305    pub fn with_primary(name: &'a str, url: &'a str, primary: bool) -> Self {
306        Self {
307            name: Cow::Borrowed(name),
308            url: Cow::Borrowed(url),
309            primary,
310        }
311    }
312}
313
314impl<'a> From<&'a str> for Url<'a> {
315    fn from(url: &'a str) -> Self {
316        Self {
317            url: Cow::Borrowed(url),
318            ..Default::default()
319        }
320    }
321}
322
323impl From<String> for Url<'_> {
324    fn from(url: String) -> Self {
325        Self {
326            url: Cow::Owned(url),
327            ..Default::default()
328        }
329    }
330}
331
332impl From<Cow<'static, str>> for Url<'_> {
333    fn from(url: Cow<'static, str>) -> Self {
334        Self {
335            url,
336            ..Default::default()
337        }
338    }
339}
340
341/// Represents servable file of Swagger UI. This is used together with [`serve`] function
342/// to serve Swagger UI files via web server.
343#[non_exhaustive]
344#[derive(Clone, Debug)]
345pub struct SwaggerFile<'a> {
346    /// Content of the file as [`Cow`] [`slice`] of bytes.
347    pub bytes: Cow<'a, [u8]>,
348    /// Content type of the file e.g `"text/xml"`.
349    pub content_type: String,
350}
351
352/// User friendly way to serve Swagger UI and its content via web server.
353///
354/// * **path** Should be the relative path to Swagger UI resource within the web server.
355/// * **config** Swagger [`Config`] to use for the Swagger UI.
356///
357/// Typically this function is implemented _**within**_ handler what serves the Swagger UI. Handler itself must
358/// match to user defined path that points to the root of the Swagger UI and match everything relatively
359/// from the root of the Swagger UI _**(tail path)**_. The relative path from root of the Swagger UI
360/// is used to serve [`SwaggerFile`]s. If Swagger UI is served from path `/swagger-ui/` then the `tail`
361/// is everything under the `/swagger-ui/` prefix.
362///
363/// _There are also implementations in [examples of salvo repository][examples]._
364///
365/// [examples]: https://github.com/salvo-rs/salvo/tree/master/examples
366pub fn serve<'a>(
367    path: &str,
368    title: &str,
369    keywords: &str,
370    description: &str,
371    config: &Config<'a>,
372) -> Result<Option<SwaggerFile<'a>>, Error> {
373    let path = if path.is_empty() || path == "/" {
374        "index.html"
375    } else {
376        path
377    };
378
379    let bytes = if path == "index.html" {
380        // The config is embedded into an inline `<script>` block, so escape any
381        // characters that could break out of the script context (e.g. `</script>`).
382        let config_json = script_safe_json(serde_json::to_string(&config)?);
383
384        // Replace {{config}} with pretty config json and remove the curly brackets `{ }` from beginning and the end.
385        let mut index = INDEX_TMPL
386            .replacen("{{config}}", &config_json, 1)
387            .replacen("{{description}}", description, 1)
388            .replacen("{{keywords}}", keywords, 1)
389            .replacen("{{title}}", &escape_html(title), 1);
390
391        if let Some(oauth) = &config.oauth {
392            let oauth_json = script_safe_json(serde_json::to_string(oauth)?);
393            index = index.replace(
394                "//{{oauth}}",
395                &format!("window.ui.initOAuth({});", &oauth_json),
396            );
397        }
398        Some(Cow::Owned(index.as_bytes().to_vec()))
399    } else {
400        SwaggerUiDist::get(path).map(|f| f.data)
401    };
402    let file = bytes.map(|bytes| SwaggerFile {
403        bytes,
404        content_type: mime_infer::from_path(path)
405            .first_or_octet_stream()
406            .to_string(),
407    });
408
409    Ok(file)
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn test_serve_index_escapes_config_for_script_context() {
418        // A url containing `</script>` must not be able to break out of the inline script.
419        let config = Config::new(["</script><script>alert(1)</script>"]);
420        let file = serve("index.html", "title", "", "", &config)
421            .expect("serve should succeed")
422            .expect("index.html should be served");
423        let html = String::from_utf8(file.bytes.into_owned()).expect("utf8");
424        assert!(
425            !html.contains("</script><script>alert(1)</script>"),
426            "raw script-breaking payload must not be present"
427        );
428        assert!(
429            html.contains("\\u003c/script\\u003e"),
430            "`<` must be escaped to \\u003c inside the config json"
431        );
432    }
433}