1use serde_json::{json, Map, Value};
43
44pub fn routes_to_spec(routes: &[crate::routing::RouteRule], spec: &mut Value) {
56 let mut tags_to_add: Vec<(String, String)> = Vec::new();
58 let mut seen: std::collections::HashSet<String> = spec
59 .get("tags")
60 .and_then(|t| t.as_array())
61 .map(|arr| {
62 arr.iter()
63 .filter_map(|t| t.get("name").and_then(|n| n.as_str()))
64 .map(|s| s.to_string())
65 .collect()
66 })
67 .unwrap_or_default();
68
69 for rule in routes {
70 let tag = rule
71 .path
72 .trim_start_matches('/')
73 .split('/')
74 .next()
75 .unwrap_or("default")
76 .to_string();
77 if seen.insert(tag.clone()) {
78 tags_to_add.push((tag.clone(), format!("{} 相关接口", tag)));
79 }
80 }
81
82 {
84 let paths = spec
85 .get_mut("paths")
86 .and_then(|p| p.as_object_mut())
87 .expect("spec must contain a paths object");
88
89 for rule in routes {
90 let method = match rule.method {
91 crate::routing::HttpMethod::GET => "get",
92 crate::routing::HttpMethod::POST => "post",
93 crate::routing::HttpMethod::PUT => "put",
94 crate::routing::HttpMethod::DELETE => "delete",
95 crate::routing::HttpMethod::PATCH => "patch",
96 crate::routing::HttpMethod::OPTIONS => "options",
97 };
98
99 let tag = rule
101 .path
102 .trim_start_matches('/')
103 .split('/')
104 .next()
105 .unwrap_or("default")
106 .to_string();
107
108 let mut parameters = Vec::new();
110 for segment in rule.path.split('/') {
111 if let Some(name) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
112 parameters.push(json!({
113 "name": name,
114 "in": "path",
115 "required": true,
116 "description": format!("路径参数 {}", name),
117 "schema": { "type": "string" },
118 }));
119 }
120 }
121
122 let mut op = json!({
123 "summary": rule.handler,
124 "tags": [tag],
125 "responses": {
126 "200": { "description": "成功" },
127 "404": { "description": "资源不存在" }
128 },
129 });
130 if !parameters.is_empty() {
131 op["parameters"] = Value::Array(parameters);
132 }
133 if !rule.middleware.is_empty() {
134 op["description"] = json!(format!("中间件: {}", rule.middleware.join(", ")));
135 }
136
137 let path_entry = paths.entry(rule.path.clone()).or_insert_with(|| json!({}));
138 if let Value::Object(ref mut obj) = path_entry {
139 obj.insert(method.to_string(), op);
140 }
141 }
142 }
143
144 if !tags_to_add.is_empty() {
146 if spec.get("tags").is_none() {
147 spec["tags"] = Value::Array(Vec::new());
148 }
149 let tag_arr = spec
150 .get_mut("tags")
151 .and_then(|t| t.as_array_mut())
152 .expect("tags must be an array after creation");
153 for (name, desc) in tags_to_add {
154 tag_arr.push(json!({ "name": name, "description": desc }));
155 }
156 }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
161pub enum HttpMethod {
162 Get,
164 Post,
166 Put,
168 Delete,
170 Patch,
172 Options,
174 Head,
176}
177
178impl HttpMethod {
179 pub fn as_str(&self) -> &'static str {
181 match self {
182 HttpMethod::Get => "get",
183 HttpMethod::Post => "post",
184 HttpMethod::Put => "put",
185 HttpMethod::Delete => "delete",
186 HttpMethod::Patch => "patch",
187 HttpMethod::Options => "options",
188 HttpMethod::Head => "head",
189 }
190 }
191}
192
193pub struct OpenApiBuilder {
197 title: String,
198 version: String,
199 description: Option<String>,
200 paths: Map<String, Value>,
201 tags: Vec<Value>,
202 security_schemes: Map<String, Value>,
203}
204
205impl OpenApiBuilder {
206 pub fn new(title: &str, version: &str) -> Self {
213 Self {
214 title: title.to_string(),
215 version: version.to_string(),
216 description: None,
217 paths: Map::new(),
218 tags: Vec::new(),
219 security_schemes: Map::new(),
220 }
221 }
222
223 pub fn description(mut self, desc: &str) -> Self {
225 self.description = Some(desc.to_string());
226 self
227 }
228
229 pub fn tag(mut self, name: &str, description: &str) -> Self {
231 self.tags.push(json!({
232 "name": name,
233 "description": description,
234 }));
235 self
236 }
237
238 pub fn path<F>(mut self, path: &str, method: HttpMethod, config: F) -> Self
246 where
247 F: FnOnce(&mut OperationBuilder),
248 {
249 let mut op = OperationBuilder::new();
250 config(&mut op);
251
252 let path_entry = self
253 .paths
254 .entry(path.to_string())
255 .or_insert_with(|| json!({}));
256 if let Value::Object(ref mut obj) = path_entry {
257 obj.insert(method.as_str().to_string(), op.build());
258 }
259 self
260 }
261
262 pub fn bearer_auth(mut self, scheme_name: &str) -> Self {
264 self.security_schemes.insert(
265 scheme_name.to_string(),
266 json!({
267 "type": "http",
268 "scheme": "bearer",
269 "bearerFormat": "JWT",
270 }),
271 );
272 self
273 }
274
275 pub fn api_key_auth(mut self, scheme_name: &str, header_name: &str) -> Self {
277 self.security_schemes.insert(
278 scheme_name.to_string(),
279 json!({
280 "type": "apiKey",
281 "in": "header",
282 "name": header_name,
283 }),
284 );
285 self
286 }
287
288 pub fn build(self) -> Value {
290 let mut info = json!({
291 "title": self.title,
292 "version": self.version,
293 });
294 if let Some(desc) = self.description {
295 info["description"] = json!(desc);
296 }
297
298 let mut spec = json!({
299 "openapi": "3.0.3",
300 "info": info,
301 "paths": self.paths,
302 });
303
304 let mut components = Map::new();
305 if !self.security_schemes.is_empty() {
306 components.insert(
307 "securitySchemes".to_string(),
308 Value::Object(self.security_schemes),
309 );
310 }
311 if !components.is_empty() {
312 spec["components"] = Value::Object(components);
313 }
314
315 if !self.tags.is_empty() {
316 spec["tags"] = Value::Array(self.tags);
317 }
318
319 spec
320 }
321
322 pub fn to_json_string(self) -> String {
324 serde_json::to_string_pretty(&self.build()).unwrap_or_else(|_| "{}".to_string())
325 }
326}
327
328pub fn spec_from_route_config(
346 builder: OpenApiBuilder,
347 config: &crate::routing::RouteConfig,
348) -> String {
349 let mut spec = builder.build();
350 let routes = config.flatten();
351 routes_to_spec(&routes, &mut spec);
352 serde_json::to_string_pretty(&spec).unwrap_or_else(|_| "{}".to_string())
353}
354
355pub struct OperationBuilder {
357 summary: Option<String>,
358 description: Option<String>,
359 tags: Vec<String>,
360 parameters: Vec<Value>,
361 responses: Map<String, Value>,
362 deprecated: bool,
363}
364
365impl OperationBuilder {
366 pub fn new() -> Self {
368 Self {
369 summary: None,
370 description: None,
371 tags: Vec::new(),
372 parameters: Vec::new(),
373 responses: Map::new(),
374 deprecated: false,
375 }
376 }
377
378 pub fn summary(&mut self, summary: &str) -> &mut Self {
380 self.summary = Some(summary.to_string());
381 self
382 }
383
384 pub fn description(&mut self, desc: &str) -> &mut Self {
386 self.description = Some(desc.to_string());
387 self
388 }
389
390 pub fn tag(&mut self, tag: &str) -> &mut Self {
392 self.tags.push(tag.to_string());
393 self
394 }
395
396 pub fn parameter(
406 &mut self,
407 name: &str,
408 location: &str,
409 desc: &str,
410 required: bool,
411 schema_type: &str,
412 ) -> &mut Self {
413 self.parameters.push(json!({
414 "name": name,
415 "in": location,
416 "description": desc,
417 "required": required,
418 "schema": {
419 "type": schema_type
420 }
421 }));
422 self
423 }
424
425 pub fn response(&mut self, status: u16, desc: &str, content_type: &str) -> &mut Self {
433 self.responses.insert(
434 status.to_string(),
435 json!({
436 "description": desc,
437 "content": {
438 content_type: {
439 "schema": {
440 "type": "object"
441 }
442 }
443 }
444 }),
445 );
446 self
447 }
448
449 pub fn response_with_schema(
458 &mut self,
459 status: u16,
460 desc: &str,
461 content_type: &str,
462 schema_ref: &str,
463 ) -> &mut Self {
464 self.responses.insert(
465 status.to_string(),
466 json!({
467 "description": desc,
468 "content": {
469 content_type: {
470 "schema": {
471 "$ref": schema_ref
472 }
473 }
474 }
475 }),
476 );
477 self
478 }
479
480 pub fn deprecated(&mut self) -> &mut Self {
482 self.deprecated = true;
483 self
484 }
485
486 fn build(self) -> Value {
488 let mut op = Map::new();
489 if let Some(s) = self.summary {
490 op.insert("summary".to_string(), json!(s));
491 }
492 if let Some(d) = self.description {
493 op.insert("description".to_string(), json!(d));
494 }
495 if !self.tags.is_empty() {
496 op.insert("tags".to_string(), json!(self.tags));
497 }
498 if !self.parameters.is_empty() {
499 op.insert("parameters".to_string(), json!(self.parameters));
500 }
501 if !self.responses.is_empty() {
502 op.insert("responses".to_string(), Value::Object(self.responses));
503 } else {
504 op.insert(
506 "responses".to_string(),
507 json!({
508 "200": {
509 "description": "成功"
510 }
511 }),
512 );
513 }
514 if self.deprecated {
515 op.insert("deprecated".to_string(), json!(true));
516 }
517 Value::Object(op)
518 }
519}
520
521impl Default for OperationBuilder {
522 fn default() -> Self {
523 Self::new()
524 }
525}
526
527pub fn swagger_ui_html(spec_json: &str) -> String {
535 format!(
536 r#"<!DOCTYPE html>
537<html lang="zh-CN">
538<head>
539 <meta charset="UTF-8">
540 <meta name="viewport" content="width=device-width, initial-scale=1.0">
541 <title>API 文档 - Swagger UI</title>
542 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
543 <style>
544 body {{ margin: 0; }}
545 </style>
546</head>
547<body>
548 <div id="swagger-ui"></div>
549 <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
550 <script>
551 window.onload = function() {{
552 const spec = {spec_json};
553 SwaggerUIBundle({{
554 spec: spec,
555 dom_id: '#swagger-ui',
556 presets: [SwaggerUIBundle.presets.apis],
557 layout: 'BaseLayout',
558 }});
559 }};
560 </script>
561</body>
562</html>"#
563 )
564}
565
566pub fn redoc_html(spec_json: &str) -> String {
572 format!(
573 r#"<!DOCTYPE html>
574<html lang="zh-CN">
575<head>
576 <meta charset="UTF-8">
577 <meta name="viewport" content="width=device-width, initial-scale=1.0">
578 <title>API 文档 - Redoc</title>
579 <style>
580 body {{ margin: 0; padding: 0; }}
581 </style>
582</head>
583<body>
584 <redoc spec-json='{spec_json}'></redoc>
585 <script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"></script>
586</body>
587</html>"#
588 )
589}
590
591#[cfg(test)]
592mod tests {
593 use super::*;
594
595 #[test]
596 fn test_http_method_as_str() {
597 assert_eq!(HttpMethod::Get.as_str(), "get");
598 assert_eq!(HttpMethod::Post.as_str(), "post");
599 assert_eq!(HttpMethod::Put.as_str(), "put");
600 assert_eq!(HttpMethod::Delete.as_str(), "delete");
601 assert_eq!(HttpMethod::Patch.as_str(), "patch");
602 assert_eq!(HttpMethod::Options.as_str(), "options");
603 assert_eq!(HttpMethod::Head.as_str(), "head");
604 }
605
606 #[test]
607 fn test_openapi_builder_basic() {
608 let spec = OpenApiBuilder::new("Test API", "1.0.0")
609 .description("A test API")
610 .build();
611
612 assert_eq!(spec["openapi"], "3.0.3");
613 assert_eq!(spec["info"]["title"], "Test API");
614 assert_eq!(spec["info"]["version"], "1.0.0");
615 assert_eq!(spec["info"]["description"], "A test API");
616 assert!(spec["paths"].is_object());
617 }
618
619 #[test]
620 fn test_openapi_builder_with_path() {
621 let spec = OpenApiBuilder::new("Test API", "1.0.0")
622 .path("/api/v1/users", HttpMethod::Get, |op| {
623 op.summary("获取用户列表")
624 .tag("用户")
625 .response(200, "成功", "application/json");
626 })
627 .build();
628
629 let path = &spec["paths"]["/api/v1/users"]["get"];
630 assert_eq!(path["summary"], "获取用户列表");
631 assert_eq!(path["tags"][0], "用户");
632 assert_eq!(path["responses"]["200"]["description"], "成功");
633 }
634
635 #[test]
636 fn test_openapi_builder_with_parameter() {
637 let spec = OpenApiBuilder::new("Test API", "1.0.0")
638 .path("/api/v1/users/{id}", HttpMethod::Get, |op| {
639 op.summary("获取用户详情")
640 .parameter("id", "path", "用户 ID", true, "integer")
641 .response(200, "成功", "application/json")
642 .response(404, "用户不存在", "application/json");
643 })
644 .build();
645
646 let path = &spec["paths"]["/api/v1/users/{id}"]["get"];
647 assert_eq!(path["parameters"][0]["name"], "id");
648 assert_eq!(path["parameters"][0]["in"], "path");
649 assert_eq!(path["parameters"][0]["required"], true);
650 assert_eq!(path["parameters"][0]["schema"]["type"], "integer");
651 assert_eq!(path["responses"]["404"]["description"], "用户不存在");
652 }
653
654 #[test]
655 fn test_openapi_builder_with_tags() {
656 let spec = OpenApiBuilder::new("Test API", "1.0.0")
657 .tag("用户", "用户管理接口")
658 .tag("订单", "订单管理接口")
659 .build();
660
661 assert_eq!(spec["tags"][0]["name"], "用户");
662 assert_eq!(spec["tags"][0]["description"], "用户管理接口");
663 assert_eq!(spec["tags"][1]["name"], "订单");
664 }
665
666 #[test]
667 fn test_openapi_builder_bearer_auth() {
668 let spec = OpenApiBuilder::new("Test API", "1.0.0")
669 .bearer_auth("BearerAuth")
670 .build();
671
672 let scheme = &spec["components"]["securitySchemes"]["BearerAuth"];
673 assert_eq!(scheme["type"], "http");
674 assert_eq!(scheme["scheme"], "bearer");
675 assert_eq!(scheme["bearerFormat"], "JWT");
676 }
677
678 #[test]
679 fn test_openapi_builder_api_key_auth() {
680 let spec = OpenApiBuilder::new("Test API", "1.0.0")
681 .api_key_auth("ApiKeyAuth", "X-API-Key")
682 .build();
683
684 let scheme = &spec["components"]["securitySchemes"]["ApiKeyAuth"];
685 assert_eq!(scheme["type"], "apiKey");
686 assert_eq!(scheme["in"], "header");
687 assert_eq!(scheme["name"], "X-API-Key");
688 }
689
690 #[test]
691 fn test_openapi_builder_default_response() {
692 let spec = OpenApiBuilder::new("Test API", "1.0.0")
693 .path("/api/v1/health", HttpMethod::Get, |op| {
694 op.summary("健康检查");
695 })
696 .build();
697
698 let path = &spec["paths"]["/api/v1/health"]["get"];
699 assert_eq!(path["responses"]["200"]["description"], "成功");
701 }
702
703 #[test]
704 fn test_openapi_builder_deprecated() {
705 let spec = OpenApiBuilder::new("Test API", "1.0.0")
706 .path("/api/v1/old", HttpMethod::Get, |op| {
707 op.summary("旧接口").deprecated();
708 })
709 .build();
710
711 let path = &spec["paths"]["/api/v1/old"]["get"];
712 assert_eq!(path["deprecated"], true);
713 }
714
715 #[test]
716 fn test_openapi_builder_multiple_methods_same_path() {
717 let spec = OpenApiBuilder::new("Test API", "1.0.0")
718 .path("/api/v1/users", HttpMethod::Get, |op| {
719 op.summary("获取列表");
720 })
721 .path("/api/v1/users", HttpMethod::Post, |op| {
722 op.summary("创建用户");
723 })
724 .build();
725
726 let path = &spec["paths"]["/api/v1/users"];
727 assert!(path["get"].is_object());
728 assert!(path["post"].is_object());
729 assert_eq!(path["get"]["summary"], "获取列表");
730 assert_eq!(path["post"]["summary"], "创建用户");
731 }
732
733 #[test]
734 fn test_openapi_builder_to_json_string() {
735 let builder = OpenApiBuilder::new("Test API", "1.0.0");
736 let json = builder.to_json_string();
737 assert!(json.contains("\"openapi\": \"3.0.3\""));
738 assert!(json.contains("\"title\": \"Test API\""));
739 assert!(json.contains("\"version\": \"1.0.0\""));
740 }
741
742 #[test]
743 fn test_openapi_builder_response_with_schema() {
744 let spec = OpenApiBuilder::new("Test API", "1.0.0")
745 .path("/api/v1/users/{id}", HttpMethod::Get, |op| {
746 op.response_with_schema(
747 200,
748 "成功",
749 "application/json",
750 "#/components/schemas/User",
751 );
752 })
753 .build();
754
755 let schema_ref = &spec["paths"]["/api/v1/users/{id}"]["get"]["responses"]["200"]["content"]
756 ["application/json"]["schema"]["$ref"];
757 assert_eq!(schema_ref, "#/components/schemas/User");
758 }
759
760 #[test]
761 fn test_swagger_ui_html_contains_spec() {
762 let spec_json = r#"{"openapi":"3.0.3","info":{"title":"Test"}}"#;
763 let html = swagger_ui_html(spec_json);
764 assert!(html.contains("<!DOCTYPE html>"));
765 assert!(html.contains("swagger-ui"));
766 assert!(html.contains(spec_json));
767 }
768
769 #[test]
770 fn test_redoc_html_contains_spec() {
771 let spec_json = r#"{"openapi":"3.0.3","info":{"title":"Test"}}"#;
772 let html = redoc_html(spec_json);
773 assert!(html.contains("<!DOCTYPE html>"));
774 assert!(html.contains("redoc"));
775 assert!(html.contains(spec_json));
776 }
777
778 #[test]
779 fn test_operation_builder_default() {
780 let op = OperationBuilder::default();
781 assert!(op.summary.is_none());
782 assert!(op.description.is_none());
783 assert!(op.tags.is_empty());
784 assert!(op.parameters.is_empty());
785 assert!(op.responses.is_empty());
786 assert!(!op.deprecated);
787 }
788
789 #[test]
790 fn test_openapi_builder_no_description() {
791 let spec = OpenApiBuilder::new("Test API", "1.0.0").build();
792 assert!(spec["info"]["description"].is_null());
793 }
794
795 #[test]
796 fn test_openapi_builder_no_security_schemes() {
797 let spec = OpenApiBuilder::new("Test API", "1.0.0").build();
798 assert!(spec.get("components").is_none() || spec["components"].is_null());
800 }
801
802 #[test]
803 fn test_openapi_builder_full_spec() {
804 let spec = OpenApiBuilder::new("SZ-Rust API", "1.0.0")
805 .description("全栈 API 文档")
806 .tag("用户", "用户管理")
807 .tag("认证", "认证授权")
808 .bearer_auth("BearerAuth")
809 .path("/api/v1/auth/login", HttpMethod::Post, |op| {
810 op.summary("用户登录")
811 .description("通过用户名密码获取 JWT Token")
812 .tag("认证")
813 .parameter("username", "query", "用户名", true, "string")
814 .parameter("password", "query", "密码", true, "string")
815 .response(200, "登录成功", "application/json")
816 .response(401, "认证失败", "application/json");
817 })
818 .path("/api/v1/users", HttpMethod::Get, |op| {
819 op.summary("获取用户列表").tag("用户").response_with_schema(
820 200,
821 "成功",
822 "application/json",
823 "#/components/schemas/UserList",
824 );
825 })
826 .path("/api/v1/users/{id}", HttpMethod::Delete, |op| {
827 op.summary("删除用户")
828 .tag("用户")
829 .parameter("id", "path", "用户 ID", true, "integer")
830 .response(204, "删除成功", "application/json")
831 .response(404, "用户不存在", "application/json");
832 })
833 .build();
834
835 assert_eq!(spec["openapi"], "3.0.3");
837 assert_eq!(spec["info"]["title"], "SZ-Rust API");
838 assert_eq!(spec["info"]["description"], "全栈 API 文档");
839
840 assert_eq!(spec["paths"].as_object().unwrap().len(), 3);
842
843 assert_eq!(spec["tags"].as_array().unwrap().len(), 2);
845
846 assert_eq!(
848 spec["components"]["securitySchemes"]["BearerAuth"]["scheme"],
849 "bearer"
850 );
851
852 let login = &spec["paths"]["/api/v1/auth/login"]["post"];
854 assert_eq!(login["summary"], "用户登录");
855 assert_eq!(login["description"], "通过用户名密码获取 JWT Token");
856 assert_eq!(login["tags"][0], "认证");
857 assert_eq!(login["parameters"].as_array().unwrap().len(), 2);
858 assert_eq!(login["responses"]["401"]["description"], "认证失败");
859
860 let delete = &spec["paths"]["/api/v1/users/{id}"]["delete"];
862 assert_eq!(delete["summary"], "删除用户");
863 assert_eq!(delete["responses"]["204"]["description"], "删除成功");
864 }
865
866 fn sample_route_config() -> crate::routing::RouteConfig {
869 use crate::routing::{HttpMethod, RouteConfig, RouteRule};
870 let mut cfg = RouteConfig::new();
871 cfg.add_route(RouteRule::new(
872 HttpMethod::GET,
873 "/api/v1/users",
874 "User@list",
875 ));
876 cfg.add_route(RouteRule::new(
877 HttpMethod::POST,
878 "/api/v1/users",
879 "User@create",
880 ));
881 cfg.add_route(RouteRule::new(
882 HttpMethod::GET,
883 "/api/v1/users/{id}",
884 "User@detail",
885 ));
886 cfg.add_route(RouteRule::new(
887 HttpMethod::DELETE,
888 "/api/v1/users/{id}",
889 "User@delete",
890 ));
891 cfg
892 }
893
894 #[test]
895 fn test_routes_to_spec_generates_paths() {
896 let cfg = sample_route_config();
897 let mut spec = OpenApiBuilder::new("Scan Test", "1.0.0").build();
898 routes_to_spec(&cfg.flatten(), &mut spec);
899
900 let paths = spec["paths"].as_object().unwrap();
901 assert_eq!(paths.len(), 2, "应有 2 个路径(/users 与 /users/{{id}})");
902 assert!(paths.contains_key("/api/v1/users"));
903 assert!(paths.contains_key("/api/v1/users/{id}"));
904 }
905
906 #[test]
907 fn test_routes_to_spec_method_mapping() {
908 let cfg = sample_route_config();
909 let mut spec = OpenApiBuilder::new("Scan Test", "1.0.0").build();
910 routes_to_spec(&cfg.flatten(), &mut spec);
911
912 let users = &spec["paths"]["/api/v1/users"];
913 assert!(users["get"].is_object(), "GET 应存在");
914 assert!(users["post"].is_object(), "POST 应存在");
915 assert_eq!(users["get"]["summary"], "User@list");
916 assert_eq!(users["post"]["summary"], "User@create");
917 }
918
919 #[test]
920 fn test_routes_to_spec_auto_detect_path_params() {
921 let cfg = sample_route_config();
922 let mut spec = OpenApiBuilder::new("Scan Test", "1.0.0").build();
923 routes_to_spec(&cfg.flatten(), &mut spec);
924
925 let detail = &spec["paths"]["/api/v1/users/{id}"]["get"];
926 let params = detail["parameters"].as_array().unwrap();
927 assert_eq!(params.len(), 1);
928 assert_eq!(params[0]["name"], "id");
929 assert_eq!(params[0]["in"], "path");
930 assert_eq!(params[0]["required"], true);
931 }
932
933 #[test]
934 fn test_routes_to_spec_tag_from_path() {
935 let cfg = sample_route_config();
936 let mut spec = OpenApiBuilder::new("Scan Test", "1.0.0").build();
937 routes_to_spec(&cfg.flatten(), &mut spec);
938
939 let users = &spec["paths"]["/api/v1/users"]["get"];
940 assert_eq!(
941 users["tags"][0], "api",
942 "tag 应取路径首段(/api/v1/users → api)"
943 );
944 }
945
946 #[test]
947 fn test_routes_to_spec_tags_collected_and_deduped() {
948 let cfg = sample_route_config();
949 let mut spec = OpenApiBuilder::new("Scan Test", "1.0.0").build();
950 routes_to_spec(&cfg.flatten(), &mut spec);
951
952 let tags = spec["tags"].as_array().unwrap();
953 let names: Vec<&str> = tags.iter().filter_map(|t| t["name"].as_str()).collect();
954 assert_eq!(names, vec!["api"], "同一 tag 不应重复收集");
955 }
956
957 #[test]
958 fn test_routes_to_spec_middleware_in_description() {
959 use crate::routing::{HttpMethod, RouteRule};
960 let mut cfg = crate::routing::RouteConfig::new();
961 cfg.add_route(
962 RouteRule::new(HttpMethod::GET, "/api/v1/admin", "Admin@index").with_middleware("auth"),
963 );
964 let mut spec = OpenApiBuilder::new("Scan Test", "1.0.0").build();
965 routes_to_spec(&cfg.flatten(), &mut spec);
966
967 let op = &spec["paths"]["/api/v1/admin"]["get"];
968 assert_eq!(op["description"], "中间件: auth");
969 }
970
971 #[test]
972 fn test_routes_to_spec_default_responses() {
973 let cfg = sample_route_config();
974 let mut spec = OpenApiBuilder::new("Scan Test", "1.0.0").build();
975 routes_to_spec(&cfg.flatten(), &mut spec);
976
977 let detail = &spec["paths"]["/api/v1/users/{id}"]["get"];
978 assert_eq!(detail["responses"]["200"]["description"], "成功");
979 assert_eq!(detail["responses"]["404"]["description"], "资源不存在");
980 }
981
982 #[test]
983 fn test_spec_from_route_config_full_flow() {
984 let cfg = sample_route_config();
985 let builder = OpenApiBuilder::new("Scan Full", "1.0.0")
986 .description("自动扫描")
987 .bearer_auth("BearerAuth");
988 let json = spec_from_route_config(builder, &cfg);
989
990 let spec: Value = serde_json::from_str(&json).unwrap();
991 assert_eq!(spec["openapi"], "3.0.3");
992 assert_eq!(spec["info"]["title"], "Scan Full");
993 assert_eq!(spec["paths"].as_object().unwrap().len(), 2);
994 assert_eq!(
995 spec["components"]["securitySchemes"]["BearerAuth"]["scheme"],
996 "bearer"
997 );
998 }
999
1000 #[test]
1001 fn test_routes_to_spec_merges_with_existing_paths() {
1002 let mut spec = OpenApiBuilder::new("Merge Test", "1.0.0")
1004 .path("/api/v1/health", HttpMethod::Get, |op| {
1005 op.summary("健康检查");
1006 })
1007 .build();
1008
1009 let cfg = sample_route_config();
1010 routes_to_spec(&cfg.flatten(), &mut spec);
1011
1012 let paths = spec["paths"].as_object().unwrap();
1013 assert_eq!(paths.len(), 3);
1014 assert!(paths.contains_key("/api/v1/health"));
1015 assert_eq!(paths["/api/v1/health"]["get"]["summary"], "健康检查");
1016 }
1017}