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,
7    lookup_extra_responses_by_handler_ptr, lookup_request_by_handler_ptr,
8    lookup_response_by_handler_ptr,
9};
10use crate::{OpenApiDoc, schema::PathInfo};
11use silent::prelude::Route;
12use utoipa::openapi::{PathItem, ResponseBuilder, path::Operation};
13
14/// 文档化的路由信息
15#[derive(Debug, Clone)]
16pub struct DocumentedRoute {
17    /// Silent框架的原始路由
18    pub route: Route,
19    /// 路径文档信息
20    pub path_docs: Vec<PathInfo>,
21}
22
23impl DocumentedRoute {
24    /// 创建新的文档化路由
25    pub fn new(route: Route) -> Self {
26        Self {
27            route,
28            path_docs: Vec::new(),
29        }
30    }
31
32    /// 添加路径文档信息
33    pub fn add_path_doc(mut self, path_info: PathInfo) -> Self {
34        self.path_docs.push(path_info);
35        self
36    }
37
38    /// 批量添加路径文档
39    pub fn add_path_docs(mut self, path_docs: Vec<PathInfo>) -> Self {
40        self.path_docs.extend(path_docs);
41        self
42    }
43
44    /// 获取底层的Silent路由
45    pub fn into_route(self) -> Route {
46        self.route
47    }
48
49    /// 生成OpenAPI路径项
50    pub fn generate_path_items(&self, base_path: &str) -> Vec<(String, PathItem)> {
51        let mut path_items = Vec::new();
52
53        for path_doc in &self.path_docs {
54            let full_path = if base_path.is_empty() {
55                path_doc.path.clone()
56            } else {
57                format!("{}{}", base_path.trim_end_matches('/'), &path_doc.path)
58            };
59
60            // 转换Silent路径参数格式到OpenAPI格式
61            let openapi_path = convert_path_format(&full_path);
62
63            // 创建操作
64            let operation = create_operation_from_path_info(path_doc);
65
66            // 创建或更新路径项
67            let path_item = create_or_update_path_item(None, &path_doc.method, operation);
68
69            path_items.push((openapi_path, path_item));
70        }
71
72        path_items
73    }
74}
75
76/// 路由文档收集trait
77///
78/// 为Silent的Route提供文档收集能力。
79pub trait RouteDocumentation {
80    /// 收集路由的文档信息
81    ///
82    /// # 参数
83    ///
84    /// - `base_path`: 基础路径前缀
85    ///
86    /// # 返回
87    ///
88    /// 返回路径和对应的OpenAPI PathItem的映射
89    fn collect_openapi_paths(&self, base_path: &str) -> Vec<(String, PathItem)>;
90
91    /// 生成完整的OpenAPI文档
92    ///
93    /// # 参数
94    ///
95    /// - `title`: API标题
96    /// - `version`: API版本
97    /// - `description`: API描述
98    ///
99    /// # 返回
100    ///
101    /// 返回完整的OpenAPI文档
102    fn generate_openapi_doc(
103        &self,
104        title: &str,
105        version: &str,
106        description: Option<&str>,
107    ) -> OpenApiDoc {
108        let mut doc = OpenApiDoc::new(title, version);
109
110        if let Some(desc) = description {
111            doc = doc.description(desc);
112        }
113
114        let paths = self.collect_openapi_paths("");
115        doc = doc.add_paths(paths).apply_registered_schemas();
116
117        doc
118    }
119}
120
121impl RouteDocumentation for Route {
122    fn collect_openapi_paths(&self, base_path: &str) -> Vec<(String, PathItem)> {
123        let mut paths = Vec::new();
124        collect_paths_recursive(self, base_path, &[], &mut paths);
125        paths
126    }
127}
128
129/// 从中间件列表推断通用响应码
130///
131/// 通过中间件类型名称启发式识别常见中间件,自动添加对应的错误响应描述。
132fn infer_middleware_responses(
133    middlewares: &[std::sync::Arc<dyn silent::MiddleWareHandler>],
134) -> Vec<crate::doc::ExtraResponse> {
135    let mut responses = Vec::new();
136    for mw in middlewares {
137        // 使用 std::any::type_name 获取底层类型名称(通过 trait object 的 Any 风格推断)
138        let type_name = std::any::type_name_of_val(&**mw).to_string();
139        // 限流中间件 → 429
140        if type_name.contains("RateLimiter") || type_name.contains("rate_limiter") {
141            responses.push(crate::doc::ExtraResponse {
142                status: 429,
143                description: "Too Many Requests".to_string(),
144            });
145        }
146        // 认证/授权中间件 → 401
147        if type_name.contains("Auth") || type_name.contains("auth") {
148            responses.push(crate::doc::ExtraResponse {
149                status: 401,
150                description: "Unauthorized".to_string(),
151            });
152        }
153        // 超时中间件 → 408
154        if type_name.contains("Timeout") || type_name.contains("timeout") {
155            responses.push(crate::doc::ExtraResponse {
156                status: 408,
157                description: "Request Timeout".to_string(),
158            });
159        }
160    }
161    // 去重
162    responses.dedup_by_key(|r| r.status);
163    responses
164}
165
166/// 递归收集路径信息
167///
168/// `parent_tags` 用于路由组级别的 tags 继承。
169fn collect_paths_recursive(
170    route: &Route,
171    current_path: &str,
172    parent_tags: &[String],
173    paths: &mut Vec<(String, PathItem)>,
174) {
175    let full_path = if current_path.is_empty() {
176        route.path.clone()
177    } else if route.path.is_empty() {
178        current_path.to_string()
179    } else {
180        format!(
181            "{}/{}",
182            current_path.trim_end_matches('/'),
183            route.path.trim_start_matches('/')
184        )
185    };
186
187    // 如果当前路由有非空路径段,作为 tag 向下传递
188    let mut current_tags = parent_tags.to_vec();
189    let seg = route.path.trim_matches('/');
190    if !seg.is_empty()
191        && !seg.starts_with('<')
192        && !seg.starts_with('{')
193        && !current_tags.contains(&seg.to_string())
194    {
195        current_tags.push(seg.to_string());
196    }
197
198    // 从中间件推断通用响应码
199    let mw_responses = infer_middleware_responses(&route.middlewares);
200
201    // 为当前路径的每个HTTP方法创建操作
202    for (method, handler) in &route.handler {
203        let openapi_path = convert_path_format(&full_path);
204        let ptr = std::sync::Arc::as_ptr(handler) as *const () as usize;
205        let doc = lookup_doc_by_handler_ptr(ptr);
206        let resp = lookup_response_by_handler_ptr(ptr);
207        let req_meta = lookup_request_by_handler_ptr(ptr);
208        let mut extra_resp_list = lookup_extra_responses_by_handler_ptr(ptr).unwrap_or_default();
209
210        // 合并中间件推断的响应(避免重复)
211        for mw_resp in &mw_responses {
212            if !extra_resp_list.iter().any(|r| r.status == mw_resp.status) {
213                extra_resp_list.push(mw_resp.clone());
214            }
215        }
216        let extra_resp = if extra_resp_list.is_empty() {
217            None
218        } else {
219            Some(extra_resp_list)
220        };
221
222        let operation = create_operation_with_doc(
223            method,
224            &full_path,
225            doc,
226            resp,
227            req_meta,
228            extra_resp,
229            &current_tags,
230        );
231        let path_item = create_or_update_path_item(None, method, operation);
232
233        // 查找是否已存在相同路径
234        if let Some((_, existing_item)) = paths.iter_mut().find(|(path, _)| path == &openapi_path) {
235            // 更新现有路径项
236            *existing_item = merge_path_items(existing_item, &path_item);
237        } else {
238            paths.push((openapi_path, path_item));
239        }
240    }
241
242    // 递归处理子路由(传递当前 tags)
243    for child in &route.children {
244        collect_paths_recursive(child, &full_path, &current_tags, paths);
245    }
246}
247
248/// 转换Silent路径格式到OpenAPI格式
249///
250/// Silent: `/users/<id:i64>/posts/<post_id:String>`
251/// OpenAPI: `/users/{id}/posts/{post_id}`
252fn convert_path_format(silent_path: &str) -> String {
253    // 归一化:空路径映射为 "/";其他路径确保以 '/' 开头,避免 Swagger 生成非法路径键
254    let mut result = if silent_path.is_empty() {
255        "/".to_string()
256    } else if silent_path.starts_with('/') {
257        silent_path.to_string()
258    } else {
259        format!("/{}", silent_path)
260    };
261
262    // 查找所有的 <name:type> 模式并替换为 {name}
263    while let Some(start) = result.find('<') {
264        if let Some(end) = result[start..].find('>') {
265            let full_match = &result[start..start + end + 1];
266            if let Some(colon_pos) = full_match.find(':') {
267                let param_name = &full_match[1..colon_pos];
268                let replacement = format!("{{{}}}", param_name);
269                result = result.replace(full_match, &replacement);
270            } else {
271                break;
272            }
273        } else {
274            break;
275        }
276    }
277
278    result
279}
280
281/// 从PathInfo创建Operation
282fn create_operation_from_path_info(path_info: &PathInfo) -> Operation {
283    use utoipa::openapi::path::OperationBuilder;
284
285    let mut builder = OperationBuilder::new();
286
287    if let Some(ref operation_id) = path_info.operation_id {
288        builder = builder.operation_id(Some(operation_id.clone()));
289    }
290
291    if let Some(ref summary) = path_info.summary {
292        builder = builder.summary(Some(summary.clone()));
293    }
294
295    if let Some(ref description) = path_info.description {
296        builder = builder.description(Some(description.clone()));
297    }
298
299    if !path_info.tags.is_empty() {
300        builder = builder.tags(Some(path_info.tags.clone()));
301    }
302
303    // 添加默认响应
304    let default_response = ResponseBuilder::new()
305        .description("Successful response")
306        .build();
307
308    builder = builder.response("200", default_response);
309
310    builder.build()
311}
312
313/// 创建默认的Operation
314fn create_operation_with_doc(
315    method: &http::Method,
316    path: &str,
317    doc: Option<DocMeta>,
318    resp: Option<ResponseMeta>,
319    req_meta: Option<Vec<RequestMeta>>,
320    extra_resp: Option<Vec<crate::doc::ExtraResponse>>,
321    #[allow(unused_variables)] parent_tags: &[String],
322) -> Operation {
323    use utoipa::openapi::Required;
324    use utoipa::openapi::path::{OperationBuilder, ParameterBuilder};
325
326    let default_summary = format!("{} {}", method, path);
327    let default_description = format!("Handler for {} {}", method, path);
328    let (summary, description, deprecated, custom_tags) = match doc {
329        Some(DocMeta {
330            summary,
331            description,
332            deprecated,
333            tags,
334        }) => (
335            summary.unwrap_or(default_summary),
336            description.unwrap_or(default_description),
337            deprecated,
338            tags,
339        ),
340        None => (default_summary, default_description, false, Vec::new()),
341    };
342
343    // 自动生成 operationId(method_去除非字母数字并用下划线连接)
344    let sanitized_path: String = path
345        .chars()
346        .map(|c| match c {
347            'a'..='z' | 'A'..='Z' | '0'..='9' => c,
348            _ => '_',
349        })
350        .collect();
351    let operation_id = format!("{}_{}", method.as_str().to_lowercase(), sanitized_path)
352        .trim_matches('_')
353        .to_string();
354
355    // 默认 tag:取首个非空路径段
356    let default_tag = path
357        .split('/')
358        .find(|s| !s.is_empty())
359        .map(|s| s.to_string());
360
361    let mut response_builder = ResponseBuilder::new().description("Successful response");
362    if let Some(rm) = resp {
363        match rm {
364            ResponseMeta::TextPlain => {
365                use utoipa::openapi::{
366                    RefOr,
367                    content::ContentBuilder,
368                    schema::{ObjectBuilder, Schema},
369                };
370                let content = ContentBuilder::new()
371                    .schema::<RefOr<Schema>>(Some(RefOr::T(Schema::Object(
372                        ObjectBuilder::new().build(),
373                    ))))
374                    .build();
375                response_builder = response_builder.content("text/plain", content);
376            }
377            ResponseMeta::Json { type_name } => {
378                use utoipa::openapi::{Ref, RefOr, content::ContentBuilder, schema::Schema};
379                let schema_ref = RefOr::Ref(Ref::from_schema_name(type_name));
380                let content = ContentBuilder::new()
381                    .schema::<RefOr<Schema>>(Some(schema_ref))
382                    .build();
383                response_builder = response_builder.content("application/json", content);
384            }
385        }
386    }
387    let default_response = response_builder.build();
388
389    // 从路径中提取 Silent 风格参数 <name:type> 或 OpenAPI 风格 {name},提供基础参数声明
390    let mut builder = OperationBuilder::new()
391        .summary(Some(summary))
392        .description(Some(description))
393        .operation_id(Some(operation_id))
394        .response("200", default_response);
395
396    // deprecated 标记
397    if deprecated {
398        builder = builder.deprecated(Some(utoipa::openapi::Deprecated::True));
399    }
400
401    // tags 优先级:自定义 tags > 路由组继承 tags > 自动生成 tag
402    if !custom_tags.is_empty() {
403        builder = builder.tags(Some(custom_tags));
404    } else if !parent_tags.is_empty() {
405        builder = builder.tags(Some(parent_tags.to_vec()));
406    } else if let Some(tag) = default_tag {
407        builder = builder.tags(Some(vec![tag]));
408    }
409
410    // 额外响应(400、401、404 等)
411    if let Some(extras) = extra_resp {
412        for er in extras {
413            let resp = ResponseBuilder::new().description(er.description).build();
414            builder = builder.response(er.status.to_string(), resp);
415        }
416    }
417
418    // 处理请求元信息:requestBody 和 query parameters
419    if let Some(req_metas) = req_meta {
420        for meta in req_metas {
421            match meta {
422                RequestMeta::JsonBody { type_name } => {
423                    use utoipa::openapi::{
424                        Ref, RefOr, content::ContentBuilder, request_body::RequestBodyBuilder,
425                        schema::Schema,
426                    };
427                    let schema_ref = RefOr::Ref(Ref::from_schema_name(type_name));
428                    let content = ContentBuilder::new()
429                        .schema::<RefOr<Schema>>(Some(schema_ref))
430                        .build();
431                    let request_body = RequestBodyBuilder::new()
432                        .content("application/json", content)
433                        .required(Some(Required::True))
434                        .build();
435                    builder = builder.request_body(Some(request_body));
436                }
437                RequestMeta::FormBody { type_name } => {
438                    use utoipa::openapi::{
439                        Ref, RefOr, content::ContentBuilder, request_body::RequestBodyBuilder,
440                        schema::Schema,
441                    };
442                    let schema_ref = RefOr::Ref(Ref::from_schema_name(type_name));
443                    let content = ContentBuilder::new()
444                        .schema::<RefOr<Schema>>(Some(schema_ref))
445                        .build();
446                    let request_body = RequestBodyBuilder::new()
447                        .content("application/x-www-form-urlencoded", content)
448                        .required(Some(Required::True))
449                        .build();
450                    builder = builder.request_body(Some(request_body));
451                }
452                RequestMeta::QueryParams { type_name } => {
453                    // 查询参数:添加一个引用 schema 的 query parameter
454                    let param = ParameterBuilder::new()
455                        .name(type_name)
456                        .parameter_in(utoipa::openapi::path::ParameterIn::Query)
457                        .required(Required::False)
458                        .schema::<utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>>(Some(
459                            utoipa::openapi::RefOr::Ref(utoipa::openapi::Ref::from_schema_name(
460                                type_name,
461                            )),
462                        ))
463                        .build();
464                    builder = builder.parameter(param);
465                }
466            }
467        }
468    }
469
470    // 先尝试解析 Silent 风格 <name:type>
471    {
472        let mut i = 0usize;
473        let mut found_any = false;
474        while let Some(start) = path[i..].find('<') {
475            let abs_start = i + start;
476            if let Some(end_rel) = path[abs_start..].find('>') {
477                let abs_end = abs_start + end_rel;
478                let inner = &path[abs_start + 1..abs_end];
479                let mut it = inner.splitn(2, ':');
480                let name = it.next().unwrap_or("");
481                let type_hint = it.next().unwrap_or("");
482
483                if !name.is_empty() {
484                    let schema = rust_type_to_schema(type_hint);
485                    let param = ParameterBuilder::new()
486                        .name(name)
487                        .parameter_in(utoipa::openapi::path::ParameterIn::Path)
488                        .required(Required::True)
489                        .schema(schema)
490                        .build();
491                    builder = builder.parameter(param);
492                    found_any = true;
493                }
494                i = abs_end + 1;
495            } else {
496                break;
497            }
498        }
499
500        // 如未找到 Silent 风格参数,则尝试解析 {name}(无类型信息,默认 string)
501        if !found_any {
502            let mut idx = 0usize;
503            while let Some(start) = path[idx..].find('{') {
504                let abs_start = idx + start;
505                if let Some(end_rel) = path[abs_start..].find('}') {
506                    let abs_end = abs_start + end_rel;
507                    let name = &path[abs_start + 1..abs_end];
508                    let schema = rust_type_to_schema("String");
509                    let param = ParameterBuilder::new()
510                        .name(name)
511                        .parameter_in(utoipa::openapi::path::ParameterIn::Path)
512                        .required(Required::True)
513                        .schema(schema)
514                        .build();
515                    builder = builder.parameter(param);
516                    idx = abs_end + 1;
517                } else {
518                    break;
519                }
520            }
521        }
522    }
523
524    builder.build()
525}
526
527/// 将 Rust 类型名称映射为 OpenAPI Schema
528///
529/// 支持常见的 Rust 基础类型:整数、浮点、字符串、布尔值。
530/// 未识别的类型默认映射为 string。
531fn rust_type_to_schema(
532    type_hint: &str,
533) -> Option<utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>> {
534    use utoipa::openapi::schema::{ObjectBuilder, Schema, SchemaType, Type};
535
536    let (schema_type, format) = match type_hint {
537        // 整数类型
538        "i8" | "i16" | "i32" | "u8" | "u16" | "u32" => {
539            (SchemaType::Type(Type::Integer), Some("int32"))
540        }
541        "i64" | "u64" | "i128" | "u128" | "isize" | "usize" => {
542            (SchemaType::Type(Type::Integer), Some("int64"))
543        }
544        // 浮点类型
545        "f32" => (SchemaType::Type(Type::Number), Some("float")),
546        "f64" => (SchemaType::Type(Type::Number), Some("double")),
547        // 布尔
548        "bool" => (SchemaType::Type(Type::Boolean), None),
549        // 字符串(默认)
550        "String" | "str" | "&str" | "" => (SchemaType::Type(Type::String), None),
551        // 未知类型也映射为 string
552        _ => (SchemaType::Type(Type::String), None),
553    };
554
555    let mut builder = ObjectBuilder::new().schema_type(schema_type);
556    if let Some(fmt) = format {
557        builder = builder.format(Some(utoipa::openapi::schema::SchemaFormat::Custom(
558            fmt.to_string(),
559        )));
560    }
561    Some(utoipa::openapi::RefOr::T(Schema::Object(builder.build())))
562}
563
564/// 创建或更新PathItem
565fn create_or_update_path_item(
566    _existing: Option<&PathItem>,
567    method: &http::Method,
568    operation: Operation,
569) -> PathItem {
570    let mut item = PathItem::default();
571    match *method {
572        http::Method::GET => item.get = Some(operation),
573        http::Method::POST => item.post = Some(operation),
574        http::Method::PUT => item.put = Some(operation),
575        http::Method::DELETE => item.delete = Some(operation),
576        http::Method::PATCH => item.patch = Some(operation),
577        http::Method::HEAD => item.head = Some(operation),
578        http::Method::OPTIONS => item.options = Some(operation),
579        http::Method::TRACE => item.trace = Some(operation),
580        _ => {}
581    }
582    item
583}
584
585/// 合并两个PathItem
586fn merge_path_items(item1: &PathItem, item2: &PathItem) -> PathItem {
587    let mut out = PathItem::default();
588    out.get = item1.get.clone().or(item2.get.clone());
589    out.post = item1.post.clone().or(item2.post.clone());
590    out.put = item1.put.clone().or(item2.put.clone());
591    out.delete = item1.delete.clone().or(item2.delete.clone());
592    out.patch = item1.patch.clone().or(item2.patch.clone());
593    out.head = item1.head.clone().or(item2.head.clone());
594    out.options = item1.options.clone().or(item2.options.clone());
595    out.trace = item1.trace.clone().or(item2.trace.clone());
596    out
597}
598
599/// Route 的便捷 OpenAPI 构建扩展
600pub trait RouteOpenApiExt {
601    fn to_openapi(&self, title: &str, version: &str) -> utoipa::openapi::OpenApi;
602}
603
604impl RouteOpenApiExt for Route {
605    fn to_openapi(&self, title: &str, version: &str) -> utoipa::openapi::OpenApi {
606        self.generate_openapi_doc(title, version, None)
607            .into_openapi()
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use crate::doc::{DocMeta, RequestMeta, ResponseMeta};
615
616    #[test]
617    fn test_path_format_conversion() {
618        assert_eq!(
619            convert_path_format("/users/<id:i64>/posts"),
620            "/users/{id}/posts"
621        );
622
623        assert_eq!(
624            convert_path_format("/api/v1/users/<user_id:String>/items/<item_id:u32>"),
625            "/api/v1/users/{user_id}/items/{item_id}"
626        );
627
628        assert_eq!(convert_path_format("/simple/path"), "/simple/path");
629        assert_eq!(convert_path_format("svc"), "/svc");
630        assert_eq!(convert_path_format(""), "/");
631    }
632
633    #[test]
634    fn test_documented_route_creation() {
635        let route = Route::new("users");
636        let doc_route = DocumentedRoute::new(route);
637
638        assert_eq!(doc_route.path_docs.len(), 0);
639    }
640
641    #[test]
642    fn test_path_info_to_operation() {
643        let path_info = PathInfo::new(http::Method::GET, "/users/{id}")
644            .operation_id("get_user")
645            .summary("获取用户")
646            .description("根据ID获取用户信息")
647            .tag("users");
648
649        let operation = create_operation_from_path_info(&path_info);
650
651        assert_eq!(operation.operation_id, Some("get_user".to_string()));
652        assert_eq!(operation.summary, Some("获取用户".to_string()));
653        assert_eq!(
654            operation.description,
655            Some("根据ID获取用户信息".to_string())
656        );
657        assert_eq!(operation.tags, Some(vec!["users".to_string()]));
658    }
659
660    #[test]
661    fn test_documented_route_generate_items() {
662        let route = DocumentedRoute::new(Route::new(""))
663            .add_path_doc(PathInfo::new(http::Method::GET, "/ping").summary("ping"));
664        let items = route.generate_path_items("");
665        let (_p, item) = items.into_iter().find(|(p, _)| p == "/ping").unwrap();
666        assert!(item.get.is_some());
667    }
668
669    #[test]
670    fn test_generate_openapi_doc_with_registered_schema() {
671        use serde::Serialize;
672        use utoipa::ToSchema;
673        #[derive(Serialize, ToSchema)]
674        struct MyType {
675            id: i32,
676        }
677        crate::doc::register_schema_for::<MyType>();
678        let route = Route::new("");
679        let openapi = route.generate_openapi_doc("t", "1", None).into_openapi();
680        assert!(
681            openapi
682                .components
683                .as_ref()
684                .expect("components")
685                .schemas
686                .contains_key("MyType")
687        );
688    }
689
690    #[test]
691    fn test_collect_paths_with_multiple_methods() {
692        async fn h1(_r: silent::Request) -> silent::Result<silent::Response> {
693            Ok(silent::Response::text("ok"))
694        }
695        async fn h2(_r: silent::Request) -> silent::Result<silent::Response> {
696            Ok(silent::Response::text("ok"))
697        }
698        let route = Route::new("svc").get(h1).post(h2);
699        let paths = route.collect_openapi_paths("");
700        // 找到 /svc 项
701        let (_p, item) = paths.into_iter().find(|(p, _)| p == "/svc").expect("/svc");
702        assert!(item.get.is_some());
703        assert!(item.post.is_some());
704    }
705
706    #[test]
707    fn test_operation_with_text_plain() {
708        let op = create_operation_with_doc(
709            &http::Method::GET,
710            "/hello",
711            Some(DocMeta {
712                summary: Some("s".into()),
713                description: Some("d".into()),
714                deprecated: false,
715                tags: Vec::new(),
716            }),
717            Some(ResponseMeta::TextPlain),
718            None,
719            None,
720            &[],
721        );
722        let resp = op.responses.responses.get("200").expect("200 resp");
723        let resp = match resp {
724            utoipa::openapi::RefOr::T(r) => r,
725            _ => panic!("expected T"),
726        };
727        let content = &resp.content;
728        assert!(content.contains_key("text/plain"));
729    }
730
731    #[test]
732    fn test_operation_with_json_ref() {
733        let op = create_operation_with_doc(
734            &http::Method::GET,
735            "/users/{id}",
736            None,
737            Some(ResponseMeta::Json { type_name: "User" }),
738            None,
739            None,
740            &[],
741        );
742        let resp = op.responses.responses.get("200").expect("200 resp");
743        let resp = match resp {
744            utoipa::openapi::RefOr::T(r) => r,
745            _ => panic!("expected T"),
746        };
747        let content = &resp.content;
748        let mt = content.get("application/json").expect("app/json");
749        let schema = mt.schema.as_ref().expect("schema");
750        match schema {
751            utoipa::openapi::RefOr::Ref(r) => assert!(r.ref_location.ends_with("/User")),
752            _ => panic!("ref expected"),
753        }
754    }
755
756    #[test]
757    fn test_operation_with_json_request_body() {
758        let op = create_operation_with_doc(
759            &http::Method::POST,
760            "/users",
761            None,
762            None,
763            Some(vec![RequestMeta::JsonBody {
764                type_name: "CreateUser",
765            }]),
766            None,
767            &[],
768        );
769        let body = op.request_body.as_ref().expect("request body");
770        let content = body.content.get("application/json").expect("app/json");
771        let schema = content.schema.as_ref().expect("schema");
772        match schema {
773            utoipa::openapi::RefOr::Ref(r) => {
774                assert!(r.ref_location.ends_with("/CreateUser"))
775            }
776            _ => panic!("ref expected"),
777        }
778    }
779
780    #[test]
781    fn test_operation_with_form_request_body() {
782        let op = create_operation_with_doc(
783            &http::Method::POST,
784            "/login",
785            None,
786            None,
787            Some(vec![RequestMeta::FormBody {
788                type_name: "LoginForm",
789            }]),
790            None,
791            &[],
792        );
793        let body = op.request_body.as_ref().expect("request body");
794        assert!(
795            body.content
796                .contains_key("application/x-www-form-urlencoded")
797        );
798    }
799
800    #[test]
801    fn test_operation_with_query_params() {
802        let op = create_operation_with_doc(
803            &http::Method::GET,
804            "/search",
805            None,
806            None,
807            Some(vec![RequestMeta::QueryParams {
808                type_name: "SearchQuery",
809            }]),
810            None,
811            &[],
812        );
813        let params = op.parameters.as_ref().expect("should have parameters");
814        assert!(!params.is_empty());
815    }
816
817    #[test]
818    fn test_merge_path_items_get_post() {
819        let get = create_or_update_path_item(
820            None,
821            &http::Method::GET,
822            create_operation_with_doc(&http::Method::GET, "/a", None, None, None, None, &[]),
823        );
824        let post = create_or_update_path_item(
825            None,
826            &http::Method::POST,
827            create_operation_with_doc(&http::Method::POST, "/a", None, None, None, None, &[]),
828        );
829        let merged = merge_path_items(&get, &post);
830        assert!(merged.get.is_some());
831        assert!(merged.post.is_some());
832    }
833
834    #[test]
835    fn test_merge_prefers_first_for_same_method() {
836        let op1 = create_operation_with_doc(&http::Method::GET, "/a", None, None, None, None, &[]);
837        let mut item1 = PathItem::default();
838        item1.get = Some(op1);
839        let op2 = create_operation_with_doc(&http::Method::GET, "/a", None, None, None, None, &[]);
840        let mut item2 = PathItem::default();
841        item2.get = Some(op2);
842        let merged = merge_path_items(&item1, &item2);
843        assert!(merged.get.is_some());
844    }
845
846    #[test]
847    fn test_operation_deprecated_flag() {
848        let op = create_operation_with_doc(
849            &http::Method::GET,
850            "/old",
851            Some(DocMeta {
852                summary: Some("旧接口".into()),
853                description: None,
854                deprecated: true,
855                tags: Vec::new(),
856            }),
857            None,
858            None,
859            None,
860            &[],
861        );
862        assert!(matches!(
863            op.deprecated,
864            Some(utoipa::openapi::Deprecated::True)
865        ));
866    }
867
868    #[test]
869    fn test_operation_custom_tags() {
870        let op = create_operation_with_doc(
871            &http::Method::GET,
872            "/users",
873            Some(DocMeta {
874                summary: None,
875                description: None,
876                deprecated: false,
877                tags: vec!["用户管理".into(), "admin".into()],
878            }),
879            None,
880            None,
881            None,
882            &[],
883        );
884        assert_eq!(
885            op.tags,
886            Some(vec!["用户管理".to_string(), "admin".to_string()])
887        );
888    }
889
890    #[test]
891    fn test_operation_extra_responses() {
892        use crate::doc::ExtraResponse;
893        let op = create_operation_with_doc(
894            &http::Method::POST,
895            "/users",
896            None,
897            None,
898            None,
899            Some(vec![
900                ExtraResponse {
901                    status: 400,
902                    description: "请求参数无效".into(),
903                },
904                ExtraResponse {
905                    status: 401,
906                    description: "未授权".into(),
907                },
908            ]),
909            &[],
910        );
911        let resp_400 = op.responses.responses.get("400").expect("400 resp");
912        let resp_401 = op.responses.responses.get("401").expect("401 resp");
913        match resp_400 {
914            utoipa::openapi::RefOr::T(r) => assert_eq!(r.description, "请求参数无效"),
915            _ => panic!("expected T"),
916        }
917        match resp_401 {
918            utoipa::openapi::RefOr::T(r) => assert_eq!(r.description, "未授权"),
919            _ => panic!("expected T"),
920        }
921    }
922
923    #[test]
924    fn test_path_param_type_mapping() {
925        let op = create_operation_with_doc(
926            &http::Method::GET,
927            "/users/<id:i64>/posts/<slug:String>",
928            None,
929            None,
930            None,
931            None,
932            &[],
933        );
934        let params = op.parameters.as_ref().expect("should have parameters");
935        assert_eq!(params.len(), 2);
936        // 验证第一个参数有 schema(不再是 None)
937        let id_param = &params[0];
938        assert!(id_param.schema.is_some());
939        let slug_param = &params[1];
940        assert!(slug_param.schema.is_some());
941    }
942
943    #[test]
944    fn test_parent_tags_inheritance() {
945        let op = create_operation_with_doc(
946            &http::Method::GET,
947            "/users/123",
948            None,
949            None,
950            None,
951            None,
952            &["users".to_string()],
953        );
954        // 没有自定义 tags,应继承 parent_tags
955        assert_eq!(op.tags, Some(vec!["users".to_string()]));
956    }
957
958    #[test]
959    fn test_rust_type_to_schema_integer() {
960        let schema = rust_type_to_schema("i64");
961        assert!(schema.is_some());
962    }
963
964    #[test]
965    fn test_rust_type_to_schema_boolean() {
966        let schema = rust_type_to_schema("bool");
967        assert!(schema.is_some());
968    }
969
970    #[test]
971    fn test_rust_type_to_schema_default_string() {
972        let schema = rust_type_to_schema("");
973        assert!(schema.is_some());
974    }
975}