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