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, deserialize_with = "deserialize_lenient_path_map")]
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/// Deserialize the `paths` map while skipping entries that are not Path Item
35/// Objects. Some real-world specs (apicurio) park extension values such as
36/// `x-codegen-contextRoot: "/apis/registry/v2"` directly inside `paths`;
37/// OpenAPI allows arbitrary `x-` extensions here, so drop non-object entries
38/// that begin with `x-` instead of rejecting the whole document.
39fn deserialize_lenient_path_map<'de, D>(
40    deserializer: D,
41) -> Result<Option<BTreeMap<String, PathItem>>, D::Error>
42where
43    D: serde::Deserializer<'de>,
44{
45    let raw = Option::<BTreeMap<String, Value>>::deserialize(deserializer)?;
46    let Some(entries) = raw else {
47        return Ok(None);
48    };
49    let mut paths = BTreeMap::new();
50    for (key, value) in entries {
51        if value.is_object() || !key.starts_with("x-") {
52            let item =
53                serde_json::from_value::<PathItem>(value).map_err(serde::de::Error::custom)?;
54            paths.insert(key, item);
55        }
56    }
57    Ok(Some(paths))
58}
59
60#[derive(Debug, Clone, Deserialize, Serialize)]
61pub struct Info {
62    pub title: String,
63    #[serde(default)]
64    pub summary: Option<String>,
65    #[serde(default)]
66    pub description: Option<String>,
67    #[serde(rename = "termsOfService", default)]
68    pub terms_of_service: Option<String>,
69    #[serde(default)]
70    pub contact: Option<Value>,
71    #[serde(default)]
72    pub license: Option<Value>,
73    #[serde(default)]
74    pub version: Option<String>,
75    #[serde(flatten, default)]
76    pub extensions: Extensions,
77}
78
79#[derive(Debug, Clone, Deserialize, Serialize)]
80pub struct Components {
81    #[serde(default)]
82    pub schemas: Option<BTreeMap<String, Schema>>,
83    #[serde(default)]
84    pub responses: Option<BTreeMap<String, Response>>,
85    #[serde(default)]
86    pub parameters: Option<BTreeMap<String, Parameter>>,
87    #[serde(default)]
88    pub examples: Option<BTreeMap<String, Example>>,
89    #[serde(rename = "requestBodies", default)]
90    pub request_bodies: Option<BTreeMap<String, RequestBody>>,
91    #[serde(default)]
92    pub headers: Option<BTreeMap<String, Header>>,
93    #[serde(rename = "securitySchemes", default)]
94    pub security_schemes: Option<BTreeMap<String, SecurityScheme>>,
95    #[serde(default)]
96    pub links: Option<BTreeMap<String, Link>>,
97    #[serde(default)]
98    pub callbacks: Option<BTreeMap<String, Callback>>,
99    /// 3.1+ §Components — reusable Path Items.
100    #[serde(rename = "pathItems", default)]
101    pub path_items: Option<BTreeMap<String, PathItem>>,
102    /// 3.2 §Components — reusable Media Types.
103    #[serde(rename = "mediaTypes", default)]
104    pub media_types: Option<BTreeMap<String, MediaType>>,
105    #[serde(flatten, default)]
106    pub extensions: Extensions,
107}
108
109#[derive(Debug, Clone, Deserialize, Serialize)]
110#[serde(untagged)]
111pub enum Schema {
112    /// Schema reference
113    Reference {
114        #[serde(rename = "$ref")]
115        reference: String,
116        #[serde(flatten)]
117        extra: BTreeMap<String, Value>,
118    },
119    /// Recursive reference (older draft, kept for OAS 3.0 compatibility)
120    RecursiveRef {
121        #[serde(rename = "$recursiveRef")]
122        recursive_ref: String,
123        #[serde(flatten)]
124        extra: BTreeMap<String, Value>,
125    },
126    /// Dynamic reference per JSON Schema 2020-12 (OAS 3.1+).
127    /// `$dynamicRef` resolves against the nearest enclosing `$dynamicAnchor`.
128    /// J1: modeled today; full dynamic resolution at analysis time is a
129    /// follow-up. Self-references via `$dynamicRef: "#x"` are treated as
130    /// recursive references to the schema bearing `$dynamicAnchor: "x"`.
131    DynamicRef {
132        #[serde(rename = "$dynamicRef")]
133        dynamic_ref: String,
134        #[serde(flatten)]
135        extra: BTreeMap<String, Value>,
136    },
137    /// OneOf union
138    OneOf {
139        /// Some specs declare the branch type alongside the union, the way
140        /// `anyOf` does — Discord ships `{type: integer, oneOf: []}`. Modeling
141        /// it keeps that type reachable instead of leaving it in `extra`.
142        #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
143        schema_type: Option<SchemaType>,
144        #[serde(rename = "oneOf")]
145        one_of: Vec<Schema>,
146        #[serde(skip_serializing_if = "Option::is_none")]
147        discriminator: Option<Discriminator>,
148        #[serde(flatten)]
149        details: SchemaDetails,
150    },
151    /// AnyOf union (must come before Typed to handle type + anyOf patterns)
152    AnyOf {
153        #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
154        schema_type: Option<SchemaType>,
155        #[serde(rename = "anyOf")]
156        any_of: Vec<Schema>,
157        #[serde(skip_serializing_if = "Option::is_none")]
158        discriminator: Option<Discriminator>,
159        #[serde(flatten)]
160        details: SchemaDetails,
161    },
162    /// AllOf composition (must come before Typed to handle type + allOf
163    /// patterns)
164    AllOf {
165        #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
166        schema_type: Option<SchemaType>,
167        #[serde(rename = "allOf")]
168        all_of: Vec<Schema>,
169        #[serde(flatten)]
170        details: SchemaDetails,
171    },
172    /// Schema with `type` as an array (OpenAPI 3.1 / JSON Schema 2020-12).
173    /// The canonical 3.1 way to express a nullable type is
174    /// `type: ["string", "null"]`. Listed before `Typed` so the array form
175    /// matches first.
176    TypedMulti {
177        #[serde(rename = "type")]
178        schema_types: Vec<SchemaType>,
179        #[serde(flatten)]
180        details: SchemaDetails,
181    },
182    /// Schema with a single explicit type
183    Typed {
184        #[serde(rename = "type")]
185        schema_type: SchemaType,
186        #[serde(flatten)]
187        details: SchemaDetails,
188    },
189    /// Schema without explicit type (inferred from other fields)
190    Untyped {
191        #[serde(flatten)]
192        details: SchemaDetails,
193    },
194    /// JSON Schema 2020-12 boolean schema. `true` accepts every value and
195    /// `false` accepts none, and both are legal anywhere a schema is — a
196    /// property, a `$defs` entry, a `oneOf` branch, `not`. Specs write
197    /// `properties: {extra: true}` to say "this key exists, any value".
198    Bool(bool),
199}
200
201#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
202#[serde(rename_all = "lowercase")]
203pub enum SchemaType {
204    String,
205    Integer,
206    Number,
207    Boolean,
208    Array,
209    Object,
210    #[serde(rename = "null")]
211    Null,
212}
213
214#[derive(Debug, Clone, Default, Deserialize, Serialize)]
215pub struct SchemaDetails {
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub description: Option<String>,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub nullable: Option<bool>,
220
221    // OpenAPI 3.0 recursive support (obsoleted by JSON Schema 2020-12).
222    #[serde(rename = "$recursiveAnchor", skip_serializing_if = "Option::is_none")]
223    pub recursive_anchor: Option<bool>,
224
225    // JSON Schema 2020-12 dynamic anchors (J1).
226    #[serde(rename = "$dynamicAnchor", skip_serializing_if = "Option::is_none")]
227    pub dynamic_anchor: Option<String>,
228    #[serde(rename = "$id", skip_serializing_if = "Option::is_none")]
229    pub schema_id: Option<String>,
230
231    // String-specific
232    #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
233    pub enum_values: Option<Vec<Value>>,
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub format: Option<String>,
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub default: Option<Value>,
238    #[serde(
239        rename = "const",
240        default,
241        deserialize_with = "deserialize_present_value",
242        skip_serializing_if = "Option::is_none"
243    )]
244    pub const_value: Option<Value>,
245
246    // Object-specific
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub properties: Option<BTreeMap<String, Schema>>,
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub required: Option<Vec<String>>,
251    #[serde(
252        rename = "additionalProperties",
253        skip_serializing_if = "Option::is_none"
254    )]
255    pub additional_properties: Option<AdditionalProperties>,
256
257    // Array-specific
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub items: Option<Items>,
260
261    // Number-specific
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub minimum: Option<serde_json::Number>,
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub maximum: Option<serde_json::Number>,
266
267    // Validation
268    #[serde(
269        rename = "minLength",
270        default,
271        deserialize_with = "deserialize_count",
272        skip_serializing_if = "Option::is_none"
273    )]
274    pub min_length: Option<u64>,
275    #[serde(
276        rename = "maxLength",
277        default,
278        deserialize_with = "deserialize_count",
279        skip_serializing_if = "Option::is_none"
280    )]
281    pub max_length: Option<u64>,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub pattern: Option<String>,
284    /// In 3.0/Swagger this was a `bool` flag relative to `minimum`; in 3.1
285    /// (JSON Schema 2020-12) it's a number. Accept either to round-trip
286    /// real-world specs. (Tracked under J3 — proper validation lowering.)
287    #[serde(rename = "exclusiveMinimum", skip_serializing_if = "Option::is_none")]
288    pub exclusive_minimum: Option<ExclusiveBound>,
289    #[serde(rename = "exclusiveMaximum", skip_serializing_if = "Option::is_none")]
290    pub exclusive_maximum: Option<ExclusiveBound>,
291    #[serde(rename = "multipleOf", skip_serializing_if = "Option::is_none")]
292    pub multiple_of: Option<f64>,
293    #[serde(
294        rename = "minItems",
295        default,
296        deserialize_with = "deserialize_count",
297        skip_serializing_if = "Option::is_none"
298    )]
299    pub min_items: Option<u64>,
300    #[serde(
301        rename = "maxItems",
302        default,
303        deserialize_with = "deserialize_count",
304        skip_serializing_if = "Option::is_none"
305    )]
306    pub max_items: Option<u64>,
307    #[serde(rename = "uniqueItems", skip_serializing_if = "Option::is_none")]
308    pub unique_items: Option<bool>,
309    #[serde(
310        rename = "minProperties",
311        default,
312        deserialize_with = "deserialize_count",
313        skip_serializing_if = "Option::is_none"
314    )]
315    pub min_properties: Option<u64>,
316    #[serde(
317        rename = "maxProperties",
318        default,
319        deserialize_with = "deserialize_count",
320        skip_serializing_if = "Option::is_none"
321    )]
322    pub max_properties: Option<u64>,
323
324    // JSON Schema 2020-12 array keywords (J4, J8).
325    #[serde(rename = "prefixItems", skip_serializing_if = "Option::is_none")]
326    pub prefix_items: Option<Vec<Schema>>,
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub contains: Option<Box<Schema>>,
329    #[serde(
330        rename = "minContains",
331        default,
332        deserialize_with = "deserialize_count",
333        skip_serializing_if = "Option::is_none"
334    )]
335    pub min_contains: Option<u64>,
336    #[serde(
337        rename = "maxContains",
338        default,
339        deserialize_with = "deserialize_count",
340        skip_serializing_if = "Option::is_none"
341    )]
342    pub max_contains: Option<u64>,
343
344    // JSON Schema 2020-12 object keywords (J5, J6, J7).
345    #[serde(rename = "patternProperties", skip_serializing_if = "Option::is_none")]
346    pub pattern_properties: Option<BTreeMap<String, Schema>>,
347    #[serde(rename = "propertyNames", skip_serializing_if = "Option::is_none")]
348    pub property_names: Option<Box<Schema>>,
349    #[serde(
350        rename = "unevaluatedProperties",
351        skip_serializing_if = "Option::is_none"
352    )]
353    pub unevaluated_properties: Option<AdditionalProperties>,
354    #[serde(rename = "unevaluatedItems", skip_serializing_if = "Option::is_none")]
355    pub unevaluated_items: Option<AdditionalProperties>,
356    #[serde(rename = "dependentRequired", skip_serializing_if = "Option::is_none")]
357    pub dependent_required: Option<BTreeMap<String, Vec<String>>>,
358    #[serde(rename = "dependentSchemas", skip_serializing_if = "Option::is_none")]
359    pub dependent_schemas: Option<BTreeMap<String, Schema>>,
360
361    // JSON Schema 2020-12 content keywords (J8).
362    #[serde(rename = "contentEncoding", skip_serializing_if = "Option::is_none")]
363    pub content_encoding: Option<String>,
364    #[serde(rename = "contentMediaType", skip_serializing_if = "Option::is_none")]
365    pub content_media_type: Option<String>,
366    #[serde(rename = "contentSchema", skip_serializing_if = "Option::is_none")]
367    pub content_schema: Option<Box<Schema>>,
368
369    // JSON Schema 2020-12 conditional keywords.
370    #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
371    pub if_schema: Option<Box<Schema>>,
372    #[serde(rename = "then", skip_serializing_if = "Option::is_none")]
373    pub then_schema: Option<Box<Schema>>,
374    #[serde(rename = "else", skip_serializing_if = "Option::is_none")]
375    pub else_schema: Option<Box<Schema>>,
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub not: Option<Box<Schema>>,
378
379    // 3.0 deprecated annotations now first-class (kept since openai-responses fixture is OAS 3.0).
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub title: Option<String>,
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub deprecated: Option<bool>,
384    #[serde(rename = "readOnly", skip_serializing_if = "Option::is_none")]
385    pub read_only: Option<bool>,
386    #[serde(rename = "writeOnly", skip_serializing_if = "Option::is_none")]
387    pub write_only: Option<bool>,
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub examples: Option<Vec<Value>>,
390    #[serde(skip_serializing_if = "Option::is_none")]
391    pub example: Option<Value>,
392    /// JSON Schema annotation `$comment`.
393    #[serde(rename = "$comment", skip_serializing_if = "Option::is_none")]
394    pub comment: Option<String>,
395    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
396    pub schema_keyword: Option<String>,
397    #[serde(rename = "$defs", skip_serializing_if = "Option::is_none")]
398    pub defs: Option<BTreeMap<String, Schema>>,
399
400    // Extensions and unknown fields. After J5–J8 above this should be x-*-only
401    // for well-formed OAS 3.1+ specs.
402    #[serde(flatten)]
403    pub extra: BTreeMap<String, Value>,
404}
405
406/// Deserialize a non-negative integer keyword that a spec may have written as a
407/// decimal.
408///
409/// JSON Schema requires these to be non-negative integers but says nothing
410/// about their JSON spelling, so `maxItems: 2.0` is valid and appears in the
411/// 2020-12 test suite. Reading them as `u64` alone rejected the whole document.
412fn deserialize_count<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
413where
414    D: serde::Deserializer<'de>,
415{
416    use serde::de::Error;
417
418    let Some(value) = Option::<Value>::deserialize(deserializer)? else {
419        return Ok(None);
420    };
421    match &value {
422        Value::Null => Ok(None),
423        Value::Number(number) => {
424            if let Some(count) = number.as_u64() {
425                return Ok(Some(count));
426            }
427            // A float with no fractional part is the same count written
428            // differently; anything else is not a count at all.
429            match number.as_f64() {
430                Some(float) if float.fract() == 0.0 && float >= 0.0 && float <= u64::MAX as f64 => {
431                    Ok(Some(float as u64))
432                }
433                _ => Err(D::Error::custom(format!(
434                    "expected a non-negative integer, found {number}"
435                ))),
436            }
437        }
438        other => Err(D::Error::custom(format!(
439            "expected a non-negative integer, found {other}"
440        ))),
441    }
442}
443
444fn deserialize_present_value<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
445where
446    D: serde::Deserializer<'de>,
447{
448    Value::deserialize(deserializer).map(Some)
449}
450
451/// 3.0 used `exclusiveMinimum: true` as a bool flag against `minimum`;
452/// 3.1 (JSON Schema 2020-12) uses `exclusiveMinimum: <number>`.
453#[derive(Debug, Clone, Deserialize, Serialize)]
454#[serde(untagged)]
455pub enum ExclusiveBound {
456    Bool(bool),
457    Number(f64),
458}
459
460/// The `items` keyword.
461///
462/// JSON Schema 2020-12 spells it as a single schema applied to every element.
463/// Draft-04 also allowed a positional array — the tuple form — and tooling
464/// that predates 2020-12 (FastAPI/pydantic v1 emits it under
465/// `openapi: "3.1.0"`) still writes `items: [A, B]` where 2020-12 would write
466/// `prefixItems: [A, B]`. Both spellings parse; consumers reach positional
467/// entries through [`SchemaDetails::positional_items`], which unifies them
468/// with `prefixItems`.
469#[derive(Debug, Clone, Deserialize, Serialize)]
470#[serde(untagged)]
471pub enum Items {
472    /// 2020-12 `items`: one schema for every element.
473    Single(Box<Schema>),
474    /// Draft-04 tuple form: one schema per position.
475    Positional(Vec<Schema>),
476}
477
478#[derive(Debug, Clone, Deserialize, Serialize)]
479#[serde(untagged)]
480pub enum AdditionalProperties {
481    Boolean(bool),
482    Schema(Box<Schema>),
483}
484
485/// OpenAPI Example Object (H6).
486#[derive(Debug, Clone, Deserialize, Serialize)]
487pub struct Example {
488    #[serde(default)]
489    pub summary: Option<String>,
490    #[serde(default)]
491    pub description: Option<String>,
492    /// Singular embedded value. Mutually exclusive with `external_value`.
493    #[serde(default)]
494    pub value: Option<Value>,
495    #[serde(rename = "externalValue", default)]
496    pub external_value: Option<String>,
497    /// 3.2 §"Example Object" — typed pre-serialization data.
498    #[serde(rename = "dataValue", default)]
499    pub data_value: Option<Value>,
500    /// 3.2 §"Example Object" — already-serialized form.
501    #[serde(rename = "serializedValue", default)]
502    pub serialized_value: Option<String>,
503    #[serde(rename = "$ref", default)]
504    pub reference: Option<String>,
505    #[serde(flatten, default)]
506    pub extensions: Extensions,
507}
508
509/// OpenAPI Link Object (H7).
510#[derive(Debug, Clone, Deserialize, Serialize)]
511pub struct Link {
512    #[serde(rename = "operationRef", default)]
513    pub operation_ref: Option<String>,
514    #[serde(rename = "operationId", default)]
515    pub operation_id: Option<String>,
516    #[serde(default)]
517    pub parameters: Option<BTreeMap<String, Value>>,
518    #[serde(rename = "requestBody", default)]
519    pub request_body: Option<Value>,
520    #[serde(default)]
521    pub description: Option<String>,
522    #[serde(default)]
523    pub server: Option<Server>,
524    #[serde(rename = "$ref", default)]
525    pub reference: Option<String>,
526    #[serde(flatten, default)]
527    pub extensions: Extensions,
528}
529
530/// OpenAPI Callback Object (H8). A map keyed by runtime-expression URL
531/// templates, with Path Item values.
532#[derive(Debug, Clone, Deserialize, Serialize)]
533#[serde(transparent)]
534pub struct Callback(pub BTreeMap<String, PathItem>);
535
536/// OpenAPI Encoding Object (H4). Used inside `multipart/form-data` and
537/// `application/x-www-form-urlencoded` Media Type bodies.
538#[derive(Debug, Clone, Deserialize, Serialize)]
539pub struct Encoding {
540    #[serde(rename = "contentType", default)]
541    pub content_type: Option<String>,
542    #[serde(default)]
543    pub headers: Option<BTreeMap<String, Header>>,
544    #[serde(default)]
545    pub style: Option<String>,
546    #[serde(default)]
547    pub explode: Option<bool>,
548    #[serde(rename = "allowReserved", default)]
549    pub allow_reserved: Option<bool>,
550    /// 3.2 §"Encoding Object" — nested encoding for arrays of items.
551    #[serde(rename = "itemEncoding", default)]
552    pub item_encoding: Option<Box<Encoding>>,
553    #[serde(flatten, default)]
554    pub extensions: Extensions,
555}
556
557/// OpenAPI Header Object (H5). Structurally a Parameter minus the `name`
558/// and `in` fields. Used in Response.headers, Encoding.headers, and
559/// Components.headers.
560#[derive(Debug, Clone, Deserialize, Serialize)]
561pub struct Header {
562    #[serde(default)]
563    pub description: Option<String>,
564    #[serde(default)]
565    pub required: Option<bool>,
566    #[serde(default)]
567    pub deprecated: Option<bool>,
568    #[serde(rename = "allowEmptyValue", default)]
569    pub allow_empty_value: Option<bool>,
570    #[serde(default)]
571    pub style: Option<String>,
572    #[serde(default)]
573    pub explode: Option<bool>,
574    #[serde(rename = "allowReserved", default)]
575    pub allow_reserved: Option<bool>,
576    #[serde(default)]
577    pub schema: Option<Schema>,
578    #[serde(default)]
579    pub content: Option<BTreeMap<String, MediaType>>,
580    #[serde(default)]
581    pub example: Option<Value>,
582    #[serde(default)]
583    pub examples: Option<Value>,
584    #[serde(rename = "$ref", default)]
585    pub reference: Option<String>,
586    #[serde(flatten, default)]
587    pub extensions: Extensions,
588}
589
590/// OpenAPI Security Scheme Object (H2). Covers all 3.x scheme types:
591/// apiKey, http (basic/bearer/digest), oauth2 (with flows), openIdConnect,
592/// and 3.1+ mutualTLS.
593#[derive(Debug, Clone, Deserialize, Serialize)]
594#[serde(tag = "type")]
595pub enum SecurityScheme {
596    #[serde(rename = "apiKey")]
597    ApiKey {
598        name: String,
599        #[serde(rename = "in")]
600        location: String, // "query" | "header" | "cookie"
601        #[serde(default)]
602        description: Option<String>,
603        /// 3.2 §"Security Scheme Object" — D10.
604        #[serde(default)]
605        deprecated: Option<bool>,
606        #[serde(flatten, default)]
607        extensions: Extensions,
608    },
609    #[serde(rename = "http")]
610    Http {
611        scheme: String, // "basic" | "bearer" | "digest" | …
612        #[serde(rename = "bearerFormat", default)]
613        bearer_format: Option<String>,
614        #[serde(default)]
615        description: Option<String>,
616        #[serde(default)]
617        deprecated: Option<bool>,
618        #[serde(flatten, default)]
619        extensions: Extensions,
620    },
621    #[serde(rename = "mutualTLS")]
622    MutualTls {
623        #[serde(default)]
624        description: Option<String>,
625        #[serde(default)]
626        deprecated: Option<bool>,
627        #[serde(flatten, default)]
628        extensions: Extensions,
629    },
630    #[serde(rename = "oauth2")]
631    OAuth2 {
632        // Boxed to keep the SecurityScheme enum's variants similarly sized
633        // (the OAuthFlows tree is ~800 bytes; clippy::large_enum_variant
634        // flagged the disparity).
635        flows: Box<OAuthFlows>,
636        #[serde(default)]
637        description: Option<String>,
638        /// 3.2 §"Security Scheme Object" — well-known metadata URL (D4).
639        #[serde(rename = "oauth2MetadataUrl", default)]
640        oauth2_metadata_url: Option<String>,
641        #[serde(default)]
642        deprecated: Option<bool>,
643        #[serde(flatten, default)]
644        extensions: Extensions,
645    },
646    #[serde(rename = "openIdConnect")]
647    OpenIdConnect {
648        #[serde(rename = "openIdConnectUrl")]
649        open_id_connect_url: String,
650        #[serde(default)]
651        description: Option<String>,
652        #[serde(default)]
653        deprecated: Option<bool>,
654        #[serde(flatten, default)]
655        extensions: Extensions,
656    },
657}
658
659#[derive(Debug, Clone, Deserialize, Serialize)]
660pub struct OAuthFlows {
661    #[serde(default)]
662    pub implicit: Option<OAuthFlow>,
663    #[serde(default)]
664    pub password: Option<OAuthFlow>,
665    #[serde(rename = "clientCredentials", default)]
666    pub client_credentials: Option<OAuthFlow>,
667    #[serde(rename = "authorizationCode", default)]
668    pub authorization_code: Option<OAuthFlow>,
669    /// 3.2 §"OAuth Flows Object" — device authorization flow (D4).
670    #[serde(rename = "deviceAuthorization", default)]
671    pub device_authorization: Option<OAuthFlow>,
672    #[serde(flatten, default)]
673    pub extensions: Extensions,
674}
675
676#[derive(Debug, Clone, Deserialize, Serialize)]
677pub struct OAuthFlow {
678    #[serde(rename = "authorizationUrl", default)]
679    pub authorization_url: Option<String>,
680    #[serde(rename = "tokenUrl", default)]
681    pub token_url: Option<String>,
682    #[serde(rename = "refreshUrl", default)]
683    pub refresh_url: Option<String>,
684    /// 3.2 §"OAuth Flow Object" — required for `deviceAuthorization` (D4).
685    #[serde(rename = "deviceAuthorizationUrl", default)]
686    pub device_authorization_url: Option<String>,
687    pub scopes: BTreeMap<String, String>,
688    #[serde(flatten, default)]
689    pub extensions: Extensions,
690}
691
692/// OpenAPI External Documentation Object (H10).
693#[derive(Debug, Clone, Deserialize, Serialize)]
694pub struct ExternalDocs {
695    pub url: String,
696    #[serde(default)]
697    pub description: Option<String>,
698    #[serde(flatten, default)]
699    pub extensions: Extensions,
700}
701
702/// OpenAPI Tag Object (H9 + D5 — 3.2 added summary/parent/kind).
703#[derive(Debug, Clone, Deserialize, Serialize)]
704pub struct Tag {
705    pub name: String,
706    /// 3.2 §"Tag Object" — short summary of the tag.
707    #[serde(default)]
708    pub summary: Option<String>,
709    #[serde(default)]
710    pub description: Option<String>,
711    /// 3.2 §"Tag Object" — name of a parent tag for hierarchical organisation.
712    #[serde(default)]
713    pub parent: Option<String>,
714    /// 3.2 §"Tag Object" — categorisation hint (e.g. "feature", "audience",
715    /// "compliance"). Free-form string; consumers MAY define their own
716    /// vocabulary.
717    #[serde(default)]
718    pub kind: Option<String>,
719    #[serde(rename = "externalDocs", default)]
720    pub external_docs: Option<ExternalDocs>,
721    #[serde(flatten, default)]
722    pub extensions: Extensions,
723}
724
725/// OpenAPI Server Object (H1). Multiple servers, server variables, and
726/// 3.2's `name` field are all modeled.
727#[derive(Debug, Clone, Deserialize, Serialize)]
728pub struct Server {
729    pub url: String,
730    /// 3.2 §"Server Object" — server identifier for runtime selection (D8).
731    #[serde(default)]
732    pub name: Option<String>,
733    #[serde(default)]
734    pub description: Option<String>,
735    #[serde(default)]
736    pub variables: Option<BTreeMap<String, ServerVariable>>,
737    #[serde(flatten, default)]
738    pub extensions: Extensions,
739}
740
741#[derive(Debug, Clone, Deserialize, Serialize)]
742pub struct ServerVariable {
743    /// REQUIRED in 3.0/3.1. In 3.2 this MAY be omitted when `enum` is present.
744    #[serde(default)]
745    pub default: Option<String>,
746    #[serde(rename = "enum", default)]
747    pub enum_values: Option<Vec<String>>,
748    #[serde(default)]
749    pub description: Option<String>,
750    #[serde(flatten, default)]
751    pub extensions: Extensions,
752}
753
754#[derive(Debug, Clone, Deserialize, Serialize)]
755pub struct Discriminator {
756    #[serde(rename = "propertyName")]
757    pub property_name: String,
758    #[serde(default)]
759    pub mapping: Option<BTreeMap<String, String>>,
760    /// 3.2 §"Discriminator Object" — fallback mapping target when the
761    /// discriminator value is unknown (D9). Captured today; a future bead
762    /// will emit a `_Other(Value)` enum variant when this is set.
763    #[serde(rename = "defaultMapping", default)]
764    pub default_mapping: Option<String>,
765    #[serde(flatten, default)]
766    pub extensions: Extensions,
767}
768
769impl Schema {
770    /// Get the schema type if explicitly set. For `Schema::TypedMulti` the
771    /// "primary" non-null type is returned; if the array contained only `null`
772    /// then `Some(&SchemaType::Null)` is returned.
773    ///
774    /// If the other non-null variants are important, consider where you should
775    /// instead use [non_null_schema_types][Self::non_null_schema_types].
776    pub fn schema_type(&self) -> Option<&SchemaType> {
777        match self {
778            Schema::Typed { schema_type, .. } => Some(schema_type),
779            Schema::TypedMulti { schema_types, .. } => schema_types
780                .iter()
781                .find(|t| **t != SchemaType::Null)
782                .or_else(|| schema_types.first()),
783            _ => None,
784        }
785    }
786
787    /// The `type` keyword as written, including on a union or composition.
788    ///
789    /// [`Self::schema_type`] deliberately reports only standalone typed
790    /// schemas, because most callers ask it in order to pick a Rust type for a
791    /// leaf. A union that also declares `type` needs the declared value —
792    /// notably when the branch list is empty and the union constrains nothing.
793    pub fn declared_type(&self) -> Option<&SchemaType> {
794        match self {
795            Schema::AnyOf { schema_type, .. }
796            | Schema::AllOf { schema_type, .. }
797            | Schema::OneOf { schema_type, .. } => schema_type.as_ref(),
798            other => other.schema_type(),
799        }
800    }
801
802    /// Gets all non-null types from `type: [...]` (unlike
803    /// [Schema::schema_type] that handles the null value). Returns `None` if
804    /// the given [Schema::TypedMulti] has either only one non-null value, or if
805    /// this isn't a `Schema::TypedMulti`.
806    ///
807    /// This also removes duplicates.
808    pub fn non_null_schema_types(&self) -> Option<Vec<SchemaType>> {
809        match self {
810            Schema::TypedMulti { schema_types, .. } => {
811                let mut non_null = Vec::new();
812                for t in schema_types {
813                    if *t != SchemaType::Null && !non_null.contains(t) {
814                        non_null.push(t.clone());
815                    }
816                }
817                (non_null.len() > 1).then_some(non_null)
818            }
819            _ => None,
820        }
821    }
822
823    /// True when the schema's type set explicitly contains `null`.
824    /// (3.1 canonical nullability via `type: ["X", "null"]`.)
825    pub fn type_array_contains_null(&self) -> bool {
826        match self {
827            Schema::TypedMulti { schema_types, .. } => schema_types.contains(&SchemaType::Null),
828            _ => false,
829        }
830    }
831
832    /// True when the schema is nullable in any form OpenAPI allows:
833    /// 3.0's `nullable: true`, 3.1's `type: ["X", "null"]`, or an
834    /// `anyOf`/`oneOf` carrying a `null` branch or a nullable branch.
835    ///
836    /// Property nullability must be decided through this, not through any
837    /// single one of the three checks. Each form was added separately and each
838    /// time a call site was missed: `nullable: true` first, then the
839    /// `anyOf`-with-null shape (openapi-generator-bgo), leaving the 3.1
840    /// canonical type-array form unhandled on properties
841    /// (openapi-generator-dsu) — which silently generated non-`Option` fields
842    /// for values the API really does send as `null`.
843    pub fn is_nullable_any(&self) -> bool {
844        self.reference_siblings_are_nullable()
845            || self.details().is_nullable()
846            || self.type_array_contains_null()
847            || match self {
848                // A nullable branch also admits null even when the union has
849                // no separate null-only branch (common in OpenAPI 3.0).
850                Schema::AnyOf { any_of, .. } => any_of
851                    .iter()
852                    .any(|branch| branch.is_explicit_null_only() || branch.is_nullable_any()),
853                // `oneOf` admits null only when exactly one branch does.
854                Schema::OneOf { one_of, .. } => {
855                    one_of
856                        .iter()
857                        .filter(|branch| branch.is_explicit_null_only() || branch.is_nullable_any())
858                        .count()
859                        == 1
860                }
861                _ => false,
862            }
863    }
864
865    /// Reference nodes retain siblings in `extra` because OpenAPI 3.0-era
866    /// documents commonly attach `nullable: true` directly beside `$ref`.
867    /// `details()` intentionally returns an empty value for references, so
868    /// nullability must read that retained annotation explicitly.
869    fn reference_siblings_are_nullable(&self) -> bool {
870        let extra = match self {
871            Schema::Reference { extra, .. }
872            | Schema::RecursiveRef { extra, .. }
873            | Schema::DynamicRef { extra, .. } => extra,
874            _ => return false,
875        };
876        extra.get("nullable").and_then(Value::as_bool) == Some(true)
877    }
878
879    /// Get schema details
880    pub fn details(&self) -> &SchemaDetails {
881        static EMPTY_DETAILS: Lazy<SchemaDetails> = Lazy::new(SchemaDetails::default);
882        match self {
883            Schema::Typed { details, .. } => details,
884            Schema::TypedMulti { details, .. } => details,
885            // Neither a reference nor a boolean schema carries details of its
886            // own: one points elsewhere, the other accepts or rejects outright.
887            Schema::Reference { .. }
888            | Schema::RecursiveRef { .. }
889            | Schema::DynamicRef { .. }
890            | Schema::Bool(_) => &EMPTY_DETAILS,
891            Schema::OneOf { details, .. } => details,
892            Schema::AnyOf { details, .. } => details,
893            Schema::AllOf { details, .. } => details,
894            Schema::Untyped { details } => details,
895        }
896    }
897
898    /// Get mutable schema details
899    pub fn details_mut(&mut self) -> &mut SchemaDetails {
900        match self {
901            Schema::Typed { details, .. } => details,
902            Schema::TypedMulti { details, .. } => details,
903            Schema::Reference { .. } => {
904                panic!("Cannot get mutable details for reference schema")
905            }
906            Schema::RecursiveRef { .. } => {
907                panic!("Cannot get mutable details for recursive reference schema")
908            }
909            Schema::DynamicRef { .. } => {
910                panic!("Cannot get mutable details for dynamic reference schema")
911            }
912            Schema::Bool(_) => {
913                panic!("Cannot get mutable details for a boolean schema")
914            }
915            Schema::OneOf { details, .. } => details,
916            Schema::AnyOf { details, .. } => details,
917            Schema::AllOf { details, .. } => details,
918            Schema::Untyped { details } => details,
919        }
920    }
921
922    /// Check if this is any kind of reference (regular or recursive)
923    pub fn is_reference(&self) -> bool {
924        matches!(self, Schema::Reference { .. } | Schema::RecursiveRef { .. })
925    }
926
927    /// Get reference string if this is a reference
928    pub fn reference(&self) -> Option<&str> {
929        match self {
930            Schema::Reference { reference, .. } => Some(reference),
931            _ => None,
932        }
933    }
934
935    /// Get recursive reference string if this is a recursive reference
936    pub fn recursive_reference(&self) -> Option<&str> {
937        match self {
938            Schema::RecursiveRef { recursive_ref, .. } => Some(recursive_ref),
939            _ => None,
940        }
941    }
942
943    /// Check if this is a discriminated union
944    pub fn is_discriminated_union(&self) -> bool {
945        match self {
946            Schema::OneOf { discriminator, .. } => discriminator.is_some(),
947            Schema::AnyOf { discriminator, .. } => discriminator.is_some(),
948            _ => false,
949        }
950    }
951
952    /// Get discriminator if this is a discriminated union
953    pub fn discriminator(&self) -> Option<&Discriminator> {
954        match self {
955            Schema::OneOf { discriminator, .. } => discriminator.as_ref(),
956            Schema::AnyOf { discriminator, .. } => discriminator.as_ref(),
957            _ => None,
958        }
959    }
960
961    /// Get union variants
962    pub fn union_variants(&self) -> Option<&[Schema]> {
963        match self {
964            Schema::OneOf { one_of, .. } => Some(one_of),
965            Schema::AnyOf { any_of, .. } => Some(any_of),
966            _ => None,
967        }
968    }
969
970    /// Check if this appears to be a nullable pattern (anyOf or oneOf with null)
971    pub fn is_nullable_pattern(&self) -> bool {
972        self.non_null_variant().is_some()
973    }
974
975    /// Whether an `anyOf` or `oneOf` contains a branch that admits only JSON
976    /// `null`. Unlike [`Self::is_nullable_pattern`], this does not require the
977    /// union to collapse to one non-null branch, so it also preserves
978    /// nullability for unions such as `[string, integer, null]`.
979    pub fn has_explicit_null_variant(&self) -> bool {
980        self.union_variants()
981            .is_some_and(|variants| variants.iter().any(Self::is_explicit_null_only))
982    }
983
984    /// The one meaningful branch of a two-branch union whose other branch
985    /// exists only to admit `null`.
986    pub fn non_null_variant(&self) -> Option<&Schema> {
987        let variants = match self {
988            Schema::AnyOf { any_of, .. } => any_of,
989            Schema::OneOf { one_of, .. } => one_of,
990            _ => return None,
991        };
992        let [first, second] = variants.as_slice() else {
993            return None;
994        };
995        match (
996            first.is_explicit_null_only(),
997            second.is_explicit_null_only(),
998        ) {
999            (true, false) => Some(second),
1000            (false, true) => Some(first),
1001            // Two markers describe nothing, and no marker is not this pattern.
1002            _ => None,
1003        }
1004    }
1005
1006    /// Whether this branch explicitly admits no JSON value other than `null`.
1007    ///
1008    /// This is intentionally branch-local. OpenAPI 3.0's `nullable: true`
1009    /// widens the schema it annotates; it does not erase that schema's other
1010    /// accepted values. In particular, `{nullable: true}` and
1011    /// `{type: object, nullable: true}` remain real alternatives even beside
1012    /// a `$ref`.
1013    pub(crate) fn is_explicit_null_only(&self) -> bool {
1014        match self {
1015            Schema::Typed { schema_type, .. } => *schema_type == SchemaType::Null,
1016            Schema::TypedMulti { schema_types, .. } => {
1017                !schema_types.is_empty()
1018                    && schema_types
1019                        .iter()
1020                        .all(|schema_type| *schema_type == SchemaType::Null)
1021            }
1022            Schema::Untyped { details } => {
1023                details.const_value.as_ref().is_some_and(Value::is_null)
1024                    || details.enum_values.as_ref().is_some_and(
1025                        |values| matches!(values.as_slice(), [value] if value.is_null()),
1026                    )
1027            }
1028            _ => false,
1029        }
1030    }
1031
1032    /// Infer schema type from structure if not explicitly set
1033    pub fn inferred_type(&self) -> Option<SchemaType> {
1034        match self {
1035            Schema::Typed { schema_type, .. } => Some(schema_type.clone()),
1036            Schema::TypedMulti { .. } => self.schema_type().cloned(),
1037            Schema::Untyped { details } => {
1038                // Infer from structure
1039                if self.is_explicit_null_only() {
1040                    Some(SchemaType::Null)
1041                } else if details.properties.is_some() {
1042                    Some(SchemaType::Object)
1043                } else if details.items.is_some() || details.prefix_items.is_some() {
1044                    Some(SchemaType::Array)
1045                } else if details.enum_values.is_some() {
1046                    Some(SchemaType::String) // Assume string enum
1047                } else {
1048                    None
1049                }
1050            }
1051            _ => None,
1052        }
1053    }
1054}
1055
1056impl SchemaDetails {
1057    /// The schema every array element must satisfy, i.e. `items` in its
1058    /// 2020-12 single-schema spelling. Returns `None` for the draft-04 tuple
1059    /// form, which constrains positions rather than every element — read that
1060    /// through [`Self::positional_items`]. A boolean schema is a schema like
1061    /// any other here: `items: true` accepts every element.
1062    pub fn item_schema(&self) -> Option<&Schema> {
1063        match self.items.as_ref()? {
1064            Items::Single(schema) => Some(schema),
1065            Items::Positional(_) => None,
1066        }
1067    }
1068
1069    /// Positional element schemas, from either spelling: 2020-12
1070    /// `prefixItems` or the draft-04 `items: [A, B]` tuple form.
1071    pub fn positional_items(&self) -> Option<&[Schema]> {
1072        if let Some(prefix_items) = self.prefix_items.as_deref() {
1073            return Some(prefix_items);
1074        }
1075        match self.items.as_ref()? {
1076            Items::Positional(schemas) => Some(schemas),
1077            Items::Single(_) => None,
1078        }
1079    }
1080
1081    /// Whether the array admits no elements beyond its positional schemas.
1082    ///
1083    /// This is not the default: `prefixItems: [A, B]` on its own permits extra
1084    /// elements of any type. An array is closed only when 2020-12 `items:
1085    /// false`, draft-04 `additionalItems: false`, or `maxItems` says so.
1086    pub fn positional_items_are_closed(&self) -> bool {
1087        let Some(positions) = self.positional_items() else {
1088            return false;
1089        };
1090        // `items: false` — no element beyond the positions may exist. It is a
1091        // boolean schema in the ordinary single-schema slot.
1092        if matches!(
1093            self.items.as_ref(),
1094            Some(Items::Single(schema)) if matches!(**schema, Schema::Bool(false))
1095        ) {
1096            return true;
1097        }
1098        if self.extra.get("additionalItems") == Some(&Value::Bool(false)) {
1099            return true;
1100        }
1101        self.max_items
1102            .is_some_and(|maximum| maximum <= positions.len() as u64)
1103    }
1104
1105    /// Whether every valid instance has exactly one element per positional
1106    /// schema — the only case a fixed-arity Rust tuple can represent. A closed
1107    /// array that also permits shorter instances is not exact.
1108    pub fn positional_items_are_exact(&self) -> bool {
1109        let Some(positions) = self.positional_items() else {
1110            return false;
1111        };
1112        self.positional_items_are_closed()
1113            && self
1114                .min_items
1115                .is_some_and(|minimum| minimum >= positions.len() as u64)
1116    }
1117
1118    /// Check if this schema is nullable
1119    pub fn is_nullable(&self) -> bool {
1120        self.nullable.unwrap_or(false)
1121    }
1122
1123    /// Check if this is a string enum
1124    ///
1125    /// A standalone string `const` (no `enum` array) is treated as a
1126    /// degenerate single-value enum so the generator emits a tightly-typed
1127    /// single-variant enum instead of a bare `String`. See issue #10.
1128    pub fn is_string_enum(&self) -> bool {
1129        self.enum_values.is_some() || self.const_string_value().is_some()
1130    }
1131
1132    /// Get enum values as strings if this is a string enum.
1133    ///
1134    /// Falls back to `[const_value]` when `enum` is absent but `const` is a
1135    /// string, so a property like `{ "type": "string", "const": "X" }`
1136    /// produces a single-variant enum.
1137    pub fn string_enum_values(&self) -> Option<Vec<String>> {
1138        if let Some(values) = self.enum_values.as_ref() {
1139            // Tolerate non-string scalars in `enum` for `type: string` schemas
1140            // (gitpod has `enum: [2000, 5000, ...]` on a string-typed field).
1141            // Without this, `filter_map(.as_str())` produced an empty Vec
1142            // and we emitted an empty enum that fails to compile.
1143            return Some(
1144                values
1145                    .iter()
1146                    .map(|v| match v {
1147                        Value::String(s) => s.clone(),
1148                        Value::Number(n) => n.to_string(),
1149                        Value::Bool(b) => b.to_string(),
1150                        Value::Null => "null".to_string(),
1151                        _ => v.to_string(),
1152                    })
1153                    .collect(),
1154            );
1155        }
1156        self.const_string_value().map(|s| vec![s])
1157    }
1158
1159    fn const_string_value(&self) -> Option<String> {
1160        self.const_value
1161            .as_ref()
1162            .and_then(|v| v.as_str())
1163            .map(|s| s.to_string())
1164    }
1165
1166    /// Check if a field is required
1167    pub fn is_field_required(&self, field_name: &str) -> bool {
1168        self.required
1169            .as_ref()
1170            .map(|req| req.contains(&field_name.to_string()))
1171            .unwrap_or(false)
1172    }
1173}
1174
1175/// OpenAPI Path Item Object
1176#[derive(Debug, Clone, Deserialize, Serialize)]
1177pub struct PathItem {
1178    #[serde(default)]
1179    pub summary: Option<String>,
1180    #[serde(default)]
1181    pub description: Option<String>,
1182    pub get: Option<Operation>,
1183    pub put: Option<Operation>,
1184    pub post: Option<Operation>,
1185    pub delete: Option<Operation>,
1186    pub options: Option<Operation>,
1187    pub head: Option<Operation>,
1188    pub patch: Option<Operation>,
1189    pub trace: Option<Operation>,
1190    /// 3.2 §"Path Item Object" — `QUERY` HTTP method (D1). Originally
1191    /// proposed for safe, idempotent reads with a body.
1192    pub query: Option<Operation>,
1193    /// 3.2 §"Path Item Object" — extension map for HTTP methods beyond the
1194    /// well-known ones (e.g. WebDAV's PROPFIND, SEARCH; LINK/UNLINK). Keys
1195    /// are uppercase method names (D1).
1196    #[serde(rename = "additionalOperations", default)]
1197    pub additional_operations: Option<BTreeMap<String, Operation>>,
1198    pub parameters: Option<Vec<Parameter>>,
1199    #[serde(default)]
1200    pub servers: Option<Vec<Server>>,
1201    #[serde(rename = "$ref", default)]
1202    pub reference: Option<String>,
1203    #[serde(flatten, default)]
1204    pub extensions: Extensions,
1205}
1206
1207impl PathItem {
1208    /// Get all operations in this path item, including 3.2's `query`
1209    /// (D1) and any custom verbs declared in `additionalOperations`.
1210    pub fn operations(&self) -> Vec<(&str, &Operation)> {
1211        let mut ops = Vec::new();
1212        if let Some(ref op) = self.get {
1213            ops.push(("get", op));
1214        }
1215        if let Some(ref op) = self.put {
1216            ops.push(("put", op));
1217        }
1218        if let Some(ref op) = self.post {
1219            ops.push(("post", op));
1220        }
1221        if let Some(ref op) = self.delete {
1222            ops.push(("delete", op));
1223        }
1224        if let Some(ref op) = self.options {
1225            ops.push(("options", op));
1226        }
1227        if let Some(ref op) = self.head {
1228            ops.push(("head", op));
1229        }
1230        if let Some(ref op) = self.patch {
1231            ops.push(("patch", op));
1232        }
1233        if let Some(ref op) = self.trace {
1234            ops.push(("trace", op));
1235        }
1236        if let Some(ref op) = self.query {
1237            ops.push(("query", op));
1238        }
1239        if let Some(map) = &self.additional_operations {
1240            for (verb, op) in map {
1241                ops.push((verb.as_str(), op));
1242            }
1243        }
1244        ops
1245    }
1246}
1247
1248/// OpenAPI Operation Object
1249#[derive(Debug, Clone, Deserialize, Serialize)]
1250pub struct Operation {
1251    #[serde(rename = "operationId", default)]
1252    pub operation_id: Option<String>,
1253    #[serde(default)]
1254    pub summary: Option<String>,
1255    #[serde(default)]
1256    pub description: Option<String>,
1257    #[serde(default)]
1258    pub tags: Option<Vec<String>>,
1259    #[serde(default)]
1260    pub deprecated: Option<bool>,
1261    pub parameters: Option<Vec<Parameter>>,
1262    #[serde(rename = "requestBody")]
1263    pub request_body: Option<RequestBody>,
1264    pub responses: Option<BTreeMap<String, Response>>,
1265    #[serde(default)]
1266    pub callbacks: Option<BTreeMap<String, Callback>>,
1267    #[serde(default)]
1268    pub security: Option<Vec<BTreeMap<String, Vec<String>>>>,
1269    #[serde(default)]
1270    pub servers: Option<Vec<Server>>,
1271    #[serde(rename = "externalDocs", default)]
1272    pub external_docs: Option<ExternalDocs>,
1273    #[serde(flatten, default)]
1274    pub extensions: Extensions,
1275}
1276
1277/// OpenAPI Parameter Object
1278#[derive(Debug, Clone, Deserialize, Serialize)]
1279pub struct Parameter {
1280    #[serde(default)]
1281    pub name: Option<String>,
1282    #[serde(rename = "in", default)]
1283    pub location: Option<String>,
1284    #[serde(default)]
1285    pub required: Option<bool>,
1286    #[serde(default)]
1287    pub deprecated: Option<bool>,
1288    #[serde(rename = "allowEmptyValue", default)]
1289    pub allow_empty_value: Option<bool>,
1290    #[serde(default)]
1291    pub style: Option<String>,
1292    #[serde(default)]
1293    pub explode: Option<bool>,
1294    #[serde(rename = "allowReserved", default)]
1295    pub allow_reserved: Option<bool>,
1296    #[serde(default)]
1297    pub schema: Option<Schema>,
1298    #[serde(default)]
1299    pub content: Option<BTreeMap<String, MediaType>>,
1300    #[serde(default)]
1301    pub example: Option<Value>,
1302    #[serde(default)]
1303    pub examples: Option<BTreeMap<String, Example>>,
1304    #[serde(default)]
1305    pub description: Option<String>,
1306    #[serde(rename = "$ref", default)]
1307    pub reference: Option<String>,
1308    #[serde(flatten, default)]
1309    pub extensions: Extensions,
1310}
1311
1312/// OpenAPI Request Body Object
1313#[derive(Debug, Clone, Deserialize, Serialize)]
1314pub struct RequestBody {
1315    pub content: Option<BTreeMap<String, MediaType>>,
1316    #[serde(default)]
1317    pub description: Option<String>,
1318    #[serde(default)]
1319    pub required: Option<bool>,
1320    #[serde(rename = "$ref", default)]
1321    pub reference: Option<String>,
1322    #[serde(flatten, default)]
1323    pub extensions: Extensions,
1324}
1325
1326/// Semantic representation used for a response media entry.
1327///
1328/// This deliberately keeps server-sent events separate from ordinary text:
1329/// although `text/event-stream` belongs to the `text` top-level type, callers
1330/// must stream it rather than buffer and UTF-8 decode it like `text/plain`.
1331#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1332#[serde(rename_all = "snake_case")]
1333pub enum ResponseMediaKind {
1334    Json,
1335    EventStream,
1336    Text,
1337    Binary,
1338    Unsupported,
1339}
1340
1341/// Return the media type essence, excluding parameters and surrounding space.
1342///
1343/// Media type comparisons remain ASCII-case-insensitive at their call sites;
1344/// this helper only provides one consistent way to discard parameters such as
1345/// `charset=utf-8` without allocating.
1346pub fn media_type_essence(content_type: &str) -> &str {
1347    content_type
1348        .split(';')
1349        .next()
1350        .unwrap_or(content_type)
1351        .trim()
1352}
1353
1354/// Returns true for media types whose payload is JSON.
1355///
1356/// Matches `application/json` exactly, plus any RFC 6839 structured-syntax
1357/// suffix variant of the form `application/<subtype>+json`
1358/// (e.g. `application/vnd.api+json`, `application/hal+json`,
1359/// `application/problem+json`). Trailing parameters such as
1360/// `; charset=utf-8` are tolerated.
1361pub fn is_json_media_type(ct: &str) -> bool {
1362    let essence = media_type_essence(ct).to_ascii_lowercase();
1363    if essence == "application/json" {
1364        return true;
1365    }
1366    if let Some(subtype) = essence.strip_prefix("application/") {
1367        return subtype.ends_with("+json");
1368    }
1369    false
1370}
1371
1372/// Returns true for `application/x-www-form-urlencoded` (with optional
1373/// parameters).
1374pub fn is_form_urlencoded_media_type(ct: &str) -> bool {
1375    let essence = media_type_essence(ct).to_ascii_lowercase();
1376    essence == "application/x-www-form-urlencoded"
1377}
1378
1379/// Returns true only for the `text/event-stream` media type essence.
1380///
1381/// Media type names are ASCII-case-insensitive and parameters do not change
1382/// the essence, so values such as `Text/Event-Stream; charset=utf-8` match,
1383/// while similarly prefixed subtypes such as `text/event-streaming` do not.
1384pub fn is_event_stream_media_type(ct: &str) -> bool {
1385    media_type_essence(ct).eq_ignore_ascii_case("text/event-stream")
1386}
1387
1388/// Returns true for non-SSE media types in the `text` top-level family.
1389///
1390/// Structured text formats in the `application` family whose instances are
1391/// UTF-8/UTF-16 character data — XML and its `+xml` suffix variants (RFC 7303,
1392/// RFC 6839) — are buffered and emitted as text as well; bytes are never
1393/// XML-parsed by the generated server, so a plain `String` body preserves
1394/// the payload losslessly.
1395pub fn is_text_media_type(ct: &str) -> bool {
1396    let Some((top_level, subtype)) = media_type_essence(ct).split_once('/') else {
1397        return false;
1398    };
1399    if top_level.eq_ignore_ascii_case("text")
1400        && !subtype.is_empty()
1401        && !is_event_stream_media_type(ct)
1402    {
1403        return true;
1404    }
1405    top_level.eq_ignore_ascii_case("application")
1406        && (subtype.eq_ignore_ascii_case("xml")
1407            || subtype.to_ascii_lowercase().ends_with("+xml")
1408            // JWT (RFC 7519) compact serializations are ASCII text: three
1409            // base64url segments joined by dots.
1410            || subtype.eq_ignore_ascii_case("jwt"))
1411}
1412
1413/// Returns true for OpenAPI media ranges with a wildcard subtype.
1414///
1415/// This recognizes both `*/*` and type-specific ranges such as `image/*`.
1416/// A wildcard is meaningful only as the complete subtype, so values such as
1417/// `image/*+json` do not match.
1418pub fn is_wildcard_media_type(ct: &str) -> bool {
1419    let Some((top_level, subtype)) = media_type_essence(ct).split_once('/') else {
1420        return false;
1421    };
1422    !top_level.is_empty() && subtype == "*"
1423}
1424
1425fn schema_has_binary_format(schema: Option<&Schema>) -> bool {
1426    schema.is_some_and(|schema| {
1427        schema
1428            .details()
1429            .format
1430            .as_deref()
1431            .is_some_and(|format| format.eq_ignore_ascii_case("binary"))
1432    })
1433}
1434
1435/// Returns true when a response representation must be handled as raw bytes.
1436///
1437/// An explicit schema `format: binary` takes precedence over a textual-looking
1438/// media type. Without that schema signal, known binary families and formats
1439/// are recognized, as are non-text OpenAPI wildcard media ranges. Text media
1440/// ranges cannot be emitted as a concrete response `Content-Type` and remain
1441/// unsupported unless their schema explicitly declares the binary format.
1442pub fn is_binary_media_type(ct: &str, schema: Option<&Schema>) -> bool {
1443    if schema_has_binary_format(schema) {
1444        return true;
1445    }
1446
1447    let essence = media_type_essence(ct);
1448    let Some((top_level, _)) = essence.split_once('/') else {
1449        return false;
1450    };
1451    if top_level.eq_ignore_ascii_case("image")
1452        || top_level.eq_ignore_ascii_case("audio")
1453        || top_level.eq_ignore_ascii_case("video")
1454    {
1455        return true;
1456    }
1457    if essence.eq_ignore_ascii_case("application/octet-stream")
1458        || essence.eq_ignore_ascii_case("application/zip")
1459        || essence.eq_ignore_ascii_case("application/pdf")
1460    {
1461        return true;
1462    }
1463
1464    !top_level.eq_ignore_ascii_case("text") && is_wildcard_media_type(ct)
1465}
1466
1467/// Classify one declared response representation for client/server analysis.
1468///
1469/// JSON and exact SSE retain their established behavior. A binary schema wins
1470/// over the media family so bytes are never accidentally UTF-8 decoded.
1471pub fn classify_response_media_type(ct: &str, schema: Option<&Schema>) -> ResponseMediaKind {
1472    if is_json_media_type(ct) {
1473        ResponseMediaKind::Json
1474    } else if is_event_stream_media_type(ct) {
1475        ResponseMediaKind::EventStream
1476    } else if schema_has_binary_format(schema) {
1477        ResponseMediaKind::Binary
1478    } else if is_text_media_type(ct) {
1479        if is_wildcard_media_type(ct) {
1480            ResponseMediaKind::Unsupported
1481        } else {
1482            ResponseMediaKind::Text
1483        }
1484    } else if is_binary_media_type(ct, schema) {
1485        ResponseMediaKind::Binary
1486    } else {
1487        ResponseMediaKind::Unsupported
1488    }
1489}
1490
1491fn find_json_content(content: &BTreeMap<String, MediaType>) -> Option<(&str, &MediaType)> {
1492    if let Some(mt) = content
1493        .get("application/json")
1494        .filter(|media_type| media_type.schema.is_some())
1495    {
1496        return Some(("application/json", mt));
1497    }
1498    content
1499        .iter()
1500        .find(|(ct, media_type)| is_json_media_type(ct) && media_type.schema.is_some())
1501        .map(|(ct, mt)| (ct.as_str(), mt))
1502        .or_else(|| {
1503            content
1504                .get("application/json")
1505                .map(|media_type| ("application/json", media_type))
1506        })
1507        .or_else(|| {
1508            content
1509                .iter()
1510                .find(|(ct, _)| is_json_media_type(ct))
1511                .map(|(ct, mt)| (ct.as_str(), mt))
1512        })
1513}
1514
1515impl RequestBody {
1516    /// Get schema for any JSON content type
1517    ///
1518    /// Prefers the canonical `application/json` entry, then falls back to
1519    /// any `application/*+json` variant (RFC 6839) such as
1520    /// `application/vnd.api+json` or `application/hal+json`.
1521    pub fn json_schema(&self) -> Option<&Schema> {
1522        self.content
1523            .as_ref()
1524            .and_then(find_json_content)
1525            .and_then(|(_, media_type)| media_type.schema.as_ref())
1526    }
1527
1528    /// Get the best content type and its schema, preferring JSON over others
1529    pub fn best_content(&self) -> Option<(&str, Option<&Schema>)> {
1530        let content = self.content.as_ref()?;
1531
1532        if let Some((ct, media_type)) = find_json_content(content) {
1533            return Some((ct, media_type.schema.as_ref()));
1534        }
1535
1536        const PRIORITY: &[&str] = &[
1537            "application/x-www-form-urlencoded",
1538            "multipart/form-data",
1539            "application/octet-stream",
1540            "text/plain",
1541        ];
1542        for preferred_essence in PRIORITY {
1543            if let Some((ct, media_type)) = content
1544                .iter()
1545                .find(|(ct, _)| media_type_essence(ct).eq_ignore_ascii_case(preferred_essence))
1546            {
1547                return Some((ct.as_str(), media_type.schema.as_ref()));
1548            }
1549        }
1550        // Character-data fallbacks (text/xml, application/xml, +xml suffixed)
1551        // are buffered as UTF-8 text like text/plain.
1552        if let Some((ct, media_type)) = content.iter().find(|(ct, _)| is_text_media_type(ct)) {
1553            return Some((ct.as_str(), media_type.schema.as_ref()));
1554        }
1555        content
1556            .iter()
1557            // A request media range is not a concrete Content-Type value. The
1558            // generated client cannot send `image/*` or `*/*`, and the server
1559            // cannot compare either range to one exact request representation,
1560            // so leave wildcard request content unsupported instead of
1561            // emitting a contract that always fails at runtime.
1562            .find(|(ct, media_type)| {
1563                !is_wildcard_media_type(ct) && is_binary_media_type(ct, media_type.schema.as_ref())
1564            })
1565            .map(|(ct, media_type)| (ct.as_str(), media_type.schema.as_ref()))
1566    }
1567}
1568
1569/// OpenAPI Response Object
1570#[derive(Debug, Clone, Deserialize, Serialize)]
1571pub struct Response {
1572    #[serde(default)]
1573    pub description: Option<String>,
1574    #[serde(default)]
1575    pub headers: Option<BTreeMap<String, Header>>,
1576    #[serde(default)]
1577    pub content: Option<BTreeMap<String, MediaType>>,
1578    #[serde(default)]
1579    pub links: Option<Value>,
1580    #[serde(rename = "$ref", default)]
1581    pub reference: Option<String>,
1582    #[serde(flatten, default)]
1583    pub extensions: Extensions,
1584}
1585
1586impl Response {
1587    /// Get schema for any JSON content type
1588    ///
1589    /// Prefers the canonical `application/json` entry, then falls back to
1590    /// any `application/*+json` variant (RFC 6839) such as
1591    /// `application/vnd.api+json`, `application/hal+json`, or
1592    /// `application/problem+json`.
1593    pub fn json_schema(&self) -> Option<&Schema> {
1594        self.content
1595            .as_ref()
1596            .and_then(find_json_content)
1597            .and_then(|(_, media_type)| media_type.schema.as_ref())
1598    }
1599
1600    /// Get the preferred JSON-compatible media type and its schema.
1601    pub fn json_content(&self) -> Option<(&str, &Schema)> {
1602        self.content
1603            .as_ref()
1604            .and_then(find_json_content)
1605            .and_then(|(content_type, media_type)| {
1606                media_type
1607                    .schema
1608                    .as_ref()
1609                    .map(|schema| (content_type, schema))
1610            })
1611    }
1612}
1613
1614/// OpenAPI Media Type Object
1615#[derive(Debug, Clone, Deserialize, Serialize)]
1616pub struct MediaType {
1617    #[serde(default)]
1618    pub schema: Option<Schema>,
1619    #[serde(default)]
1620    pub example: Option<Value>,
1621    #[serde(default)]
1622    pub examples: Option<BTreeMap<String, Example>>,
1623    #[serde(default)]
1624    pub encoding: Option<BTreeMap<String, Encoding>>,
1625    /// 3.2 §"Media Type Object" — schema for each item when streaming
1626    /// (D3). Common in `text/event-stream` and JSON-lines payloads.
1627    #[serde(rename = "itemSchema", default)]
1628    pub item_schema: Option<Schema>,
1629    /// 3.2 §"Media Type Object" — encoding for the leading prefix of a
1630    /// streamed body (D3).
1631    #[serde(rename = "prefixEncoding", default)]
1632    pub prefix_encoding: Option<Vec<Encoding>>,
1633    /// 3.2 §"Media Type Object" — encoding applied to each streamed item
1634    /// (D3).
1635    #[serde(rename = "itemEncoding", default)]
1636    pub item_encoding: Option<Encoding>,
1637    #[serde(rename = "$ref", default)]
1638    pub reference: Option<String>,
1639    #[serde(flatten, default)]
1640    pub extensions: Extensions,
1641}
1642
1643#[cfg(test)]
1644#[allow(clippy::unwrap_used, clippy::expect_used)]
1645mod tests {
1646    use super::*;
1647    use serde_json::json;
1648
1649    #[test]
1650    fn paths_map_skips_extension_scalars() {
1651        // apicurio registry: an `x-codegen-contextRoot` scalar sits inside
1652        // `paths`; the document must still parse with the extension dropped.
1653        let spec: OpenApiSpec = serde_json::from_value(json!({
1654            "openapi": "3.0.0",
1655            "info": { "title": "lenient paths", "version": "1" },
1656            "paths": {
1657                "x-codegen-contextRoot": "/apis/registry/v2",
1658                "/items": {
1659                    "get": {
1660                        "operationId": "listItems",
1661                        "responses": { "204": { "description": "ok" } }
1662                    }
1663                }
1664            }
1665        }))
1666        .unwrap();
1667        let paths = spec.paths.unwrap();
1668        assert!(paths.contains_key("/items"));
1669        assert!(!paths.contains_key("x-codegen-contextRoot"));
1670    }
1671
1672    #[test]
1673    fn test_parse_simple_object_schema() {
1674        let schema_json = json!({
1675            "type": "object",
1676            "properties": {
1677                "name": {
1678                    "type": "string",
1679                    "description": "User name"
1680                },
1681                "age": {
1682                    "type": "integer"
1683                }
1684            },
1685            "required": ["name"]
1686        });
1687
1688        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1689
1690        match schema {
1691            Schema::Typed {
1692                schema_type: SchemaType::Object,
1693                details,
1694            } => {
1695                assert!(details.properties.is_some());
1696                assert_eq!(details.required, Some(vec!["name".to_string()]));
1697                assert!(details.is_field_required("name"));
1698                assert!(!details.is_field_required("age"));
1699            }
1700            _ => panic!("Expected object schema"),
1701        }
1702    }
1703
1704    #[test]
1705    fn test_parse_string_enum() {
1706        let schema_json = json!({
1707            "type": "string",
1708            "enum": ["active", "inactive", "pending"],
1709            "description": "User status"
1710        });
1711
1712        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1713
1714        match schema {
1715            Schema::Typed {
1716                schema_type: SchemaType::String,
1717                details,
1718            } => {
1719                assert!(details.is_string_enum());
1720                let values = details.string_enum_values().unwrap();
1721                assert_eq!(values, vec!["active", "inactive", "pending"]);
1722            }
1723            _ => panic!("Expected string enum schema"),
1724        }
1725    }
1726
1727    #[test]
1728    fn test_parse_reference_schema() {
1729        let schema_json = json!({
1730            "$ref": "#/components/schemas/User"
1731        });
1732
1733        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1734
1735        assert!(schema.is_reference());
1736        assert_eq!(schema.reference(), Some("#/components/schemas/User"));
1737    }
1738
1739    #[test]
1740    fn test_parse_discriminated_union() {
1741        let schema_json = json!({
1742            "oneOf": [
1743                {"$ref": "#/components/schemas/Dog"},
1744                {"$ref": "#/components/schemas/Cat"}
1745            ],
1746            "discriminator": {
1747                "propertyName": "petType"
1748            }
1749        });
1750
1751        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1752
1753        assert!(schema.is_discriminated_union());
1754        let discriminator = schema.discriminator().unwrap();
1755        assert_eq!(discriminator.property_name, "petType");
1756    }
1757
1758    #[test]
1759    fn test_parse_nullable_pattern() {
1760        let schema_json = json!({
1761            "anyOf": [
1762                {"$ref": "#/components/schemas/User"},
1763                {"type": "null"}
1764            ]
1765        });
1766
1767        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1768
1769        assert!(schema.is_nullable_pattern());
1770        let non_null = schema.non_null_variant().unwrap();
1771        assert!(non_null.is_reference());
1772    }
1773
1774    #[test]
1775    fn explicit_null_only_branches_are_order_independent_nullable_patterns() {
1776        for union_keyword in ["anyOf", "oneOf"] {
1777            for null_schema in [
1778                json!({"type": "null"}),
1779                json!({"type": ["null"]}),
1780                json!({"const": null}),
1781                json!({"enum": [null]}),
1782            ] {
1783                for variants in [
1784                    vec![
1785                        json!({"$ref": "#/components/schemas/User"}),
1786                        null_schema.clone(),
1787                    ],
1788                    vec![
1789                        null_schema.clone(),
1790                        json!({"$ref": "#/components/schemas/User"}),
1791                    ],
1792                ] {
1793                    let schema: Schema = serde_json::from_value(json!({
1794                        (union_keyword): variants,
1795                    }))
1796                    .unwrap();
1797                    let non_null = schema.non_null_variant().unwrap_or_else(|| {
1798                        panic!("{union_keyword} must recognize {null_schema} as null-only")
1799                    });
1800                    assert_eq!(
1801                        non_null.reference(),
1802                        Some("#/components/schemas/User"),
1803                        "{union_keyword} with {null_schema}"
1804                    );
1805                }
1806            }
1807        }
1808    }
1809
1810    #[test]
1811    fn nullable_schemas_are_real_union_branches_even_beside_references() {
1812        for union_keyword in ["anyOf", "oneOf"] {
1813            for nullable_branch in [
1814                json!({"nullable": true}),
1815                json!({"type": "object", "nullable": true}),
1816                json!({
1817                    "$ref": "#/components/schemas/Other",
1818                    "nullable": true
1819                }),
1820            ] {
1821                for variants in [
1822                    vec![
1823                        json!({"$ref": "#/components/schemas/User"}),
1824                        nullable_branch.clone(),
1825                    ],
1826                    vec![
1827                        nullable_branch.clone(),
1828                        json!({"$ref": "#/components/schemas/User"}),
1829                    ],
1830                ] {
1831                    let schema: Schema = serde_json::from_value(json!({
1832                        (union_keyword): variants,
1833                    }))
1834                    .unwrap();
1835                    assert!(
1836                        schema.non_null_variant().is_none(),
1837                        "{union_keyword} must preserve nullable branch {nullable_branch}"
1838                    );
1839                }
1840            }
1841        }
1842    }
1843
1844    #[test]
1845    fn reference_sibling_nullable_annotation_is_not_lost() {
1846        for reference_keyword in ["$ref", "$recursiveRef", "$dynamicRef"] {
1847            let nullable: Schema = serde_json::from_value(json!({
1848                (reference_keyword): "#/components/schemas/Value",
1849                "nullable": true,
1850                "description": "retained sibling"
1851            }))
1852            .unwrap();
1853            assert!(
1854                nullable.is_nullable_any(),
1855                "{reference_keyword} should retain nullable:true"
1856            );
1857
1858            let non_nullable: Schema = serde_json::from_value(json!({
1859                (reference_keyword): "#/components/schemas/Value",
1860                "nullable": false
1861            }))
1862            .unwrap();
1863            assert!(
1864                !non_nullable.is_nullable_any(),
1865                "{reference_keyword} nullable:false must stay non-nullable"
1866            );
1867        }
1868    }
1869
1870    #[test]
1871    fn nullable_union_branches_preserve_union_nullability() {
1872        for (schema, expected) in [
1873            (
1874                json!({"anyOf": [{"type": "object", "nullable": true}, {"type": "string", "nullable": true}]}),
1875                true,
1876            ),
1877            (
1878                json!({"oneOf": [{"type": "object", "nullable": true}, {"type": "string"}]}),
1879                true,
1880            ),
1881            (
1882                json!({"oneOf": [{"type": "object", "nullable": true}, {"type": "string", "nullable": true}]}),
1883                false,
1884            ),
1885        ] {
1886            let schema: Schema = serde_json::from_value(schema.clone()).unwrap();
1887            assert_eq!(schema.is_nullable_any(), expected, "{schema:?}");
1888        }
1889    }
1890
1891    #[test]
1892    fn null_only_enum_and_const_infer_null_instead_of_string() {
1893        for source in [json!({"enum": [null]}), json!({"const": null})] {
1894            let schema: Schema = serde_json::from_value(source.clone()).unwrap();
1895            assert_eq!(schema.inferred_type(), Some(SchemaType::Null), "{source}");
1896        }
1897
1898        for source in [
1899            json!({"enum": ["null"]}),
1900            json!({"enum": ["ready", null]}),
1901            json!({"type": "string", "enum": ["ready"]}),
1902        ] {
1903            let schema: Schema = serde_json::from_value(source.clone()).unwrap();
1904            assert_ne!(schema.inferred_type(), Some(SchemaType::Null), "{source}");
1905        }
1906    }
1907
1908    #[test]
1909    fn three_branch_unions_do_not_collapse_even_with_an_explicit_null() {
1910        for union_keyword in ["anyOf", "oneOf"] {
1911            let schema: Schema = serde_json::from_value(json!({
1912                (union_keyword): [
1913                    {"$ref": "#/components/schemas/User"},
1914                    {"type": "null"},
1915                    {"type": "array", "items": {"type": "string"}}
1916                ],
1917            }))
1918            .unwrap();
1919            assert!(
1920                schema.non_null_variant().is_none(),
1921                "{union_keyword} with three branches must retain its non-null union"
1922            );
1923            assert!(
1924                schema.is_nullable_any(),
1925                "{union_keyword} with an explicit null branch must remain nullable"
1926            );
1927        }
1928    }
1929
1930    #[test]
1931    fn is_json_media_type_accepts_canonical_and_structured_suffix() {
1932        // Canonical
1933        assert!(is_json_media_type("application/json"));
1934        // Parameters tolerated (RFC 7231 §3.1.1.1)
1935        assert!(is_json_media_type("application/json; charset=utf-8"));
1936        assert!(is_json_media_type("APPLICATION/JSON"));
1937        // RFC 6839 +json structured-syntax suffix
1938        assert!(is_json_media_type("application/vnd.api+json"));
1939        assert!(is_json_media_type("application/hal+json"));
1940        assert!(is_json_media_type("application/problem+json"));
1941        assert!(is_json_media_type("application/ld+json"));
1942        assert!(is_json_media_type(
1943            "application/vnd.api+json; charset=utf-8"
1944        ));
1945        // Negatives
1946        assert!(!is_json_media_type("application/xml"));
1947        assert!(!is_json_media_type("application/x-www-form-urlencoded"));
1948        assert!(!is_json_media_type("text/plain"));
1949        assert!(!is_json_media_type("application/jsonbutnotreally"));
1950        // +json suffix only applies to application/* per RFC 6839
1951        assert!(!is_json_media_type("text/something+json"));
1952    }
1953
1954    #[test]
1955    fn response_media_helpers_normalize_parameters_and_case() {
1956        assert_eq!(
1957            media_type_essence("  Text/Plain ; charset=utf-8  "),
1958            "Text/Plain"
1959        );
1960        assert!(is_text_media_type("TEXT/HTML; charset=UTF-8"));
1961        assert!(!is_text_media_type("Text/Event-Stream; charset=utf-8"));
1962        assert!(is_wildcard_media_type("*/*; q=0.8"));
1963        assert!(is_wildcard_media_type("IMAGE/*"));
1964        assert!(is_wildcard_media_type("text/*"));
1965        assert!(!is_wildcard_media_type("image/*+json"));
1966        assert!(!is_wildcard_media_type("application/json"));
1967    }
1968
1969    #[test]
1970    fn response_media_classifier_keeps_json_sse_and_text_distinct() {
1971        for media_type in [
1972            "application/json",
1973            "APPLICATION/PROBLEM+JSON; charset=utf-8",
1974        ] {
1975            assert_eq!(
1976                classify_response_media_type(media_type, None),
1977                ResponseMediaKind::Json,
1978                "{media_type}"
1979            );
1980        }
1981
1982        assert_eq!(
1983            classify_response_media_type("Text/Event-Stream; charset=utf-8", None),
1984            ResponseMediaKind::EventStream
1985        );
1986        for media_type in ["text/plain", "TEXT/HTML; charset=UTF-8"] {
1987            assert_eq!(
1988                classify_response_media_type(media_type, None),
1989                ResponseMediaKind::Text,
1990                "{media_type}"
1991            );
1992        }
1993        assert_eq!(
1994            classify_response_media_type("text/event-streaming", None),
1995            ResponseMediaKind::Text
1996        );
1997        assert_eq!(
1998            classify_response_media_type("text/*", None),
1999            ResponseMediaKind::Unsupported,
2000            "a media range is not a valid concrete response Content-Type"
2001        );
2002    }
2003
2004    #[test]
2005    fn response_json_content_skips_schema_less_canonical_entry() {
2006        let response: Response = serde_json::from_value(json!({
2007            "description": "mixed JSON",
2008            "content": {
2009                "application/json": {},
2010                "application/vnd.example+json": {
2011                    "schema": { "type": "string" }
2012                }
2013            }
2014        }))
2015        .unwrap();
2016
2017        let (media_type, schema) = response.json_content().expect("schema-bearing JSON");
2018        assert_eq!(media_type, "application/vnd.example+json");
2019        assert!(matches!(schema.schema_type(), Some(SchemaType::String)));
2020    }
2021
2022    #[test]
2023    fn response_media_classifier_recognizes_binary_formats_and_wildcards() {
2024        for media_type in [
2025            "image/png",
2026            "IMAGE/*; version=1",
2027            "audio/mpeg",
2028            "video/mp4",
2029            "application/octet-stream",
2030            "APPLICATION/ZIP; version=1",
2031            "application/*",
2032            "*/*",
2033        ] {
2034            assert_eq!(
2035                classify_response_media_type(media_type, None),
2036                ResponseMediaKind::Binary,
2037                "{media_type}"
2038            );
2039        }
2040
2041        let binary_schema: Schema = serde_json::from_value(json!({
2042            "type": "string",
2043            "format": "BINARY"
2044        }))
2045        .unwrap();
2046        assert_eq!(
2047            classify_response_media_type("application/x-custom", Some(&binary_schema)),
2048            ResponseMediaKind::Binary
2049        );
2050        assert_eq!(
2051            classify_response_media_type("text/plain", Some(&binary_schema)),
2052            ResponseMediaKind::Binary,
2053            "an explicit binary schema must prevent UTF-8 decoding"
2054        );
2055        assert!(is_binary_media_type(
2056            "application/x-custom",
2057            Some(&binary_schema)
2058        ));
2059    }
2060
2061    #[test]
2062    fn response_media_classifier_leaves_ambiguous_formats_unsupported() {
2063        let string_schema: Schema = serde_json::from_value(json!({ "type": "string" })).unwrap();
2064        for media_type in ["application/x-unknown", "not-a-media-type"] {
2065            assert_eq!(
2066                classify_response_media_type(media_type, Some(&string_schema)),
2067                ResponseMediaKind::Unsupported,
2068                "{media_type}"
2069            );
2070        }
2071        // PDF bodies are raw bytes; XML bodies are character data. Both are
2072        // pass-through lossless for a server that never parses the payload,
2073        // so they classify instead of failing generation.
2074        assert_eq!(
2075            classify_response_media_type("application/pdf", Some(&string_schema)),
2076            ResponseMediaKind::Binary
2077        );
2078        assert_eq!(
2079            classify_response_media_type("application/xml", Some(&string_schema)),
2080            ResponseMediaKind::Text
2081        );
2082        assert_eq!(
2083            classify_response_media_type("application/atom+xml", Some(&string_schema)),
2084            ResponseMediaKind::Text
2085        );
2086        // JWT compact serializations (RFC 7519) are ASCII text.
2087        assert_eq!(
2088            classify_response_media_type("application/jwt", Some(&string_schema)),
2089            ResponseMediaKind::Text
2090        );
2091        assert!(!is_binary_media_type("text/plain", None));
2092    }
2093
2094    #[test]
2095    fn request_body_json_schema_finds_vnd_api_plus_json() {
2096        // Mirrors Latitude.sh: request body declared under
2097        // application/vnd.api+json without a sibling application/json.
2098        let body_json = json!({
2099            "required": true,
2100            "content": {
2101                "application/vnd.api+json": {
2102                    "schema": {"$ref": "#/components/schemas/create_api_key"}
2103                }
2104            }
2105        });
2106
2107        let body: RequestBody = serde_json::from_value(body_json).unwrap();
2108        let schema = body.json_schema().expect("expected +json schema match");
2109        assert!(schema.is_reference());
2110    }
2111
2112    #[test]
2113    fn request_body_best_content_prefers_canonical_json_over_plus_json() {
2114        // When both are present (e.g. Latitude.sh's POST /auth/api_keys),
2115        // best_content should still pick application/json for backwards
2116        // compatibility with the existing snapshot suite.
2117        let body_json = json!({
2118            "required": true,
2119            "content": {
2120                "application/json": {
2121                    "schema": {"$ref": "#/components/schemas/A"}
2122                },
2123                "application/vnd.api+json": {
2124                    "schema": {"$ref": "#/components/schemas/B"}
2125                }
2126            }
2127        });
2128
2129        let body: RequestBody = serde_json::from_value(body_json).unwrap();
2130        let (ct, _) = body.best_content().expect("expected best_content");
2131        assert_eq!(ct, "application/json");
2132    }
2133
2134    #[test]
2135    fn request_body_best_content_falls_back_to_plus_json() {
2136        // When only the +json variant is declared, best_content returns
2137        // it instead of skipping straight to form-urlencoded.
2138        let body_json = json!({
2139            "required": true,
2140            "content": {
2141                "application/vnd.api+json": {
2142                    "schema": {"$ref": "#/components/schemas/B"}
2143                }
2144            }
2145        });
2146
2147        let body: RequestBody = serde_json::from_value(body_json).unwrap();
2148        let (ct, _) = body.best_content().expect("expected best_content");
2149        assert_eq!(ct, "application/vnd.api+json");
2150    }
2151
2152    #[test]
2153    fn request_body_best_content_does_not_select_wildcard_media_ranges() {
2154        let body: RequestBody = serde_json::from_value(json!({
2155            "required": true,
2156            "content": {
2157                "image/*": {
2158                    "schema": { "type": "string", "format": "binary" }
2159                },
2160                "*/*": {
2161                    "schema": { "type": "string", "format": "binary" }
2162                }
2163            }
2164        }))
2165        .unwrap();
2166
2167        assert!(
2168            body.best_content().is_none(),
2169            "request media ranges require a runtime concrete Content-Type"
2170        );
2171    }
2172
2173    #[test]
2174    fn request_body_best_content_matches_parameterized_text_plain_by_essence() {
2175        let body: RequestBody = serde_json::from_value(json!({
2176            "required": true,
2177            "content": {
2178                "Text/Plain; charset=utf-8": {
2179                    "schema": { "type": "string" }
2180                }
2181            }
2182        }))
2183        .unwrap();
2184
2185        let (media_type, _) = body.best_content().expect("parameterized text body");
2186        assert_eq!(media_type, "Text/Plain; charset=utf-8");
2187    }
2188
2189    #[test]
2190    fn response_json_schema_finds_vnd_api_plus_json() {
2191        // Mirrors every Latitude.sh response: schema lives under
2192        // application/vnd.api+json only.
2193        let resp_json = json!({
2194            "description": "OK",
2195            "content": {
2196                "application/vnd.api+json": {
2197                    "schema": {"$ref": "#/components/schemas/api_keys"}
2198                }
2199            }
2200        });
2201
2202        let resp: Response = serde_json::from_value(resp_json).unwrap();
2203        let schema = resp.json_schema().expect("expected +json schema match");
2204        assert!(schema.is_reference());
2205    }
2206}