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