silent_openapi/
route.rs

1//! 路由文档收集和扩展
2//!
3//! 提供路由文档自动收集功能和路由扩展trait。
4
5use crate::doc::{
6    DocMeta, ResponseMeta, lookup_doc_by_handler_ptr, lookup_response_by_handler_ptr,
7};
8use crate::{OpenApiDoc, schema::PathInfo};
9use silent::prelude::Route;
10use utoipa::openapi::{PathItem, ResponseBuilder, path::Operation};
11
12/// 文档化的路由信息
13#[derive(Debug, Clone)]
14pub struct DocumentedRoute {
15    /// Silent框架的原始路由
16    pub route: Route,
17    /// 路径文档信息
18    pub path_docs: Vec<PathInfo>,
19}
20
21impl DocumentedRoute {
22    /// 创建新的文档化路由
23    pub fn new(route: Route) -> Self {
24        Self {
25            route,
26            path_docs: Vec::new(),
27        }
28    }
29
30    /// 添加路径文档信息
31    pub fn add_path_doc(mut self, path_info: PathInfo) -> Self {
32        self.path_docs.push(path_info);
33        self
34    }
35
36    /// 批量添加路径文档
37    pub fn add_path_docs(mut self, path_docs: Vec<PathInfo>) -> Self {
38        self.path_docs.extend(path_docs);
39        self
40    }
41
42    /// 获取底层的Silent路由
43    pub fn into_route(self) -> Route {
44        self.route
45    }
46
47    /// 生成OpenAPI路径项
48    pub fn generate_path_items(&self, base_path: &str) -> Vec<(String, PathItem)> {
49        let mut path_items = Vec::new();
50
51        for path_doc in &self.path_docs {
52            let full_path = if base_path.is_empty() {
53                path_doc.path.clone()
54            } else {
55                format!("{}{}", base_path.trim_end_matches('/'), &path_doc.path)
56            };
57
58            // 转换Silent路径参数格式到OpenAPI格式
59            let openapi_path = convert_path_format(&full_path);
60
61            // 创建操作
62            let operation = create_operation_from_path_info(path_doc);
63
64            // 创建或更新路径项
65            let path_item = create_or_update_path_item(None, &path_doc.method, operation);
66
67            path_items.push((openapi_path, path_item));
68        }
69
70        path_items
71    }
72}
73
74/// 路由文档收集trait
75///
76/// 为Silent的Route提供文档收集能力。
77pub trait RouteDocumentation {
78    /// 收集路由的文档信息
79    ///
80    /// # 参数
81    ///
82    /// - `base_path`: 基础路径前缀
83    ///
84    /// # 返回
85    ///
86    /// 返回路径和对应的OpenAPI PathItem的映射
87    fn collect_openapi_paths(&self, base_path: &str) -> Vec<(String, PathItem)>;
88
89    /// 生成完整的OpenAPI文档
90    ///
91    /// # 参数
92    ///
93    /// - `title`: API标题
94    /// - `version`: API版本
95    /// - `description`: API描述
96    ///
97    /// # 返回
98    ///
99    /// 返回完整的OpenAPI文档
100    fn generate_openapi_doc(
101        &self,
102        title: &str,
103        version: &str,
104        description: Option<&str>,
105    ) -> OpenApiDoc {
106        let mut doc = OpenApiDoc::new(title, version);
107
108        if let Some(desc) = description {
109            doc = doc.description(desc);
110        }
111
112        let paths = self.collect_openapi_paths("");
113        doc = doc.add_paths(paths).apply_registered_schemas();
114
115        doc
116    }
117}
118
119impl RouteDocumentation for Route {
120    fn collect_openapi_paths(&self, base_path: &str) -> Vec<(String, PathItem)> {
121        let mut paths = Vec::new();
122        collect_paths_recursive(self, base_path, &mut paths);
123        paths
124    }
125}
126
127/// 递归收集路径信息
128fn collect_paths_recursive(route: &Route, current_path: &str, paths: &mut Vec<(String, PathItem)>) {
129    let full_path = if current_path.is_empty() {
130        route.path.clone()
131    } else if route.path.is_empty() {
132        current_path.to_string()
133    } else {
134        format!(
135            "{}/{}",
136            current_path.trim_end_matches('/'),
137            route.path.trim_start_matches('/')
138        )
139    };
140
141    // 为当前路径的每个HTTP方法创建操作
142    for (method, handler) in &route.handler {
143        let openapi_path = convert_path_format(&full_path);
144        let ptr = std::sync::Arc::as_ptr(handler) as *const () as usize;
145        let doc = lookup_doc_by_handler_ptr(ptr);
146        let resp = lookup_response_by_handler_ptr(ptr);
147        let operation = create_operation_with_doc(method, &full_path, doc, resp);
148        let path_item = create_or_update_path_item(None, method, operation);
149
150        // 查找是否已存在相同路径
151        if let Some((_, existing_item)) = paths.iter_mut().find(|(path, _)| path == &openapi_path) {
152            // 更新现有路径项
153            *existing_item = merge_path_items(existing_item, &path_item);
154        } else {
155            paths.push((openapi_path, path_item));
156        }
157    }
158
159    // 递归处理子路由
160    for child in &route.children {
161        collect_paths_recursive(child, &full_path, paths);
162    }
163}
164
165/// 转换Silent路径格式到OpenAPI格式
166///
167/// Silent: `/users/<id:i64>/posts/<post_id:String>`
168/// OpenAPI: `/users/{id}/posts/{post_id}`
169fn convert_path_format(silent_path: &str) -> String {
170    // 归一化:空路径映射为 "/";其他路径确保以 '/' 开头,避免 Swagger 生成非法路径键
171    let mut result = if silent_path.is_empty() {
172        "/".to_string()
173    } else if silent_path.starts_with('/') {
174        silent_path.to_string()
175    } else {
176        format!("/{}", silent_path)
177    };
178
179    // 查找所有的 <name:type> 模式并替换为 {name}
180    while let Some(start) = result.find('<') {
181        if let Some(end) = result[start..].find('>') {
182            let full_match = &result[start..start + end + 1];
183            if let Some(colon_pos) = full_match.find(':') {
184                let param_name = &full_match[1..colon_pos];
185                let replacement = format!("{{{}}}", param_name);
186                result = result.replace(full_match, &replacement);
187            } else {
188                break;
189            }
190        } else {
191            break;
192        }
193    }
194
195    result
196}
197
198/// 从PathInfo创建Operation
199fn create_operation_from_path_info(path_info: &PathInfo) -> Operation {
200    use utoipa::openapi::path::OperationBuilder;
201
202    let mut builder = OperationBuilder::new();
203
204    if let Some(ref operation_id) = path_info.operation_id {
205        builder = builder.operation_id(Some(operation_id.clone()));
206    }
207
208    if let Some(ref summary) = path_info.summary {
209        builder = builder.summary(Some(summary.clone()));
210    }
211
212    if let Some(ref description) = path_info.description {
213        builder = builder.description(Some(description.clone()));
214    }
215
216    if !path_info.tags.is_empty() {
217        builder = builder.tags(Some(path_info.tags.clone()));
218    }
219
220    // 添加默认响应
221    let default_response = ResponseBuilder::new()
222        .description("Successful response")
223        .build();
224
225    builder = builder.response("200", default_response);
226
227    builder.build()
228}
229
230/// 创建默认的Operation
231fn create_operation_with_doc(
232    method: &http::Method,
233    path: &str,
234    doc: Option<DocMeta>,
235    resp: Option<ResponseMeta>,
236) -> Operation {
237    use utoipa::openapi::Required;
238    use utoipa::openapi::path::{OperationBuilder, ParameterBuilder};
239
240    let default_summary = format!("{} {}", method, path);
241    let default_description = format!("Handler for {} {}", method, path);
242    let (summary, description) = match doc {
243        Some(DocMeta {
244            summary,
245            description,
246        }) => (
247            summary.unwrap_or(default_summary),
248            description.unwrap_or(default_description),
249        ),
250        None => (default_summary, default_description),
251    };
252
253    // 自动生成 operationId(method_去除非字母数字并用下划线连接)
254    let sanitized_path: String = path
255        .chars()
256        .map(|c| match c {
257            'a'..='z' | 'A'..='Z' | '0'..='9' => c,
258            _ => '_',
259        })
260        .collect();
261    let operation_id = format!("{}_{}", method.as_str().to_lowercase(), sanitized_path)
262        .trim_matches('_')
263        .to_string();
264
265    // 默认 tag:取首个非空路径段
266    let default_tag = path
267        .split('/')
268        .find(|s| !s.is_empty())
269        .map(|s| s.to_string());
270
271    let mut response_builder = ResponseBuilder::new().description("Successful response");
272    if let Some(rm) = resp {
273        match rm {
274            ResponseMeta::TextPlain => {
275                use utoipa::openapi::{
276                    RefOr,
277                    content::ContentBuilder,
278                    schema::{ObjectBuilder, Schema},
279                };
280                let content = ContentBuilder::new()
281                    .schema::<RefOr<Schema>>(Some(RefOr::T(Schema::Object(
282                        ObjectBuilder::new().build(),
283                    ))))
284                    .build();
285                response_builder = response_builder.content("text/plain", content);
286            }
287            ResponseMeta::Json { type_name } => {
288                use utoipa::openapi::{Ref, RefOr, content::ContentBuilder, schema::Schema};
289                let schema_ref = RefOr::Ref(Ref::from_schema_name(type_name));
290                let content = ContentBuilder::new()
291                    .schema::<RefOr<Schema>>(Some(schema_ref))
292                    .build();
293                response_builder = response_builder.content("application/json", content);
294            }
295        }
296    }
297    let default_response = response_builder.build();
298
299    // 从路径中提取 Silent 风格参数 <name:type> 或 OpenAPI 风格 {name},提供基础参数声明
300    let mut builder = OperationBuilder::new()
301        .summary(Some(summary))
302        .description(Some(description))
303        .operation_id(Some(operation_id))
304        .response("200", default_response);
305
306    if let Some(tag) = default_tag {
307        builder = builder.tags(Some(vec![tag]));
308    }
309
310    // 先尝试解析 Silent 风格 <name:type>
311    {
312        let mut i = 0usize;
313        let mut found_any = false;
314        while let Some(start) = path[i..].find('<') {
315            let abs_start = i + start;
316            if let Some(end_rel) = path[abs_start..].find('>') {
317                let abs_end = abs_start + end_rel;
318                let inner = &path[abs_start + 1..abs_end];
319                let mut it = inner.splitn(2, ':');
320                let name = it.next().unwrap_or("");
321
322                if !name.is_empty() {
323                    let param = ParameterBuilder::new()
324                        .name(name)
325                        .parameter_in(utoipa::openapi::path::ParameterIn::Path)
326                        .required(Required::True)
327                        .schema::<utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>>(None)
328                        .build();
329                    builder = builder.parameter(param);
330                    found_any = true;
331                }
332                i = abs_end + 1;
333            } else {
334                break;
335            }
336        }
337
338        // 如未找到 Silent 风格参数,则尝试解析 {name}
339        if !found_any {
340            let mut idx = 0usize;
341            while let Some(start) = path[idx..].find('{') {
342                let abs_start = idx + start;
343                if let Some(end_rel) = path[abs_start..].find('}') {
344                    let abs_end = abs_start + end_rel;
345                    let name = &path[abs_start + 1..abs_end];
346                    let param = ParameterBuilder::new()
347                        .name(name)
348                        .parameter_in(utoipa::openapi::path::ParameterIn::Path)
349                        .required(Required::True)
350                        .schema::<utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>>(None)
351                        .build();
352                    builder = builder.parameter(param);
353                    idx = abs_end + 1;
354                } else {
355                    break;
356                }
357            }
358        }
359    }
360
361    builder.build()
362}
363
364/// 创建或更新PathItem
365fn create_or_update_path_item(
366    _existing: Option<&PathItem>,
367    method: &http::Method,
368    operation: Operation,
369) -> PathItem {
370    let mut item = PathItem::default();
371    match *method {
372        http::Method::GET => item.get = Some(operation),
373        http::Method::POST => item.post = Some(operation),
374        http::Method::PUT => item.put = Some(operation),
375        http::Method::DELETE => item.delete = Some(operation),
376        http::Method::PATCH => item.patch = Some(operation),
377        http::Method::HEAD => item.head = Some(operation),
378        http::Method::OPTIONS => item.options = Some(operation),
379        http::Method::TRACE => item.trace = Some(operation),
380        _ => {}
381    }
382    item
383}
384
385/// 合并两个PathItem
386fn merge_path_items(item1: &PathItem, item2: &PathItem) -> PathItem {
387    let mut out = PathItem::default();
388    out.get = item1.get.clone().or(item2.get.clone());
389    out.post = item1.post.clone().or(item2.post.clone());
390    out.put = item1.put.clone().or(item2.put.clone());
391    out.delete = item1.delete.clone().or(item2.delete.clone());
392    out.patch = item1.patch.clone().or(item2.patch.clone());
393    out.head = item1.head.clone().or(item2.head.clone());
394    out.options = item1.options.clone().or(item2.options.clone());
395    out.trace = item1.trace.clone().or(item2.trace.clone());
396    out
397}
398
399/// Route 的便捷 OpenAPI 构建扩展
400pub trait RouteOpenApiExt {
401    fn to_openapi(&self, title: &str, version: &str) -> utoipa::openapi::OpenApi;
402}
403
404impl RouteOpenApiExt for Route {
405    fn to_openapi(&self, title: &str, version: &str) -> utoipa::openapi::OpenApi {
406        self.generate_openapi_doc(title, version, None)
407            .into_openapi()
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use crate::doc::{DocMeta, ResponseMeta};
415
416    #[test]
417    fn test_path_format_conversion() {
418        assert_eq!(
419            convert_path_format("/users/<id:i64>/posts"),
420            "/users/{id}/posts"
421        );
422
423        assert_eq!(
424            convert_path_format("/api/v1/users/<user_id:String>/items/<item_id:u32>"),
425            "/api/v1/users/{user_id}/items/{item_id}"
426        );
427
428        assert_eq!(convert_path_format("/simple/path"), "/simple/path");
429        assert_eq!(convert_path_format("svc"), "/svc");
430        assert_eq!(convert_path_format(""), "/");
431    }
432
433    #[test]
434    fn test_documented_route_creation() {
435        let route = Route::new("users");
436        let doc_route = DocumentedRoute::new(route);
437
438        assert_eq!(doc_route.path_docs.len(), 0);
439    }
440
441    #[test]
442    fn test_path_info_to_operation() {
443        let path_info = PathInfo::new(http::Method::GET, "/users/{id}")
444            .operation_id("get_user")
445            .summary("获取用户")
446            .description("根据ID获取用户信息")
447            .tag("users");
448
449        let operation = create_operation_from_path_info(&path_info);
450
451        assert_eq!(operation.operation_id, Some("get_user".to_string()));
452        assert_eq!(operation.summary, Some("获取用户".to_string()));
453        assert_eq!(
454            operation.description,
455            Some("根据ID获取用户信息".to_string())
456        );
457        assert_eq!(operation.tags, Some(vec!["users".to_string()]));
458    }
459
460    #[test]
461    fn test_documented_route_generate_items() {
462        let route = DocumentedRoute::new(Route::new(""))
463            .add_path_doc(PathInfo::new(http::Method::GET, "/ping").summary("ping"));
464        let items = route.generate_path_items("");
465        let (_p, item) = items.into_iter().find(|(p, _)| p == "/ping").unwrap();
466        assert!(item.get.is_some());
467    }
468
469    #[test]
470    fn test_generate_openapi_doc_with_registered_schema() {
471        use serde::Serialize;
472        use utoipa::ToSchema;
473        #[derive(Serialize, ToSchema)]
474        struct MyType {
475            id: i32,
476        }
477        crate::doc::register_schema_for::<MyType>();
478        let route = Route::new("");
479        let openapi = route.generate_openapi_doc("t", "1", None).into_openapi();
480        assert!(
481            openapi
482                .components
483                .as_ref()
484                .expect("components")
485                .schemas
486                .contains_key("MyType")
487        );
488    }
489
490    #[test]
491    fn test_collect_paths_with_multiple_methods() {
492        async fn h1(_r: silent::Request) -> silent::Result<silent::Response> {
493            Ok(silent::Response::text("ok"))
494        }
495        async fn h2(_r: silent::Request) -> silent::Result<silent::Response> {
496            Ok(silent::Response::text("ok"))
497        }
498        let route = Route::new("svc").get(h1).post(h2);
499        let paths = route.collect_openapi_paths("");
500        // 找到 /svc 项
501        let (_p, item) = paths.into_iter().find(|(p, _)| p == "/svc").expect("/svc");
502        assert!(item.get.is_some());
503        assert!(item.post.is_some());
504    }
505
506    #[test]
507    fn test_operation_with_text_plain() {
508        let op = create_operation_with_doc(
509            &http::Method::GET,
510            "/hello",
511            Some(DocMeta {
512                summary: Some("s".into()),
513                description: Some("d".into()),
514            }),
515            Some(ResponseMeta::TextPlain),
516        );
517        let resp = op.responses.responses.get("200").expect("200 resp");
518        let resp = match resp {
519            utoipa::openapi::RefOr::T(r) => r,
520            _ => panic!("expected T"),
521        };
522        let content = &resp.content;
523        assert!(content.contains_key("text/plain"));
524    }
525
526    #[test]
527    fn test_operation_with_json_ref() {
528        let op = create_operation_with_doc(
529            &http::Method::GET,
530            "/users/{id}",
531            None,
532            Some(ResponseMeta::Json { type_name: "User" }),
533        );
534        let resp = op.responses.responses.get("200").expect("200 resp");
535        let resp = match resp {
536            utoipa::openapi::RefOr::T(r) => r,
537            _ => panic!("expected T"),
538        };
539        let content = &resp.content;
540        let mt = content.get("application/json").expect("app/json");
541        let schema = mt.schema.as_ref().expect("schema");
542        match schema {
543            utoipa::openapi::RefOr::Ref(r) => assert!(r.ref_location.ends_with("/User")),
544            _ => panic!("ref expected"),
545        }
546    }
547
548    #[test]
549    fn test_merge_path_items_get_post() {
550        let get = create_or_update_path_item(
551            None,
552            &http::Method::GET,
553            create_operation_with_doc(&http::Method::GET, "/a", None, None),
554        );
555        let post = create_or_update_path_item(
556            None,
557            &http::Method::POST,
558            create_operation_with_doc(&http::Method::POST, "/a", None, None),
559        );
560        let merged = merge_path_items(&get, &post);
561        assert!(merged.get.is_some());
562        assert!(merged.post.is_some());
563    }
564
565    #[test]
566    fn test_merge_prefers_first_for_same_method() {
567        // 创建两个 GET 操作,operation_id 不同,合并后应保留第一个
568        let op1 = create_operation_with_doc(&http::Method::GET, "/a", None, None);
569        let mut item1 = PathItem::default();
570        item1.get = Some(op1);
571        let op2 = create_operation_with_doc(&http::Method::GET, "/a", None, None);
572        let mut item2 = PathItem::default();
573        item2.get = Some(op2);
574        let merged = merge_path_items(&item1, &item2);
575        assert!(merged.get.is_some());
576        // 简要校验:合并后仍有 GET,且未被覆盖(函数当前实现按先后 or 保留)
577        // 无直接字段比较,存在即视为保留先者语义
578    }
579}