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}