silent_openapi/
handler.rs

1//! Swagger UI 处理器
2//!
3//! 提供Swagger UI的处理器实现,可以直接作为Silent路由使用。
4
5use crate::{OpenApiError, Result, SwaggerUiOptions};
6use async_trait::async_trait;
7use silent::{Handler, Request, Response, StatusCode};
8use utoipa::openapi::OpenApi;
9
10/// Swagger UI 处理器
11///
12/// 实现了Silent的Handler trait,可以直接添加到路由中。
13/// 负责处理Swagger UI相关的所有请求,包括:
14/// - Swagger UI 静态资源
15/// - OpenAPI 规范JSON
16/// - 重定向到Swagger UI主页
17#[derive(Clone)]
18pub struct SwaggerUiHandler {
19    /// Swagger UI的基础路径
20    ui_path: String,
21    /// OpenAPI JSON的路径
22    api_doc_path: String,
23    /// OpenAPI 规范的JSON字符串
24    openapi_json: String,
25    /// UI 配置
26    options: SwaggerUiOptions,
27}
28
29impl SwaggerUiHandler {
30    /// 创建新的Swagger UI处理器
31    ///
32    /// # 参数
33    ///
34    /// - `ui_path`: Swagger UI的访问路径,如 "/swagger-ui"
35    /// - `openapi`: OpenAPI规范对象
36    ///
37    /// # 示例
38    ///
39    /// ```rust
40    /// use silent_openapi::SwaggerUiHandler;
41    /// use utoipa::OpenApi;
42    ///
43    /// #[derive(OpenApi)]
44    /// #[openapi(paths(), components(schemas()))]
45    /// struct ApiDoc;
46    ///
47    /// let handler = SwaggerUiHandler::new("/swagger-ui", ApiDoc::openapi());
48    /// ```
49    pub fn new(ui_path: &str, openapi: OpenApi) -> Result<Self> {
50        let api_doc_path = format!("{}/openapi.json", ui_path.trim_end_matches('/'));
51        let openapi_json = serde_json::to_string_pretty(&openapi).map_err(OpenApiError::Json)?;
52
53        Ok(Self {
54            ui_path: ui_path.to_string(),
55            api_doc_path,
56            openapi_json,
57            options: SwaggerUiOptions::default(),
58        })
59    }
60
61    /// 使用自定义的API文档路径
62    ///
63    /// # 参数
64    ///
65    /// - `ui_path`: Swagger UI的访问路径
66    /// - `api_doc_path`: OpenAPI JSON的访问路径
67    /// - `openapi`: OpenAPI规范对象
68    pub fn with_custom_api_doc_path(
69        ui_path: &str,
70        api_doc_path: &str,
71        openapi: OpenApi,
72    ) -> Result<Self> {
73        let openapi_json = serde_json::to_string_pretty(&openapi).map_err(OpenApiError::Json)?;
74
75        Ok(Self {
76            ui_path: ui_path.to_string(),
77            api_doc_path: api_doc_path.to_string(),
78            openapi_json,
79            options: SwaggerUiOptions::default(),
80        })
81    }
82
83    /// 使用自定义选项创建处理器
84    pub fn with_options(
85        ui_path: &str,
86        openapi: OpenApi,
87        options: SwaggerUiOptions,
88    ) -> Result<Self> {
89        let api_doc_path = format!("{}/openapi.json", ui_path.trim_end_matches('/'));
90        let openapi_json = serde_json::to_string_pretty(&openapi).map_err(OpenApiError::Json)?;
91
92        Ok(Self {
93            ui_path: ui_path.to_string(),
94            api_doc_path,
95            openapi_json,
96            options,
97        })
98    }
99
100    /// 检查请求路径是否匹配
101    fn matches_path(&self, path: &str) -> bool {
102        // 匹配以下情况:
103        // 1. 完全匹配 ui_path (重定向到主页)
104        // 2. 以 ui_path/ 开头的路径 (Swagger UI资源)
105        // 3. 完全匹配 api_doc_path (OpenAPI JSON)
106        path == self.ui_path
107            || path.starts_with(&format!("{}/", self.ui_path))
108            || path == self.api_doc_path
109    }
110
111    /// 处理OpenAPI JSON请求
112    async fn handle_openapi_json(&self) -> Result<Response> {
113        let mut response = Response::empty();
114        response.set_status(StatusCode::OK);
115        response.set_header(
116            http::header::CONTENT_TYPE,
117            http::HeaderValue::from_static("application/json; charset=utf-8"),
118        );
119        response.set_body(self.openapi_json.clone().into());
120        Ok(response)
121    }
122
123    /// 处理Swagger UI重定向
124    async fn handle_ui_redirect(&self) -> Result<Response> {
125        let redirect_url = format!("{}/", self.ui_path);
126        let mut response = Response::empty();
127        response.set_status(StatusCode::MOVED_PERMANENTLY);
128        response.set_header(
129            http::header::LOCATION,
130            http::HeaderValue::from_str(&redirect_url)
131                .unwrap_or_else(|_| http::HeaderValue::from_static("/")),
132        );
133        Ok(response)
134    }
135
136    /// 处理Swagger UI资源请求
137    async fn handle_ui_resource(&self, path: &str) -> Result<Response> {
138        // 移除基础路径前缀,获取相对路径
139        let relative_path = path
140            .strip_prefix(&format!("{}/", self.ui_path))
141            .unwrap_or("");
142
143        // 处理根路径请求(显示Swagger UI主页)
144        if relative_path.is_empty() || relative_path == "index.html" {
145            return self.serve_swagger_ui_index().await;
146        }
147
148        // 处理其他静态资源
149        self.serve_swagger_ui_asset(relative_path).await
150    }
151
152    /// 服务Swagger UI主页
153    async fn serve_swagger_ui_index(&self) -> Result<Response> {
154        // 生成Swagger UI的HTML页面
155        let html = format!(
156            r#"<!DOCTYPE html>
157<html lang="zh-CN">
158<head>
159    <meta charset="UTF-8">
160    <meta name="viewport" content="width=device-width, initial-scale=1.0">
161    <title>Swagger UI</title>
162    <link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.17.14/swagger-ui.css" />
163    <link rel="icon" type="image/png" href="https://unpkg.com/swagger-ui-dist@5.17.14/favicon-32x32.png" sizes="32x32" />
164    <style>
165        html {{
166            box-sizing: border-box;
167            overflow: -moz-scrollbars-vertical;
168            overflow-y: scroll;
169        }}
170        *, *:before, *:after {{
171            box-sizing: inherit;
172        }}
173        body {{
174            margin:0;
175            background: #fafafa;
176        }}
177    </style>
178</head>
179<body>
180    <div id="swagger-ui"></div>
181
182    <script src="https://unpkg.com/swagger-ui-dist@5.17.14/swagger-ui-bundle.js"></script>
183    <script src="https://unpkg.com/swagger-ui-dist@5.17.14/swagger-ui-standalone-preset.js"></script>
184    <script>
185        window.onload = function() {{
186            const ui = SwaggerUIBundle({{
187                url: '{}',
188                dom_id: '#swagger-ui',
189                deepLinking: true,
190                presets: [
191                    SwaggerUIBundle.presets.apis,
192                    SwaggerUIStandalonePreset
193                ],
194                plugins: [
195                    SwaggerUIBundle.plugins.DownloadUrl
196                ],
197                layout: "StandaloneLayout",
198                tryItOutEnabled: {}
199            }})
200        }}
201    </script>
202</body>
203</html>"#,
204            self.api_doc_path,
205            if self.options.try_it_out_enabled {
206                "true"
207            } else {
208                "false"
209            }
210        );
211
212        let mut response = Response::empty();
213        response.set_status(StatusCode::OK);
214        response.set_header(
215            http::header::CONTENT_TYPE,
216            http::HeaderValue::from_static("text/html; charset=utf-8"),
217        );
218        response.set_body(html.into());
219        Ok(response)
220    }
221
222    /// 服务Swagger UI静态资源
223    async fn serve_swagger_ui_asset(&self, _asset_path: &str) -> Result<Response> {
224        // 对于基础版本,我们使用CDN资源,所以这里返回404
225        // 在后续版本中可以考虑嵌入静态资源
226        let mut response = Response::empty();
227        response.set_status(StatusCode::NOT_FOUND);
228        response.set_body("Asset not found".into());
229        Ok(response)
230    }
231
232    /// 将处理器转换为可直接挂载的 Route 树
233    ///
234    /// 自动在 `<ui_path>` 下注册以下路由(GET/HEAD):
235    /// - `<ui_path>`
236    /// - `<ui_path>/openapi.json`
237    /// - `<ui_path>/<path:**>`
238    pub fn into_route(self) -> silent::prelude::Route {
239        use silent::prelude::{HandlerGetter, Method, Route};
240        use std::sync::Arc;
241
242        let mount = self.ui_path.trim_start_matches('/');
243
244        let base = Route::new(mount)
245            .insert_handler(Method::GET, Arc::new(self.clone()))
246            .insert_handler(Method::HEAD, Arc::new(self.clone()))
247            .append(
248                Route::new("<path:**>")
249                    .insert_handler(Method::GET, Arc::new(self.clone()))
250                    .insert_handler(Method::HEAD, Arc::new(self)),
251            );
252
253        Route::new("").append(base)
254    }
255}
256
257// 允许在 Route::append 直接使用处理器
258impl silent::prelude::RouterAdapt for SwaggerUiHandler {
259    fn into_router(self) -> silent::prelude::Route {
260        self.into_route()
261    }
262}
263
264#[async_trait]
265impl Handler for SwaggerUiHandler {
266    async fn call(&self, req: Request) -> silent::Result<Response> {
267        let path = req.uri().path();
268
269        // 检查路径是否匹配
270        if !self.matches_path(path) {
271            return Err(silent::SilentError::NotFound);
272        }
273
274        let result = if path == self.api_doc_path {
275            // 返回OpenAPI JSON
276            self.handle_openapi_json().await
277        } else if path == self.ui_path {
278            // 重定向到Swagger UI主页
279            self.handle_ui_redirect().await
280        } else {
281            // 处理Swagger UI资源
282            self.handle_ui_resource(path).await
283        };
284
285        match result {
286            Ok(response) => Ok(response),
287            Err(e) => {
288                eprintln!("Swagger UI处理错误: {}", e);
289                Err(silent::SilentError::NotFound)
290            }
291        }
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use utoipa::OpenApi;
299
300    #[derive(OpenApi)]
301    #[openapi(
302        info(title = "Test API", version = "1.0.0"),
303        paths(),
304        components(schemas())
305    )]
306    struct TestApiDoc;
307
308    #[test]
309    fn test_swagger_ui_handler_creation() {
310        let handler = SwaggerUiHandler::new("/swagger-ui", TestApiDoc::openapi());
311        assert!(handler.is_ok());
312
313        let handler = handler.unwrap();
314        assert_eq!(handler.ui_path, "/swagger-ui");
315        assert_eq!(handler.api_doc_path, "/swagger-ui/openapi.json");
316    }
317
318    #[test]
319    fn test_path_matching() {
320        let handler = SwaggerUiHandler::new("/swagger-ui", TestApiDoc::openapi()).unwrap();
321
322        assert!(handler.matches_path("/swagger-ui"));
323        assert!(handler.matches_path("/swagger-ui/"));
324        assert!(handler.matches_path("/swagger-ui/index.html"));
325        assert!(handler.matches_path("/swagger-ui/openapi.json"));
326        assert!(handler.matches_path("/swagger-ui/any/asset.js"));
327        assert!(!handler.matches_path("/api/users"));
328        assert!(!handler.matches_path("/swagger"));
329    }
330
331    #[tokio::test]
332    async fn test_openapi_json_response() {
333        let handler = SwaggerUiHandler::new("/swagger-ui", TestApiDoc::openapi()).unwrap();
334        let response = handler.handle_openapi_json().await.unwrap();
335
336        // 注意:Silent的Response没有public的status()方法
337        // 这里只验证响应能成功创建
338        assert!(response.headers().get(http::header::CONTENT_TYPE).is_some());
339    }
340
341    #[tokio::test]
342    async fn test_call_openapi_json_via_dispatch() {
343        let handler = SwaggerUiHandler::new("/docs", TestApiDoc::openapi()).unwrap();
344        let mut req = Request::empty();
345        *req.uri_mut() = http::Uri::from_static("http://localhost/docs/openapi.json");
346        let resp = handler.call(req).await.unwrap();
347        assert!(
348            resp.headers()
349                .get(http::header::CONTENT_TYPE)
350                .map(|v| v.to_str().unwrap_or("").contains("application/json"))
351                .unwrap_or(false)
352        );
353    }
354
355    #[tokio::test]
356    async fn test_call_redirect_and_asset() {
357        let handler = SwaggerUiHandler::new("/docs", TestApiDoc::openapi()).unwrap();
358        // 重定向
359        let mut req = Request::empty();
360        *req.uri_mut() = http::Uri::from_static("http://localhost/docs");
361        let resp = handler.call(req).await.unwrap();
362        assert!(resp.headers().get(http::header::LOCATION).is_some());
363
364        // 静态资源404 分支可达
365        let mut req2 = Request::empty();
366        *req2.uri_mut() = http::Uri::from_static("http://localhost/docs/unknown.css");
367        let _resp2 = handler.call(req2).await.unwrap();
368    }
369
370    #[tokio::test]
371    async fn test_handle_ui_resource_index_html() {
372        let handler = SwaggerUiHandler::new("/docs", TestApiDoc::openapi()).unwrap();
373        let resp = handler
374            .handle_ui_resource("/docs/index.html")
375            .await
376            .unwrap();
377        let ct = resp.headers().get(http::header::CONTENT_TYPE).unwrap();
378        assert!(ct.to_str().unwrap_or("").contains("text/html"));
379    }
380
381    #[tokio::test]
382    async fn test_head_fallback_via_route() {
383        // 使用 into_route 挂载后,通过 Route 执行 HEAD,验证可达(GET 回退 HEAD)。
384        let handler = SwaggerUiHandler::new("/docs", TestApiDoc::openapi()).unwrap();
385        let route = handler.into_route();
386        let mut req = Request::empty();
387        *req.method_mut() = http::Method::HEAD;
388        *req.uri_mut() = http::Uri::from_static("http://localhost/docs/openapi.json");
389        let resp = route.call(req).await.unwrap();
390        assert!(resp.headers().get(http::header::CONTENT_TYPE).is_some());
391    }
392}
393
394// 选项类型在 crate 根导出