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::{HeaderValue, ResBody, StatusError, header};
16use salvo_core::writing::Redirect;
17use salvo_core::{Depot, Error, FlowCtrl, Handler, Request, Response, Router, async_trait};
18use serde::Serialize;
19
20#[derive(RustEmbed)]
21#[folder = "src/swagger_ui/v5.29.0"]
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    #[must_use]
111    pub fn title(mut self, title: impl Into<Cow<'static, str>>) -> Self {
112        self.title = title.into();
113        self
114    }
115
116    /// Set keywords of the html page.
117    #[must_use]
118    pub fn keywords(mut self, keywords: impl Into<Cow<'static, str>>) -> Self {
119        self.keywords = Some(keywords.into());
120        self
121    }
122
123    /// Set description of the html page.
124    #[must_use]
125    pub fn description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
126        self.description = Some(description.into());
127        self
128    }
129
130    /// Add api doc [`Url`] into [`SwaggerUi`].
131    ///
132    /// Calling this again will add another url to the Swagger UI.
133    ///
134    /// # Examples
135    ///
136    /// ```rust
137    /// # use salvo_oapi::swagger_ui::SwaggerUi;
138    /// # use salvo_oapi::OpenApi;
139    ///
140    /// let swagger = SwaggerUi::new("/api-doc/openapi.json")
141    ///     .url("/api-docs/openapi2.json");
142    /// ```
143    #[must_use]
144    pub fn url<U: Into<Url<'static>>>(mut self, url: U) -> Self {
145        self.config.urls.push(url.into());
146        self
147    }
148
149    /// Add multiple [`Url`]s to Swagger UI.
150    ///
151    /// Takes one [`Vec`] argument containing tuples of [`Url`] and [OpenApi][crate::OpenApi].
152    ///
153    /// Situations where this comes handy is when there is a need or wish to separate different parts
154    /// of the api to separate api docs.
155    ///
156    /// # Examples
157    ///
158    /// Expose multiple api docs via Swagger UI.
159    /// ```rust
160    /// # use salvo_oapi::swagger_ui::{SwaggerUi, Url};
161    /// # use salvo_oapi::OpenApi;
162    ///
163    /// let swagger = SwaggerUi::new("/swagger-ui/{_:.*}")
164    ///     .urls(
165    ///       vec![
166    ///          (Url::with_primary("api doc 1", "/api-docs/openapi.json", true)),
167    ///          (Url::new("api doc 2", "/api-docs/openapi2.json"))
168    ///     ]
169    /// );
170    /// ```
171    #[must_use]
172    pub fn urls(mut self, urls: Vec<Url<'static>>) -> Self {
173        self.config.urls = urls;
174        self
175    }
176
177    /// Add oauth [`oauth::Config`] into [`SwaggerUi`].
178    ///
179    /// Method takes one argument which exposes the [`oauth::Config`] to the user.
180    ///
181    /// # Examples
182    ///
183    /// Enable pkce with default client_id.
184    /// ```rust
185    /// # use salvo_oapi::swagger_ui::{SwaggerUi, oauth};
186    /// # use salvo_oapi::OpenApi;
187    ///
188    /// let swagger = SwaggerUi::new("/swagger-ui/{_:.*}")
189    ///     .url("/api-docs/openapi.json")
190    ///     .oauth(oauth::Config::new()
191    ///         .client_id("client-id")
192    ///         .scopes(vec![String::from("openid")])
193    ///         .use_pkce_with_authorization_code_grant(true)
194    ///     );
195    /// ```
196    #[must_use]
197    pub fn oauth(mut self, oauth: oauth::Config) -> Self {
198        self.config.oauth = Some(oauth);
199        self
200    }
201
202    /// Consusmes the [`SwaggerUi`] and returns [`Router`] with the [`SwaggerUi`] as handler.
203    pub fn into_router(self, path: impl Into<String>) -> Router {
204        Router::with_path(format!("{}/{{**}}", path.into())).goal(self)
205    }
206}
207
208#[inline]
209pub(crate) fn redirect_to_dir_url(req_uri: &Uri, res: &mut Response) {
210    let UriParts {
211        scheme,
212        authority,
213        path_and_query,
214        ..
215    } = req_uri.clone().into_parts();
216    let mut builder = Uri::builder();
217    if let Some(scheme) = scheme {
218        builder = builder.scheme(scheme);
219    }
220    if let Some(authority) = authority {
221        builder = builder.authority(authority);
222    }
223    if let Some(path_and_query) = path_and_query {
224        if let Some(query) = path_and_query.query() {
225            builder = builder.path_and_query(format!("{}/?{}", path_and_query.path(), query));
226        } else {
227            builder = builder.path_and_query(format!("{}/", path_and_query.path()));
228        }
229    }
230    match builder.build() {
231        Ok(redirect_uri) => res.render(Redirect::found(redirect_uri)),
232        Err(e) => {
233            tracing::error!(error = ?e, "failed to build redirect uri");
234            res.render(StatusError::internal_server_error());
235        }
236    }
237}
238
239#[async_trait]
240impl Handler for SwaggerUi {
241    async fn handle(
242        &self,
243        req: &mut Request,
244        _depot: &mut Depot,
245        res: &mut Response,
246        _ctrl: &mut FlowCtrl,
247    ) {
248        let path = req.params().tail().unwrap_or_default();
249        // Redirect to dir url if path is empty and not end with '/'
250        if path.is_empty() && !req.uri().path().ends_with('/') {
251            redirect_to_dir_url(req.uri(), res);
252            return;
253        }
254
255        let keywords = self
256            .keywords
257            .as_ref()
258            .map(|s| {
259                format!(
260                    "<meta name=\"keywords\" content=\"{}\">",
261                    s.split(',').map(|s| s.trim()).collect::<Vec<_>>().join(",")
262                )
263            })
264            .unwrap_or_default();
265        let description = self
266            .description
267            .as_ref()
268            .map(|s| format!("<meta name=\"description\" content=\"{s}\">"))
269            .unwrap_or_default();
270        match serve(path, &self.title, &keywords, &description, &self.config) {
271            Ok(Some(file)) => {
272                res.headers_mut().insert(
273                    header::CONTENT_TYPE,
274                    HeaderValue::from_str(&file.content_type).expect("content type parse failed"),
275                );
276                res.body(ResBody::Once(file.bytes.to_vec().into()));
277            }
278            Ok(None) => {
279                tracing::warn!(path, "swagger ui file not found");
280                res.render(StatusError::not_found());
281            }
282            Err(e) => {
283                tracing::error!(error = ?e, path, "failed to fetch swagger ui file");
284                res.render(StatusError::internal_server_error());
285            }
286        }
287    }
288}
289
290/// Rust type for Swagger UI url configuration object.
291#[non_exhaustive]
292#[derive(Default, Serialize, Clone, Debug)]
293pub struct Url<'a> {
294    name: Cow<'a, str>,
295    url: Cow<'a, str>,
296    #[serde(skip)]
297    primary: bool,
298}
299
300impl<'a> Url<'a> {
301    /// Create new [`Url`].
302    ///
303    /// Name is shown in the select dropdown when there are multiple docs in Swagger UI.
304    ///
305    /// Url is path which exposes the OpenAPI doc.
306    ///
307    /// # Examples
308    ///
309    /// ```rust
310    /// # use salvo_oapi::swagger_ui::Url;
311    /// let url = Url::new("My Api", "/api-docs/openapi.json");
312    /// ```
313    #[must_use]
314    pub fn new(name: &'a str, url: &'a str) -> Self {
315        Self {
316            name: Cow::Borrowed(name),
317            url: Cow::Borrowed(url),
318            ..Default::default()
319        }
320    }
321
322    /// Create new [`Url`] with primary flag.
323    ///
324    /// Primary flag allows users to override the default behavior of the Swagger UI for selecting the primary
325    /// doc to display. By default when there are multiple docs in Swagger UI the first one in the list
326    /// will be the primary.
327    ///
328    /// Name is shown in the select dropdown when there are multiple docs in Swagger UI.
329    ///
330    /// Url is path which exposes the OpenAPI doc.
331    ///
332    /// # Examples
333    ///
334    /// Set "My Api" as primary.
335    /// ```rust
336    /// # use salvo_oapi::swagger_ui::Url;
337    /// let url = Url::with_primary("My Api", "/api-docs/openapi.json", true);
338    /// ```
339    #[must_use]
340    pub fn with_primary(name: &'a str, url: &'a str, primary: bool) -> Self {
341        Self {
342            name: Cow::Borrowed(name),
343            url: Cow::Borrowed(url),
344            primary,
345        }
346    }
347}
348
349impl<'a> From<&'a str> for Url<'a> {
350    fn from(url: &'a str) -> Self {
351        Self {
352            url: Cow::Borrowed(url),
353            ..Default::default()
354        }
355    }
356}
357
358impl From<String> for Url<'_> {
359    fn from(url: String) -> Self {
360        Self {
361            url: Cow::Owned(url),
362            ..Default::default()
363        }
364    }
365}
366
367impl From<Cow<'static, str>> for Url<'_> {
368    fn from(url: Cow<'static, str>) -> Self {
369        Self {
370            url,
371            ..Default::default()
372        }
373    }
374}
375
376/// Represents servable file of Swagger UI. This is used together with [`serve`] function
377/// to serve Swagger UI files via web server.
378#[non_exhaustive]
379#[derive(Clone, Debug)]
380pub struct SwaggerFile<'a> {
381    /// Content of the file as [`Cow`] [`slice`] of bytes.
382    pub bytes: Cow<'a, [u8]>,
383    /// Content type of the file e.g `"text/xml"`.
384    pub content_type: String,
385}
386
387/// User friendly way to serve Swagger UI and its content via web server.
388///
389/// * **path** Should be the relative path to Swagger UI resource within the web server.
390/// * **config** Swagger [`Config`] to use for the Swagger UI.
391///
392/// Typically this function is implemented _**within**_ handler what serves the Swagger UI. Handler itself must
393/// match to user defined path that points to the root of the Swagger UI and match everything relatively
394/// from the root of the Swagger UI _**(tail path)**_. The relative path from root of the Swagger UI
395/// is used to serve [`SwaggerFile`]s. If Swagger UI is served from path `/swagger-ui/` then the `tail`
396/// is everything under the `/swagger-ui/` prefix.
397///
398/// _There are also implementations in [examples of salvo repository][examples]._
399///
400/// [examples]: https://github.com/salvo-rs/salvo/tree/master/examples
401pub fn serve<'a>(
402    path: &str,
403    title: &str,
404    keywords: &str,
405    description: &str,
406    config: &Config<'a>,
407) -> Result<Option<SwaggerFile<'a>>, Error> {
408    let path = if path.is_empty() || path == "/" {
409        "index.html"
410    } else {
411        path
412    };
413
414    let bytes = if path == "index.html" {
415        let config_json = serde_json::to_string(&config)?;
416
417        // Replace {{config}} with pretty config json and remove the curly brackets `{ }` from beginning and the end.
418        let mut index = INDEX_TMPL
419            .replacen("{{config}}", &config_json, 1)
420            .replacen("{{description}}", description, 1)
421            .replacen("{{keywords}}", keywords, 1)
422            .replacen("{{title}}", title, 1);
423
424        if let Some(oauth) = &config.oauth {
425            let oauth_json = serde_json::to_string(oauth)?;
426            index = index.replace(
427                "//{{oauth}}",
428                &format!("window.ui.initOAuth({});", &oauth_json),
429            );
430        }
431        Some(Cow::Owned(index.as_bytes().to_vec()))
432    } else {
433        SwaggerUiDist::get(path).map(|f| f.data)
434    };
435    let file = bytes.map(|bytes| SwaggerFile {
436        bytes,
437        content_type: mime_infer::from_path(path)
438            .first_or_octet_stream()
439            .to_string(),
440    });
441
442    Ok(file)
443}