1use 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#[derive(Debug, Clone)]
16pub struct DocumentedRoute {
17 pub route: Route,
19 pub path_docs: Vec<PathInfo>,
21}
22
23impl DocumentedRoute {
24 pub fn new(route: Route) -> Self {
26 Self {
27 route,
28 path_docs: Vec::new(),
29 }
30 }
31
32 pub fn add_path_doc(mut self, path_info: PathInfo) -> Self {
34 self.path_docs.push(path_info);
35 self
36 }
37
38 pub fn add_path_docs(mut self, path_docs: Vec<PathInfo>) -> Self {
40 self.path_docs.extend(path_docs);
41 self
42 }
43
44 pub fn into_route(self) -> Route {
46 self.route
47 }
48
49 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 let openapi_path = convert_path_format(&full_path);
62
63 let operation = create_operation_from_path_info(path_doc);
65
66 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
76pub trait RouteDocumentation {
80 fn collect_openapi_paths(&self, base_path: &str) -> Vec<(String, PathItem)>;
90
91 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
129fn 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 let type_name = std::any::type_name_of_val(&**mw).to_string();
139 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 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 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 responses.dedup_by_key(|r| r.status);
163 responses
164}
165
166fn 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 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 let mw_responses = infer_middleware_responses(&route.middlewares);
200
201 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 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 ¤t_tags,
230 );
231 let path_item = create_or_update_path_item(None, method, operation);
232
233 if let Some((_, existing_item)) = paths.iter_mut().find(|(path, _)| path == &openapi_path) {
235 *existing_item = merge_path_items(existing_item, &path_item);
237 } else {
238 paths.push((openapi_path, path_item));
239 }
240 }
241
242 for child in &route.children {
244 collect_paths_recursive(child, &full_path, ¤t_tags, paths);
245 }
246}
247
248fn convert_path_format(silent_path: &str) -> String {
253 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 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
281fn 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 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
313fn 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 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 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 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 if deprecated {
398 builder = builder.deprecated(Some(utoipa::openapi::Deprecated::True));
399 }
400
401 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 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 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 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 {
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 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
527fn 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 "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 "f32" => (SchemaType::Type(Type::Number), Some("float")),
546 "f64" => (SchemaType::Type(Type::Number), Some("double")),
547 "bool" => (SchemaType::Type(Type::Boolean), None),
549 "String" | "str" | "&str" | "" => (SchemaType::Type(Type::String), None),
551 _ => (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
564fn 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
585fn 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
599pub 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 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 let id_param = ¶ms[0];
938 assert!(id_param.schema.is_some());
939 let slug_param = ¶ms[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 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}