1use crate::response::{Response, ResponseBody};
36
37#[derive(Debug, Clone)]
39pub struct DocsConfig {
40 pub docs_path: Option<String>,
42 pub redoc_path: Option<String>,
44 pub openapi_path: String,
46 pub title: String,
48 pub swagger_ui_parameters: Option<String>,
50 pub swagger_ui_init_oauth: Option<String>,
52 pub favicon_url: Option<String>,
54 pub swagger_cdn_url: String,
56 pub redoc_cdn_url: String,
58}
59
60impl Default for DocsConfig {
61 fn default() -> Self {
62 Self {
63 docs_path: Some("/docs".to_string()),
64 redoc_path: Some("/redoc".to_string()),
65 openapi_path: "/openapi.json".to_string(),
66 title: "API Documentation".to_string(),
67 swagger_ui_parameters: None,
68 swagger_ui_init_oauth: None,
69 favicon_url: None,
70 swagger_cdn_url: "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5".to_string(),
71 redoc_cdn_url: "https://cdn.jsdelivr.net/npm/redoc@latest".to_string(),
72 }
73 }
74}
75
76impl DocsConfig {
77 #[must_use]
79 pub fn new() -> Self {
80 Self::default()
81 }
82
83 #[must_use]
85 pub fn docs_path(mut self, path: impl Into<Option<String>>) -> Self {
86 self.docs_path = path.into();
87 self
88 }
89
90 #[must_use]
92 pub fn redoc_path(mut self, path: impl Into<Option<String>>) -> Self {
93 self.redoc_path = path.into();
94 self
95 }
96
97 #[must_use]
99 pub fn openapi_path(mut self, path: impl Into<String>) -> Self {
100 self.openapi_path = path.into();
101 self
102 }
103
104 #[must_use]
106 pub fn title(mut self, title: impl Into<String>) -> Self {
107 self.title = title.into();
108 self
109 }
110
111 #[must_use]
119 pub fn swagger_ui_parameters(mut self, params: impl Into<String>) -> Self {
120 self.swagger_ui_parameters = Some(params.into());
121 self
122 }
123
124 #[must_use]
132 pub fn swagger_ui_init_oauth(mut self, config: impl Into<String>) -> Self {
133 self.swagger_ui_init_oauth = Some(config.into());
134 self
135 }
136
137 #[must_use]
139 pub fn favicon_url(mut self, url: impl Into<String>) -> Self {
140 self.favicon_url = Some(url.into());
141 self
142 }
143
144 #[must_use]
146 pub fn swagger_cdn_url(mut self, url: impl Into<String>) -> Self {
147 self.swagger_cdn_url = url.into();
148 self
149 }
150
151 #[must_use]
153 pub fn redoc_cdn_url(mut self, url: impl Into<String>) -> Self {
154 self.redoc_cdn_url = url.into();
155 self
156 }
157}
158
159#[must_use]
166pub fn swagger_ui_html(config: &DocsConfig, openapi_url: &str) -> String {
167 let title = html_escape(&config.title);
168 let swagger_cdn = &config.swagger_cdn_url;
169
170 let favicon = config.favicon_url.as_ref().map_or_else(
171 || format!(r#"<link rel="icon" type="image/png" href="{swagger_cdn}/favicon-32x32.png" sizes="32x32" />"#),
172 |url| format!(r#"<link rel="icon" href="{}" />"#, html_escape(url)),
173 );
174
175 let ui_parameters = config
176 .swagger_ui_parameters
177 .as_ref()
178 .map_or_else(|| "{}".to_string(), String::clone);
179
180 let init_oauth = config
181 .swagger_ui_init_oauth
182 .as_ref()
183 .map_or_else(String::new, |o| format!("ui.initOAuth({});", o));
184
185 format!(
186 r#"<!DOCTYPE html>
187<html lang="en">
188<head>
189 <meta charset="UTF-8">
190 <meta name="viewport" content="width=device-width, initial-scale=1.0">
191 <title>{title}</title>
192 {favicon}
193 <link rel="stylesheet" type="text/css" href="{swagger_cdn}/swagger-ui.css">
194</head>
195<body>
196 <div id="swagger-ui"></div>
197 <script src="{swagger_cdn}/swagger-ui-bundle.js"></script>
198 <script src="{swagger_cdn}/swagger-ui-standalone-preset.js"></script>
199 <script>
200 window.onload = function() {{
201 const ui = SwaggerUIBundle(Object.assign({{
202 url: "{openapi_url}",
203 dom_id: '#swagger-ui',
204 deepLinking: true,
205 presets: [
206 SwaggerUIBundle.presets.apis,
207 SwaggerUIStandalonePreset
208 ],
209 plugins: [
210 SwaggerUIBundle.plugins.DownloadUrl
211 ],
212 layout: "StandaloneLayout"
213 }}, {ui_parameters}));
214 {init_oauth}
215 window.ui = ui;
216 }};
217 </script>
218</body>
219</html>"#,
220 title = title,
221 favicon = favicon,
222 swagger_cdn = swagger_cdn,
223 openapi_url = html_escape(openapi_url),
224 ui_parameters = ui_parameters,
225 init_oauth = init_oauth,
226 )
227}
228
229#[must_use]
236pub fn redoc_html(config: &DocsConfig, openapi_url: &str) -> String {
237 let title = html_escape(&config.title);
238 let redoc_cdn = &config.redoc_cdn_url;
239
240 let favicon = config.favicon_url.as_ref().map_or_else(String::new, |url| {
241 format!(r#"<link rel="icon" href="{}" />"#, html_escape(url))
242 });
243
244 format!(
245 r#"<!DOCTYPE html>
246<html lang="en">
247<head>
248 <meta charset="UTF-8">
249 <meta name="viewport" content="width=device-width, initial-scale=1.0">
250 <title>{title}</title>
251 {favicon}
252 <style>
253 body {{
254 margin: 0;
255 padding: 0;
256 }}
257 </style>
258</head>
259<body>
260 <redoc spec-url="{openapi_url}"></redoc>
261 <script src="{redoc_cdn}/bundles/redoc.standalone.js"></script>
262</body>
263</html>"#,
264 title = title,
265 favicon = favicon,
266 openapi_url = html_escape(openapi_url),
267 redoc_cdn = redoc_cdn,
268 )
269}
270
271#[must_use]
276pub fn oauth2_redirect_html() -> &'static str {
277 r#"<!DOCTYPE html>
278<html lang="en">
279<head>
280 <meta charset="UTF-8">
281 <title>OAuth2 Redirect</title>
282</head>
283<body>
284 <script>
285 'use strict';
286 function run() {
287 var oauth2 = window.opener.swaggerUIRedirectOauth2;
288 var sentState = oauth2.state;
289 var redirectUrl = oauth2.redirectUrl;
290 var isValid, qp, arr;
291
292 if (/code|token|error/.test(window.location.hash)) {
293 qp = window.location.hash.substring(1);
294 } else {
295 qp = window.location.search.substring(1);
296 }
297
298 arr = qp.split("&");
299 arr.forEach(function(v, i, _arr) { _arr[i] = '"' + v.replace('=', '":"') + '"'; });
300 qp = qp ? JSON.parse('{' + arr.join(',') + '}',
301 function(key, value) {
302 return key === "" ? value : decodeURIComponent(value);
303 }
304 ) : {};
305
306 isValid = qp.state === sentState;
307
308 if ((oauth2.auth.schema.get("flow") === "accessCode" ||
309 oauth2.auth.schema.get("flow") === "authorizationCode" ||
310 oauth2.auth.schema.get("flow") === "authorization_code") &&
311 !oauth2.auth.code) {
312 if (!isValid) {
313 oauth2.errCb({
314 authId: oauth2.auth.name,
315 source: "auth",
316 level: "warning",
317 message: "Authorization may be unsafe, passed state was changed in server. The passed state wasn't returned from auth server."
318 });
319 }
320
321 if (qp.code) {
322 delete oauth2.state;
323 oauth2.auth.code = qp.code;
324 oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
325 } else {
326 var oauthErrorMsg;
327 if (qp.error) {
328 oauthErrorMsg = "[" + qp.error + "]: " +
329 (qp.error_description ? qp.error_description + ". " : "no accessCode received from the server. ") +
330 (qp.error_uri ? "More info: " + qp.error_uri : "");
331 }
332
333 oauth2.errCb({
334 authId: oauth2.auth.name,
335 source: "auth",
336 level: "error",
337 message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server."
338 });
339 }
340 } else {
341 oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
342 }
343 window.close();
344 }
345
346 if (document.readyState !== 'loading') {
347 run();
348 } else {
349 document.addEventListener('DOMContentLoaded', function() {
350 run();
351 });
352 }
353 </script>
354</body>
355</html>"#
356}
357
358#[must_use]
360pub fn swagger_ui_response(config: &DocsConfig, openapi_url: &str) -> Response {
361 let html = swagger_ui_html(config, openapi_url);
362 Response::ok()
363 .header("content-type", b"text/html; charset=utf-8".to_vec())
364 .body(ResponseBody::Bytes(html.into_bytes()))
365}
366
367#[must_use]
369pub fn redoc_response(config: &DocsConfig, openapi_url: &str) -> Response {
370 let html = redoc_html(config, openapi_url);
371 Response::ok()
372 .header("content-type", b"text/html; charset=utf-8".to_vec())
373 .body(ResponseBody::Bytes(html.into_bytes()))
374}
375
376#[must_use]
378pub fn oauth2_redirect_response() -> Response {
379 Response::ok()
380 .header("content-type", b"text/html; charset=utf-8".to_vec())
381 .body(ResponseBody::Bytes(
382 oauth2_redirect_html().as_bytes().to_vec(),
383 ))
384}
385
386fn html_escape(s: &str) -> String {
388 s.replace('&', "&")
389 .replace('<', "<")
390 .replace('>', ">")
391 .replace('"', """)
392 .replace('\'', "'")
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[test]
400 fn test_default_config() {
401 let config = DocsConfig::default();
402 assert_eq!(config.docs_path, Some("/docs".to_string()));
403 assert_eq!(config.redoc_path, Some("/redoc".to_string()));
404 assert_eq!(config.openapi_path, "/openapi.json");
405 assert_eq!(config.title, "API Documentation");
406 }
407
408 #[test]
409 fn test_config_builder() {
410 let config = DocsConfig::new()
411 .docs_path(Some("/api-docs".to_string()))
412 .redoc_path(None::<String>)
413 .openapi_path("/spec.json")
414 .title("My API")
415 .swagger_ui_parameters(r#"{"docExpansion": "none"}"#)
416 .swagger_ui_init_oauth(r#"{"clientId": "test"}"#);
417
418 assert_eq!(config.docs_path, Some("/api-docs".to_string()));
419 assert_eq!(config.redoc_path, None);
420 assert_eq!(config.openapi_path, "/spec.json");
421 assert_eq!(config.title, "My API");
422 assert!(config.swagger_ui_parameters.is_some());
423 assert!(config.swagger_ui_init_oauth.is_some());
424 }
425
426 #[test]
427 fn test_swagger_ui_html() {
428 let config = DocsConfig::new().title("Test API");
429 let html = swagger_ui_html(&config, "/openapi.json");
430
431 assert!(html.contains("<title>Test API</title>"));
432 assert!(html.contains("swagger-ui-bundle.js"));
433 assert!(html.contains("url: \"/openapi.json\""));
434 }
435
436 #[test]
437 fn test_redoc_html() {
438 let config = DocsConfig::new().title("Test API");
439 let html = redoc_html(&config, "/openapi.json");
440
441 assert!(html.contains("<title>Test API</title>"));
442 assert!(html.contains("redoc.standalone.js"));
443 assert!(html.contains("spec-url=\"/openapi.json\""));
444 }
445
446 #[test]
447 fn test_oauth2_redirect_html() {
448 let html = oauth2_redirect_html();
449 assert!(html.contains("OAuth2 Redirect"));
450 assert!(html.contains("swaggerUIRedirectOauth2"));
451 }
452
453 #[test]
454 fn test_html_escape() {
455 assert_eq!(html_escape("<script>"), "<script>");
456 assert_eq!(html_escape("a&b"), "a&b");
457 assert_eq!(html_escape("\"test\""), ""test"");
458 }
459
460 #[test]
461 fn test_swagger_ui_with_custom_params() {
462 let config = DocsConfig::new().swagger_ui_parameters(r#"{"filter": true}"#);
463 let html = swagger_ui_html(&config, "/openapi.json");
464
465 assert!(html.contains(r#"{"filter": true}"#));
466 }
467
468 #[test]
469 fn test_swagger_ui_with_oauth() {
470 let config = DocsConfig::new().swagger_ui_init_oauth(r#"{"clientId": "my-app"}"#);
471 let html = swagger_ui_html(&config, "/openapi.json");
472
473 assert!(html.contains(r#"ui.initOAuth({"clientId": "my-app"});"#));
474 }
475
476 #[test]
477 fn test_custom_cdn_urls() {
478 let config = DocsConfig::new()
479 .swagger_cdn_url("https://custom.cdn/swagger")
480 .redoc_cdn_url("https://custom.cdn/redoc");
481
482 let swagger_html = swagger_ui_html(&config, "/spec.json");
483 let redoc_html = redoc_html(&config, "/spec.json");
484
485 assert!(swagger_html.contains("https://custom.cdn/swagger"));
486 assert!(redoc_html.contains("https://custom.cdn/redoc"));
487 }
488}