Skip to main content

silent_openapi/
redoc.rs

1//! ReDoc 文档 UI
2//!
3//! 提供 ReDoc 风格的 API 文档页面,作为 Swagger UI 的替代选择。
4//! 支持 Handler 和 Middleware 两种集成方式,与 Swagger UI 共用同一 OpenAPI JSON 端点。
5
6use crate::{OpenApiError, Result};
7use async_trait::async_trait;
8use silent::{Handler, MiddleWareHandler, Next, Request, Response, StatusCode};
9use utoipa::openapi::OpenApi;
10
11const REDOC_VERSION: &str = "2.1.5";
12
13/// 生成 ReDoc HTML 页面
14fn generate_redoc_html(api_doc_url: &str) -> String {
15    format!(
16        r#"<!DOCTYPE html>
17<html lang="zh-CN">
18<head>
19    <meta charset="UTF-8">
20    <meta name="viewport" content="width=device-width, initial-scale=1.0">
21    <title>API Documentation - ReDoc</title>
22    <style>
23        body {{ margin: 0; padding: 0; }}
24    </style>
25</head>
26<body>
27    <redoc spec-url='{api_doc_url}'></redoc>
28    <script src="https://unpkg.com/redoc@{REDOC_VERSION}/bundles/redoc.standalone.js"></script>
29</body>
30</html>"#
31    )
32}
33
34/// ReDoc 处理器
35///
36/// 以 Handler 方式提供 ReDoc 文档页面。
37/// 可与 `SwaggerUiHandler` 并存,共用同一 OpenAPI JSON 路径。
38#[derive(Clone)]
39pub struct ReDocHandler {
40    ui_path: String,
41    api_doc_path: String,
42    openapi_json: String,
43}
44
45impl ReDocHandler {
46    /// 创建 ReDoc 处理器
47    ///
48    /// # 参数
49    ///
50    /// - `ui_path`: ReDoc 页面路径,如 "/redoc"
51    /// - `openapi`: OpenAPI 规范对象
52    pub fn new(ui_path: &str, openapi: OpenApi) -> Result<Self> {
53        let api_doc_path = format!("{}/openapi.json", ui_path.trim_end_matches('/'));
54        let openapi_json = serde_json::to_string_pretty(&openapi).map_err(OpenApiError::Json)?;
55
56        Ok(Self {
57            ui_path: ui_path.to_string(),
58            api_doc_path,
59            openapi_json,
60        })
61    }
62
63    /// 使用自定义 OpenAPI JSON 路径(如与 Swagger UI 共用同一端点)
64    pub fn with_custom_api_doc_path(
65        ui_path: &str,
66        api_doc_path: &str,
67        openapi: OpenApi,
68    ) -> Result<Self> {
69        let openapi_json = serde_json::to_string_pretty(&openapi).map_err(OpenApiError::Json)?;
70
71        Ok(Self {
72            ui_path: ui_path.to_string(),
73            api_doc_path: api_doc_path.to_string(),
74            openapi_json,
75        })
76    }
77
78    fn matches_path(&self, path: &str) -> bool {
79        path == self.ui_path
80            || path.starts_with(&format!("{}/", self.ui_path))
81            || path == self.api_doc_path
82    }
83
84    /// 将处理器转换为可直接挂载的 Route 树
85    pub fn into_route(self) -> silent::prelude::Route {
86        use silent::prelude::{HandlerGetter, Method, Route};
87        use std::sync::Arc;
88
89        let mount = self.ui_path.trim_start_matches('/');
90
91        let base = Route::new(mount)
92            .insert_handler(Method::GET, Arc::new(self.clone()))
93            .insert_handler(Method::HEAD, Arc::new(self.clone()))
94            .append(
95                Route::new("<path:**>")
96                    .insert_handler(Method::GET, Arc::new(self.clone()))
97                    .insert_handler(Method::HEAD, Arc::new(self)),
98            );
99
100        Route::new("").append(base)
101    }
102}
103
104impl silent::prelude::RouterAdapt for ReDocHandler {
105    fn into_router(self) -> silent::prelude::Route {
106        self.into_route()
107    }
108}
109
110#[async_trait]
111impl Handler for ReDocHandler {
112    async fn call(&self, req: Request) -> silent::Result<Response> {
113        let path = req.uri().path();
114
115        if !self.matches_path(path) {
116            return Err(silent::SilentError::NotFound);
117        }
118
119        if path == self.api_doc_path {
120            let mut response = Response::empty();
121            response.set_status(StatusCode::OK);
122            response.set_header(
123                http::header::CONTENT_TYPE,
124                http::HeaderValue::from_static("application/json; charset=utf-8"),
125            );
126            response.set_body(self.openapi_json.clone().into());
127            Ok(response)
128        } else if path == self.ui_path {
129            let mut response = Response::empty();
130            response.set_status(StatusCode::MOVED_PERMANENTLY);
131            response.set_header(
132                http::header::LOCATION,
133                http::HeaderValue::from_str(&format!("{}/", self.ui_path))
134                    .unwrap_or_else(|_| http::HeaderValue::from_static("/")),
135            );
136            Ok(response)
137        } else {
138            let html = generate_redoc_html(&self.api_doc_path);
139            let mut response = Response::empty();
140            response.set_status(StatusCode::OK);
141            response.set_header(
142                http::header::CONTENT_TYPE,
143                http::HeaderValue::from_static("text/html; charset=utf-8"),
144            );
145            response.set_body(html.into());
146            Ok(response)
147        }
148    }
149}
150
151/// ReDoc 中间件
152///
153/// 以 Middleware 方式提供 ReDoc 文档页面。
154/// 当请求匹配 ReDoc 路径时拦截返回,否则透传到下游处理器。
155#[derive(Clone)]
156pub struct ReDocMiddleware {
157    ui_path: String,
158    api_doc_path: String,
159    openapi_json: String,
160}
161
162impl ReDocMiddleware {
163    /// 创建 ReDoc 中间件
164    pub fn new(ui_path: &str, openapi: OpenApi) -> Result<Self> {
165        let api_doc_path = format!("{}/openapi.json", ui_path.trim_end_matches('/'));
166        let openapi_json = serde_json::to_string_pretty(&openapi).map_err(OpenApiError::Json)?;
167
168        Ok(Self {
169            ui_path: ui_path.to_string(),
170            api_doc_path,
171            openapi_json,
172        })
173    }
174
175    /// 使用自定义 OpenAPI JSON 路径
176    pub fn with_custom_api_doc_path(
177        ui_path: &str,
178        api_doc_path: &str,
179        openapi: OpenApi,
180    ) -> Result<Self> {
181        let openapi_json = serde_json::to_string_pretty(&openapi).map_err(OpenApiError::Json)?;
182
183        Ok(Self {
184            ui_path: ui_path.to_string(),
185            api_doc_path: api_doc_path.to_string(),
186            openapi_json,
187        })
188    }
189
190    fn matches_path(&self, path: &str) -> bool {
191        path == self.ui_path
192            || path.starts_with(&format!("{}/", self.ui_path))
193            || path == self.api_doc_path
194    }
195}
196
197#[async_trait]
198impl MiddleWareHandler for ReDocMiddleware {
199    async fn handle(&self, req: Request, next: &Next) -> silent::Result<Response> {
200        let path = req.uri().path();
201
202        if !self.matches_path(path) {
203            return next.call(req).await;
204        }
205
206        if path == self.api_doc_path {
207            let mut response = Response::empty();
208            response.set_status(StatusCode::OK);
209            response.set_header(
210                http::header::CONTENT_TYPE,
211                http::HeaderValue::from_static("application/json; charset=utf-8"),
212            );
213            response.set_header(
214                http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
215                http::HeaderValue::from_static("*"),
216            );
217            response.set_body(self.openapi_json.clone().into());
218            Ok(response)
219        } else if path == self.ui_path {
220            let mut response = Response::empty();
221            response.set_status(StatusCode::MOVED_PERMANENTLY);
222            response.set_header(
223                http::header::LOCATION,
224                http::HeaderValue::from_str(&format!("{}/", self.ui_path))
225                    .unwrap_or_else(|_| http::HeaderValue::from_static("/")),
226            );
227            Ok(response)
228        } else {
229            let html = generate_redoc_html(&self.api_doc_path);
230            let mut response = Response::empty();
231            response.set_status(StatusCode::OK);
232            response.set_header(
233                http::header::CONTENT_TYPE,
234                http::HeaderValue::from_static("text/html; charset=utf-8"),
235            );
236            response.set_header(
237                http::header::CACHE_CONTROL,
238                http::HeaderValue::from_static("no-cache, no-store, must-revalidate"),
239            );
240            response.set_body(html.into());
241            Ok(response)
242        }
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use utoipa::OpenApi;
250
251    #[derive(OpenApi)]
252    #[openapi(
253        info(title = "Test API", version = "1.0.0"),
254        paths(),
255        components(schemas())
256    )]
257    struct TestApiDoc;
258
259    #[test]
260    fn test_redoc_handler_creation() {
261        let handler = ReDocHandler::new("/redoc", TestApiDoc::openapi());
262        assert!(handler.is_ok());
263        let handler = handler.unwrap();
264        assert_eq!(handler.ui_path, "/redoc");
265        assert_eq!(handler.api_doc_path, "/redoc/openapi.json");
266    }
267
268    #[test]
269    fn test_redoc_handler_path_matching() {
270        let handler = ReDocHandler::new("/redoc", TestApiDoc::openapi()).unwrap();
271        assert!(handler.matches_path("/redoc"));
272        assert!(handler.matches_path("/redoc/"));
273        assert!(handler.matches_path("/redoc/openapi.json"));
274        assert!(!handler.matches_path("/api/users"));
275    }
276
277    #[test]
278    fn test_redoc_handler_custom_api_doc_path() {
279        let handler = ReDocHandler::with_custom_api_doc_path(
280            "/redoc",
281            "/docs/openapi.json",
282            TestApiDoc::openapi(),
283        )
284        .unwrap();
285        assert_eq!(handler.api_doc_path, "/docs/openapi.json");
286        assert!(handler.matches_path("/docs/openapi.json"));
287    }
288
289    #[tokio::test]
290    async fn test_redoc_handler_openapi_json() {
291        let handler = ReDocHandler::new("/redoc", TestApiDoc::openapi()).unwrap();
292        let mut req = Request::empty();
293        *req.uri_mut() = http::Uri::from_static("http://localhost/redoc/openapi.json");
294        let resp = handler.call(req).await.unwrap();
295        assert!(
296            resp.headers()
297                .get(http::header::CONTENT_TYPE)
298                .map(|v| v.to_str().unwrap_or("").contains("application/json"))
299                .unwrap_or(false)
300        );
301    }
302
303    #[tokio::test]
304    async fn test_redoc_handler_redirect() {
305        let handler = ReDocHandler::new("/redoc", TestApiDoc::openapi()).unwrap();
306        let mut req = Request::empty();
307        *req.uri_mut() = http::Uri::from_static("http://localhost/redoc");
308        let resp = handler.call(req).await.unwrap();
309        assert!(resp.headers().get(http::header::LOCATION).is_some());
310    }
311
312    #[tokio::test]
313    async fn test_redoc_handler_html_page() {
314        let handler = ReDocHandler::new("/redoc", TestApiDoc::openapi()).unwrap();
315        let mut req = Request::empty();
316        *req.uri_mut() = http::Uri::from_static("http://localhost/redoc/");
317        let resp = handler.call(req).await.unwrap();
318        assert!(
319            resp.headers()
320                .get(http::header::CONTENT_TYPE)
321                .map(|v| v.to_str().unwrap_or("").contains("text/html"))
322                .unwrap_or(false)
323        );
324    }
325
326    #[test]
327    fn test_redoc_middleware_creation() {
328        let mw = ReDocMiddleware::new("/redoc", TestApiDoc::openapi());
329        assert!(mw.is_ok());
330    }
331
332    #[test]
333    fn test_redoc_middleware_path_matching() {
334        let mw = ReDocMiddleware::new("/redoc", TestApiDoc::openapi()).unwrap();
335        assert!(mw.matches_path("/redoc"));
336        assert!(mw.matches_path("/redoc/"));
337        assert!(!mw.matches_path("/other"));
338    }
339
340    #[test]
341    fn test_generate_redoc_html() {
342        let html = generate_redoc_html("/api/openapi.json");
343        assert!(html.contains("/api/openapi.json"));
344        assert!(html.contains("redoc"));
345        assert!(html.contains("redoc.standalone.js"));
346    }
347
348    #[tokio::test]
349    async fn test_redoc_handler_into_route() {
350        let handler = ReDocHandler::new("/redoc", TestApiDoc::openapi()).unwrap();
351        let route = handler.into_route();
352        let mut req = Request::empty();
353        *req.uri_mut() = http::Uri::from_static("http://localhost/redoc/openapi.json");
354        let resp = route.call(req).await.unwrap();
355        assert!(resp.headers().get(http::header::CONTENT_TYPE).is_some());
356    }
357}