Skip to main content

fastapi_core/
docs.rs

1//! Interactive API documentation endpoints.
2//!
3//! This module provides handlers for serving Swagger UI and ReDoc
4//! documentation interfaces, as well as the OpenAPI JSON specification.
5//!
6//! # Usage
7//!
8//! ```ignore
9//! use fastapi_core::{App, DocsConfig};
10//!
11//! let app = App::builder()
12//!     .get("/items", list_items)
13//!     .post("/items", create_item)
14//!     .enable_docs(DocsConfig::default())
15//!     .build();
16//! ```
17//!
18//! This will add the following endpoints:
19//! - `GET /docs` - Swagger UI interface
20//! - `GET /redoc` - ReDoc interface
21//! - `GET /openapi.json` - OpenAPI 3.1 specification
22//! - `GET /docs/oauth2-redirect` - OAuth2 callback handler
23//!
24//! # Configuration
25//!
26//! ```ignore
27//! let config = DocsConfig::new()
28//!     .docs_path("/api-docs")  // Default: /docs
29//!     .redoc_path("/api-redoc")  // Default: /redoc
30//!     .openapi_path("/api-spec.json")  // Default: /openapi.json
31//!     .title("My API Documentation")
32//!     .swagger_ui_parameters(r#"{"docExpansion": "none"}"#);
33//! ```
34
35use crate::response::{Response, ResponseBody};
36
37/// Configuration for the API documentation endpoints.
38#[derive(Debug, Clone)]
39pub struct DocsConfig {
40    /// Path for Swagger UI. Set to None to disable.
41    pub docs_path: Option<String>,
42    /// Path for ReDoc. Set to None to disable.
43    pub redoc_path: Option<String>,
44    /// Path for the OpenAPI JSON specification.
45    pub openapi_path: String,
46    /// Title shown in the documentation.
47    pub title: String,
48    /// Swagger UI configuration parameters (JSON).
49    pub swagger_ui_parameters: Option<String>,
50    /// Swagger UI OAuth initialization config (JSON).
51    pub swagger_ui_init_oauth: Option<String>,
52    /// Custom favicon URL.
53    pub favicon_url: Option<String>,
54    /// CDN base URL for Swagger UI assets.
55    pub swagger_cdn_url: String,
56    /// CDN base URL for ReDoc assets.
57    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    /// Create a new DocsConfig with default settings.
78    #[must_use]
79    pub fn new() -> Self {
80        Self::default()
81    }
82
83    /// Set the path for Swagger UI. Use None to disable.
84    #[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    /// Set the path for ReDoc. Use None to disable.
91    #[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    /// Set the path for the OpenAPI JSON specification.
98    #[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    /// Set the title shown in documentation.
105    #[must_use]
106    pub fn title(mut self, title: impl Into<String>) -> Self {
107        self.title = title.into();
108        self
109    }
110
111    /// Set Swagger UI configuration parameters (JSON object).
112    ///
113    /// # Example
114    ///
115    /// ```ignore
116    /// config.swagger_ui_parameters(r#"{"docExpansion": "none", "filter": true}"#)
117    /// ```
118    #[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    /// Set Swagger UI OAuth initialization config (JSON object).
125    ///
126    /// # Example
127    ///
128    /// ```ignore
129    /// config.swagger_ui_init_oauth(r#"{"clientId": "my-client-id"}"#)
130    /// ```
131    #[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    /// Set a custom favicon URL.
138    #[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    /// Set the CDN base URL for Swagger UI assets.
145    #[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    /// Set the CDN base URL for ReDoc assets.
152    #[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/// Generate the Swagger UI HTML page.
160///
161/// # Arguments
162///
163/// * `config` - Documentation configuration
164/// * `openapi_url` - URL to the OpenAPI JSON specification
165#[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/// Generate the ReDoc HTML page.
230///
231/// # Arguments
232///
233/// * `config` - Documentation configuration
234/// * `openapi_url` - URL to the OpenAPI JSON specification
235#[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/// Generate the OAuth2 redirect HTML page.
272///
273/// This page is used as the callback URL for OAuth2 authorization flows
274/// in Swagger UI.
275#[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/// Create a response with Swagger UI HTML.
359#[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/// Create a response with ReDoc HTML.
368#[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/// Create a response with OAuth2 redirect HTML.
377#[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
386/// Simple HTML escaping for attribute values.
387fn html_escape(s: &str) -> String {
388    s.replace('&', "&amp;")
389        .replace('<', "&lt;")
390        .replace('>', "&gt;")
391        .replace('"', "&quot;")
392        .replace('\'', "&#x27;")
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>"), "&lt;script&gt;");
456        assert_eq!(html_escape("a&b"), "a&amp;b");
457        assert_eq!(html_escape("\"test\""), "&quot;test&quot;");
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}