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    pub name: Option<String>,
94    pub email: Option<String>,
95    pub url: Option<String>,
96}
97
98/// License information
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct LicenseInfo {
101    pub name: String,
102    pub url: Option<String>,
103}
104
105/// Server information
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct ServerInfo {
108    pub url: String,
109    pub description: Option<String>,
110}
111
112/// Security scheme types
113#[derive(Debug, Clone, Serialize, Deserialize)]
114#[serde(tag = "type", rename_all = "lowercase")]
115pub enum SecuritySchemeInfo {
116    #[serde(rename = "http")]
117    Http {
118        scheme: String,
119        #[serde(rename = "bearerFormat")]
120        bearer_format: Option<String>,
121    },
122    #[serde(rename = "apiKey")]
123    ApiKey {
124        #[serde(rename = "in")]
125        location: String,
126        name: String,
127    },
128}
129
130/// Convert SecuritySchemeInfo to OpenAPI SecurityScheme
131pub fn security_scheme_info_to_openapi(info: &SecuritySchemeInfo) -> SecurityScheme {
132    match info {
133        SecuritySchemeInfo::Http { scheme, bearer_format } => {
134            let mut http_scheme = SecurityScheme::Http(utoipa::openapi::security::Http::new(
135                utoipa::openapi::security::HttpAuthScheme::Bearer,
136            ));
137            if let (SecurityScheme::Http(http), "bearer") = (&mut http_scheme, scheme.as_str()) {
138                http.scheme = utoipa::openapi::security::HttpAuthScheme::Bearer;
139                if let Some(format) = bearer_format {
140                    http.bearer_format = Some(format.clone());
141                }
142            }
143            http_scheme
144        }
145        SecuritySchemeInfo::ApiKey { location, name } => {
146            use utoipa::openapi::security::ApiKey;
147
148            let api_key = match location.as_str() {
149                "header" => ApiKey::Header(utoipa::openapi::security::ApiKeyValue::new(name)),
150                "query" => ApiKey::Query(utoipa::openapi::security::ApiKeyValue::new(name)),
151                "cookie" => ApiKey::Cookie(utoipa::openapi::security::ApiKeyValue::new(name)),
152                _ => ApiKey::Header(utoipa::openapi::security::ApiKeyValue::new(name)),
153            };
154            SecurityScheme::ApiKey(api_key)
155        }
156    }
157}
158
159/// Generate OpenAPI specification from routes with auto-detection of security schemes
160pub fn generate_openapi_spec(
161    routes: &[crate::RouteMetadata],
162    config: &OpenApiConfig,
163    _schema_registry: &SchemaRegistry,
164    server_config: Option<&crate::ServerConfig>,
165) -> Result<utoipa::openapi::OpenApi, String> {
166    spec_generation::assemble_openapi_spec(routes, config, server_config)
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn test_openapi_config_default() {
175        let config = OpenApiConfig::default();
176        assert!(!config.enabled);
177        assert_eq!(config.title, "API");
178        assert_eq!(config.version, "1.0.0");
179        assert_eq!(config.swagger_ui_path, "/docs");
180        assert_eq!(config.redoc_path, "/redoc");
181        assert_eq!(config.openapi_json_path, "/openapi.json");
182    }
183
184    #[test]
185    fn test_generate_minimal_spec() {
186        let config = OpenApiConfig {
187            enabled: true,
188            title: "Test API".to_string(),
189            version: "1.0.0".to_string(),
190            ..Default::default()
191        };
192
193        let routes = vec![];
194        let registry = SchemaRegistry::new();
195
196        let spec = generate_openapi_spec(&routes, &config, &registry, None).unwrap();
197        assert_eq!(spec.info.title, "Test API");
198        assert_eq!(spec.info.version, "1.0.0");
199    }
200
201    #[test]
202    fn test_generate_spec_with_contact() {
203        let config = OpenApiConfig {
204            enabled: true,
205            title: "Test API".to_string(),
206            version: "1.0.0".to_string(),
207            contact: Some(ContactInfo {
208                name: Some("API Team".to_string()),
209                email: Some("api@example.com".to_string()),
210                url: Some("https://example.com".to_string()),
211            }),
212            ..Default::default()
213        };
214
215        let routes = vec![];
216        let registry = SchemaRegistry::new();
217
218        let spec = generate_openapi_spec(&routes, &config, &registry, None).unwrap();
219        assert!(spec.info.contact.is_some());
220        let contact = spec.info.contact.unwrap();
221        assert_eq!(contact.name, Some("API Team".to_string()));
222        assert_eq!(contact.email, Some("api@example.com".to_string()));
223    }
224
225    #[test]
226    fn test_generate_spec_with_license() {
227        let config = OpenApiConfig {
228            enabled: true,
229            title: "Test API".to_string(),
230            version: "1.0.0".to_string(),
231            license: Some(LicenseInfo {
232                name: "MIT".to_string(),
233                url: Some("https://opensource.org/licenses/MIT".to_string()),
234            }),
235            ..Default::default()
236        };
237
238        let routes = vec![];
239        let registry = SchemaRegistry::new();
240
241        let spec = generate_openapi_spec(&routes, &config, &registry, None).unwrap();
242        assert!(spec.info.license.is_some());
243        let license = spec.info.license.unwrap();
244        assert_eq!(license.name, "MIT");
245    }
246
247    #[test]
248    fn test_generate_spec_with_servers() {
249        let config = OpenApiConfig {
250            enabled: true,
251            title: "Test API".to_string(),
252            version: "1.0.0".to_string(),
253            servers: vec![
254                ServerInfo {
255                    url: "https://api.example.com".to_string(),
256                    description: Some("Production".to_string()),
257                },
258                ServerInfo {
259                    url: "http://localhost:8080".to_string(),
260                    description: Some("Development".to_string()),
261                },
262            ],
263            ..Default::default()
264        };
265
266        let routes = vec![];
267        let registry = SchemaRegistry::new();
268
269        let spec = generate_openapi_spec(&routes, &config, &registry, None).unwrap();
270        assert!(spec.servers.is_some());
271        let servers = spec.servers.unwrap();
272        assert_eq!(servers.len(), 2);
273        assert_eq!(servers[0].url, "https://api.example.com");
274        assert_eq!(servers[1].url, "http://localhost:8080");
275    }
276
277    #[test]
278    fn test_security_scheme_http_bearer() {
279        let scheme_info = SecuritySchemeInfo::Http {
280            scheme: "bearer".to_string(),
281            bearer_format: Some("JWT".to_string()),
282        };
283
284        let scheme = security_scheme_info_to_openapi(&scheme_info);
285        match scheme {
286            SecurityScheme::Http(http) => {
287                assert!(matches!(http.scheme, utoipa::openapi::security::HttpAuthScheme::Bearer));
288                assert_eq!(http.bearer_format, Some("JWT".to_string()));
289            }
290            _ => panic!("Expected Http security scheme"),
291        }
292    }
293
294    #[test]
295    fn test_security_scheme_api_key() {
296        let scheme_info = SecuritySchemeInfo::ApiKey {
297            location: "header".to_string(),
298            name: "X-API-Key".to_string(),
299        };
300
301        let scheme = security_scheme_info_to_openapi(&scheme_info);
302        match scheme {
303            SecurityScheme::ApiKey(api_key) => {
304                assert!(matches!(api_key, utoipa::openapi::security::ApiKey::Header(_)));
305            }
306            _ => panic!("Expected ApiKey security scheme"),
307        }
308    }
309}