1use crate::RouteMetadata;
4use utoipa::openapi::HttpMethod;
5use utoipa::openapi::security::SecurityScheme;
6use utoipa::openapi::{Components, Info, OpenApi, OpenApiBuilder, PathItem, Paths, RefOr, Response, Responses};
7
8fn route_to_path_item(route: &RouteMetadata) -> Result<PathItem, String> {
10 let operation = route_to_operation(route)?;
11
12 let http_method = match route.method.to_uppercase().as_str() {
13 "GET" => HttpMethod::Get,
14 "POST" => HttpMethod::Post,
15 "PUT" => HttpMethod::Put,
16 "DELETE" => HttpMethod::Delete,
17 "PATCH" => HttpMethod::Patch,
18 "HEAD" => HttpMethod::Head,
19 "OPTIONS" => HttpMethod::Options,
20 _ => return Err(format!("Unsupported HTTP method: {}", route.method)),
21 };
22
23 let path_item = PathItem::new(http_method, operation);
24
25 Ok(path_item)
26}
27
28fn route_to_operation(route: &RouteMetadata) -> Result<utoipa::openapi::path::Operation, String> {
30 let mut operation = utoipa::openapi::path::Operation::new();
31
32 if let Some(param_schema) = &route.parameter_schema {
33 let parameters =
34 crate::openapi::parameter_extraction::extract_parameters_from_schema(param_schema, &route.path)?;
35 if !parameters.is_empty() {
36 let unwrapped: Vec<_> = parameters
37 .into_iter()
38 .filter_map(|p| if let RefOr::T(param) = p { Some(param) } else { None })
39 .collect();
40 operation.parameters = Some(unwrapped);
41 }
42 }
43
44 if let Some(request_schema) = &route.request_schema {
45 let request_body = crate::openapi::schema_conversion::json_schema_to_request_body(request_schema)?;
46 operation.request_body = Some(request_body);
47 }
48
49 let mut responses = Responses::new();
50 if let Some(response_schema) = &route.response_schema {
51 let response = crate::openapi::schema_conversion::json_schema_to_response(response_schema)?;
52 responses.responses.insert("200".to_string(), RefOr::T(response));
53 } else {
54 responses
55 .responses
56 .insert("200".to_string(), RefOr::T(Response::new("Successful response")));
57 }
58 operation.responses = responses;
59
60 Ok(operation)
61}
62
63pub fn assemble_openapi_spec(
65 routes: &[RouteMetadata],
66 config: &super::OpenApiConfig,
67 server_config: Option<&crate::ServerConfig>,
68) -> Result<OpenApi, String> {
69 let mut info = Info::new(&config.title, &config.version);
70 if let Some(desc) = &config.description {
71 info.description = Some(desc.clone());
72 }
73 if let Some(contact_info) = &config.contact {
74 let mut contact = utoipa::openapi::Contact::default();
75 if let Some(name) = &contact_info.name {
76 contact.name = Some(name.clone());
77 }
78 if let Some(email) = &contact_info.email {
79 contact.email = Some(email.clone());
80 }
81 if let Some(url) = &contact_info.url {
82 contact.url = Some(url.clone());
83 }
84 info.contact = Some(contact);
85 }
86 if let Some(license_info) = &config.license {
87 let mut license = utoipa::openapi::License::new(&license_info.name);
88 if let Some(url) = &license_info.url {
89 license.url = Some(url.clone());
90 }
91 info.license = Some(license);
92 }
93
94 let servers = if config.servers.is_empty() {
95 None
96 } else {
97 Some(
98 config
99 .servers
100 .iter()
101 .map(|s| {
102 let mut server = utoipa::openapi::Server::new(&s.url);
103 if let Some(desc) = &s.description {
104 server.description = Some(desc.clone());
105 }
106 server
107 })
108 .collect(),
109 )
110 };
111
112 let mut paths = Paths::new();
113 for route in routes {
114 let path_item = route_to_path_item(route)?;
115 paths.paths.insert(route.path.clone(), path_item);
116 }
117
118 let mut components = Components::new();
119 let mut global_security = Vec::new();
120
121 if let Some(server_cfg) = server_config {
122 if let Some(_jwt_cfg) = &server_cfg.jwt_auth {
123 let jwt_scheme = SecurityScheme::Http(
124 utoipa::openapi::security::HttpBuilder::new()
125 .scheme(utoipa::openapi::security::HttpAuthScheme::Bearer)
126 .bearer_format("JWT")
127 .build(),
128 );
129 components.add_security_scheme("bearerAuth", jwt_scheme);
130
131 let security_req = utoipa::openapi::security::SecurityRequirement::new("bearerAuth", Vec::<String>::new());
132 global_security.push(security_req);
133 }
134
135 if let Some(api_key_cfg) = &server_cfg.api_key_auth {
136 use utoipa::openapi::security::ApiKey;
137 let api_key_scheme = SecurityScheme::ApiKey(ApiKey::Header(utoipa::openapi::security::ApiKeyValue::new(
138 &api_key_cfg.header_name,
139 )));
140 components.add_security_scheme("apiKeyAuth", api_key_scheme);
141
142 let security_req = utoipa::openapi::security::SecurityRequirement::new("apiKeyAuth", Vec::<String>::new());
143 global_security.push(security_req);
144 }
145 }
146
147 if !config.security_schemes.is_empty() {
148 for (name, scheme_info) in &config.security_schemes {
149 let scheme = crate::openapi::security_scheme_info_to_openapi(scheme_info);
150 components.add_security_scheme(name, scheme);
151 }
152 }
153
154 let mut openapi = OpenApiBuilder::new()
155 .info(info)
156 .paths(paths)
157 .components(Some(components))
158 .build();
159
160 if let Some(servers) = servers {
161 openapi.servers = Some(servers);
162 }
163
164 if !global_security.is_empty() {
165 openapi.security = Some(global_security);
166 }
167
168 Ok(openapi)
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use crate::{ApiKeyConfig, JwtConfig};
175
176 fn make_route(method: &str, path: &str) -> RouteMetadata {
177 RouteMetadata {
178 method: method.to_string(),
179 path: path.to_string(),
180 handler_name: format!("{}_handler", method.to_lowercase()),
181 request_schema: None,
182 response_schema: None,
183 parameter_schema: None,
184 file_params: None,
185 is_async: true,
186 cors: None,
187 body_param_name: None,
188 #[cfg(feature = "di")]
189 handler_dependencies: None,
190 jsonrpc_method: None,
191 static_response: None,
192 }
193 }
194
195 fn make_server_config_with_jwt() -> crate::ServerConfig {
196 crate::ServerConfig {
197 jwt_auth: Some(JwtConfig {
198 secret: "test-secret".to_string(),
199 algorithm: "HS256".to_string(),
200 audience: None,
201 issuer: None,
202 leeway: 0,
203 }),
204 ..Default::default()
205 }
206 }
207
208 fn make_server_config_with_api_key() -> crate::ServerConfig {
209 crate::ServerConfig {
210 api_key_auth: Some(ApiKeyConfig {
211 keys: vec!["test-key".to_string()],
212 header_name: "X-API-Key".to_string(),
213 }),
214 ..Default::default()
215 }
216 }
217
218 #[test]
219 fn test_route_to_path_item_get() {
220 let route = make_route("GET", "/users");
221 let result = route_to_path_item(&route);
222 assert!(result.is_ok());
223 }
224
225 #[test]
226 fn test_route_to_path_item_post() {
227 let route = make_route("POST", "/users");
228 let result = route_to_path_item(&route);
229 assert!(result.is_ok());
230 }
231
232 #[test]
233 fn test_route_to_path_item_put() {
234 let route = make_route("PUT", "/users/123");
235 let result = route_to_path_item(&route);
236 assert!(result.is_ok());
237 }
238
239 #[test]
240 fn test_route_to_path_item_patch() {
241 let route = make_route("PATCH", "/users/123");
242 let result = route_to_path_item(&route);
243 assert!(result.is_ok());
244 }
245
246 #[test]
247 fn test_route_to_path_item_delete() {
248 let route = make_route("DELETE", "/users/123");
249 let result = route_to_path_item(&route);
250 assert!(result.is_ok());
251 }
252
253 #[test]
254 fn test_route_to_path_item_head() {
255 let route = make_route("HEAD", "/users");
256 let result = route_to_path_item(&route);
257 assert!(result.is_ok());
258 }
259
260 #[test]
261 fn test_route_to_path_item_options() {
262 let route = make_route("OPTIONS", "/users");
263 let result = route_to_path_item(&route);
264 assert!(result.is_ok());
265 }
266
267 #[test]
268 fn test_route_to_path_item_case_insensitive_method() {
269 let route_lower = make_route("get", "/users");
270 let route_mixed = make_route("GeT", "/users");
271
272 assert!(route_to_path_item(&route_lower).is_ok());
273 assert!(route_to_path_item(&route_mixed).is_ok());
274 }
275
276 #[test]
277 fn test_route_to_path_item_unsupported_method() {
278 let route = make_route("CONNECT", "/users");
279 let result = route_to_path_item(&route);
280 assert!(result.is_err());
281 if let Err(err) = result {
282 assert!(err.contains("Unsupported HTTP method"));
283 }
284 }
285
286 #[test]
287 fn test_assemble_openapi_spec_minimal() {
288 let config = super::super::OpenApiConfig {
289 enabled: true,
290 title: "Test API".to_string(),
291 version: "1.0.0".to_string(),
292 ..Default::default()
293 };
294
295 let result = assemble_openapi_spec(&[], &config, None);
296 assert!(result.is_ok());
297 let spec = result.unwrap();
298 assert_eq!(spec.info.title, "Test API");
299 assert_eq!(spec.info.version, "1.0.0");
300 }
301
302 #[test]
303 fn test_assemble_openapi_spec_with_description() {
304 let config = super::super::OpenApiConfig {
305 enabled: true,
306 title: "Test API".to_string(),
307 version: "1.0.0".to_string(),
308 description: Some("This is a test API".to_string()),
309 ..Default::default()
310 };
311
312 let result = assemble_openapi_spec(&[], &config, None);
313 assert!(result.is_ok());
314 let spec = result.unwrap();
315 assert_eq!(spec.info.description, Some("This is a test API".to_string()));
316 }
317
318 #[test]
319 fn test_assemble_openapi_spec_with_contact() {
320 let config = super::super::OpenApiConfig {
321 enabled: true,
322 title: "Test API".to_string(),
323 version: "1.0.0".to_string(),
324 contact: Some(super::super::ContactInfo {
325 name: Some("Support Team".to_string()),
326 email: Some("support@example.com".to_string()),
327 url: Some("https://example.com/support".to_string()),
328 }),
329 ..Default::default()
330 };
331
332 let result = assemble_openapi_spec(&[], &config, None);
333 assert!(result.is_ok());
334 let spec = result.unwrap();
335 assert!(spec.info.contact.is_some());
336 let contact = spec.info.contact.unwrap();
337 assert_eq!(contact.name, Some("Support Team".to_string()));
338 assert_eq!(contact.email, Some("support@example.com".to_string()));
339 }
340
341 #[test]
342 fn test_assemble_openapi_spec_with_license() {
343 let config = super::super::OpenApiConfig {
344 enabled: true,
345 title: "Test API".to_string(),
346 version: "1.0.0".to_string(),
347 license: Some(super::super::LicenseInfo {
348 name: "Apache 2.0".to_string(),
349 url: Some("https://www.apache.org/licenses/LICENSE-2.0.html".to_string()),
350 }),
351 ..Default::default()
352 };
353
354 let result = assemble_openapi_spec(&[], &config, None);
355 assert!(result.is_ok());
356 let spec = result.unwrap();
357 assert!(spec.info.license.is_some());
358 let license = spec.info.license.unwrap();
359 assert_eq!(license.name, "Apache 2.0");
360 assert_eq!(
361 license.url,
362 Some("https://www.apache.org/licenses/LICENSE-2.0.html".to_string())
363 );
364 }
365
366 #[test]
367 fn test_assemble_openapi_spec_with_servers() {
368 let config = super::super::OpenApiConfig {
369 enabled: true,
370 title: "Test API".to_string(),
371 version: "1.0.0".to_string(),
372 servers: vec![
373 super::super::ServerInfo {
374 url: "https://api.example.com".to_string(),
375 description: Some("Production".to_string()),
376 },
377 super::super::ServerInfo {
378 url: "http://localhost:8080".to_string(),
379 description: Some("Development".to_string()),
380 },
381 ],
382 ..Default::default()
383 };
384
385 let result = assemble_openapi_spec(&[], &config, None);
386 assert!(result.is_ok());
387 let spec = result.unwrap();
388 assert!(spec.servers.is_some());
389 let servers = spec.servers.unwrap();
390 assert_eq!(servers.len(), 2);
391 }
392
393 #[test]
394 fn test_assemble_openapi_spec_with_jwt_auth() {
395 let config = super::super::OpenApiConfig {
396 enabled: true,
397 title: "Test API".to_string(),
398 version: "1.0.0".to_string(),
399 ..Default::default()
400 };
401
402 let server_config = make_server_config_with_jwt();
403 let result = assemble_openapi_spec(&[], &config, Some(&server_config));
404 assert!(result.is_ok());
405 let spec = result.unwrap();
406
407 assert!(spec.components.is_some());
408 let components = spec.components.unwrap();
409 assert!(components.security_schemes.get("bearerAuth").is_some());
410
411 assert!(spec.security.is_some());
412 let security_reqs = spec.security.unwrap();
413 assert!(!security_reqs.is_empty());
414 assert_eq!(security_reqs.len(), 1);
415 }
416
417 #[test]
418 fn test_assemble_openapi_spec_with_api_key_auth() {
419 let config = super::super::OpenApiConfig {
420 enabled: true,
421 title: "Test API".to_string(),
422 version: "1.0.0".to_string(),
423 ..Default::default()
424 };
425
426 let server_config = make_server_config_with_api_key();
427 let result = assemble_openapi_spec(&[], &config, Some(&server_config));
428 assert!(result.is_ok());
429 let spec = result.unwrap();
430
431 assert!(spec.components.is_some());
432 let components = spec.components.unwrap();
433 assert!(components.security_schemes.get("apiKeyAuth").is_some());
434
435 assert!(spec.security.is_some());
436 let security_reqs = spec.security.unwrap();
437 assert!(!security_reqs.is_empty());
438 assert_eq!(security_reqs.len(), 1);
439 }
440
441 #[test]
442 fn test_assemble_openapi_spec_with_both_auth_schemes() {
443 let config = super::super::OpenApiConfig {
444 enabled: true,
445 title: "Test API".to_string(),
446 version: "1.0.0".to_string(),
447 ..Default::default()
448 };
449
450 let mut server_config = make_server_config_with_jwt();
451 server_config.api_key_auth = Some(ApiKeyConfig {
452 keys: vec!["test-key".to_string()],
453 header_name: "X-API-Key".to_string(),
454 });
455
456 let result = assemble_openapi_spec(&[], &config, Some(&server_config));
457 assert!(result.is_ok());
458 let spec = result.unwrap();
459
460 assert!(spec.components.is_some());
461 let components = spec.components.unwrap();
462 assert!(components.security_schemes.get("bearerAuth").is_some());
463 assert!(components.security_schemes.get("apiKeyAuth").is_some());
464 }
465
466 #[test]
467 fn test_assemble_openapi_spec_with_custom_security_schemes() {
468 use std::collections::HashMap;
469
470 let mut security_schemes = HashMap::new();
471 security_schemes.insert(
472 "oauth2".to_string(),
473 super::super::SecuritySchemeInfo::Http {
474 scheme: "bearer".to_string(),
475 bearer_format: Some("OAuth2".to_string()),
476 },
477 );
478
479 let config = super::super::OpenApiConfig {
480 enabled: true,
481 title: "Test API".to_string(),
482 version: "1.0.0".to_string(),
483 security_schemes,
484 ..Default::default()
485 };
486
487 let result = assemble_openapi_spec(&[], &config, None);
488 assert!(result.is_ok());
489 let spec = result.unwrap();
490
491 assert!(spec.components.is_some());
492 let components = spec.components.unwrap();
493 assert!(components.security_schemes.get("oauth2").is_some());
494 }
495
496 #[test]
497 fn test_assemble_openapi_spec_with_multiple_routes() {
498 let routes: Vec<RouteMetadata> = vec![
499 make_route("GET", "/users"),
500 make_route("POST", "/users"),
501 make_route("GET", "/users/{id}"),
502 make_route("PUT", "/users/{id}"),
503 make_route("DELETE", "/users/{id}"),
504 ];
505
506 let config = super::super::OpenApiConfig {
507 enabled: true,
508 title: "User API".to_string(),
509 version: "1.0.0".to_string(),
510 ..Default::default()
511 };
512
513 let result = assemble_openapi_spec(&routes, &config, None);
514 assert!(result.is_ok());
515 let spec = result.unwrap();
516
517 assert!(!spec.paths.paths.is_empty());
518 assert!(spec.paths.paths.contains_key("/users"));
519 assert!(spec.paths.paths.contains_key("/users/{id}"));
520 }
521
522 #[test]
523 fn test_route_to_operation_default_response() {
524 let route = make_route("GET", "/health");
525 let result = route_to_operation(&route);
526
527 assert!(result.is_ok());
528 let operation = result.unwrap();
529 assert!(!operation.responses.responses.is_empty());
530 assert!(operation.responses.responses.contains_key("200"));
531 }
532
533 #[test]
534 fn test_assemble_openapi_spec_empty_routes() {
535 let config = super::super::OpenApiConfig {
536 enabled: true,
537 title: "Empty API".to_string(),
538 version: "0.1.0".to_string(),
539 ..Default::default()
540 };
541
542 let result = assemble_openapi_spec(&[], &config, None);
543 assert!(result.is_ok());
544 let spec = result.unwrap();
545 assert!(spec.paths.paths.is_empty());
546 }
547
548 #[test]
549 fn test_assemble_openapi_spec_with_partial_contact() {
550 let config = super::super::OpenApiConfig {
551 enabled: true,
552 title: "Test API".to_string(),
553 version: "1.0.0".to_string(),
554 contact: Some(super::super::ContactInfo {
555 name: Some("Support".to_string()),
556 email: None,
557 url: None,
558 }),
559 ..Default::default()
560 };
561
562 let result = assemble_openapi_spec(&[], &config, None);
563 assert!(result.is_ok());
564 let spec = result.unwrap();
565 let contact = spec.info.contact.unwrap();
566 assert_eq!(contact.name, Some("Support".to_string()));
567 assert!(contact.email.is_none());
568 }
569
570 #[test]
571 fn test_assemble_openapi_spec_without_servers() {
572 let config = super::super::OpenApiConfig {
573 enabled: true,
574 title: "Test API".to_string(),
575 version: "1.0.0".to_string(),
576 servers: vec![],
577 ..Default::default()
578 };
579
580 let result = assemble_openapi_spec(&[], &config, None);
581 assert!(result.is_ok());
582 let spec = result.unwrap();
583 assert!(spec.servers.is_none());
584 }
585
586 #[test]
587 fn test_route_to_path_item_lowercase_method() {
588 let route = make_route("post", "/items");
589 let result = route_to_path_item(&route);
590 assert!(result.is_ok());
591 }
592
593 #[test]
594 fn test_route_to_path_item_mixed_case_method() {
595 let route = make_route("PoSt", "/items");
596 let result = route_to_path_item(&route);
597 assert!(result.is_ok());
598 }
599
600 #[test]
601 fn test_assemble_openapi_spec_preserves_route_order() {
602 let routes: Vec<RouteMetadata> = vec![
603 make_route("GET", "/a"),
604 make_route("GET", "/b"),
605 make_route("GET", "/c"),
606 ];
607
608 let config = super::super::OpenApiConfig {
609 enabled: true,
610 title: "Test API".to_string(),
611 version: "1.0.0".to_string(),
612 ..Default::default()
613 };
614
615 let result = assemble_openapi_spec(&routes, &config, None);
616 assert!(result.is_ok());
617 let spec = result.unwrap();
618
619 assert!(spec.paths.paths.contains_key("/a"));
620 assert!(spec.paths.paths.contains_key("/b"));
621 assert!(spec.paths.paths.contains_key("/c"));
622 }
623
624 #[test]
625 fn test_assemble_openapi_spec_with_server_config_none() {
626 let config = super::super::OpenApiConfig {
627 enabled: true,
628 title: "Test API".to_string(),
629 version: "1.0.0".to_string(),
630 ..Default::default()
631 };
632
633 let result = assemble_openapi_spec(&[], &config, None);
634 assert!(result.is_ok());
635 let spec = result.unwrap();
636 if let Some(components) = spec.components {
637 assert!(!components.security_schemes.contains_key("bearerAuth"));
638 assert!(!components.security_schemes.contains_key("apiKeyAuth"));
639 }
640 }
641
642 #[test]
643 fn test_route_to_operation_with_no_schemas() {
644 let route = RouteMetadata {
645 method: "GET".to_string(),
646 path: "/endpoint".to_string(),
647 handler_name: "test_handler".to_string(),
648 request_schema: None,
649 response_schema: None,
650 parameter_schema: None,
651 file_params: None,
652 is_async: true,
653 cors: None,
654 body_param_name: None,
655 #[cfg(feature = "di")]
656 handler_dependencies: None,
657 jsonrpc_method: None,
658 static_response: None,
659 };
660
661 let result = route_to_operation(&route);
662 assert!(result.is_ok());
663 let operation = result.unwrap();
664 assert!(operation.request_body.is_none());
665 assert!(operation.responses.responses.contains_key("200"));
666 }
667}