Skip to main content

spikard_http/openapi/
mod.rs

1//! OpenAPI 3.1.0 specification generation
2//!
3//! Generates OpenAPI specs from route definitions using existing JSON Schema infrastructure.
4//! OpenAPI 3.1.0 is fully compatible with JSON Schema Draft 2020-12.
5
6pub mod parameter_extraction;
7pub mod schema_conversion;
8pub mod spec_generation;
9
10use crate::SchemaRegistry;
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use utoipa::openapi::security::SecurityScheme;
14
15/// OpenAPI configuration
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct OpenApiConfig {
18    /// Enable OpenAPI generation (default: false for zero overhead)
19    pub enabled: bool,
20
21    /// API title
22    pub title: String,
23
24    /// API version
25    pub version: String,
26
27    /// API description (supports markdown)
28    #[serde(default)]
29    pub description: Option<String>,
30
31    /// Path to serve Swagger UI (default: "/docs")
32    #[serde(default = "default_swagger_path")]
33    pub swagger_ui_path: String,
34
35    /// Path to serve Redoc (default: "/redoc")
36    #[serde(default = "default_redoc_path")]
37    pub redoc_path: String,
38
39    /// Path to serve OpenAPI JSON spec (default: "/openapi.json")
40    #[serde(default = "default_openapi_json_path")]
41    pub openapi_json_path: String,
42
43    /// Contact information
44    #[serde(default)]
45    pub contact: Option<ContactInfo>,
46
47    /// License information
48    #[serde(default)]
49    pub license: Option<LicenseInfo>,
50
51    /// Server definitions
52    #[serde(default)]
53    pub servers: Vec<ServerInfo>,
54
55    /// Security schemes (auto-detected from middleware if not provided)
56    #[serde(default)]
57    pub security_schemes: HashMap<String, SecuritySchemeInfo>,
58}
59
60impl Default for OpenApiConfig {
61    fn default() -> Self {
62        Self {
63            enabled: false,
64            title: "API".to_string(),
65            version: "1.0.0".to_string(),
66            description: None,
67            swagger_ui_path: default_swagger_path(),
68            redoc_path: default_redoc_path(),
69            openapi_json_path: default_openapi_json_path(),
70            contact: None,
71            license: None,
72            servers: Vec::new(),
73            security_schemes: HashMap::new(),
74        }
75    }
76}
77
78fn default_swagger_path() -> String {
79    "/docs".to_string()
80}
81
82fn default_redoc_path() -> String {
83    "/redoc".to_string()
84}
85
86fn default_openapi_json_path() -> String {
87    "/openapi.json".to_string()
88}
89
90/// Contact information
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct ContactInfo {
93    /// Name of the contact person or organisation.
94    pub name: Option<String>,
95    /// Contact email address.
96    pub email: Option<String>,
97    /// URL pointing to the contact information page.
98    pub url: Option<String>,
99}
100
101/// License information
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct LicenseInfo {
104    /// SPDX license identifier or display name (e.g. `"MIT"`).
105    pub name: String,
106    /// URL to the full license text.
107    pub url: Option<String>,
108}
109
110/// Server information
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct ServerInfo {
113    /// Base URL of the server (e.g. `"https://api.example.com/v1"`).
114    pub url: String,
115    /// Optional human-readable description of the server environment.
116    pub description: Option<String>,
117}
118
119/// Security scheme types
120#[derive(Debug, Clone, Serialize, Deserialize)]
121#[serde(tag = "type", rename_all = "lowercase")]
122pub enum SecuritySchemeInfo {
123    #[serde(rename = "http")]
124    Http {
125        scheme: String,
126        #[serde(rename = "bearerFormat")]
127        bearer_format: Option<String>,
128    },
129    #[serde(rename = "apiKey")]
130    ApiKey {
131        #[serde(rename = "in")]
132        location: String,
133        name: String,
134    },
135}
136
137impl Default for SecuritySchemeInfo {
138    fn default() -> Self {
139        Self::Http {
140            scheme: "bearer".to_string(),
141            bearer_format: None,
142        }
143    }
144}
145
146/// Convert SecuritySchemeInfo to OpenAPI SecurityScheme
147pub fn security_scheme_info_to_openapi(info: &SecuritySchemeInfo) -> SecurityScheme {
148    match info {
149        SecuritySchemeInfo::Http { scheme, bearer_format } => {
150            let mut http_scheme = SecurityScheme::Http(utoipa::openapi::security::Http::new(
151                utoipa::openapi::security::HttpAuthScheme::Bearer,
152            ));
153            if let (SecurityScheme::Http(http), "bearer") = (&mut http_scheme, scheme.as_str()) {
154                http.scheme = utoipa::openapi::security::HttpAuthScheme::Bearer;
155                if let Some(format) = bearer_format {
156                    http.bearer_format = Some(format.clone());
157                }
158            }
159            http_scheme
160        }
161        SecuritySchemeInfo::ApiKey { location, name } => {
162            use utoipa::openapi::security::ApiKey;
163
164            let api_key = match location.as_str() {
165                "header" => ApiKey::Header(utoipa::openapi::security::ApiKeyValue::new(name)),
166                "query" => ApiKey::Query(utoipa::openapi::security::ApiKeyValue::new(name)),
167                "cookie" => ApiKey::Cookie(utoipa::openapi::security::ApiKeyValue::new(name)),
168                _ => ApiKey::Header(utoipa::openapi::security::ApiKeyValue::new(name)),
169            };
170            SecurityScheme::ApiKey(api_key)
171        }
172    }
173}
174
175/// Generate OpenAPI specification from routes with auto-detection of security schemes
176pub fn generate_openapi_spec(
177    routes: &[crate::RouteMetadata],
178    config: &OpenApiConfig,
179    _schema_registry: &SchemaRegistry,
180    server_config: Option<&crate::ServerConfig>,
181) -> Result<utoipa::openapi::OpenApi, String> {
182    spec_generation::assemble_openapi_spec(routes, config, server_config)
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_openapi_config_default() {
191        let config = OpenApiConfig::default();
192        assert!(!config.enabled);
193        assert_eq!(config.title, "API");
194        assert_eq!(config.version, "1.0.0");
195        assert_eq!(config.swagger_ui_path, "/docs");
196        assert_eq!(config.redoc_path, "/redoc");
197        assert_eq!(config.openapi_json_path, "/openapi.json");
198    }
199
200    #[test]
201    fn test_generate_minimal_spec() {
202        let config = OpenApiConfig {
203            enabled: true,
204            title: "Test API".to_string(),
205            version: "1.0.0".to_string(),
206            ..Default::default()
207        };
208
209        let routes = vec![];
210        let registry = SchemaRegistry::new();
211
212        let spec = generate_openapi_spec(&routes, &config, &registry, None).unwrap();
213        assert_eq!(spec.info.title, "Test API");
214        assert_eq!(spec.info.version, "1.0.0");
215    }
216
217    #[test]
218    fn test_generate_spec_with_contact() {
219        let config = OpenApiConfig {
220            enabled: true,
221            title: "Test API".to_string(),
222            version: "1.0.0".to_string(),
223            contact: Some(ContactInfo {
224                name: Some("API Team".to_string()),
225                email: Some("api@example.com".to_string()),
226                url: Some("https://example.com".to_string()),
227            }),
228            ..Default::default()
229        };
230
231        let routes = vec![];
232        let registry = SchemaRegistry::new();
233
234        let spec = generate_openapi_spec(&routes, &config, &registry, None).unwrap();
235        assert!(spec.info.contact.is_some());
236        let contact = spec.info.contact.unwrap();
237        assert_eq!(contact.name, Some("API Team".to_string()));
238        assert_eq!(contact.email, Some("api@example.com".to_string()));
239    }
240
241    #[test]
242    fn test_generate_spec_with_license() {
243        let config = OpenApiConfig {
244            enabled: true,
245            title: "Test API".to_string(),
246            version: "1.0.0".to_string(),
247            license: Some(LicenseInfo {
248                name: "MIT".to_string(),
249                url: Some("https://opensource.org/licenses/MIT".to_string()),
250            }),
251            ..Default::default()
252        };
253
254        let routes = vec![];
255        let registry = SchemaRegistry::new();
256
257        let spec = generate_openapi_spec(&routes, &config, &registry, None).unwrap();
258        assert!(spec.info.license.is_some());
259        let license = spec.info.license.unwrap();
260        assert_eq!(license.name, "MIT");
261    }
262
263    #[test]
264    fn test_generate_spec_with_servers() {
265        let config = OpenApiConfig {
266            enabled: true,
267            title: "Test API".to_string(),
268            version: "1.0.0".to_string(),
269            servers: vec![
270                ServerInfo {
271                    url: "https://api.example.com".to_string(),
272                    description: Some("Production".to_string()),
273                },
274                ServerInfo {
275                    url: "http://localhost:8080".to_string(),
276                    description: Some("Development".to_string()),
277                },
278            ],
279            ..Default::default()
280        };
281
282        let routes = vec![];
283        let registry = SchemaRegistry::new();
284
285        let spec = generate_openapi_spec(&routes, &config, &registry, None).unwrap();
286        assert!(spec.servers.is_some());
287        let servers = spec.servers.unwrap();
288        assert_eq!(servers.len(), 2);
289        assert_eq!(servers[0].url, "https://api.example.com");
290        assert_eq!(servers[1].url, "http://localhost:8080");
291    }
292
293    #[test]
294    fn test_security_scheme_http_bearer() {
295        let scheme_info = SecuritySchemeInfo::Http {
296            scheme: "bearer".to_string(),
297            bearer_format: Some("JWT".to_string()),
298        };
299
300        let scheme = security_scheme_info_to_openapi(&scheme_info);
301        match scheme {
302            SecurityScheme::Http(http) => {
303                assert!(matches!(http.scheme, utoipa::openapi::security::HttpAuthScheme::Bearer));
304                assert_eq!(http.bearer_format, Some("JWT".to_string()));
305            }
306            _ => panic!("Expected Http security scheme"),
307        }
308    }
309
310    #[test]
311    fn test_security_scheme_api_key() {
312        let scheme_info = SecuritySchemeInfo::ApiKey {
313            location: "header".to_string(),
314            name: "X-API-Key".to_string(),
315        };
316
317        let scheme = security_scheme_info_to_openapi(&scheme_info);
318        match scheme {
319            SecurityScheme::ApiKey(api_key) => {
320                assert!(matches!(api_key, utoipa::openapi::security::ApiKey::Header(_)));
321            }
322            _ => panic!("Expected ApiKey security scheme"),
323        }
324    }
325}