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