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