Skip to main content

openapi_to_rust/
openapi.rs

1use crate::extensions::Extensions;
2use once_cell::sync::Lazy;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::BTreeMap;
6
7#[derive(Debug, Clone, Deserialize, Serialize)]
8pub struct OpenApiSpec {
9    pub openapi: String,
10    pub info: Info,
11    #[serde(rename = "jsonSchemaDialect", default)]
12    pub json_schema_dialect: Option<String>,
13    #[serde(default)]
14    pub servers: Option<Vec<Server>>,
15    #[serde(default)]
16    pub paths: Option<BTreeMap<String, PathItem>>,
17    #[serde(default)]
18    pub webhooks: Option<BTreeMap<String, PathItem>>,
19    #[serde(default)]
20    pub components: Option<Components>,
21    #[serde(default)]
22    pub security: Option<Vec<BTreeMap<String, Vec<String>>>>,
23    #[serde(default)]
24    pub tags: Option<Vec<Tag>>,
25    #[serde(rename = "externalDocs", default)]
26    pub external_docs: Option<ExternalDocs>,
27    /// 3.2 §"$self" — see Appendix F base-URI rules. Captured but not yet used.
28    #[serde(rename = "$self", default)]
29    pub self_uri: Option<String>,
30    #[serde(flatten, default)]
31    pub extensions: Extensions,
32}
33
34#[derive(Debug, Clone, Deserialize, Serialize)]
35pub struct Info {
36    pub title: String,
37    #[serde(default)]
38    pub summary: Option<String>,
39    #[serde(default)]
40    pub description: Option<String>,
41    #[serde(rename = "termsOfService", default)]
42    pub terms_of_service: Option<String>,
43    #[serde(default)]
44    pub contact: Option<Value>,
45    #[serde(default)]
46    pub license: Option<Value>,
47    #[serde(default)]
48    pub version: Option<String>,
49    #[serde(flatten, default)]
50    pub extensions: Extensions,
51}
52
53#[derive(Debug, Clone, Deserialize, Serialize)]
54pub struct Components {
55    #[serde(default)]
56    pub schemas: Option<BTreeMap<String, Schema>>,
57    #[serde(default)]
58    pub responses: Option<BTreeMap<String, Response>>,
59    #[serde(default)]
60    pub parameters: Option<BTreeMap<String, Parameter>>,
61    #[serde(default)]
62    pub examples: Option<BTreeMap<String, Example>>,
63    #[serde(rename = "requestBodies", default)]
64    pub request_bodies: Option<BTreeMap<String, RequestBody>>,
65    #[serde(default)]
66    pub headers: Option<BTreeMap<String, Header>>,
67    #[serde(rename = "securitySchemes", default)]
68    pub security_schemes: Option<BTreeMap<String, SecurityScheme>>,
69    #[serde(default)]
70    pub links: Option<BTreeMap<String, Link>>,
71    #[serde(default)]
72    pub callbacks: Option<BTreeMap<String, Callback>>,
73    /// 3.1+ §Components — reusable Path Items.
74    #[serde(rename = "pathItems", default)]
75    pub path_items: Option<BTreeMap<String, PathItem>>,
76    /// 3.2 §Components — reusable Media Types.
77    #[serde(rename = "mediaTypes", default)]
78    pub media_types: Option<BTreeMap<String, MediaType>>,
79    #[serde(flatten, default)]
80    pub extensions: Extensions,
81}
82
83#[derive(Debug, Clone, Deserialize, Serialize)]
84#[serde(untagged)]
85pub enum Schema {
86    /// Schema reference
87    Reference {
88        #[serde(rename = "$ref")]
89        reference: String,
90        #[serde(flatten)]
91        extra: BTreeMap<String, Value>,
92    },
93    /// Recursive reference (older draft, kept for OAS 3.0 compatibility)
94    RecursiveRef {
95        #[serde(rename = "$recursiveRef")]
96        recursive_ref: String,
97        #[serde(flatten)]
98        extra: BTreeMap<String, Value>,
99    },
100    /// Dynamic reference per JSON Schema 2020-12 (OAS 3.1+).
101    /// `$dynamicRef` resolves against the nearest enclosing `$dynamicAnchor`.
102    /// J1: modeled today; full dynamic resolution at analysis time is a
103    /// follow-up. Self-references via `$dynamicRef: "#x"` are treated as
104    /// recursive references to the schema bearing `$dynamicAnchor: "x"`.
105    DynamicRef {
106        #[serde(rename = "$dynamicRef")]
107        dynamic_ref: String,
108        #[serde(flatten)]
109        extra: BTreeMap<String, Value>,
110    },
111    /// OneOf union
112    OneOf {
113        #[serde(rename = "oneOf")]
114        one_of: Vec<Schema>,
115        discriminator: Option<Discriminator>,
116        #[serde(flatten)]
117        details: SchemaDetails,
118    },
119    /// AnyOf union (must come before Typed to handle type + anyOf patterns)
120    AnyOf {
121        #[serde(rename = "type")]
122        schema_type: Option<SchemaType>,
123        #[serde(rename = "anyOf")]
124        any_of: Vec<Schema>,
125        discriminator: Option<Discriminator>,
126        #[serde(flatten)]
127        details: SchemaDetails,
128    },
129    /// Schema with `type` as an array (OpenAPI 3.1 / JSON Schema 2020-12).
130    /// The canonical 3.1 way to express a nullable type is
131    /// `type: ["string", "null"]`. Listed before `Typed` so the array form
132    /// matches first.
133    TypedMulti {
134        #[serde(rename = "type")]
135        schema_types: Vec<SchemaType>,
136        #[serde(flatten)]
137        details: SchemaDetails,
138    },
139    /// Schema with a single explicit type
140    Typed {
141        #[serde(rename = "type")]
142        schema_type: SchemaType,
143        #[serde(flatten)]
144        details: SchemaDetails,
145    },
146    /// AllOf composition
147    AllOf {
148        #[serde(rename = "allOf")]
149        all_of: Vec<Schema>,
150        #[serde(flatten)]
151        details: SchemaDetails,
152    },
153    /// Schema without explicit type (inferred from other fields)
154    Untyped {
155        #[serde(flatten)]
156        details: SchemaDetails,
157    },
158}
159
160#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
161#[serde(rename_all = "lowercase")]
162pub enum SchemaType {
163    String,
164    Integer,
165    Number,
166    Boolean,
167    Array,
168    Object,
169    #[serde(rename = "null")]
170    Null,
171}
172
173#[derive(Debug, Clone, Default, Deserialize, Serialize)]
174pub struct SchemaDetails {
175    pub description: Option<String>,
176    pub nullable: Option<bool>,
177
178    // OpenAPI 3.0 recursive support (obsoleted by JSON Schema 2020-12).
179    #[serde(rename = "$recursiveAnchor")]
180    pub recursive_anchor: Option<bool>,
181
182    // JSON Schema 2020-12 dynamic anchors (J1).
183    #[serde(rename = "$dynamicAnchor")]
184    pub dynamic_anchor: Option<String>,
185    #[serde(rename = "$id")]
186    pub schema_id: Option<String>,
187
188    // String-specific
189    #[serde(rename = "enum")]
190    pub enum_values: Option<Vec<Value>>,
191    pub format: Option<String>,
192    pub default: Option<Value>,
193    #[serde(rename = "const")]
194    pub const_value: Option<Value>,
195
196    // Object-specific
197    pub properties: Option<BTreeMap<String, Schema>>,
198    pub required: Option<Vec<String>>,
199    #[serde(rename = "additionalProperties")]
200    pub additional_properties: Option<AdditionalProperties>,
201
202    // Array-specific
203    pub items: Option<Box<Schema>>,
204
205    // Number-specific
206    pub minimum: Option<f64>,
207    pub maximum: Option<f64>,
208
209    // Validation
210    #[serde(rename = "minLength")]
211    pub min_length: Option<u64>,
212    #[serde(rename = "maxLength")]
213    pub max_length: Option<u64>,
214    pub pattern: Option<String>,
215    /// In 3.0/Swagger this was a `bool` flag relative to `minimum`; in 3.1
216    /// (JSON Schema 2020-12) it's a number. Accept either to round-trip
217    /// real-world specs. (Tracked under J3 — proper validation lowering.)
218    #[serde(rename = "exclusiveMinimum")]
219    pub exclusive_minimum: Option<ExclusiveBound>,
220    #[serde(rename = "exclusiveMaximum")]
221    pub exclusive_maximum: Option<ExclusiveBound>,
222    #[serde(rename = "multipleOf")]
223    pub multiple_of: Option<f64>,
224    #[serde(rename = "minItems")]
225    pub min_items: Option<u64>,
226    #[serde(rename = "maxItems")]
227    pub max_items: Option<u64>,
228    #[serde(rename = "uniqueItems")]
229    pub unique_items: Option<bool>,
230    #[serde(rename = "minProperties")]
231    pub min_properties: Option<u64>,
232    #[serde(rename = "maxProperties")]
233    pub max_properties: Option<u64>,
234
235    // JSON Schema 2020-12 array keywords (J4, J8).
236    #[serde(rename = "prefixItems")]
237    pub prefix_items: Option<Vec<Schema>>,
238    pub contains: Option<Box<Schema>>,
239    #[serde(rename = "minContains")]
240    pub min_contains: Option<u64>,
241    #[serde(rename = "maxContains")]
242    pub max_contains: Option<u64>,
243
244    // JSON Schema 2020-12 object keywords (J5, J6, J7).
245    #[serde(rename = "patternProperties")]
246    pub pattern_properties: Option<BTreeMap<String, Schema>>,
247    #[serde(rename = "propertyNames")]
248    pub property_names: Option<Box<Schema>>,
249    #[serde(rename = "unevaluatedProperties")]
250    pub unevaluated_properties: Option<AdditionalProperties>,
251    #[serde(rename = "unevaluatedItems")]
252    pub unevaluated_items: Option<AdditionalProperties>,
253    #[serde(rename = "dependentRequired")]
254    pub dependent_required: Option<BTreeMap<String, Vec<String>>>,
255    #[serde(rename = "dependentSchemas")]
256    pub dependent_schemas: Option<BTreeMap<String, Schema>>,
257
258    // JSON Schema 2020-12 content keywords (J8).
259    #[serde(rename = "contentEncoding")]
260    pub content_encoding: Option<String>,
261    #[serde(rename = "contentMediaType")]
262    pub content_media_type: Option<String>,
263    #[serde(rename = "contentSchema")]
264    pub content_schema: Option<Box<Schema>>,
265
266    // JSON Schema 2020-12 conditional keywords.
267    #[serde(rename = "if")]
268    pub if_schema: Option<Box<Schema>>,
269    #[serde(rename = "then")]
270    pub then_schema: Option<Box<Schema>>,
271    #[serde(rename = "else")]
272    pub else_schema: Option<Box<Schema>>,
273    pub not: Option<Box<Schema>>,
274
275    // 3.0 deprecated annotations now first-class (kept since openai-responses fixture is OAS 3.0).
276    pub title: Option<String>,
277    pub deprecated: Option<bool>,
278    #[serde(rename = "readOnly")]
279    pub read_only: Option<bool>,
280    #[serde(rename = "writeOnly")]
281    pub write_only: Option<bool>,
282    pub examples: Option<Vec<Value>>,
283    pub example: Option<Value>,
284    /// JSON Schema annotation `$comment`.
285    #[serde(rename = "$comment")]
286    pub comment: Option<String>,
287    #[serde(rename = "$schema")]
288    pub schema_keyword: Option<String>,
289    #[serde(rename = "$defs")]
290    pub defs: Option<BTreeMap<String, Schema>>,
291
292    // Extensions and unknown fields. After J5–J8 above this should be x-*-only
293    // for well-formed OAS 3.1+ specs.
294    #[serde(flatten)]
295    pub extra: BTreeMap<String, Value>,
296}
297
298/// 3.0 used `exclusiveMinimum: true` as a bool flag against `minimum`;
299/// 3.1 (JSON Schema 2020-12) uses `exclusiveMinimum: <number>`.
300#[derive(Debug, Clone, Deserialize, Serialize)]
301#[serde(untagged)]
302pub enum ExclusiveBound {
303    Bool(bool),
304    Number(f64),
305}
306
307#[derive(Debug, Clone, Deserialize, Serialize)]
308#[serde(untagged)]
309pub enum AdditionalProperties {
310    Boolean(bool),
311    Schema(Box<Schema>),
312}
313
314/// OpenAPI Example Object (H6).
315#[derive(Debug, Clone, Deserialize, Serialize)]
316pub struct Example {
317    #[serde(default)]
318    pub summary: Option<String>,
319    #[serde(default)]
320    pub description: Option<String>,
321    /// Singular embedded value. Mutually exclusive with `external_value`.
322    #[serde(default)]
323    pub value: Option<Value>,
324    #[serde(rename = "externalValue", default)]
325    pub external_value: Option<String>,
326    /// 3.2 §"Example Object" — typed pre-serialization data.
327    #[serde(rename = "dataValue", default)]
328    pub data_value: Option<Value>,
329    /// 3.2 §"Example Object" — already-serialized form.
330    #[serde(rename = "serializedValue", default)]
331    pub serialized_value: Option<String>,
332    #[serde(rename = "$ref", default)]
333    pub reference: Option<String>,
334    #[serde(flatten, default)]
335    pub extensions: Extensions,
336}
337
338/// OpenAPI Link Object (H7).
339#[derive(Debug, Clone, Deserialize, Serialize)]
340pub struct Link {
341    #[serde(rename = "operationRef", default)]
342    pub operation_ref: Option<String>,
343    #[serde(rename = "operationId", default)]
344    pub operation_id: Option<String>,
345    #[serde(default)]
346    pub parameters: Option<BTreeMap<String, Value>>,
347    #[serde(rename = "requestBody", default)]
348    pub request_body: Option<Value>,
349    #[serde(default)]
350    pub description: Option<String>,
351    #[serde(default)]
352    pub server: Option<Server>,
353    #[serde(rename = "$ref", default)]
354    pub reference: Option<String>,
355    #[serde(flatten, default)]
356    pub extensions: Extensions,
357}
358
359/// OpenAPI Callback Object (H8). A map keyed by runtime-expression URL
360/// templates, with Path Item values.
361#[derive(Debug, Clone, Deserialize, Serialize)]
362#[serde(transparent)]
363pub struct Callback(pub BTreeMap<String, PathItem>);
364
365/// OpenAPI Encoding Object (H4). Used inside `multipart/form-data` and
366/// `application/x-www-form-urlencoded` Media Type bodies.
367#[derive(Debug, Clone, Deserialize, Serialize)]
368pub struct Encoding {
369    #[serde(rename = "contentType", default)]
370    pub content_type: Option<String>,
371    #[serde(default)]
372    pub headers: Option<BTreeMap<String, Header>>,
373    #[serde(default)]
374    pub style: Option<String>,
375    #[serde(default)]
376    pub explode: Option<bool>,
377    #[serde(rename = "allowReserved", default)]
378    pub allow_reserved: Option<bool>,
379    /// 3.2 §"Encoding Object" — nested encoding for arrays of items.
380    #[serde(rename = "itemEncoding", default)]
381    pub item_encoding: Option<Box<Encoding>>,
382    #[serde(flatten, default)]
383    pub extensions: Extensions,
384}
385
386/// OpenAPI Header Object (H5). Structurally a Parameter minus the `name`
387/// and `in` fields. Used in Response.headers, Encoding.headers, and
388/// Components.headers.
389#[derive(Debug, Clone, Deserialize, Serialize)]
390pub struct Header {
391    #[serde(default)]
392    pub description: Option<String>,
393    #[serde(default)]
394    pub required: Option<bool>,
395    #[serde(default)]
396    pub deprecated: Option<bool>,
397    #[serde(rename = "allowEmptyValue", default)]
398    pub allow_empty_value: Option<bool>,
399    #[serde(default)]
400    pub style: Option<String>,
401    #[serde(default)]
402    pub explode: Option<bool>,
403    #[serde(rename = "allowReserved", default)]
404    pub allow_reserved: Option<bool>,
405    #[serde(default)]
406    pub schema: Option<Schema>,
407    #[serde(default)]
408    pub content: Option<BTreeMap<String, MediaType>>,
409    #[serde(default)]
410    pub example: Option<Value>,
411    #[serde(default)]
412    pub examples: Option<Value>,
413    #[serde(rename = "$ref", default)]
414    pub reference: Option<String>,
415    #[serde(flatten, default)]
416    pub extensions: Extensions,
417}
418
419/// OpenAPI Security Scheme Object (H2). Covers all 3.x scheme types:
420/// apiKey, http (basic/bearer/digest), oauth2 (with flows), openIdConnect,
421/// and 3.1+ mutualTLS.
422#[derive(Debug, Clone, Deserialize, Serialize)]
423#[serde(tag = "type")]
424pub enum SecurityScheme {
425    #[serde(rename = "apiKey")]
426    ApiKey {
427        name: String,
428        #[serde(rename = "in")]
429        location: String, // "query" | "header" | "cookie"
430        #[serde(default)]
431        description: Option<String>,
432        /// 3.2 §"Security Scheme Object" — D10.
433        #[serde(default)]
434        deprecated: Option<bool>,
435        #[serde(flatten, default)]
436        extensions: Extensions,
437    },
438    #[serde(rename = "http")]
439    Http {
440        scheme: String, // "basic" | "bearer" | "digest" | …
441        #[serde(rename = "bearerFormat", default)]
442        bearer_format: Option<String>,
443        #[serde(default)]
444        description: Option<String>,
445        #[serde(default)]
446        deprecated: Option<bool>,
447        #[serde(flatten, default)]
448        extensions: Extensions,
449    },
450    #[serde(rename = "mutualTLS")]
451    MutualTls {
452        #[serde(default)]
453        description: Option<String>,
454        #[serde(default)]
455        deprecated: Option<bool>,
456        #[serde(flatten, default)]
457        extensions: Extensions,
458    },
459    #[serde(rename = "oauth2")]
460    OAuth2 {
461        // Boxed to keep the SecurityScheme enum's variants similarly sized
462        // (the OAuthFlows tree is ~800 bytes; clippy::large_enum_variant
463        // flagged the disparity).
464        flows: Box<OAuthFlows>,
465        #[serde(default)]
466        description: Option<String>,
467        /// 3.2 §"Security Scheme Object" — well-known metadata URL (D4).
468        #[serde(rename = "oauth2MetadataUrl", default)]
469        oauth2_metadata_url: Option<String>,
470        #[serde(default)]
471        deprecated: Option<bool>,
472        #[serde(flatten, default)]
473        extensions: Extensions,
474    },
475    #[serde(rename = "openIdConnect")]
476    OpenIdConnect {
477        #[serde(rename = "openIdConnectUrl")]
478        open_id_connect_url: String,
479        #[serde(default)]
480        description: Option<String>,
481        #[serde(default)]
482        deprecated: Option<bool>,
483        #[serde(flatten, default)]
484        extensions: Extensions,
485    },
486}
487
488#[derive(Debug, Clone, Deserialize, Serialize)]
489pub struct OAuthFlows {
490    #[serde(default)]
491    pub implicit: Option<OAuthFlow>,
492    #[serde(default)]
493    pub password: Option<OAuthFlow>,
494    #[serde(rename = "clientCredentials", default)]
495    pub client_credentials: Option<OAuthFlow>,
496    #[serde(rename = "authorizationCode", default)]
497    pub authorization_code: Option<OAuthFlow>,
498    /// 3.2 §"OAuth Flows Object" — device authorization flow (D4).
499    #[serde(rename = "deviceAuthorization", default)]
500    pub device_authorization: Option<OAuthFlow>,
501    #[serde(flatten, default)]
502    pub extensions: Extensions,
503}
504
505#[derive(Debug, Clone, Deserialize, Serialize)]
506pub struct OAuthFlow {
507    #[serde(rename = "authorizationUrl", default)]
508    pub authorization_url: Option<String>,
509    #[serde(rename = "tokenUrl", default)]
510    pub token_url: Option<String>,
511    #[serde(rename = "refreshUrl", default)]
512    pub refresh_url: Option<String>,
513    /// 3.2 §"OAuth Flow Object" — required for `deviceAuthorization` (D4).
514    #[serde(rename = "deviceAuthorizationUrl", default)]
515    pub device_authorization_url: Option<String>,
516    pub scopes: BTreeMap<String, String>,
517    #[serde(flatten, default)]
518    pub extensions: Extensions,
519}
520
521/// OpenAPI External Documentation Object (H10).
522#[derive(Debug, Clone, Deserialize, Serialize)]
523pub struct ExternalDocs {
524    pub url: String,
525    #[serde(default)]
526    pub description: Option<String>,
527    #[serde(flatten, default)]
528    pub extensions: Extensions,
529}
530
531/// OpenAPI Tag Object (H9 + D5 — 3.2 added summary/parent/kind).
532#[derive(Debug, Clone, Deserialize, Serialize)]
533pub struct Tag {
534    pub name: String,
535    /// 3.2 §"Tag Object" — short summary of the tag.
536    #[serde(default)]
537    pub summary: Option<String>,
538    #[serde(default)]
539    pub description: Option<String>,
540    /// 3.2 §"Tag Object" — name of a parent tag for hierarchical organisation.
541    #[serde(default)]
542    pub parent: Option<String>,
543    /// 3.2 §"Tag Object" — categorisation hint (e.g. "feature", "audience",
544    /// "compliance"). Free-form string; consumers MAY define their own
545    /// vocabulary.
546    #[serde(default)]
547    pub kind: Option<String>,
548    #[serde(rename = "externalDocs", default)]
549    pub external_docs: Option<ExternalDocs>,
550    #[serde(flatten, default)]
551    pub extensions: Extensions,
552}
553
554/// OpenAPI Server Object (H1). Multiple servers, server variables, and
555/// 3.2's `name` field are all modeled.
556#[derive(Debug, Clone, Deserialize, Serialize)]
557pub struct Server {
558    pub url: String,
559    /// 3.2 §"Server Object" — server identifier for runtime selection (D8).
560    #[serde(default)]
561    pub name: Option<String>,
562    #[serde(default)]
563    pub description: Option<String>,
564    #[serde(default)]
565    pub variables: Option<BTreeMap<String, ServerVariable>>,
566    #[serde(flatten, default)]
567    pub extensions: Extensions,
568}
569
570#[derive(Debug, Clone, Deserialize, Serialize)]
571pub struct ServerVariable {
572    /// REQUIRED in 3.0/3.1. In 3.2 this MAY be omitted when `enum` is present.
573    #[serde(default)]
574    pub default: Option<String>,
575    #[serde(rename = "enum", default)]
576    pub enum_values: Option<Vec<String>>,
577    #[serde(default)]
578    pub description: Option<String>,
579    #[serde(flatten, default)]
580    pub extensions: Extensions,
581}
582
583#[derive(Debug, Clone, Deserialize, Serialize)]
584pub struct Discriminator {
585    #[serde(rename = "propertyName")]
586    pub property_name: String,
587    #[serde(default)]
588    pub mapping: Option<BTreeMap<String, String>>,
589    /// 3.2 §"Discriminator Object" — fallback mapping target when the
590    /// discriminator value is unknown (D9). Captured today; a future bead
591    /// will emit a `_Other(Value)` enum variant when this is set.
592    #[serde(rename = "defaultMapping", default)]
593    pub default_mapping: Option<String>,
594    #[serde(flatten, default)]
595    pub extensions: Extensions,
596}
597
598impl Schema {
599    /// Get the schema type if explicitly set. For `Schema::TypedMulti` the
600    /// "primary" non-null type is returned; if the array contained only `null`
601    /// then `Some(&SchemaType::Null)` is returned.
602    pub fn schema_type(&self) -> Option<&SchemaType> {
603        match self {
604            Schema::Typed { schema_type, .. } => Some(schema_type),
605            Schema::TypedMulti { schema_types, .. } => schema_types
606                .iter()
607                .find(|t| **t != SchemaType::Null)
608                .or_else(|| schema_types.first()),
609            _ => None,
610        }
611    }
612
613    /// True when the schema's type set explicitly contains `null`.
614    /// (3.1 canonical nullability via `type: ["X", "null"]`.)
615    pub fn type_array_contains_null(&self) -> bool {
616        match self {
617            Schema::TypedMulti { schema_types, .. } => schema_types.contains(&SchemaType::Null),
618            _ => false,
619        }
620    }
621
622    /// Get schema details
623    pub fn details(&self) -> &SchemaDetails {
624        static EMPTY_DETAILS: Lazy<SchemaDetails> = Lazy::new(SchemaDetails::default);
625        match self {
626            Schema::Typed { details, .. } => details,
627            Schema::TypedMulti { details, .. } => details,
628            Schema::Reference { .. } | Schema::RecursiveRef { .. } | Schema::DynamicRef { .. } => {
629                &EMPTY_DETAILS
630            }
631            Schema::OneOf { details, .. } => details,
632            Schema::AnyOf { details, .. } => details,
633            Schema::AllOf { details, .. } => details,
634            Schema::Untyped { details } => details,
635        }
636    }
637
638    /// Get mutable schema details
639    pub fn details_mut(&mut self) -> &mut SchemaDetails {
640        match self {
641            Schema::Typed { details, .. } => details,
642            Schema::TypedMulti { details, .. } => details,
643            Schema::Reference { .. } => {
644                panic!("Cannot get mutable details for reference schema")
645            }
646            Schema::RecursiveRef { .. } => {
647                panic!("Cannot get mutable details for recursive reference schema")
648            }
649            Schema::DynamicRef { .. } => {
650                panic!("Cannot get mutable details for dynamic reference schema")
651            }
652            Schema::OneOf { details, .. } => details,
653            Schema::AnyOf { details, .. } => details,
654            Schema::AllOf { details, .. } => details,
655            Schema::Untyped { details } => details,
656        }
657    }
658
659    /// Check if this is any kind of reference (regular or recursive)
660    pub fn is_reference(&self) -> bool {
661        matches!(self, Schema::Reference { .. } | Schema::RecursiveRef { .. })
662    }
663
664    /// Get reference string if this is a reference
665    pub fn reference(&self) -> Option<&str> {
666        match self {
667            Schema::Reference { reference, .. } => Some(reference),
668            _ => None,
669        }
670    }
671
672    /// Get recursive reference string if this is a recursive reference
673    pub fn recursive_reference(&self) -> Option<&str> {
674        match self {
675            Schema::RecursiveRef { recursive_ref, .. } => Some(recursive_ref),
676            _ => None,
677        }
678    }
679
680    /// Check if this is a discriminated union
681    pub fn is_discriminated_union(&self) -> bool {
682        match self {
683            Schema::OneOf { discriminator, .. } => discriminator.is_some(),
684            Schema::AnyOf { discriminator, .. } => discriminator.is_some(),
685            _ => false,
686        }
687    }
688
689    /// Get discriminator if this is a discriminated union
690    pub fn discriminator(&self) -> Option<&Discriminator> {
691        match self {
692            Schema::OneOf { discriminator, .. } => discriminator.as_ref(),
693            Schema::AnyOf { discriminator, .. } => discriminator.as_ref(),
694            _ => None,
695        }
696    }
697
698    /// Get union variants
699    pub fn union_variants(&self) -> Option<&[Schema]> {
700        match self {
701            Schema::OneOf { one_of, .. } => Some(one_of),
702            Schema::AnyOf { any_of, .. } => Some(any_of),
703            _ => None,
704        }
705    }
706
707    /// Check if this appears to be a nullable pattern (anyOf or oneOf with null)
708    pub fn is_nullable_pattern(&self) -> bool {
709        let variants = match self {
710            Schema::AnyOf { any_of, .. } => any_of,
711            Schema::OneOf { one_of, .. } => one_of,
712            _ => return false,
713        };
714        variants.len() == 2
715            && variants
716                .iter()
717                .any(|s| matches!(s.schema_type(), Some(SchemaType::Null)))
718    }
719
720    /// Get the non-null variant from a nullable pattern
721    pub fn non_null_variant(&self) -> Option<&Schema> {
722        if !self.is_nullable_pattern() {
723            return None;
724        }
725        let variants = match self {
726            Schema::AnyOf { any_of, .. } => any_of,
727            Schema::OneOf { one_of, .. } => one_of,
728            _ => return None,
729        };
730        variants
731            .iter()
732            .find(|s| !matches!(s.schema_type(), Some(SchemaType::Null)))
733    }
734
735    /// Infer schema type from structure if not explicitly set
736    pub fn inferred_type(&self) -> Option<SchemaType> {
737        match self {
738            Schema::Typed { schema_type, .. } => Some(schema_type.clone()),
739            Schema::TypedMulti { .. } => self.schema_type().cloned(),
740            Schema::Untyped { details } => {
741                // Infer from structure
742                if details.properties.is_some() {
743                    Some(SchemaType::Object)
744                } else if details.items.is_some() {
745                    Some(SchemaType::Array)
746                } else if details.enum_values.is_some() {
747                    Some(SchemaType::String) // Assume string enum
748                } else {
749                    None
750                }
751            }
752            _ => None,
753        }
754    }
755}
756
757impl SchemaDetails {
758    /// Check if this schema is nullable
759    pub fn is_nullable(&self) -> bool {
760        self.nullable.unwrap_or(false)
761    }
762
763    /// Check if this is a string enum
764    ///
765    /// A standalone string `const` (no `enum` array) is treated as a
766    /// degenerate single-value enum so the generator emits a tightly-typed
767    /// single-variant enum instead of a bare `String`. See issue #10.
768    pub fn is_string_enum(&self) -> bool {
769        self.enum_values.is_some() || self.const_string_value().is_some()
770    }
771
772    /// Get enum values as strings if this is a string enum.
773    ///
774    /// Falls back to `[const_value]` when `enum` is absent but `const` is a
775    /// string, so a property like `{ "type": "string", "const": "X" }`
776    /// produces a single-variant enum.
777    pub fn string_enum_values(&self) -> Option<Vec<String>> {
778        if let Some(values) = self.enum_values.as_ref() {
779            // Tolerate non-string scalars in `enum` for `type: string` schemas
780            // (gitpod has `enum: [2000, 5000, ...]` on a string-typed field).
781            // Without this, `filter_map(.as_str())` produced an empty Vec
782            // and we emitted an empty enum that fails to compile.
783            return Some(
784                values
785                    .iter()
786                    .map(|v| match v {
787                        Value::String(s) => s.clone(),
788                        Value::Number(n) => n.to_string(),
789                        Value::Bool(b) => b.to_string(),
790                        Value::Null => "null".to_string(),
791                        _ => v.to_string(),
792                    })
793                    .collect(),
794            );
795        }
796        self.const_string_value().map(|s| vec![s])
797    }
798
799    fn const_string_value(&self) -> Option<String> {
800        self.const_value
801            .as_ref()
802            .and_then(|v| v.as_str())
803            .map(|s| s.to_string())
804    }
805
806    /// Check if a field is required
807    pub fn is_field_required(&self, field_name: &str) -> bool {
808        self.required
809            .as_ref()
810            .map(|req| req.contains(&field_name.to_string()))
811            .unwrap_or(false)
812    }
813}
814
815/// OpenAPI Path Item Object
816#[derive(Debug, Clone, Deserialize, Serialize)]
817pub struct PathItem {
818    #[serde(default)]
819    pub summary: Option<String>,
820    #[serde(default)]
821    pub description: Option<String>,
822    pub get: Option<Operation>,
823    pub put: Option<Operation>,
824    pub post: Option<Operation>,
825    pub delete: Option<Operation>,
826    pub options: Option<Operation>,
827    pub head: Option<Operation>,
828    pub patch: Option<Operation>,
829    pub trace: Option<Operation>,
830    /// 3.2 §"Path Item Object" — `QUERY` HTTP method (D1). Originally
831    /// proposed for safe, idempotent reads with a body.
832    pub query: Option<Operation>,
833    /// 3.2 §"Path Item Object" — extension map for HTTP methods beyond the
834    /// well-known ones (e.g. WebDAV's PROPFIND, SEARCH; LINK/UNLINK). Keys
835    /// are uppercase method names (D1).
836    #[serde(rename = "additionalOperations", default)]
837    pub additional_operations: Option<BTreeMap<String, Operation>>,
838    pub parameters: Option<Vec<Parameter>>,
839    #[serde(default)]
840    pub servers: Option<Vec<Server>>,
841    #[serde(rename = "$ref", default)]
842    pub reference: Option<String>,
843    #[serde(flatten, default)]
844    pub extensions: Extensions,
845}
846
847impl PathItem {
848    /// Get all operations in this path item, including 3.2's `query`
849    /// (D1) and any custom verbs declared in `additionalOperations`.
850    pub fn operations(&self) -> Vec<(&str, &Operation)> {
851        let mut ops = Vec::new();
852        if let Some(ref op) = self.get {
853            ops.push(("get", op));
854        }
855        if let Some(ref op) = self.put {
856            ops.push(("put", op));
857        }
858        if let Some(ref op) = self.post {
859            ops.push(("post", op));
860        }
861        if let Some(ref op) = self.delete {
862            ops.push(("delete", op));
863        }
864        if let Some(ref op) = self.options {
865            ops.push(("options", op));
866        }
867        if let Some(ref op) = self.head {
868            ops.push(("head", op));
869        }
870        if let Some(ref op) = self.patch {
871            ops.push(("patch", op));
872        }
873        if let Some(ref op) = self.trace {
874            ops.push(("trace", op));
875        }
876        if let Some(ref op) = self.query {
877            ops.push(("query", op));
878        }
879        if let Some(map) = &self.additional_operations {
880            for (verb, op) in map {
881                ops.push((verb.as_str(), op));
882            }
883        }
884        ops
885    }
886}
887
888/// OpenAPI Operation Object
889#[derive(Debug, Clone, Deserialize, Serialize)]
890pub struct Operation {
891    #[serde(rename = "operationId", default)]
892    pub operation_id: Option<String>,
893    #[serde(default)]
894    pub summary: Option<String>,
895    #[serde(default)]
896    pub description: Option<String>,
897    #[serde(default)]
898    pub tags: Option<Vec<String>>,
899    #[serde(default)]
900    pub deprecated: Option<bool>,
901    pub parameters: Option<Vec<Parameter>>,
902    #[serde(rename = "requestBody")]
903    pub request_body: Option<RequestBody>,
904    pub responses: Option<BTreeMap<String, Response>>,
905    #[serde(default)]
906    pub callbacks: Option<BTreeMap<String, Callback>>,
907    #[serde(default)]
908    pub security: Option<Vec<BTreeMap<String, Vec<String>>>>,
909    #[serde(default)]
910    pub servers: Option<Vec<Server>>,
911    #[serde(rename = "externalDocs", default)]
912    pub external_docs: Option<ExternalDocs>,
913    #[serde(flatten, default)]
914    pub extensions: Extensions,
915}
916
917/// OpenAPI Parameter Object
918#[derive(Debug, Clone, Deserialize, Serialize)]
919pub struct Parameter {
920    #[serde(default)]
921    pub name: Option<String>,
922    #[serde(rename = "in", default)]
923    pub location: Option<String>,
924    #[serde(default)]
925    pub required: Option<bool>,
926    #[serde(default)]
927    pub deprecated: Option<bool>,
928    #[serde(rename = "allowEmptyValue", default)]
929    pub allow_empty_value: Option<bool>,
930    #[serde(default)]
931    pub style: Option<String>,
932    #[serde(default)]
933    pub explode: Option<bool>,
934    #[serde(rename = "allowReserved", default)]
935    pub allow_reserved: Option<bool>,
936    #[serde(default)]
937    pub schema: Option<Schema>,
938    #[serde(default)]
939    pub content: Option<BTreeMap<String, MediaType>>,
940    #[serde(default)]
941    pub example: Option<Value>,
942    #[serde(default)]
943    pub examples: Option<BTreeMap<String, Example>>,
944    #[serde(default)]
945    pub description: Option<String>,
946    #[serde(rename = "$ref", default)]
947    pub reference: Option<String>,
948    #[serde(flatten, default)]
949    pub extensions: Extensions,
950}
951
952/// OpenAPI Request Body Object
953#[derive(Debug, Clone, Deserialize, Serialize)]
954pub struct RequestBody {
955    pub content: Option<BTreeMap<String, MediaType>>,
956    #[serde(default)]
957    pub description: Option<String>,
958    #[serde(default)]
959    pub required: Option<bool>,
960    #[serde(rename = "$ref", default)]
961    pub reference: Option<String>,
962    #[serde(flatten, default)]
963    pub extensions: Extensions,
964}
965
966/// Returns true for media types whose payload is JSON.
967///
968/// Matches `application/json` exactly, plus any RFC 6839 structured-syntax
969/// suffix variant of the form `application/<subtype>+json`
970/// (e.g. `application/vnd.api+json`, `application/hal+json`,
971/// `application/problem+json`). Trailing parameters such as
972/// `; charset=utf-8` are tolerated.
973pub fn is_json_media_type(ct: &str) -> bool {
974    let essence = ct
975        .split(';')
976        .next()
977        .unwrap_or(ct)
978        .trim()
979        .to_ascii_lowercase();
980    if essence == "application/json" {
981        return true;
982    }
983    if let Some(subtype) = essence.strip_prefix("application/") {
984        return subtype.ends_with("+json");
985    }
986    false
987}
988
989/// Returns true for `application/x-www-form-urlencoded` (with optional
990/// parameters).
991pub fn is_form_urlencoded_media_type(ct: &str) -> bool {
992    let essence = ct
993        .split(';')
994        .next()
995        .unwrap_or(ct)
996        .trim()
997        .to_ascii_lowercase();
998    essence == "application/x-www-form-urlencoded"
999}
1000
1001fn find_json_content(content: &BTreeMap<String, MediaType>) -> Option<(&str, &MediaType)> {
1002    if let Some(mt) = content.get("application/json") {
1003        return Some(("application/json", mt));
1004    }
1005    content
1006        .iter()
1007        .find(|(ct, _)| is_json_media_type(ct))
1008        .map(|(ct, mt)| (ct.as_str(), mt))
1009}
1010
1011impl RequestBody {
1012    /// Get schema for any JSON content type
1013    ///
1014    /// Prefers the canonical `application/json` entry, then falls back to
1015    /// any `application/*+json` variant (RFC 6839) such as
1016    /// `application/vnd.api+json` or `application/hal+json`.
1017    pub fn json_schema(&self) -> Option<&Schema> {
1018        self.content
1019            .as_ref()
1020            .and_then(find_json_content)
1021            .and_then(|(_, media_type)| media_type.schema.as_ref())
1022    }
1023
1024    /// Get the best content type and its schema, preferring JSON over others
1025    pub fn best_content(&self) -> Option<(&str, Option<&Schema>)> {
1026        let content = self.content.as_ref()?;
1027
1028        if let Some((ct, media_type)) = find_json_content(content) {
1029            return Some((ct, media_type.schema.as_ref()));
1030        }
1031
1032        const PRIORITY: &[&str] = &[
1033            "application/x-www-form-urlencoded",
1034            "multipart/form-data",
1035            "application/octet-stream",
1036            "text/plain",
1037        ];
1038        for ct in PRIORITY {
1039            if let Some(media_type) = content.get(*ct) {
1040                return Some((*ct, media_type.schema.as_ref()));
1041            }
1042        }
1043        None
1044    }
1045}
1046
1047/// OpenAPI Response Object
1048#[derive(Debug, Clone, Deserialize, Serialize)]
1049pub struct Response {
1050    #[serde(default)]
1051    pub description: Option<String>,
1052    #[serde(default)]
1053    pub headers: Option<BTreeMap<String, Header>>,
1054    #[serde(default)]
1055    pub content: Option<BTreeMap<String, MediaType>>,
1056    #[serde(default)]
1057    pub links: Option<Value>,
1058    #[serde(rename = "$ref", default)]
1059    pub reference: Option<String>,
1060    #[serde(flatten, default)]
1061    pub extensions: Extensions,
1062}
1063
1064impl Response {
1065    /// Get schema for any JSON content type
1066    ///
1067    /// Prefers the canonical `application/json` entry, then falls back to
1068    /// any `application/*+json` variant (RFC 6839) such as
1069    /// `application/vnd.api+json`, `application/hal+json`, or
1070    /// `application/problem+json`.
1071    pub fn json_schema(&self) -> Option<&Schema> {
1072        self.content
1073            .as_ref()
1074            .and_then(find_json_content)
1075            .and_then(|(_, media_type)| media_type.schema.as_ref())
1076    }
1077}
1078
1079/// OpenAPI Media Type Object
1080#[derive(Debug, Clone, Deserialize, Serialize)]
1081pub struct MediaType {
1082    #[serde(default)]
1083    pub schema: Option<Schema>,
1084    #[serde(default)]
1085    pub example: Option<Value>,
1086    #[serde(default)]
1087    pub examples: Option<BTreeMap<String, Example>>,
1088    #[serde(default)]
1089    pub encoding: Option<BTreeMap<String, Encoding>>,
1090    /// 3.2 §"Media Type Object" — schema for each item when streaming
1091    /// (D3). Common in `text/event-stream` and JSON-lines payloads.
1092    #[serde(rename = "itemSchema", default)]
1093    pub item_schema: Option<Schema>,
1094    /// 3.2 §"Media Type Object" — encoding for the leading prefix of a
1095    /// streamed body (D3).
1096    #[serde(rename = "prefixEncoding", default)]
1097    pub prefix_encoding: Option<Vec<Encoding>>,
1098    /// 3.2 §"Media Type Object" — encoding applied to each streamed item
1099    /// (D3).
1100    #[serde(rename = "itemEncoding", default)]
1101    pub item_encoding: Option<Encoding>,
1102    #[serde(rename = "$ref", default)]
1103    pub reference: Option<String>,
1104    #[serde(flatten, default)]
1105    pub extensions: Extensions,
1106}
1107
1108#[cfg(test)]
1109#[allow(clippy::unwrap_used, clippy::expect_used)]
1110mod tests {
1111    use super::*;
1112    use serde_json::json;
1113
1114    #[test]
1115    fn test_parse_simple_object_schema() {
1116        let schema_json = json!({
1117            "type": "object",
1118            "properties": {
1119                "name": {
1120                    "type": "string",
1121                    "description": "User name"
1122                },
1123                "age": {
1124                    "type": "integer"
1125                }
1126            },
1127            "required": ["name"]
1128        });
1129
1130        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1131
1132        match schema {
1133            Schema::Typed {
1134                schema_type: SchemaType::Object,
1135                details,
1136            } => {
1137                assert!(details.properties.is_some());
1138                assert_eq!(details.required, Some(vec!["name".to_string()]));
1139                assert!(details.is_field_required("name"));
1140                assert!(!details.is_field_required("age"));
1141            }
1142            _ => panic!("Expected object schema"),
1143        }
1144    }
1145
1146    #[test]
1147    fn test_parse_string_enum() {
1148        let schema_json = json!({
1149            "type": "string",
1150            "enum": ["active", "inactive", "pending"],
1151            "description": "User status"
1152        });
1153
1154        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1155
1156        match schema {
1157            Schema::Typed {
1158                schema_type: SchemaType::String,
1159                details,
1160            } => {
1161                assert!(details.is_string_enum());
1162                let values = details.string_enum_values().unwrap();
1163                assert_eq!(values, vec!["active", "inactive", "pending"]);
1164            }
1165            _ => panic!("Expected string enum schema"),
1166        }
1167    }
1168
1169    #[test]
1170    fn test_parse_reference_schema() {
1171        let schema_json = json!({
1172            "$ref": "#/components/schemas/User"
1173        });
1174
1175        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1176
1177        assert!(schema.is_reference());
1178        assert_eq!(schema.reference(), Some("#/components/schemas/User"));
1179    }
1180
1181    #[test]
1182    fn test_parse_discriminated_union() {
1183        let schema_json = json!({
1184            "oneOf": [
1185                {"$ref": "#/components/schemas/Dog"},
1186                {"$ref": "#/components/schemas/Cat"}
1187            ],
1188            "discriminator": {
1189                "propertyName": "petType"
1190            }
1191        });
1192
1193        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1194
1195        assert!(schema.is_discriminated_union());
1196        let discriminator = schema.discriminator().unwrap();
1197        assert_eq!(discriminator.property_name, "petType");
1198    }
1199
1200    #[test]
1201    fn test_parse_nullable_pattern() {
1202        let schema_json = json!({
1203            "anyOf": [
1204                {"$ref": "#/components/schemas/User"},
1205                {"type": "null"}
1206            ]
1207        });
1208
1209        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1210
1211        assert!(schema.is_nullable_pattern());
1212        let non_null = schema.non_null_variant().unwrap();
1213        assert!(non_null.is_reference());
1214    }
1215
1216    #[test]
1217    fn is_json_media_type_accepts_canonical_and_structured_suffix() {
1218        // Canonical
1219        assert!(is_json_media_type("application/json"));
1220        // Parameters tolerated (RFC 7231 §3.1.1.1)
1221        assert!(is_json_media_type("application/json; charset=utf-8"));
1222        assert!(is_json_media_type("APPLICATION/JSON"));
1223        // RFC 6839 +json structured-syntax suffix
1224        assert!(is_json_media_type("application/vnd.api+json"));
1225        assert!(is_json_media_type("application/hal+json"));
1226        assert!(is_json_media_type("application/problem+json"));
1227        assert!(is_json_media_type("application/ld+json"));
1228        assert!(is_json_media_type(
1229            "application/vnd.api+json; charset=utf-8"
1230        ));
1231        // Negatives
1232        assert!(!is_json_media_type("application/xml"));
1233        assert!(!is_json_media_type("application/x-www-form-urlencoded"));
1234        assert!(!is_json_media_type("text/plain"));
1235        assert!(!is_json_media_type("application/jsonbutnotreally"));
1236        // +json suffix only applies to application/* per RFC 6839
1237        assert!(!is_json_media_type("text/something+json"));
1238    }
1239
1240    #[test]
1241    fn request_body_json_schema_finds_vnd_api_plus_json() {
1242        // Mirrors Latitude.sh: request body declared under
1243        // application/vnd.api+json without a sibling application/json.
1244        let body_json = json!({
1245            "required": true,
1246            "content": {
1247                "application/vnd.api+json": {
1248                    "schema": {"$ref": "#/components/schemas/create_api_key"}
1249                }
1250            }
1251        });
1252
1253        let body: RequestBody = serde_json::from_value(body_json).unwrap();
1254        let schema = body.json_schema().expect("expected +json schema match");
1255        assert!(schema.is_reference());
1256    }
1257
1258    #[test]
1259    fn request_body_best_content_prefers_canonical_json_over_plus_json() {
1260        // When both are present (e.g. Latitude.sh's POST /auth/api_keys),
1261        // best_content should still pick application/json for backwards
1262        // compatibility with the existing snapshot suite.
1263        let body_json = json!({
1264            "required": true,
1265            "content": {
1266                "application/json": {
1267                    "schema": {"$ref": "#/components/schemas/A"}
1268                },
1269                "application/vnd.api+json": {
1270                    "schema": {"$ref": "#/components/schemas/B"}
1271                }
1272            }
1273        });
1274
1275        let body: RequestBody = serde_json::from_value(body_json).unwrap();
1276        let (ct, _) = body.best_content().expect("expected best_content");
1277        assert_eq!(ct, "application/json");
1278    }
1279
1280    #[test]
1281    fn request_body_best_content_falls_back_to_plus_json() {
1282        // When only the +json variant is declared, best_content returns
1283        // it instead of skipping straight to form-urlencoded.
1284        let body_json = json!({
1285            "required": true,
1286            "content": {
1287                "application/vnd.api+json": {
1288                    "schema": {"$ref": "#/components/schemas/B"}
1289                }
1290            }
1291        });
1292
1293        let body: RequestBody = serde_json::from_value(body_json).unwrap();
1294        let (ct, _) = body.best_content().expect("expected best_content");
1295        assert_eq!(ct, "application/vnd.api+json");
1296    }
1297
1298    #[test]
1299    fn response_json_schema_finds_vnd_api_plus_json() {
1300        // Mirrors every Latitude.sh response: schema lives under
1301        // application/vnd.api+json only.
1302        let resp_json = json!({
1303            "description": "OK",
1304            "content": {
1305                "application/vnd.api+json": {
1306                    "schema": {"$ref": "#/components/schemas/api_keys"}
1307                }
1308            }
1309        });
1310
1311        let resp: Response = serde_json::from_value(resp_json).unwrap();
1312        let schema = resp.json_schema().expect("expected +json schema match");
1313        assert!(schema.is_reference());
1314    }
1315}