Skip to main content

spikard_http/openapi/
spec_generation.rs

1//! OpenAPI specification generation and assembly
2
3use crate::RouteMetadata;
4use utoipa::openapi::HttpMethod;
5use utoipa::openapi::security::SecurityScheme;
6use utoipa::openapi::{Components, Info, OpenApi, OpenApiBuilder, PathItem, Paths, RefOr, Response, Responses};
7
8/// Convert route to OpenAPI PathItem
9fn 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
28/// Convert route to OpenAPI Operation
29fn 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
63/// Assemble OpenAPI specification from routes with auto-detection of security schemes
64pub 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}