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 compression: None,
192 static_response: None,
193 }
194 }
195
196 fn make_server_config_with_jwt() -> crate::ServerConfig {
197 crate::ServerConfig {
198 jwt_auth: Some(JwtConfig {
199 secret: "test-secret".to_string(),
200 algorithm: "HS256".to_string(),
201 audience: None,
202 issuer: None,
203 leeway: 0,
204 }),
205 ..Default::default()
206 }
207 }
208
209 fn make_server_config_with_api_key() -> crate::ServerConfig {
210 crate::ServerConfig {
211 api_key_auth: Some(ApiKeyConfig {
212 keys: vec!["test-key".to_string()],
213 header_name: "X-API-Key".to_string(),
214 }),
215 ..Default::default()
216 }
217 }
218
219 #[test]
220 fn test_route_to_path_item_get() {
221 let route = make_route("GET", "/users");
222 let result = route_to_path_item(&route);
223 assert!(result.is_ok());
224 }
225
226 #[test]
227 fn test_route_to_path_item_post() {
228 let route = make_route("POST", "/users");
229 let result = route_to_path_item(&route);
230 assert!(result.is_ok());
231 }
232
233 #[test]
234 fn test_route_to_path_item_put() {
235 let route = make_route("PUT", "/users/123");
236 let result = route_to_path_item(&route);
237 assert!(result.is_ok());
238 }
239
240 #[test]
241 fn test_route_to_path_item_patch() {
242 let route = make_route("PATCH", "/users/123");
243 let result = route_to_path_item(&route);
244 assert!(result.is_ok());
245 }
246
247 #[test]
248 fn test_route_to_path_item_delete() {
249 let route = make_route("DELETE", "/users/123");
250 let result = route_to_path_item(&route);
251 assert!(result.is_ok());
252 }
253
254 #[test]
255 fn test_route_to_path_item_head() {
256 let route = make_route("HEAD", "/users");
257 let result = route_to_path_item(&route);
258 assert!(result.is_ok());
259 }
260
261 #[test]
262 fn test_route_to_path_item_options() {
263 let route = make_route("OPTIONS", "/users");
264 let result = route_to_path_item(&route);
265 assert!(result.is_ok());
266 }
267
268 #[test]
269 fn test_route_to_path_item_case_insensitive_method() {
270 let route_lower = make_route("get", "/users");
271 let route_mixed = make_route("GeT", "/users");
272
273 assert!(route_to_path_item(&route_lower).is_ok());
274 assert!(route_to_path_item(&route_mixed).is_ok());
275 }
276
277 #[test]
278 fn test_route_to_path_item_unsupported_method() {
279 let route = make_route("CONNECT", "/users");
280 let result = route_to_path_item(&route);
281 assert!(result.is_err());
282 if let Err(err) = result {
283 assert!(err.contains("Unsupported HTTP method"));
284 }
285 }
286
287 #[test]
288 fn test_assemble_openapi_spec_minimal() {
289 let config = super::super::OpenApiConfig {
290 enabled: true,
291 title: "Test API".to_string(),
292 version: "1.0.0".to_string(),
293 ..Default::default()
294 };
295
296 let result = assemble_openapi_spec(&[], &config, None);
297 assert!(result.is_ok());
298 let spec = result.unwrap();
299 assert_eq!(spec.info.title, "Test API");
300 assert_eq!(spec.info.version, "1.0.0");
301 }
302
303 #[test]
304 fn test_assemble_openapi_spec_with_description() {
305 let config = super::super::OpenApiConfig {
306 enabled: true,
307 title: "Test API".to_string(),
308 version: "1.0.0".to_string(),
309 description: Some("This is a test API".to_string()),
310 ..Default::default()
311 };
312
313 let result = assemble_openapi_spec(&[], &config, None);
314 assert!(result.is_ok());
315 let spec = result.unwrap();
316 assert_eq!(spec.info.description, Some("This is a test API".to_string()));
317 }
318
319 #[test]
320 fn test_assemble_openapi_spec_with_contact() {
321 let config = super::super::OpenApiConfig {
322 enabled: true,
323 title: "Test API".to_string(),
324 version: "1.0.0".to_string(),
325 contact: Some(super::super::ContactInfo {
326 name: Some("Support Team".to_string()),
327 email: Some("support@example.com".to_string()),
328 url: Some("https://example.com/support".to_string()),
329 }),
330 ..Default::default()
331 };
332
333 let result = assemble_openapi_spec(&[], &config, None);
334 assert!(result.is_ok());
335 let spec = result.unwrap();
336 assert!(spec.info.contact.is_some());
337 let contact = spec.info.contact.unwrap();
338 assert_eq!(contact.name, Some("Support Team".to_string()));
339 assert_eq!(contact.email, Some("support@example.com".to_string()));
340 }
341
342 #[test]
343 fn test_assemble_openapi_spec_with_license() {
344 let config = super::super::OpenApiConfig {
345 enabled: true,
346 title: "Test API".to_string(),
347 version: "1.0.0".to_string(),
348 license: Some(super::super::LicenseInfo {
349 name: "Apache 2.0".to_string(),
350 url: Some("https://www.apache.org/licenses/LICENSE-2.0.html".to_string()),
351 }),
352 ..Default::default()
353 };
354
355 let result = assemble_openapi_spec(&[], &config, None);
356 assert!(result.is_ok());
357 let spec = result.unwrap();
358 assert!(spec.info.license.is_some());
359 let license = spec.info.license.unwrap();
360 assert_eq!(license.name, "Apache 2.0");
361 assert_eq!(
362 license.url,
363 Some("https://www.apache.org/licenses/LICENSE-2.0.html".to_string())
364 );
365 }
366
367 #[test]
368 fn test_assemble_openapi_spec_with_servers() {
369 let config = super::super::OpenApiConfig {
370 enabled: true,
371 title: "Test API".to_string(),
372 version: "1.0.0".to_string(),
373 servers: vec![
374 super::super::ServerInfo {
375 url: "https://api.example.com".to_string(),
376 description: Some("Production".to_string()),
377 },
378 super::super::ServerInfo {
379 url: "http://localhost:8080".to_string(),
380 description: Some("Development".to_string()),
381 },
382 ],
383 ..Default::default()
384 };
385
386 let result = assemble_openapi_spec(&[], &config, None);
387 assert!(result.is_ok());
388 let spec = result.unwrap();
389 assert!(spec.servers.is_some());
390 let servers = spec.servers.unwrap();
391 assert_eq!(servers.len(), 2);
392 }
393
394 #[test]
395 fn test_assemble_openapi_spec_with_jwt_auth() {
396 let config = super::super::OpenApiConfig {
397 enabled: true,
398 title: "Test API".to_string(),
399 version: "1.0.0".to_string(),
400 ..Default::default()
401 };
402
403 let server_config = make_server_config_with_jwt();
404 let result = assemble_openapi_spec(&[], &config, Some(&server_config));
405 assert!(result.is_ok());
406 let spec = result.unwrap();
407
408 assert!(spec.components.is_some());
409 let components = spec.components.unwrap();
410 assert!(components.security_schemes.get("bearerAuth").is_some());
411
412 assert!(spec.security.is_some());
413 let security_reqs = spec.security.unwrap();
414 assert!(!security_reqs.is_empty());
415 assert_eq!(security_reqs.len(), 1);
416 }
417
418 #[test]
419 fn test_assemble_openapi_spec_with_api_key_auth() {
420 let config = super::super::OpenApiConfig {
421 enabled: true,
422 title: "Test API".to_string(),
423 version: "1.0.0".to_string(),
424 ..Default::default()
425 };
426
427 let server_config = make_server_config_with_api_key();
428 let result = assemble_openapi_spec(&[], &config, Some(&server_config));
429 assert!(result.is_ok());
430 let spec = result.unwrap();
431
432 assert!(spec.components.is_some());
433 let components = spec.components.unwrap();
434 assert!(components.security_schemes.get("apiKeyAuth").is_some());
435
436 assert!(spec.security.is_some());
437 let security_reqs = spec.security.unwrap();
438 assert!(!security_reqs.is_empty());
439 assert_eq!(security_reqs.len(), 1);
440 }
441
442 #[test]
443 fn test_assemble_openapi_spec_with_both_auth_schemes() {
444 let config = super::super::OpenApiConfig {
445 enabled: true,
446 title: "Test API".to_string(),
447 version: "1.0.0".to_string(),
448 ..Default::default()
449 };
450
451 let mut server_config = make_server_config_with_jwt();
452 server_config.api_key_auth = Some(ApiKeyConfig {
453 keys: vec!["test-key".to_string()],
454 header_name: "X-API-Key".to_string(),
455 });
456
457 let result = assemble_openapi_spec(&[], &config, Some(&server_config));
458 assert!(result.is_ok());
459 let spec = result.unwrap();
460
461 assert!(spec.components.is_some());
462 let components = spec.components.unwrap();
463 assert!(components.security_schemes.get("bearerAuth").is_some());
464 assert!(components.security_schemes.get("apiKeyAuth").is_some());
465 }
466
467 #[test]
468 fn test_assemble_openapi_spec_with_custom_security_schemes() {
469 use std::collections::HashMap;
470
471 let mut security_schemes = HashMap::new();
472 security_schemes.insert(
473 "oauth2".to_string(),
474 super::super::SecuritySchemeInfo::Http {
475 scheme: "bearer".to_string(),
476 bearer_format: Some("OAuth2".to_string()),
477 },
478 );
479
480 let config = super::super::OpenApiConfig {
481 enabled: true,
482 title: "Test API".to_string(),
483 version: "1.0.0".to_string(),
484 security_schemes,
485 ..Default::default()
486 };
487
488 let result = assemble_openapi_spec(&[], &config, None);
489 assert!(result.is_ok());
490 let spec = result.unwrap();
491
492 assert!(spec.components.is_some());
493 let components = spec.components.unwrap();
494 assert!(components.security_schemes.get("oauth2").is_some());
495 }
496
497 #[test]
498 fn test_assemble_openapi_spec_with_multiple_routes() {
499 let routes: Vec<RouteMetadata> = vec![
500 make_route("GET", "/users"),
501 make_route("POST", "/users"),
502 make_route("GET", "/users/{id}"),
503 make_route("PUT", "/users/{id}"),
504 make_route("DELETE", "/users/{id}"),
505 ];
506
507 let config = super::super::OpenApiConfig {
508 enabled: true,
509 title: "User API".to_string(),
510 version: "1.0.0".to_string(),
511 ..Default::default()
512 };
513
514 let result = assemble_openapi_spec(&routes, &config, None);
515 assert!(result.is_ok());
516 let spec = result.unwrap();
517
518 assert!(!spec.paths.paths.is_empty());
519 assert!(spec.paths.paths.contains_key("/users"));
520 assert!(spec.paths.paths.contains_key("/users/{id}"));
521 }
522
523 #[test]
524 fn test_route_to_operation_default_response() {
525 let route = make_route("GET", "/health");
526 let result = route_to_operation(&route);
527
528 assert!(result.is_ok());
529 let operation = result.unwrap();
530 assert!(!operation.responses.responses.is_empty());
531 assert!(operation.responses.responses.contains_key("200"));
532 }
533
534 #[test]
535 fn test_assemble_openapi_spec_empty_routes() {
536 let config = super::super::OpenApiConfig {
537 enabled: true,
538 title: "Empty API".to_string(),
539 version: "0.1.0".to_string(),
540 ..Default::default()
541 };
542
543 let result = assemble_openapi_spec(&[], &config, None);
544 assert!(result.is_ok());
545 let spec = result.unwrap();
546 assert!(spec.paths.paths.is_empty());
547 }
548
549 #[test]
550 fn test_assemble_openapi_spec_with_partial_contact() {
551 let config = super::super::OpenApiConfig {
552 enabled: true,
553 title: "Test API".to_string(),
554 version: "1.0.0".to_string(),
555 contact: Some(super::super::ContactInfo {
556 name: Some("Support".to_string()),
557 email: None,
558 url: None,
559 }),
560 ..Default::default()
561 };
562
563 let result = assemble_openapi_spec(&[], &config, None);
564 assert!(result.is_ok());
565 let spec = result.unwrap();
566 let contact = spec.info.contact.unwrap();
567 assert_eq!(contact.name, Some("Support".to_string()));
568 assert!(contact.email.is_none());
569 }
570
571 #[test]
572 fn test_assemble_openapi_spec_without_servers() {
573 let config = super::super::OpenApiConfig {
574 enabled: true,
575 title: "Test API".to_string(),
576 version: "1.0.0".to_string(),
577 servers: vec![],
578 ..Default::default()
579 };
580
581 let result = assemble_openapi_spec(&[], &config, None);
582 assert!(result.is_ok());
583 let spec = result.unwrap();
584 assert!(spec.servers.is_none());
585 }
586
587 #[test]
588 fn test_route_to_path_item_lowercase_method() {
589 let route = make_route("post", "/items");
590 let result = route_to_path_item(&route);
591 assert!(result.is_ok());
592 }
593
594 #[test]
595 fn test_route_to_path_item_mixed_case_method() {
596 let route = make_route("PoSt", "/items");
597 let result = route_to_path_item(&route);
598 assert!(result.is_ok());
599 }
600
601 #[test]
602 fn test_assemble_openapi_spec_preserves_route_order() {
603 let routes: Vec<RouteMetadata> = vec![
604 make_route("GET", "/a"),
605 make_route("GET", "/b"),
606 make_route("GET", "/c"),
607 ];
608
609 let config = super::super::OpenApiConfig {
610 enabled: true,
611 title: "Test API".to_string(),
612 version: "1.0.0".to_string(),
613 ..Default::default()
614 };
615
616 let result = assemble_openapi_spec(&routes, &config, None);
617 assert!(result.is_ok());
618 let spec = result.unwrap();
619
620 assert!(spec.paths.paths.contains_key("/a"));
621 assert!(spec.paths.paths.contains_key("/b"));
622 assert!(spec.paths.paths.contains_key("/c"));
623 }
624
625 #[test]
626 fn test_assemble_openapi_spec_with_server_config_none() {
627 let config = super::super::OpenApiConfig {
628 enabled: true,
629 title: "Test API".to_string(),
630 version: "1.0.0".to_string(),
631 ..Default::default()
632 };
633
634 let result = assemble_openapi_spec(&[], &config, None);
635 assert!(result.is_ok());
636 let spec = result.unwrap();
637 if let Some(components) = spec.components {
638 assert!(!components.security_schemes.contains_key("bearerAuth"));
639 assert!(!components.security_schemes.contains_key("apiKeyAuth"));
640 }
641 }
642
643 #[test]
644 fn test_route_to_operation_with_no_schemas() {
645 let route = RouteMetadata {
646 method: "GET".to_string(),
647 path: "/endpoint".to_string(),
648 handler_name: "test_handler".to_string(),
649 request_schema: None,
650 response_schema: None,
651 parameter_schema: None,
652 file_params: None,
653 is_async: true,
654 cors: None,
655 body_param_name: None,
656 #[cfg(feature = "di")]
657 handler_dependencies: None,
658 jsonrpc_method: None,
659 compression: None,
660 static_response: None,
661 };
662
663 let result = route_to_operation(&route);
664 assert!(result.is_ok());
665 let operation = result.unwrap();
666 assert!(operation.request_body.is_none());
667 assert!(operation.responses.responses.contains_key("200"));
668 }
669}