Skip to main content

openapi_to_rust/
openapi.rs

1use once_cell::sync::Lazy;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::BTreeMap;
5
6#[derive(Debug, Clone, Deserialize, Serialize)]
7pub struct OpenApiSpec {
8    pub openapi: String,
9    pub info: Info,
10    pub paths: Option<BTreeMap<String, PathItem>>,
11    pub components: Option<Components>,
12    #[serde(flatten)]
13    pub extra: BTreeMap<String, Value>,
14}
15
16#[derive(Debug, Clone, Deserialize, Serialize)]
17pub struct Info {
18    pub title: String,
19    #[serde(default)]
20    pub version: Option<String>,
21    #[serde(flatten)]
22    pub extra: BTreeMap<String, Value>,
23}
24
25#[derive(Debug, Clone, Deserialize, Serialize)]
26pub struct Components {
27    pub schemas: Option<BTreeMap<String, Schema>>,
28    pub parameters: Option<BTreeMap<String, Parameter>>,
29    #[serde(flatten)]
30    pub extra: BTreeMap<String, Value>,
31}
32
33#[derive(Debug, Clone, Deserialize, Serialize)]
34#[serde(untagged)]
35pub enum Schema {
36    /// Schema reference
37    Reference {
38        #[serde(rename = "$ref")]
39        reference: String,
40        #[serde(flatten)]
41        extra: BTreeMap<String, Value>,
42    },
43    /// Recursive reference (OpenAPI 3.1)
44    RecursiveRef {
45        #[serde(rename = "$recursiveRef")]
46        recursive_ref: String,
47        #[serde(flatten)]
48        extra: BTreeMap<String, Value>,
49    },
50    /// OneOf union
51    OneOf {
52        #[serde(rename = "oneOf")]
53        one_of: Vec<Schema>,
54        discriminator: Option<Discriminator>,
55        #[serde(flatten)]
56        details: SchemaDetails,
57    },
58    /// AnyOf union (must come before Typed to handle type + anyOf patterns)
59    AnyOf {
60        #[serde(rename = "type")]
61        schema_type: Option<SchemaType>,
62        #[serde(rename = "anyOf")]
63        any_of: Vec<Schema>,
64        discriminator: Option<Discriminator>,
65        #[serde(flatten)]
66        details: SchemaDetails,
67    },
68    /// Schema with explicit type
69    Typed {
70        #[serde(rename = "type")]
71        schema_type: SchemaType,
72        #[serde(flatten)]
73        details: SchemaDetails,
74    },
75    /// AllOf composition
76    AllOf {
77        #[serde(rename = "allOf")]
78        all_of: Vec<Schema>,
79        #[serde(flatten)]
80        details: SchemaDetails,
81    },
82    /// Schema without explicit type (inferred from other fields)
83    Untyped {
84        #[serde(flatten)]
85        details: SchemaDetails,
86    },
87}
88
89#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
90#[serde(rename_all = "lowercase")]
91pub enum SchemaType {
92    String,
93    Integer,
94    Number,
95    Boolean,
96    Array,
97    Object,
98    #[serde(rename = "null")]
99    Null,
100}
101
102#[derive(Debug, Clone, Deserialize, Serialize)]
103pub struct SchemaDetails {
104    pub description: Option<String>,
105    pub nullable: Option<bool>,
106
107    // OpenAPI 3.1 recursive support
108    #[serde(rename = "$recursiveAnchor")]
109    pub recursive_anchor: Option<bool>,
110
111    // String-specific
112    #[serde(rename = "enum")]
113    pub enum_values: Option<Vec<Value>>,
114    pub format: Option<String>,
115    pub default: Option<Value>,
116    #[serde(rename = "const")]
117    pub const_value: Option<Value>,
118
119    // Object-specific
120    pub properties: Option<BTreeMap<String, Schema>>,
121    pub required: Option<Vec<String>>,
122    #[serde(rename = "additionalProperties")]
123    pub additional_properties: Option<AdditionalProperties>,
124
125    // Array-specific
126    pub items: Option<Box<Schema>>,
127
128    // Number-specific
129    pub minimum: Option<f64>,
130    pub maximum: Option<f64>,
131
132    // Validation
133    #[serde(rename = "minLength")]
134    pub min_length: Option<u64>,
135    #[serde(rename = "maxLength")]
136    pub max_length: Option<u64>,
137    pub pattern: Option<String>,
138
139    // Extensions and unknown fields
140    #[serde(flatten)]
141    pub extra: BTreeMap<String, Value>,
142}
143
144#[derive(Debug, Clone, Deserialize, Serialize)]
145#[serde(untagged)]
146pub enum AdditionalProperties {
147    Boolean(bool),
148    Schema(Box<Schema>),
149}
150
151#[derive(Debug, Clone, Deserialize, Serialize)]
152pub struct Discriminator {
153    #[serde(rename = "propertyName")]
154    pub property_name: String,
155    pub mapping: Option<BTreeMap<String, String>>,
156    #[serde(flatten)]
157    pub extra: BTreeMap<String, Value>,
158}
159
160impl Schema {
161    /// Get the schema type if explicitly set
162    pub fn schema_type(&self) -> Option<&SchemaType> {
163        match self {
164            Schema::Typed { schema_type, .. } => Some(schema_type),
165            _ => None,
166        }
167    }
168
169    /// Get schema details
170    pub fn details(&self) -> &SchemaDetails {
171        match self {
172            Schema::Typed { details, .. } => details,
173            Schema::Reference { .. } => {
174                static EMPTY_DETAILS: Lazy<SchemaDetails> = Lazy::new(|| SchemaDetails {
175                    description: None,
176                    nullable: None,
177                    recursive_anchor: None,
178                    enum_values: None,
179                    format: None,
180                    default: None,
181                    const_value: None,
182                    properties: None,
183                    required: None,
184                    additional_properties: None,
185                    items: None,
186                    minimum: None,
187                    maximum: None,
188                    min_length: None,
189                    max_length: None,
190                    pattern: None,
191                    extra: BTreeMap::new(),
192                });
193                &EMPTY_DETAILS
194            }
195            Schema::RecursiveRef { .. } => {
196                static EMPTY_DETAILS_RECURSIVE: Lazy<SchemaDetails> = Lazy::new(|| SchemaDetails {
197                    description: None,
198                    nullable: None,
199                    recursive_anchor: None,
200                    enum_values: None,
201                    format: None,
202                    default: None,
203                    const_value: None,
204                    properties: None,
205                    required: None,
206                    additional_properties: None,
207                    items: None,
208                    minimum: None,
209                    maximum: None,
210                    min_length: None,
211                    max_length: None,
212                    pattern: None,
213                    extra: BTreeMap::new(),
214                });
215                &EMPTY_DETAILS_RECURSIVE
216            }
217            Schema::OneOf { details, .. } => details,
218            Schema::AnyOf { details, .. } => details,
219            Schema::AllOf { details, .. } => details,
220            Schema::Untyped { details } => details,
221        }
222    }
223
224    /// Get mutable schema details
225    pub fn details_mut(&mut self) -> &mut SchemaDetails {
226        match self {
227            Schema::Typed { details, .. } => details,
228            Schema::Reference { .. } => {
229                // Cannot mutate reference details
230                panic!("Cannot get mutable details for reference schema")
231            }
232            Schema::RecursiveRef { .. } => {
233                // Cannot mutate recursive reference details
234                panic!("Cannot get mutable details for recursive reference schema")
235            }
236            Schema::OneOf { details, .. } => details,
237            Schema::AnyOf { details, .. } => details,
238            Schema::AllOf { details, .. } => details,
239            Schema::Untyped { details } => details,
240        }
241    }
242
243    /// Check if this is any kind of reference (regular or recursive)
244    pub fn is_reference(&self) -> bool {
245        matches!(self, Schema::Reference { .. } | Schema::RecursiveRef { .. })
246    }
247
248    /// Get reference string if this is a reference
249    pub fn reference(&self) -> Option<&str> {
250        match self {
251            Schema::Reference { reference, .. } => Some(reference),
252            _ => None,
253        }
254    }
255
256    /// Get recursive reference string if this is a recursive reference
257    pub fn recursive_reference(&self) -> Option<&str> {
258        match self {
259            Schema::RecursiveRef { recursive_ref, .. } => Some(recursive_ref),
260            _ => None,
261        }
262    }
263
264    /// Check if this is a discriminated union
265    pub fn is_discriminated_union(&self) -> bool {
266        match self {
267            Schema::OneOf { discriminator, .. } => discriminator.is_some(),
268            Schema::AnyOf { discriminator, .. } => discriminator.is_some(),
269            _ => false,
270        }
271    }
272
273    /// Get discriminator if this is a discriminated union
274    pub fn discriminator(&self) -> Option<&Discriminator> {
275        match self {
276            Schema::OneOf { discriminator, .. } => discriminator.as_ref(),
277            Schema::AnyOf { discriminator, .. } => discriminator.as_ref(),
278            _ => None,
279        }
280    }
281
282    /// Get union variants
283    pub fn union_variants(&self) -> Option<&[Schema]> {
284        match self {
285            Schema::OneOf { one_of, .. } => Some(one_of),
286            Schema::AnyOf { any_of, .. } => Some(any_of),
287            _ => None,
288        }
289    }
290
291    /// Check if this appears to be a nullable pattern (anyOf with null)
292    pub fn is_nullable_pattern(&self) -> bool {
293        match self {
294            Schema::AnyOf { any_of, .. } => {
295                any_of.len() == 2
296                    && any_of
297                        .iter()
298                        .any(|s| matches!(s.schema_type(), Some(SchemaType::Null)))
299            }
300            _ => false,
301        }
302    }
303
304    /// Get the non-null variant from a nullable pattern
305    pub fn non_null_variant(&self) -> Option<&Schema> {
306        if self.is_nullable_pattern() {
307            if let Schema::AnyOf { any_of, .. } = self {
308                return any_of
309                    .iter()
310                    .find(|s| !matches!(s.schema_type(), Some(SchemaType::Null)));
311            }
312        }
313        None
314    }
315
316    /// Infer schema type from structure if not explicitly set
317    pub fn inferred_type(&self) -> Option<SchemaType> {
318        match self {
319            Schema::Typed { schema_type, .. } => Some(schema_type.clone()),
320            Schema::Untyped { details } => {
321                // Infer from structure
322                if details.properties.is_some() {
323                    Some(SchemaType::Object)
324                } else if details.items.is_some() {
325                    Some(SchemaType::Array)
326                } else if details.enum_values.is_some() {
327                    Some(SchemaType::String) // Assume string enum
328                } else {
329                    None
330                }
331            }
332            _ => None,
333        }
334    }
335}
336
337impl SchemaDetails {
338    /// Check if this schema is nullable
339    pub fn is_nullable(&self) -> bool {
340        self.nullable.unwrap_or(false)
341    }
342
343    /// Check if this is a string enum
344    pub fn is_string_enum(&self) -> bool {
345        self.enum_values.is_some()
346    }
347
348    /// Get enum values as strings if this is a string enum
349    pub fn string_enum_values(&self) -> Option<Vec<String>> {
350        self.enum_values.as_ref().map(|values| {
351            values
352                .iter()
353                .filter_map(|v| v.as_str())
354                .map(|s| s.to_string())
355                .collect()
356        })
357    }
358
359    /// Check if a field is required
360    pub fn is_field_required(&self, field_name: &str) -> bool {
361        self.required
362            .as_ref()
363            .map(|req| req.contains(&field_name.to_string()))
364            .unwrap_or(false)
365    }
366}
367
368/// OpenAPI Path Item Object  
369#[derive(Debug, Clone, Deserialize, Serialize)]
370pub struct PathItem {
371    #[serde(rename = "get")]
372    pub get: Option<Operation>,
373    #[serde(rename = "put")]
374    pub put: Option<Operation>,
375    #[serde(rename = "post")]
376    pub post: Option<Operation>,
377    #[serde(rename = "delete")]
378    pub delete: Option<Operation>,
379    #[serde(rename = "options")]
380    pub options: Option<Operation>,
381    #[serde(rename = "head")]
382    pub head: Option<Operation>,
383    #[serde(rename = "patch")]
384    pub patch: Option<Operation>,
385    #[serde(rename = "trace")]
386    pub trace: Option<Operation>,
387    pub parameters: Option<Vec<Parameter>>,
388    #[serde(flatten)]
389    pub extra: BTreeMap<String, Value>,
390}
391
392impl PathItem {
393    /// Get all operations in this path item
394    pub fn operations(&self) -> Vec<(&str, &Operation)> {
395        let mut ops = Vec::new();
396        if let Some(ref op) = self.get {
397            ops.push(("get", op));
398        }
399        if let Some(ref op) = self.put {
400            ops.push(("put", op));
401        }
402        if let Some(ref op) = self.post {
403            ops.push(("post", op));
404        }
405        if let Some(ref op) = self.delete {
406            ops.push(("delete", op));
407        }
408        if let Some(ref op) = self.options {
409            ops.push(("options", op));
410        }
411        if let Some(ref op) = self.head {
412            ops.push(("head", op));
413        }
414        if let Some(ref op) = self.patch {
415            ops.push(("patch", op));
416        }
417        if let Some(ref op) = self.trace {
418            ops.push(("trace", op));
419        }
420        ops
421    }
422}
423
424/// OpenAPI Operation Object
425#[derive(Debug, Clone, Deserialize, Serialize)]
426pub struct Operation {
427    #[serde(rename = "operationId")]
428    pub operation_id: Option<String>,
429    pub summary: Option<String>,
430    pub description: Option<String>,
431    pub parameters: Option<Vec<Parameter>>,
432    #[serde(rename = "requestBody")]
433    pub request_body: Option<RequestBody>,
434    pub responses: Option<BTreeMap<String, Response>>,
435    #[serde(flatten)]
436    pub extra: BTreeMap<String, Value>,
437}
438
439/// OpenAPI Parameter Object
440#[derive(Debug, Clone, Deserialize, Serialize)]
441pub struct Parameter {
442    pub name: Option<String>,
443    #[serde(rename = "in")]
444    pub location: Option<String>,
445    pub required: Option<bool>,
446    pub schema: Option<Schema>,
447    pub description: Option<String>,
448    #[serde(flatten)]
449    pub extra: BTreeMap<String, Value>,
450}
451
452/// OpenAPI Request Body Object
453#[derive(Debug, Clone, Deserialize, Serialize)]
454pub struct RequestBody {
455    pub content: Option<BTreeMap<String, MediaType>>,
456    pub description: Option<String>,
457    pub required: Option<bool>,
458    #[serde(flatten)]
459    pub extra: BTreeMap<String, Value>,
460}
461
462impl RequestBody {
463    /// Get schema for application/json content type
464    pub fn json_schema(&self) -> Option<&Schema> {
465        self.content
466            .as_ref()
467            .and_then(|content| content.get("application/json"))
468            .and_then(|media_type| media_type.schema.as_ref())
469    }
470
471    /// Get the best content type and its schema, preferring JSON over others
472    pub fn best_content(&self) -> Option<(&str, Option<&Schema>)> {
473        let content = self.content.as_ref()?;
474        const PRIORITY: &[&str] = &[
475            "application/json",
476            "application/x-www-form-urlencoded",
477            "multipart/form-data",
478            "application/octet-stream",
479            "text/plain",
480        ];
481        for ct in PRIORITY {
482            if let Some(media_type) = content.get(*ct) {
483                return Some((*ct, media_type.schema.as_ref()));
484            }
485        }
486        None
487    }
488}
489
490/// OpenAPI Response Object
491#[derive(Debug, Clone, Deserialize, Serialize)]
492pub struct Response {
493    pub description: Option<String>,
494    pub content: Option<BTreeMap<String, MediaType>>,
495    #[serde(flatten)]
496    pub extra: BTreeMap<String, Value>,
497}
498
499impl Response {
500    /// Get schema for application/json content type
501    pub fn json_schema(&self) -> Option<&Schema> {
502        self.content
503            .as_ref()
504            .and_then(|content| content.get("application/json"))
505            .and_then(|media_type| media_type.schema.as_ref())
506    }
507}
508
509/// OpenAPI Media Type Object
510#[derive(Debug, Clone, Deserialize, Serialize)]
511pub struct MediaType {
512    pub schema: Option<Schema>,
513    #[serde(flatten)]
514    pub extra: BTreeMap<String, Value>,
515}
516
517#[cfg(test)]
518#[allow(clippy::unwrap_used)]
519mod tests {
520    use super::*;
521    use serde_json::json;
522
523    #[test]
524    fn test_parse_simple_object_schema() {
525        let schema_json = json!({
526            "type": "object",
527            "properties": {
528                "name": {
529                    "type": "string",
530                    "description": "User name"
531                },
532                "age": {
533                    "type": "integer"
534                }
535            },
536            "required": ["name"]
537        });
538
539        let schema: Schema = serde_json::from_value(schema_json).unwrap();
540
541        match schema {
542            Schema::Typed {
543                schema_type: SchemaType::Object,
544                details,
545            } => {
546                assert!(details.properties.is_some());
547                assert_eq!(details.required, Some(vec!["name".to_string()]));
548                assert!(details.is_field_required("name"));
549                assert!(!details.is_field_required("age"));
550            }
551            _ => panic!("Expected object schema"),
552        }
553    }
554
555    #[test]
556    fn test_parse_string_enum() {
557        let schema_json = json!({
558            "type": "string",
559            "enum": ["active", "inactive", "pending"],
560            "description": "User status"
561        });
562
563        let schema: Schema = serde_json::from_value(schema_json).unwrap();
564
565        match schema {
566            Schema::Typed {
567                schema_type: SchemaType::String,
568                details,
569            } => {
570                assert!(details.is_string_enum());
571                let values = details.string_enum_values().unwrap();
572                assert_eq!(values, vec!["active", "inactive", "pending"]);
573            }
574            _ => panic!("Expected string enum schema"),
575        }
576    }
577
578    #[test]
579    fn test_parse_reference_schema() {
580        let schema_json = json!({
581            "$ref": "#/components/schemas/User"
582        });
583
584        let schema: Schema = serde_json::from_value(schema_json).unwrap();
585
586        assert!(schema.is_reference());
587        assert_eq!(schema.reference(), Some("#/components/schemas/User"));
588    }
589
590    #[test]
591    fn test_parse_discriminated_union() {
592        let schema_json = json!({
593            "oneOf": [
594                {"$ref": "#/components/schemas/Dog"},
595                {"$ref": "#/components/schemas/Cat"}
596            ],
597            "discriminator": {
598                "propertyName": "petType"
599            }
600        });
601
602        let schema: Schema = serde_json::from_value(schema_json).unwrap();
603
604        assert!(schema.is_discriminated_union());
605        let discriminator = schema.discriminator().unwrap();
606        assert_eq!(discriminator.property_name, "petType");
607    }
608
609    #[test]
610    fn test_parse_nullable_pattern() {
611        let schema_json = json!({
612            "anyOf": [
613                {"$ref": "#/components/schemas/User"},
614                {"type": "null"}
615            ]
616        });
617
618        let schema: Schema = serde_json::from_value(schema_json).unwrap();
619
620        assert!(schema.is_nullable_pattern());
621        let non_null = schema.non_null_variant().unwrap();
622        assert!(non_null.is_reference());
623    }
624}