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        }
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}