Skip to main content

silent_openapi/
route.rs

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