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