rustapi_openapi/v31/
spec.rs

1//! OpenAPI 3.1.0 specification types
2//!
3//! This module provides the complete OpenAPI 3.1.0 specification builder
4//! with JSON Schema 2020-12 support.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use super::schema::{JsonSchema2020, SchemaTransformer};
10use super::webhooks::{Callback, Webhook};
11use crate::{Operation, PathItem};
12
13/// OpenAPI 3.1.0 specification
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct OpenApi31Spec {
17    /// OpenAPI version (always "3.1.0")
18    pub openapi: String,
19
20    /// API information
21    pub info: ApiInfo31,
22
23    /// JSON Schema dialect (optional, defaults to JSON Schema 2020-12)
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub json_schema_dialect: Option<String>,
26
27    /// Server list
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub servers: Option<Vec<Server>>,
30
31    /// API paths
32    #[serde(skip_serializing_if = "HashMap::is_empty")]
33    pub paths: HashMap<String, PathItem>,
34
35    /// Webhooks (new in 3.1)
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub webhooks: Option<HashMap<String, Webhook>>,
38
39    /// Components (schemas, security schemes, etc.)
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub components: Option<Components31>,
42
43    /// Security requirements
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub security: Option<Vec<HashMap<String, Vec<String>>>>,
46
47    /// Tags for organization
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub tags: Option<Vec<Tag>>,
50
51    /// External documentation
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub external_docs: Option<ExternalDocs>,
54}
55
56impl OpenApi31Spec {
57    /// Create a new OpenAPI 3.1.0 specification
58    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
59        Self {
60            openapi: "3.1.0".to_string(),
61            info: ApiInfo31 {
62                title: title.into(),
63                version: version.into(),
64                summary: None,
65                description: None,
66                terms_of_service: None,
67                contact: None,
68                license: None,
69            },
70            json_schema_dialect: Some("https://json-schema.org/draft/2020-12/schema".to_string()),
71            servers: None,
72            paths: HashMap::new(),
73            webhooks: None,
74            components: None,
75            security: None,
76            tags: None,
77            external_docs: None,
78        }
79    }
80
81    /// Set API description
82    pub fn description(mut self, desc: impl Into<String>) -> Self {
83        self.info.description = Some(desc.into());
84        self
85    }
86
87    /// Set API summary (new in 3.1)
88    pub fn summary(mut self, summary: impl Into<String>) -> Self {
89        self.info.summary = Some(summary.into());
90        self
91    }
92
93    /// Set terms of service URL
94    pub fn terms_of_service(mut self, url: impl Into<String>) -> Self {
95        self.info.terms_of_service = Some(url.into());
96        self
97    }
98
99    /// Set contact information
100    pub fn contact(mut self, contact: Contact) -> Self {
101        self.info.contact = Some(contact);
102        self
103    }
104
105    /// Set license information
106    pub fn license(mut self, license: License) -> Self {
107        self.info.license = Some(license);
108        self
109    }
110
111    /// Add a server
112    pub fn server(mut self, server: Server) -> Self {
113        self.servers.get_or_insert_with(Vec::new).push(server);
114        self
115    }
116
117    /// Add a path operation
118    pub fn path(mut self, path: &str, method: &str, operation: Operation) -> Self {
119        let item = self.paths.entry(path.to_string()).or_default();
120        match method.to_uppercase().as_str() {
121            "GET" => item.get = Some(operation),
122            "POST" => item.post = Some(operation),
123            "PUT" => item.put = Some(operation),
124            "PATCH" => item.patch = Some(operation),
125            "DELETE" => item.delete = Some(operation),
126            _ => {}
127        }
128        self
129    }
130
131    /// Add a webhook (new in 3.1)
132    pub fn webhook(mut self, name: impl Into<String>, webhook: Webhook) -> Self {
133        self.webhooks
134            .get_or_insert_with(HashMap::new)
135            .insert(name.into(), webhook);
136        self
137    }
138
139    /// Add a schema to components
140    pub fn schema(mut self, name: impl Into<String>, schema: JsonSchema2020) -> Self {
141        let components = self.components.get_or_insert_with(Components31::default);
142        components
143            .schemas
144            .get_or_insert_with(HashMap::new)
145            .insert(name.into(), schema);
146        self
147    }
148
149    /// Add a schema from an existing OpenAPI 3.0 schema (will be transformed)
150    pub fn schema_from_30(mut self, name: impl Into<String>, schema: serde_json::Value) -> Self {
151        let transformed = SchemaTransformer::transform_30_to_31(schema);
152        if let Ok(schema31) = serde_json::from_value::<JsonSchema2020>(transformed) {
153            let components = self.components.get_or_insert_with(Components31::default);
154            components
155                .schemas
156                .get_or_insert_with(HashMap::new)
157                .insert(name.into(), schema31);
158        }
159        self
160    }
161
162    /// Register a type that implements utoipa::ToSchema
163    ///
164    /// The schema will be automatically transformed to OpenAPI 3.1 format
165    pub fn register<T: for<'a> utoipa::ToSchema<'a>>(mut self) -> Self {
166        let (name, schema) = T::schema();
167        if let Ok(json_schema) = serde_json::to_value(schema) {
168            let transformed = SchemaTransformer::transform_30_to_31(json_schema);
169            if let Ok(schema31) = serde_json::from_value::<JsonSchema2020>(transformed) {
170                let components = self.components.get_or_insert_with(Components31::default);
171                components
172                    .schemas
173                    .get_or_insert_with(HashMap::new)
174                    .insert(name.to_string(), schema31);
175            }
176        }
177        self
178    }
179
180    /// Add a security scheme
181    pub fn security_scheme(mut self, name: impl Into<String>, scheme: SecurityScheme) -> Self {
182        let components = self.components.get_or_insert_with(Components31::default);
183        components
184            .security_schemes
185            .get_or_insert_with(HashMap::new)
186            .insert(name.into(), scheme);
187        self
188    }
189
190    /// Add a global security requirement
191    pub fn security_requirement(mut self, name: impl Into<String>, scopes: Vec<String>) -> Self {
192        let mut req = HashMap::new();
193        req.insert(name.into(), scopes);
194        self.security.get_or_insert_with(Vec::new).push(req);
195        self
196    }
197
198    /// Add a tag
199    pub fn tag(mut self, tag: Tag) -> Self {
200        self.tags.get_or_insert_with(Vec::new).push(tag);
201        self
202    }
203
204    /// Set external documentation
205    pub fn external_docs(mut self, docs: ExternalDocs) -> Self {
206        self.external_docs = Some(docs);
207        self
208    }
209
210    /// Add a callback to components
211    pub fn callback(mut self, name: impl Into<String>, callback: Callback) -> Self {
212        let components = self.components.get_or_insert_with(Components31::default);
213        components
214            .callbacks
215            .get_or_insert_with(HashMap::new)
216            .insert(name.into(), callback);
217        self
218    }
219
220    /// Convert to JSON value
221    pub fn to_json(&self) -> serde_json::Value {
222        serde_json::to_value(self).unwrap_or_else(|_| serde_json::json!({}))
223    }
224
225    /// Convert to pretty-printed JSON string
226    pub fn to_json_pretty(&self) -> String {
227        serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
228    }
229}
230
231/// API information for OpenAPI 3.1
232#[derive(Debug, Clone, Serialize, Deserialize)]
233#[serde(rename_all = "camelCase")]
234pub struct ApiInfo31 {
235    /// API title
236    pub title: String,
237
238    /// API version
239    pub version: String,
240
241    /// Short summary (new in 3.1)
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub summary: Option<String>,
244
245    /// Full description
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub description: Option<String>,
248
249    /// Terms of service URL
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub terms_of_service: Option<String>,
252
253    /// Contact information
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub contact: Option<Contact>,
256
257    /// License information
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub license: Option<License>,
260}
261
262/// Contact information
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct Contact {
265    /// Contact name
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub name: Option<String>,
268
269    /// Contact URL
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub url: Option<String>,
272
273    /// Contact email
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub email: Option<String>,
276}
277
278impl Contact {
279    /// Create new contact
280    pub fn new() -> Self {
281        Self {
282            name: None,
283            url: None,
284            email: None,
285        }
286    }
287
288    /// Set name
289    pub fn name(mut self, name: impl Into<String>) -> Self {
290        self.name = Some(name.into());
291        self
292    }
293
294    /// Set URL
295    pub fn url(mut self, url: impl Into<String>) -> Self {
296        self.url = Some(url.into());
297        self
298    }
299
300    /// Set email
301    pub fn email(mut self, email: impl Into<String>) -> Self {
302        self.email = Some(email.into());
303        self
304    }
305}
306
307impl Default for Contact {
308    fn default() -> Self {
309        Self::new()
310    }
311}
312
313/// License information
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct License {
316    /// License name
317    pub name: String,
318
319    /// License identifier (SPDX, new in 3.1)
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub identifier: Option<String>,
322
323    /// License URL
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub url: Option<String>,
326}
327
328impl License {
329    /// Create a new license
330    pub fn new(name: impl Into<String>) -> Self {
331        Self {
332            name: name.into(),
333            identifier: None,
334            url: None,
335        }
336    }
337
338    /// Create a license with SPDX identifier (new in 3.1)
339    pub fn spdx(name: impl Into<String>, identifier: impl Into<String>) -> Self {
340        Self {
341            name: name.into(),
342            identifier: Some(identifier.into()),
343            url: None,
344        }
345    }
346
347    /// Set URL
348    pub fn url(mut self, url: impl Into<String>) -> Self {
349        self.url = Some(url.into());
350        self
351    }
352}
353
354/// Server definition
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct Server {
357    /// Server URL
358    pub url: String,
359
360    /// Server description
361    #[serde(skip_serializing_if = "Option::is_none")]
362    pub description: Option<String>,
363
364    /// Server variables
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub variables: Option<HashMap<String, ServerVariable>>,
367}
368
369impl Server {
370    /// Create a new server
371    pub fn new(url: impl Into<String>) -> Self {
372        Self {
373            url: url.into(),
374            description: None,
375            variables: None,
376        }
377    }
378
379    /// Set description
380    pub fn description(mut self, desc: impl Into<String>) -> Self {
381        self.description = Some(desc.into());
382        self
383    }
384
385    /// Add a variable
386    pub fn variable(mut self, name: impl Into<String>, var: ServerVariable) -> Self {
387        self.variables
388            .get_or_insert_with(HashMap::new)
389            .insert(name.into(), var);
390        self
391    }
392}
393
394/// Server variable
395#[derive(Debug, Clone, Serialize, Deserialize)]
396#[serde(rename_all = "camelCase")]
397pub struct ServerVariable {
398    /// Possible values
399    #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
400    pub enum_values: Option<Vec<String>>,
401
402    /// Default value
403    pub default: String,
404
405    /// Description
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub description: Option<String>,
408}
409
410impl ServerVariable {
411    /// Create a new variable with default value
412    pub fn new(default: impl Into<String>) -> Self {
413        Self {
414            enum_values: None,
415            default: default.into(),
416            description: None,
417        }
418    }
419
420    /// Set allowed values
421    pub fn enum_values(mut self, values: Vec<String>) -> Self {
422        self.enum_values = Some(values);
423        self
424    }
425
426    /// Set description
427    pub fn description(mut self, desc: impl Into<String>) -> Self {
428        self.description = Some(desc.into());
429        self
430    }
431}
432
433/// Components container (schemas, security schemes, etc.)
434#[derive(Debug, Clone, Serialize, Deserialize, Default)]
435#[serde(rename_all = "camelCase")]
436pub struct Components31 {
437    /// Schema definitions
438    #[serde(skip_serializing_if = "Option::is_none")]
439    pub schemas: Option<HashMap<String, JsonSchema2020>>,
440
441    /// Response definitions
442    #[serde(skip_serializing_if = "Option::is_none")]
443    pub responses: Option<HashMap<String, serde_json::Value>>,
444
445    /// Parameter definitions
446    #[serde(skip_serializing_if = "Option::is_none")]
447    pub parameters: Option<HashMap<String, serde_json::Value>>,
448
449    /// Example definitions
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub examples: Option<HashMap<String, serde_json::Value>>,
452
453    /// Request body definitions
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub request_bodies: Option<HashMap<String, serde_json::Value>>,
456
457    /// Header definitions
458    #[serde(skip_serializing_if = "Option::is_none")]
459    pub headers: Option<HashMap<String, serde_json::Value>>,
460
461    /// Security scheme definitions
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub security_schemes: Option<HashMap<String, SecurityScheme>>,
464
465    /// Link definitions
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub links: Option<HashMap<String, serde_json::Value>>,
468
469    /// Callback definitions
470    #[serde(skip_serializing_if = "Option::is_none")]
471    pub callbacks: Option<HashMap<String, Callback>>,
472
473    /// Path item definitions (new in 3.1)
474    #[serde(skip_serializing_if = "Option::is_none")]
475    pub path_items: Option<HashMap<String, PathItem>>,
476}
477
478/// Security scheme definition
479#[derive(Debug, Clone, Serialize, Deserialize)]
480#[serde(rename_all = "camelCase")]
481pub struct SecurityScheme {
482    /// Type of security scheme
483    #[serde(rename = "type")]
484    pub scheme_type: String,
485
486    /// Description
487    #[serde(skip_serializing_if = "Option::is_none")]
488    pub description: Option<String>,
489
490    /// Header/query parameter name (for apiKey)
491    #[serde(skip_serializing_if = "Option::is_none")]
492    pub name: Option<String>,
493
494    /// Location (header, query, cookie) for apiKey
495    #[serde(rename = "in", skip_serializing_if = "Option::is_none")]
496    pub location: Option<String>,
497
498    /// Scheme name (for http)
499    #[serde(skip_serializing_if = "Option::is_none")]
500    pub scheme: Option<String>,
501
502    /// Bearer format (for http bearer)
503    #[serde(skip_serializing_if = "Option::is_none")]
504    pub bearer_format: Option<String>,
505
506    /// OAuth flows
507    #[serde(skip_serializing_if = "Option::is_none")]
508    pub flows: Option<OAuthFlows>,
509
510    /// OpenID Connect URL
511    #[serde(skip_serializing_if = "Option::is_none")]
512    pub open_id_connect_url: Option<String>,
513}
514
515impl SecurityScheme {
516    /// Create an API key security scheme
517    pub fn api_key(name: impl Into<String>, location: impl Into<String>) -> Self {
518        Self {
519            scheme_type: "apiKey".to_string(),
520            description: None,
521            name: Some(name.into()),
522            location: Some(location.into()),
523            scheme: None,
524            bearer_format: None,
525            flows: None,
526            open_id_connect_url: None,
527        }
528    }
529
530    /// Create a bearer token security scheme
531    pub fn bearer(format: impl Into<String>) -> Self {
532        Self {
533            scheme_type: "http".to_string(),
534            description: None,
535            name: None,
536            location: None,
537            scheme: Some("bearer".to_string()),
538            bearer_format: Some(format.into()),
539            flows: None,
540            open_id_connect_url: None,
541        }
542    }
543
544    /// Create a basic auth security scheme
545    pub fn basic() -> Self {
546        Self {
547            scheme_type: "http".to_string(),
548            description: None,
549            name: None,
550            location: None,
551            scheme: Some("basic".to_string()),
552            bearer_format: None,
553            flows: None,
554            open_id_connect_url: None,
555        }
556    }
557
558    /// Create an OAuth2 security scheme
559    pub fn oauth2(flows: OAuthFlows) -> Self {
560        Self {
561            scheme_type: "oauth2".to_string(),
562            description: None,
563            name: None,
564            location: None,
565            scheme: None,
566            bearer_format: None,
567            flows: Some(flows),
568            open_id_connect_url: None,
569        }
570    }
571
572    /// Create an OpenID Connect security scheme
573    pub fn openid_connect(url: impl Into<String>) -> Self {
574        Self {
575            scheme_type: "openIdConnect".to_string(),
576            description: None,
577            name: None,
578            location: None,
579            scheme: None,
580            bearer_format: None,
581            flows: None,
582            open_id_connect_url: Some(url.into()),
583        }
584    }
585
586    /// Set description
587    pub fn description(mut self, desc: impl Into<String>) -> Self {
588        self.description = Some(desc.into());
589        self
590    }
591}
592
593/// OAuth2 flows
594#[derive(Debug, Clone, Serialize, Deserialize, Default)]
595#[serde(rename_all = "camelCase")]
596pub struct OAuthFlows {
597    /// Implicit flow
598    #[serde(skip_serializing_if = "Option::is_none")]
599    pub implicit: Option<OAuthFlow>,
600
601    /// Password flow
602    #[serde(skip_serializing_if = "Option::is_none")]
603    pub password: Option<OAuthFlow>,
604
605    /// Client credentials flow
606    #[serde(skip_serializing_if = "Option::is_none")]
607    pub client_credentials: Option<OAuthFlow>,
608
609    /// Authorization code flow
610    #[serde(skip_serializing_if = "Option::is_none")]
611    pub authorization_code: Option<OAuthFlow>,
612}
613
614/// OAuth2 flow
615#[derive(Debug, Clone, Serialize, Deserialize)]
616#[serde(rename_all = "camelCase")]
617pub struct OAuthFlow {
618    /// Authorization URL
619    #[serde(skip_serializing_if = "Option::is_none")]
620    pub authorization_url: Option<String>,
621
622    /// Token URL
623    #[serde(skip_serializing_if = "Option::is_none")]
624    pub token_url: Option<String>,
625
626    /// Refresh URL
627    #[serde(skip_serializing_if = "Option::is_none")]
628    pub refresh_url: Option<String>,
629
630    /// Available scopes
631    pub scopes: HashMap<String, String>,
632}
633
634/// Tag for grouping operations
635#[derive(Debug, Clone, Serialize, Deserialize)]
636#[serde(rename_all = "camelCase")]
637pub struct Tag {
638    /// Tag name
639    pub name: String,
640
641    /// Tag description
642    #[serde(skip_serializing_if = "Option::is_none")]
643    pub description: Option<String>,
644
645    /// External documentation
646    #[serde(skip_serializing_if = "Option::is_none")]
647    pub external_docs: Option<ExternalDocs>,
648}
649
650impl Tag {
651    /// Create a new tag
652    pub fn new(name: impl Into<String>) -> Self {
653        Self {
654            name: name.into(),
655            description: None,
656            external_docs: None,
657        }
658    }
659
660    /// Set description
661    pub fn description(mut self, desc: impl Into<String>) -> Self {
662        self.description = Some(desc.into());
663        self
664    }
665
666    /// Set external documentation
667    pub fn external_docs(mut self, docs: ExternalDocs) -> Self {
668        self.external_docs = Some(docs);
669        self
670    }
671}
672
673/// External documentation
674#[derive(Debug, Clone, Serialize, Deserialize)]
675pub struct ExternalDocs {
676    /// URL to external documentation
677    pub url: String,
678
679    /// Description
680    #[serde(skip_serializing_if = "Option::is_none")]
681    pub description: Option<String>,
682}
683
684impl ExternalDocs {
685    /// Create new external documentation
686    pub fn new(url: impl Into<String>) -> Self {
687        Self {
688            url: url.into(),
689            description: None,
690        }
691    }
692
693    /// Set description
694    pub fn description(mut self, desc: impl Into<String>) -> Self {
695        self.description = Some(desc.into());
696        self
697    }
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703    use crate::v31::Webhook;
704
705    #[test]
706    fn test_openapi31_spec_creation() {
707        let spec = OpenApi31Spec::new("Test API", "1.0.0")
708            .description("A test API")
709            .summary("Test API Summary");
710
711        assert_eq!(spec.openapi, "3.1.0");
712        assert_eq!(spec.info.title, "Test API");
713        assert_eq!(spec.info.version, "1.0.0");
714        assert_eq!(spec.info.summary, Some("Test API Summary".to_string()));
715        assert_eq!(
716            spec.json_schema_dialect,
717            Some("https://json-schema.org/draft/2020-12/schema".to_string())
718        );
719    }
720
721    #[test]
722    fn test_license_spdx() {
723        let license = License::spdx("MIT License", "MIT");
724        assert_eq!(license.name, "MIT License");
725        assert_eq!(license.identifier, Some("MIT".to_string()));
726    }
727
728    #[test]
729    fn test_webhook_addition() {
730        let spec = OpenApi31Spec::new("Test API", "1.0.0").webhook(
731            "orderPlaced",
732            Webhook::with_summary("Order placed notification"),
733        );
734
735        assert!(spec.webhooks.is_some());
736        assert!(spec.webhooks.as_ref().unwrap().contains_key("orderPlaced"));
737    }
738
739    #[test]
740    fn test_security_scheme_bearer() {
741        let scheme = SecurityScheme::bearer("JWT").description("JWT Bearer token authentication");
742
743        assert_eq!(scheme.scheme_type, "http");
744        assert_eq!(scheme.scheme, Some("bearer".to_string()));
745        assert_eq!(scheme.bearer_format, Some("JWT".to_string()));
746    }
747
748    #[test]
749    fn test_server_with_variables() {
750        let server = Server::new("https://{environment}.api.example.com")
751            .description("Server with environment variable")
752            .variable(
753                "environment",
754                ServerVariable::new("production")
755                    .enum_values(vec![
756                        "development".to_string(),
757                        "staging".to_string(),
758                        "production".to_string(),
759                    ])
760                    .description("Server environment"),
761            );
762
763        assert!(server.variables.is_some());
764        assert!(server
765            .variables
766            .as_ref()
767            .unwrap()
768            .contains_key("environment"));
769    }
770
771    #[test]
772    fn test_spec_to_json() {
773        let spec =
774            OpenApi31Spec::new("Test API", "1.0.0").server(Server::new("https://api.example.com"));
775
776        let json = spec.to_json();
777        assert_eq!(json["openapi"], "3.1.0");
778        assert_eq!(json["info"]["title"], "Test API");
779    }
780}