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}