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.
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            || self.has_explicit_null_variant()
848    }
849
850    /// Reference nodes retain siblings in `extra` because OpenAPI 3.0-era
851    /// documents commonly attach `nullable: true` directly beside `$ref`.
852    /// `details()` intentionally returns an empty value for references, so
853    /// nullability must read that retained annotation explicitly.
854    fn reference_siblings_are_nullable(&self) -> bool {
855        let extra = match self {
856            Schema::Reference { extra, .. }
857            | Schema::RecursiveRef { extra, .. }
858            | Schema::DynamicRef { extra, .. } => extra,
859            _ => return false,
860        };
861        extra.get("nullable").and_then(Value::as_bool) == Some(true)
862    }
863
864    /// Get schema details
865    pub fn details(&self) -> &SchemaDetails {
866        static EMPTY_DETAILS: Lazy<SchemaDetails> = Lazy::new(SchemaDetails::default);
867        match self {
868            Schema::Typed { details, .. } => details,
869            Schema::TypedMulti { details, .. } => details,
870            // Neither a reference nor a boolean schema carries details of its
871            // own: one points elsewhere, the other accepts or rejects outright.
872            Schema::Reference { .. }
873            | Schema::RecursiveRef { .. }
874            | Schema::DynamicRef { .. }
875            | Schema::Bool(_) => &EMPTY_DETAILS,
876            Schema::OneOf { details, .. } => details,
877            Schema::AnyOf { details, .. } => details,
878            Schema::AllOf { details, .. } => details,
879            Schema::Untyped { details } => details,
880        }
881    }
882
883    /// Get mutable schema details
884    pub fn details_mut(&mut self) -> &mut SchemaDetails {
885        match self {
886            Schema::Typed { details, .. } => details,
887            Schema::TypedMulti { details, .. } => details,
888            Schema::Reference { .. } => {
889                panic!("Cannot get mutable details for reference schema")
890            }
891            Schema::RecursiveRef { .. } => {
892                panic!("Cannot get mutable details for recursive reference schema")
893            }
894            Schema::DynamicRef { .. } => {
895                panic!("Cannot get mutable details for dynamic reference schema")
896            }
897            Schema::Bool(_) => {
898                panic!("Cannot get mutable details for a boolean schema")
899            }
900            Schema::OneOf { details, .. } => details,
901            Schema::AnyOf { details, .. } => details,
902            Schema::AllOf { details, .. } => details,
903            Schema::Untyped { details } => details,
904        }
905    }
906
907    /// Check if this is any kind of reference (regular or recursive)
908    pub fn is_reference(&self) -> bool {
909        matches!(self, Schema::Reference { .. } | Schema::RecursiveRef { .. })
910    }
911
912    /// Get reference string if this is a reference
913    pub fn reference(&self) -> Option<&str> {
914        match self {
915            Schema::Reference { reference, .. } => Some(reference),
916            _ => None,
917        }
918    }
919
920    /// Get recursive reference string if this is a recursive reference
921    pub fn recursive_reference(&self) -> Option<&str> {
922        match self {
923            Schema::RecursiveRef { recursive_ref, .. } => Some(recursive_ref),
924            _ => None,
925        }
926    }
927
928    /// Check if this is a discriminated union
929    pub fn is_discriminated_union(&self) -> bool {
930        match self {
931            Schema::OneOf { discriminator, .. } => discriminator.is_some(),
932            Schema::AnyOf { discriminator, .. } => discriminator.is_some(),
933            _ => false,
934        }
935    }
936
937    /// Get discriminator if this is a discriminated union
938    pub fn discriminator(&self) -> Option<&Discriminator> {
939        match self {
940            Schema::OneOf { discriminator, .. } => discriminator.as_ref(),
941            Schema::AnyOf { discriminator, .. } => discriminator.as_ref(),
942            _ => None,
943        }
944    }
945
946    /// Get union variants
947    pub fn union_variants(&self) -> Option<&[Schema]> {
948        match self {
949            Schema::OneOf { one_of, .. } => Some(one_of),
950            Schema::AnyOf { any_of, .. } => Some(any_of),
951            _ => None,
952        }
953    }
954
955    /// Check if this appears to be a nullable pattern (anyOf or oneOf with null)
956    pub fn is_nullable_pattern(&self) -> bool {
957        self.non_null_variant().is_some()
958    }
959
960    /// Whether an `anyOf` or `oneOf` contains a branch that admits only JSON
961    /// `null`. Unlike [`Self::is_nullable_pattern`], this does not require the
962    /// union to collapse to one non-null branch, so it also preserves
963    /// nullability for unions such as `[string, integer, null]`.
964    pub fn has_explicit_null_variant(&self) -> bool {
965        self.union_variants()
966            .is_some_and(|variants| variants.iter().any(Self::is_explicit_null_only))
967    }
968
969    /// The one meaningful branch of a two-branch union whose other branch
970    /// exists only to admit `null`.
971    pub fn non_null_variant(&self) -> Option<&Schema> {
972        let variants = match self {
973            Schema::AnyOf { any_of, .. } => any_of,
974            Schema::OneOf { one_of, .. } => one_of,
975            _ => return None,
976        };
977        let [first, second] = variants.as_slice() else {
978            return None;
979        };
980        match (
981            first.is_explicit_null_only(),
982            second.is_explicit_null_only(),
983        ) {
984            (true, false) => Some(second),
985            (false, true) => Some(first),
986            // Two markers describe nothing, and no marker is not this pattern.
987            _ => None,
988        }
989    }
990
991    /// Whether this branch explicitly admits no JSON value other than `null`.
992    ///
993    /// This is intentionally branch-local. OpenAPI 3.0's `nullable: true`
994    /// widens the schema it annotates; it does not erase that schema's other
995    /// accepted values. In particular, `{nullable: true}` and
996    /// `{type: object, nullable: true}` remain real alternatives even beside
997    /// a `$ref`.
998    pub(crate) fn is_explicit_null_only(&self) -> bool {
999        match self {
1000            Schema::Typed { schema_type, .. } => *schema_type == SchemaType::Null,
1001            Schema::TypedMulti { schema_types, .. } => {
1002                !schema_types.is_empty()
1003                    && schema_types
1004                        .iter()
1005                        .all(|schema_type| *schema_type == SchemaType::Null)
1006            }
1007            Schema::Untyped { details } => {
1008                details.const_value.as_ref().is_some_and(Value::is_null)
1009                    || details.enum_values.as_ref().is_some_and(
1010                        |values| matches!(values.as_slice(), [value] if value.is_null()),
1011                    )
1012            }
1013            _ => false,
1014        }
1015    }
1016
1017    /// Infer schema type from structure if not explicitly set
1018    pub fn inferred_type(&self) -> Option<SchemaType> {
1019        match self {
1020            Schema::Typed { schema_type, .. } => Some(schema_type.clone()),
1021            Schema::TypedMulti { .. } => self.schema_type().cloned(),
1022            Schema::Untyped { details } => {
1023                // Infer from structure
1024                if self.is_explicit_null_only() {
1025                    Some(SchemaType::Null)
1026                } else if details.properties.is_some() {
1027                    Some(SchemaType::Object)
1028                } else if details.items.is_some() || details.prefix_items.is_some() {
1029                    Some(SchemaType::Array)
1030                } else if details.enum_values.is_some() {
1031                    Some(SchemaType::String) // Assume string enum
1032                } else {
1033                    None
1034                }
1035            }
1036            _ => None,
1037        }
1038    }
1039}
1040
1041impl SchemaDetails {
1042    /// The schema every array element must satisfy, i.e. `items` in its
1043    /// 2020-12 single-schema spelling. Returns `None` for the draft-04 tuple
1044    /// form, which constrains positions rather than every element — read that
1045    /// through [`Self::positional_items`]. A boolean schema is a schema like
1046    /// any other here: `items: true` accepts every element.
1047    pub fn item_schema(&self) -> Option<&Schema> {
1048        match self.items.as_ref()? {
1049            Items::Single(schema) => Some(schema),
1050            Items::Positional(_) => None,
1051        }
1052    }
1053
1054    /// Positional element schemas, from either spelling: 2020-12
1055    /// `prefixItems` or the draft-04 `items: [A, B]` tuple form.
1056    pub fn positional_items(&self) -> Option<&[Schema]> {
1057        if let Some(prefix_items) = self.prefix_items.as_deref() {
1058            return Some(prefix_items);
1059        }
1060        match self.items.as_ref()? {
1061            Items::Positional(schemas) => Some(schemas),
1062            Items::Single(_) => None,
1063        }
1064    }
1065
1066    /// Whether the array admits no elements beyond its positional schemas.
1067    ///
1068    /// This is not the default: `prefixItems: [A, B]` on its own permits extra
1069    /// elements of any type. An array is closed only when 2020-12 `items:
1070    /// false`, draft-04 `additionalItems: false`, or `maxItems` says so.
1071    pub fn positional_items_are_closed(&self) -> bool {
1072        let Some(positions) = self.positional_items() else {
1073            return false;
1074        };
1075        // `items: false` — no element beyond the positions may exist. It is a
1076        // boolean schema in the ordinary single-schema slot.
1077        if matches!(
1078            self.items.as_ref(),
1079            Some(Items::Single(schema)) if matches!(**schema, Schema::Bool(false))
1080        ) {
1081            return true;
1082        }
1083        if self.extra.get("additionalItems") == Some(&Value::Bool(false)) {
1084            return true;
1085        }
1086        self.max_items
1087            .is_some_and(|maximum| maximum <= positions.len() as u64)
1088    }
1089
1090    /// Whether every valid instance has exactly one element per positional
1091    /// schema — the only case a fixed-arity Rust tuple can represent. A closed
1092    /// array that also permits shorter instances is not exact.
1093    pub fn positional_items_are_exact(&self) -> bool {
1094        let Some(positions) = self.positional_items() else {
1095            return false;
1096        };
1097        self.positional_items_are_closed()
1098            && self
1099                .min_items
1100                .is_some_and(|minimum| minimum >= positions.len() as u64)
1101    }
1102
1103    /// Check if this schema is nullable
1104    pub fn is_nullable(&self) -> bool {
1105        self.nullable.unwrap_or(false)
1106    }
1107
1108    /// Check if this is a string enum
1109    ///
1110    /// A standalone string `const` (no `enum` array) is treated as a
1111    /// degenerate single-value enum so the generator emits a tightly-typed
1112    /// single-variant enum instead of a bare `String`. See issue #10.
1113    pub fn is_string_enum(&self) -> bool {
1114        self.enum_values.is_some() || self.const_string_value().is_some()
1115    }
1116
1117    /// Get enum values as strings if this is a string enum.
1118    ///
1119    /// Falls back to `[const_value]` when `enum` is absent but `const` is a
1120    /// string, so a property like `{ "type": "string", "const": "X" }`
1121    /// produces a single-variant enum.
1122    pub fn string_enum_values(&self) -> Option<Vec<String>> {
1123        if let Some(values) = self.enum_values.as_ref() {
1124            // Tolerate non-string scalars in `enum` for `type: string` schemas
1125            // (gitpod has `enum: [2000, 5000, ...]` on a string-typed field).
1126            // Without this, `filter_map(.as_str())` produced an empty Vec
1127            // and we emitted an empty enum that fails to compile.
1128            return Some(
1129                values
1130                    .iter()
1131                    .map(|v| match v {
1132                        Value::String(s) => s.clone(),
1133                        Value::Number(n) => n.to_string(),
1134                        Value::Bool(b) => b.to_string(),
1135                        Value::Null => "null".to_string(),
1136                        _ => v.to_string(),
1137                    })
1138                    .collect(),
1139            );
1140        }
1141        self.const_string_value().map(|s| vec![s])
1142    }
1143
1144    fn const_string_value(&self) -> Option<String> {
1145        self.const_value
1146            .as_ref()
1147            .and_then(|v| v.as_str())
1148            .map(|s| s.to_string())
1149    }
1150
1151    /// Check if a field is required
1152    pub fn is_field_required(&self, field_name: &str) -> bool {
1153        self.required
1154            .as_ref()
1155            .map(|req| req.contains(&field_name.to_string()))
1156            .unwrap_or(false)
1157    }
1158}
1159
1160/// OpenAPI Path Item Object
1161#[derive(Debug, Clone, Deserialize, Serialize)]
1162pub struct PathItem {
1163    #[serde(default)]
1164    pub summary: Option<String>,
1165    #[serde(default)]
1166    pub description: Option<String>,
1167    pub get: Option<Operation>,
1168    pub put: Option<Operation>,
1169    pub post: Option<Operation>,
1170    pub delete: Option<Operation>,
1171    pub options: Option<Operation>,
1172    pub head: Option<Operation>,
1173    pub patch: Option<Operation>,
1174    pub trace: Option<Operation>,
1175    /// 3.2 §"Path Item Object" — `QUERY` HTTP method (D1). Originally
1176    /// proposed for safe, idempotent reads with a body.
1177    pub query: Option<Operation>,
1178    /// 3.2 §"Path Item Object" — extension map for HTTP methods beyond the
1179    /// well-known ones (e.g. WebDAV's PROPFIND, SEARCH; LINK/UNLINK). Keys
1180    /// are uppercase method names (D1).
1181    #[serde(rename = "additionalOperations", default)]
1182    pub additional_operations: Option<BTreeMap<String, Operation>>,
1183    pub parameters: Option<Vec<Parameter>>,
1184    #[serde(default)]
1185    pub servers: Option<Vec<Server>>,
1186    #[serde(rename = "$ref", default)]
1187    pub reference: Option<String>,
1188    #[serde(flatten, default)]
1189    pub extensions: Extensions,
1190}
1191
1192impl PathItem {
1193    /// Get all operations in this path item, including 3.2's `query`
1194    /// (D1) and any custom verbs declared in `additionalOperations`.
1195    pub fn operations(&self) -> Vec<(&str, &Operation)> {
1196        let mut ops = Vec::new();
1197        if let Some(ref op) = self.get {
1198            ops.push(("get", op));
1199        }
1200        if let Some(ref op) = self.put {
1201            ops.push(("put", op));
1202        }
1203        if let Some(ref op) = self.post {
1204            ops.push(("post", op));
1205        }
1206        if let Some(ref op) = self.delete {
1207            ops.push(("delete", op));
1208        }
1209        if let Some(ref op) = self.options {
1210            ops.push(("options", op));
1211        }
1212        if let Some(ref op) = self.head {
1213            ops.push(("head", op));
1214        }
1215        if let Some(ref op) = self.patch {
1216            ops.push(("patch", op));
1217        }
1218        if let Some(ref op) = self.trace {
1219            ops.push(("trace", op));
1220        }
1221        if let Some(ref op) = self.query {
1222            ops.push(("query", op));
1223        }
1224        if let Some(map) = &self.additional_operations {
1225            for (verb, op) in map {
1226                ops.push((verb.as_str(), op));
1227            }
1228        }
1229        ops
1230    }
1231}
1232
1233/// OpenAPI Operation Object
1234#[derive(Debug, Clone, Deserialize, Serialize)]
1235pub struct Operation {
1236    #[serde(rename = "operationId", default)]
1237    pub operation_id: Option<String>,
1238    #[serde(default)]
1239    pub summary: Option<String>,
1240    #[serde(default)]
1241    pub description: Option<String>,
1242    #[serde(default)]
1243    pub tags: Option<Vec<String>>,
1244    #[serde(default)]
1245    pub deprecated: Option<bool>,
1246    pub parameters: Option<Vec<Parameter>>,
1247    #[serde(rename = "requestBody")]
1248    pub request_body: Option<RequestBody>,
1249    pub responses: Option<BTreeMap<String, Response>>,
1250    #[serde(default)]
1251    pub callbacks: Option<BTreeMap<String, Callback>>,
1252    #[serde(default)]
1253    pub security: Option<Vec<BTreeMap<String, Vec<String>>>>,
1254    #[serde(default)]
1255    pub servers: Option<Vec<Server>>,
1256    #[serde(rename = "externalDocs", default)]
1257    pub external_docs: Option<ExternalDocs>,
1258    #[serde(flatten, default)]
1259    pub extensions: Extensions,
1260}
1261
1262/// OpenAPI Parameter Object
1263#[derive(Debug, Clone, Deserialize, Serialize)]
1264pub struct Parameter {
1265    #[serde(default)]
1266    pub name: Option<String>,
1267    #[serde(rename = "in", default)]
1268    pub location: Option<String>,
1269    #[serde(default)]
1270    pub required: Option<bool>,
1271    #[serde(default)]
1272    pub deprecated: Option<bool>,
1273    #[serde(rename = "allowEmptyValue", default)]
1274    pub allow_empty_value: Option<bool>,
1275    #[serde(default)]
1276    pub style: Option<String>,
1277    #[serde(default)]
1278    pub explode: Option<bool>,
1279    #[serde(rename = "allowReserved", default)]
1280    pub allow_reserved: Option<bool>,
1281    #[serde(default)]
1282    pub schema: Option<Schema>,
1283    #[serde(default)]
1284    pub content: Option<BTreeMap<String, MediaType>>,
1285    #[serde(default)]
1286    pub example: Option<Value>,
1287    #[serde(default)]
1288    pub examples: Option<BTreeMap<String, Example>>,
1289    #[serde(default)]
1290    pub description: Option<String>,
1291    #[serde(rename = "$ref", default)]
1292    pub reference: Option<String>,
1293    #[serde(flatten, default)]
1294    pub extensions: Extensions,
1295}
1296
1297/// OpenAPI Request Body Object
1298#[derive(Debug, Clone, Deserialize, Serialize)]
1299pub struct RequestBody {
1300    pub content: Option<BTreeMap<String, MediaType>>,
1301    #[serde(default)]
1302    pub description: Option<String>,
1303    #[serde(default)]
1304    pub required: Option<bool>,
1305    #[serde(rename = "$ref", default)]
1306    pub reference: Option<String>,
1307    #[serde(flatten, default)]
1308    pub extensions: Extensions,
1309}
1310
1311/// Semantic representation used for a response media entry.
1312///
1313/// This deliberately keeps server-sent events separate from ordinary text:
1314/// although `text/event-stream` belongs to the `text` top-level type, callers
1315/// must stream it rather than buffer and UTF-8 decode it like `text/plain`.
1316#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1317#[serde(rename_all = "snake_case")]
1318pub enum ResponseMediaKind {
1319    Json,
1320    EventStream,
1321    Text,
1322    Binary,
1323    Unsupported,
1324}
1325
1326/// Return the media type essence, excluding parameters and surrounding space.
1327///
1328/// Media type comparisons remain ASCII-case-insensitive at their call sites;
1329/// this helper only provides one consistent way to discard parameters such as
1330/// `charset=utf-8` without allocating.
1331pub fn media_type_essence(content_type: &str) -> &str {
1332    content_type
1333        .split(';')
1334        .next()
1335        .unwrap_or(content_type)
1336        .trim()
1337}
1338
1339/// Returns true for media types whose payload is JSON.
1340///
1341/// Matches `application/json` exactly, plus any RFC 6839 structured-syntax
1342/// suffix variant of the form `application/<subtype>+json`
1343/// (e.g. `application/vnd.api+json`, `application/hal+json`,
1344/// `application/problem+json`). Trailing parameters such as
1345/// `; charset=utf-8` are tolerated.
1346pub fn is_json_media_type(ct: &str) -> bool {
1347    let essence = media_type_essence(ct).to_ascii_lowercase();
1348    if essence == "application/json" {
1349        return true;
1350    }
1351    if let Some(subtype) = essence.strip_prefix("application/") {
1352        return subtype.ends_with("+json");
1353    }
1354    false
1355}
1356
1357/// Returns true for `application/x-www-form-urlencoded` (with optional
1358/// parameters).
1359pub fn is_form_urlencoded_media_type(ct: &str) -> bool {
1360    let essence = media_type_essence(ct).to_ascii_lowercase();
1361    essence == "application/x-www-form-urlencoded"
1362}
1363
1364/// Returns true only for the `text/event-stream` media type essence.
1365///
1366/// Media type names are ASCII-case-insensitive and parameters do not change
1367/// the essence, so values such as `Text/Event-Stream; charset=utf-8` match,
1368/// while similarly prefixed subtypes such as `text/event-streaming` do not.
1369pub fn is_event_stream_media_type(ct: &str) -> bool {
1370    media_type_essence(ct).eq_ignore_ascii_case("text/event-stream")
1371}
1372
1373/// Returns true for non-SSE media types in the `text` top-level family.
1374///
1375/// Structured text formats in the `application` family whose instances are
1376/// UTF-8/UTF-16 character data — XML and its `+xml` suffix variants (RFC 7303,
1377/// RFC 6839) — are buffered and emitted as text as well; bytes are never
1378/// XML-parsed by the generated server, so a plain `String` body preserves
1379/// the payload losslessly.
1380pub fn is_text_media_type(ct: &str) -> bool {
1381    let Some((top_level, subtype)) = media_type_essence(ct).split_once('/') else {
1382        return false;
1383    };
1384    if top_level.eq_ignore_ascii_case("text")
1385        && !subtype.is_empty()
1386        && !is_event_stream_media_type(ct)
1387    {
1388        return true;
1389    }
1390    top_level.eq_ignore_ascii_case("application")
1391        && (subtype.eq_ignore_ascii_case("xml")
1392            || subtype.to_ascii_lowercase().ends_with("+xml")
1393            // JWT (RFC 7519) compact serializations are ASCII text: three
1394            // base64url segments joined by dots.
1395            || subtype.eq_ignore_ascii_case("jwt"))
1396}
1397
1398/// Returns true for OpenAPI media ranges with a wildcard subtype.
1399///
1400/// This recognizes both `*/*` and type-specific ranges such as `image/*`.
1401/// A wildcard is meaningful only as the complete subtype, so values such as
1402/// `image/*+json` do not match.
1403pub fn is_wildcard_media_type(ct: &str) -> bool {
1404    let Some((top_level, subtype)) = media_type_essence(ct).split_once('/') else {
1405        return false;
1406    };
1407    !top_level.is_empty() && subtype == "*"
1408}
1409
1410fn schema_has_binary_format(schema: Option<&Schema>) -> bool {
1411    schema.is_some_and(|schema| {
1412        schema
1413            .details()
1414            .format
1415            .as_deref()
1416            .is_some_and(|format| format.eq_ignore_ascii_case("binary"))
1417    })
1418}
1419
1420/// Returns true when a response representation must be handled as raw bytes.
1421///
1422/// An explicit schema `format: binary` takes precedence over a textual-looking
1423/// media type. Without that schema signal, known binary families and formats
1424/// are recognized, as are non-text OpenAPI wildcard media ranges. Text media
1425/// ranges cannot be emitted as a concrete response `Content-Type` and remain
1426/// unsupported unless their schema explicitly declares the binary format.
1427pub fn is_binary_media_type(ct: &str, schema: Option<&Schema>) -> bool {
1428    if schema_has_binary_format(schema) {
1429        return true;
1430    }
1431
1432    let essence = media_type_essence(ct);
1433    let Some((top_level, _)) = essence.split_once('/') else {
1434        return false;
1435    };
1436    if top_level.eq_ignore_ascii_case("image")
1437        || top_level.eq_ignore_ascii_case("audio")
1438        || top_level.eq_ignore_ascii_case("video")
1439    {
1440        return true;
1441    }
1442    if essence.eq_ignore_ascii_case("application/octet-stream")
1443        || essence.eq_ignore_ascii_case("application/zip")
1444        || essence.eq_ignore_ascii_case("application/pdf")
1445    {
1446        return true;
1447    }
1448
1449    !top_level.eq_ignore_ascii_case("text") && is_wildcard_media_type(ct)
1450}
1451
1452/// Classify one declared response representation for client/server analysis.
1453///
1454/// JSON and exact SSE retain their established behavior. A binary schema wins
1455/// over the media family so bytes are never accidentally UTF-8 decoded.
1456pub fn classify_response_media_type(ct: &str, schema: Option<&Schema>) -> ResponseMediaKind {
1457    if is_json_media_type(ct) {
1458        ResponseMediaKind::Json
1459    } else if is_event_stream_media_type(ct) {
1460        ResponseMediaKind::EventStream
1461    } else if schema_has_binary_format(schema) {
1462        ResponseMediaKind::Binary
1463    } else if is_text_media_type(ct) {
1464        if is_wildcard_media_type(ct) {
1465            ResponseMediaKind::Unsupported
1466        } else {
1467            ResponseMediaKind::Text
1468        }
1469    } else if is_binary_media_type(ct, schema) {
1470        ResponseMediaKind::Binary
1471    } else {
1472        ResponseMediaKind::Unsupported
1473    }
1474}
1475
1476fn find_json_content(content: &BTreeMap<String, MediaType>) -> Option<(&str, &MediaType)> {
1477    if let Some(mt) = content
1478        .get("application/json")
1479        .filter(|media_type| media_type.schema.is_some())
1480    {
1481        return Some(("application/json", mt));
1482    }
1483    content
1484        .iter()
1485        .find(|(ct, media_type)| is_json_media_type(ct) && media_type.schema.is_some())
1486        .map(|(ct, mt)| (ct.as_str(), mt))
1487        .or_else(|| {
1488            content
1489                .get("application/json")
1490                .map(|media_type| ("application/json", media_type))
1491        })
1492        .or_else(|| {
1493            content
1494                .iter()
1495                .find(|(ct, _)| is_json_media_type(ct))
1496                .map(|(ct, mt)| (ct.as_str(), mt))
1497        })
1498}
1499
1500impl RequestBody {
1501    /// Get schema for any JSON content type
1502    ///
1503    /// Prefers the canonical `application/json` entry, then falls back to
1504    /// any `application/*+json` variant (RFC 6839) such as
1505    /// `application/vnd.api+json` or `application/hal+json`.
1506    pub fn json_schema(&self) -> Option<&Schema> {
1507        self.content
1508            .as_ref()
1509            .and_then(find_json_content)
1510            .and_then(|(_, media_type)| media_type.schema.as_ref())
1511    }
1512
1513    /// Get the best content type and its schema, preferring JSON over others
1514    pub fn best_content(&self) -> Option<(&str, Option<&Schema>)> {
1515        let content = self.content.as_ref()?;
1516
1517        if let Some((ct, media_type)) = find_json_content(content) {
1518            return Some((ct, media_type.schema.as_ref()));
1519        }
1520
1521        const PRIORITY: &[&str] = &[
1522            "application/x-www-form-urlencoded",
1523            "multipart/form-data",
1524            "application/octet-stream",
1525            "text/plain",
1526        ];
1527        for preferred_essence in PRIORITY {
1528            if let Some((ct, media_type)) = content
1529                .iter()
1530                .find(|(ct, _)| media_type_essence(ct).eq_ignore_ascii_case(preferred_essence))
1531            {
1532                return Some((ct.as_str(), media_type.schema.as_ref()));
1533            }
1534        }
1535        // Character-data fallbacks (text/xml, application/xml, +xml suffixed)
1536        // are buffered as UTF-8 text like text/plain.
1537        if let Some((ct, media_type)) = content.iter().find(|(ct, _)| is_text_media_type(ct)) {
1538            return Some((ct.as_str(), media_type.schema.as_ref()));
1539        }
1540        content
1541            .iter()
1542            // A request media range is not a concrete Content-Type value. The
1543            // generated client cannot send `image/*` or `*/*`, and the server
1544            // cannot compare either range to one exact request representation,
1545            // so leave wildcard request content unsupported instead of
1546            // emitting a contract that always fails at runtime.
1547            .find(|(ct, media_type)| {
1548                !is_wildcard_media_type(ct) && is_binary_media_type(ct, media_type.schema.as_ref())
1549            })
1550            .map(|(ct, media_type)| (ct.as_str(), media_type.schema.as_ref()))
1551    }
1552}
1553
1554/// OpenAPI Response Object
1555#[derive(Debug, Clone, Deserialize, Serialize)]
1556pub struct Response {
1557    #[serde(default)]
1558    pub description: Option<String>,
1559    #[serde(default)]
1560    pub headers: Option<BTreeMap<String, Header>>,
1561    #[serde(default)]
1562    pub content: Option<BTreeMap<String, MediaType>>,
1563    #[serde(default)]
1564    pub links: Option<Value>,
1565    #[serde(rename = "$ref", default)]
1566    pub reference: Option<String>,
1567    #[serde(flatten, default)]
1568    pub extensions: Extensions,
1569}
1570
1571impl Response {
1572    /// Get schema for any JSON content type
1573    ///
1574    /// Prefers the canonical `application/json` entry, then falls back to
1575    /// any `application/*+json` variant (RFC 6839) such as
1576    /// `application/vnd.api+json`, `application/hal+json`, or
1577    /// `application/problem+json`.
1578    pub fn json_schema(&self) -> Option<&Schema> {
1579        self.content
1580            .as_ref()
1581            .and_then(find_json_content)
1582            .and_then(|(_, media_type)| media_type.schema.as_ref())
1583    }
1584
1585    /// Get the preferred JSON-compatible media type and its schema.
1586    pub fn json_content(&self) -> Option<(&str, &Schema)> {
1587        self.content
1588            .as_ref()
1589            .and_then(find_json_content)
1590            .and_then(|(content_type, media_type)| {
1591                media_type
1592                    .schema
1593                    .as_ref()
1594                    .map(|schema| (content_type, schema))
1595            })
1596    }
1597}
1598
1599/// OpenAPI Media Type Object
1600#[derive(Debug, Clone, Deserialize, Serialize)]
1601pub struct MediaType {
1602    #[serde(default)]
1603    pub schema: Option<Schema>,
1604    #[serde(default)]
1605    pub example: Option<Value>,
1606    #[serde(default)]
1607    pub examples: Option<BTreeMap<String, Example>>,
1608    #[serde(default)]
1609    pub encoding: Option<BTreeMap<String, Encoding>>,
1610    /// 3.2 §"Media Type Object" — schema for each item when streaming
1611    /// (D3). Common in `text/event-stream` and JSON-lines payloads.
1612    #[serde(rename = "itemSchema", default)]
1613    pub item_schema: Option<Schema>,
1614    /// 3.2 §"Media Type Object" — encoding for the leading prefix of a
1615    /// streamed body (D3).
1616    #[serde(rename = "prefixEncoding", default)]
1617    pub prefix_encoding: Option<Vec<Encoding>>,
1618    /// 3.2 §"Media Type Object" — encoding applied to each streamed item
1619    /// (D3).
1620    #[serde(rename = "itemEncoding", default)]
1621    pub item_encoding: Option<Encoding>,
1622    #[serde(rename = "$ref", default)]
1623    pub reference: Option<String>,
1624    #[serde(flatten, default)]
1625    pub extensions: Extensions,
1626}
1627
1628#[cfg(test)]
1629#[allow(clippy::unwrap_used, clippy::expect_used)]
1630mod tests {
1631    use super::*;
1632    use serde_json::json;
1633
1634    #[test]
1635    fn paths_map_skips_extension_scalars() {
1636        // apicurio registry: an `x-codegen-contextRoot` scalar sits inside
1637        // `paths`; the document must still parse with the extension dropped.
1638        let spec: OpenApiSpec = serde_json::from_value(json!({
1639            "openapi": "3.0.0",
1640            "info": { "title": "lenient paths", "version": "1" },
1641            "paths": {
1642                "x-codegen-contextRoot": "/apis/registry/v2",
1643                "/items": {
1644                    "get": {
1645                        "operationId": "listItems",
1646                        "responses": { "204": { "description": "ok" } }
1647                    }
1648                }
1649            }
1650        }))
1651        .unwrap();
1652        let paths = spec.paths.unwrap();
1653        assert!(paths.contains_key("/items"));
1654        assert!(!paths.contains_key("x-codegen-contextRoot"));
1655    }
1656
1657    #[test]
1658    fn test_parse_simple_object_schema() {
1659        let schema_json = json!({
1660            "type": "object",
1661            "properties": {
1662                "name": {
1663                    "type": "string",
1664                    "description": "User name"
1665                },
1666                "age": {
1667                    "type": "integer"
1668                }
1669            },
1670            "required": ["name"]
1671        });
1672
1673        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1674
1675        match schema {
1676            Schema::Typed {
1677                schema_type: SchemaType::Object,
1678                details,
1679            } => {
1680                assert!(details.properties.is_some());
1681                assert_eq!(details.required, Some(vec!["name".to_string()]));
1682                assert!(details.is_field_required("name"));
1683                assert!(!details.is_field_required("age"));
1684            }
1685            _ => panic!("Expected object schema"),
1686        }
1687    }
1688
1689    #[test]
1690    fn test_parse_string_enum() {
1691        let schema_json = json!({
1692            "type": "string",
1693            "enum": ["active", "inactive", "pending"],
1694            "description": "User status"
1695        });
1696
1697        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1698
1699        match schema {
1700            Schema::Typed {
1701                schema_type: SchemaType::String,
1702                details,
1703            } => {
1704                assert!(details.is_string_enum());
1705                let values = details.string_enum_values().unwrap();
1706                assert_eq!(values, vec!["active", "inactive", "pending"]);
1707            }
1708            _ => panic!("Expected string enum schema"),
1709        }
1710    }
1711
1712    #[test]
1713    fn test_parse_reference_schema() {
1714        let schema_json = json!({
1715            "$ref": "#/components/schemas/User"
1716        });
1717
1718        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1719
1720        assert!(schema.is_reference());
1721        assert_eq!(schema.reference(), Some("#/components/schemas/User"));
1722    }
1723
1724    #[test]
1725    fn test_parse_discriminated_union() {
1726        let schema_json = json!({
1727            "oneOf": [
1728                {"$ref": "#/components/schemas/Dog"},
1729                {"$ref": "#/components/schemas/Cat"}
1730            ],
1731            "discriminator": {
1732                "propertyName": "petType"
1733            }
1734        });
1735
1736        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1737
1738        assert!(schema.is_discriminated_union());
1739        let discriminator = schema.discriminator().unwrap();
1740        assert_eq!(discriminator.property_name, "petType");
1741    }
1742
1743    #[test]
1744    fn test_parse_nullable_pattern() {
1745        let schema_json = json!({
1746            "anyOf": [
1747                {"$ref": "#/components/schemas/User"},
1748                {"type": "null"}
1749            ]
1750        });
1751
1752        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1753
1754        assert!(schema.is_nullable_pattern());
1755        let non_null = schema.non_null_variant().unwrap();
1756        assert!(non_null.is_reference());
1757    }
1758
1759    #[test]
1760    fn explicit_null_only_branches_are_order_independent_nullable_patterns() {
1761        for union_keyword in ["anyOf", "oneOf"] {
1762            for null_schema in [
1763                json!({"type": "null"}),
1764                json!({"type": ["null"]}),
1765                json!({"const": null}),
1766                json!({"enum": [null]}),
1767            ] {
1768                for variants in [
1769                    vec![
1770                        json!({"$ref": "#/components/schemas/User"}),
1771                        null_schema.clone(),
1772                    ],
1773                    vec![
1774                        null_schema.clone(),
1775                        json!({"$ref": "#/components/schemas/User"}),
1776                    ],
1777                ] {
1778                    let schema: Schema = serde_json::from_value(json!({
1779                        (union_keyword): variants,
1780                    }))
1781                    .unwrap();
1782                    let non_null = schema.non_null_variant().unwrap_or_else(|| {
1783                        panic!("{union_keyword} must recognize {null_schema} as null-only")
1784                    });
1785                    assert_eq!(
1786                        non_null.reference(),
1787                        Some("#/components/schemas/User"),
1788                        "{union_keyword} with {null_schema}"
1789                    );
1790                }
1791            }
1792        }
1793    }
1794
1795    #[test]
1796    fn nullable_schemas_are_real_union_branches_even_beside_references() {
1797        for union_keyword in ["anyOf", "oneOf"] {
1798            for nullable_branch in [
1799                json!({"nullable": true}),
1800                json!({"type": "object", "nullable": true}),
1801                json!({
1802                    "$ref": "#/components/schemas/Other",
1803                    "nullable": true
1804                }),
1805            ] {
1806                for variants in [
1807                    vec![
1808                        json!({"$ref": "#/components/schemas/User"}),
1809                        nullable_branch.clone(),
1810                    ],
1811                    vec![
1812                        nullable_branch.clone(),
1813                        json!({"$ref": "#/components/schemas/User"}),
1814                    ],
1815                ] {
1816                    let schema: Schema = serde_json::from_value(json!({
1817                        (union_keyword): variants,
1818                    }))
1819                    .unwrap();
1820                    assert!(
1821                        schema.non_null_variant().is_none(),
1822                        "{union_keyword} must preserve nullable branch {nullable_branch}"
1823                    );
1824                }
1825            }
1826        }
1827    }
1828
1829    #[test]
1830    fn reference_sibling_nullable_annotation_is_not_lost() {
1831        for reference_keyword in ["$ref", "$recursiveRef", "$dynamicRef"] {
1832            let nullable: Schema = serde_json::from_value(json!({
1833                (reference_keyword): "#/components/schemas/Value",
1834                "nullable": true,
1835                "description": "retained sibling"
1836            }))
1837            .unwrap();
1838            assert!(
1839                nullable.is_nullable_any(),
1840                "{reference_keyword} should retain nullable:true"
1841            );
1842
1843            let non_nullable: Schema = serde_json::from_value(json!({
1844                (reference_keyword): "#/components/schemas/Value",
1845                "nullable": false
1846            }))
1847            .unwrap();
1848            assert!(
1849                !non_nullable.is_nullable_any(),
1850                "{reference_keyword} nullable:false must stay non-nullable"
1851            );
1852        }
1853    }
1854
1855    #[test]
1856    fn null_only_enum_and_const_infer_null_instead_of_string() {
1857        for source in [json!({"enum": [null]}), json!({"const": null})] {
1858            let schema: Schema = serde_json::from_value(source.clone()).unwrap();
1859            assert_eq!(schema.inferred_type(), Some(SchemaType::Null), "{source}");
1860        }
1861
1862        for source in [
1863            json!({"enum": ["null"]}),
1864            json!({"enum": ["ready", null]}),
1865            json!({"type": "string", "enum": ["ready"]}),
1866        ] {
1867            let schema: Schema = serde_json::from_value(source.clone()).unwrap();
1868            assert_ne!(schema.inferred_type(), Some(SchemaType::Null), "{source}");
1869        }
1870    }
1871
1872    #[test]
1873    fn three_branch_unions_do_not_collapse_even_with_an_explicit_null() {
1874        for union_keyword in ["anyOf", "oneOf"] {
1875            let schema: Schema = serde_json::from_value(json!({
1876                (union_keyword): [
1877                    {"$ref": "#/components/schemas/User"},
1878                    {"type": "null"},
1879                    {"type": "array", "items": {"type": "string"}}
1880                ],
1881            }))
1882            .unwrap();
1883            assert!(
1884                schema.non_null_variant().is_none(),
1885                "{union_keyword} with three branches must retain its non-null union"
1886            );
1887            assert!(
1888                schema.is_nullable_any(),
1889                "{union_keyword} with an explicit null branch must remain nullable"
1890            );
1891        }
1892    }
1893
1894    #[test]
1895    fn is_json_media_type_accepts_canonical_and_structured_suffix() {
1896        // Canonical
1897        assert!(is_json_media_type("application/json"));
1898        // Parameters tolerated (RFC 7231 §3.1.1.1)
1899        assert!(is_json_media_type("application/json; charset=utf-8"));
1900        assert!(is_json_media_type("APPLICATION/JSON"));
1901        // RFC 6839 +json structured-syntax suffix
1902        assert!(is_json_media_type("application/vnd.api+json"));
1903        assert!(is_json_media_type("application/hal+json"));
1904        assert!(is_json_media_type("application/problem+json"));
1905        assert!(is_json_media_type("application/ld+json"));
1906        assert!(is_json_media_type(
1907            "application/vnd.api+json; charset=utf-8"
1908        ));
1909        // Negatives
1910        assert!(!is_json_media_type("application/xml"));
1911        assert!(!is_json_media_type("application/x-www-form-urlencoded"));
1912        assert!(!is_json_media_type("text/plain"));
1913        assert!(!is_json_media_type("application/jsonbutnotreally"));
1914        // +json suffix only applies to application/* per RFC 6839
1915        assert!(!is_json_media_type("text/something+json"));
1916    }
1917
1918    #[test]
1919    fn response_media_helpers_normalize_parameters_and_case() {
1920        assert_eq!(
1921            media_type_essence("  Text/Plain ; charset=utf-8  "),
1922            "Text/Plain"
1923        );
1924        assert!(is_text_media_type("TEXT/HTML; charset=UTF-8"));
1925        assert!(!is_text_media_type("Text/Event-Stream; charset=utf-8"));
1926        assert!(is_wildcard_media_type("*/*; q=0.8"));
1927        assert!(is_wildcard_media_type("IMAGE/*"));
1928        assert!(is_wildcard_media_type("text/*"));
1929        assert!(!is_wildcard_media_type("image/*+json"));
1930        assert!(!is_wildcard_media_type("application/json"));
1931    }
1932
1933    #[test]
1934    fn response_media_classifier_keeps_json_sse_and_text_distinct() {
1935        for media_type in [
1936            "application/json",
1937            "APPLICATION/PROBLEM+JSON; charset=utf-8",
1938        ] {
1939            assert_eq!(
1940                classify_response_media_type(media_type, None),
1941                ResponseMediaKind::Json,
1942                "{media_type}"
1943            );
1944        }
1945
1946        assert_eq!(
1947            classify_response_media_type("Text/Event-Stream; charset=utf-8", None),
1948            ResponseMediaKind::EventStream
1949        );
1950        for media_type in ["text/plain", "TEXT/HTML; charset=UTF-8"] {
1951            assert_eq!(
1952                classify_response_media_type(media_type, None),
1953                ResponseMediaKind::Text,
1954                "{media_type}"
1955            );
1956        }
1957        assert_eq!(
1958            classify_response_media_type("text/event-streaming", None),
1959            ResponseMediaKind::Text
1960        );
1961        assert_eq!(
1962            classify_response_media_type("text/*", None),
1963            ResponseMediaKind::Unsupported,
1964            "a media range is not a valid concrete response Content-Type"
1965        );
1966    }
1967
1968    #[test]
1969    fn response_json_content_skips_schema_less_canonical_entry() {
1970        let response: Response = serde_json::from_value(json!({
1971            "description": "mixed JSON",
1972            "content": {
1973                "application/json": {},
1974                "application/vnd.example+json": {
1975                    "schema": { "type": "string" }
1976                }
1977            }
1978        }))
1979        .unwrap();
1980
1981        let (media_type, schema) = response.json_content().expect("schema-bearing JSON");
1982        assert_eq!(media_type, "application/vnd.example+json");
1983        assert!(matches!(schema.schema_type(), Some(SchemaType::String)));
1984    }
1985
1986    #[test]
1987    fn response_media_classifier_recognizes_binary_formats_and_wildcards() {
1988        for media_type in [
1989            "image/png",
1990            "IMAGE/*; version=1",
1991            "audio/mpeg",
1992            "video/mp4",
1993            "application/octet-stream",
1994            "APPLICATION/ZIP; version=1",
1995            "application/*",
1996            "*/*",
1997        ] {
1998            assert_eq!(
1999                classify_response_media_type(media_type, None),
2000                ResponseMediaKind::Binary,
2001                "{media_type}"
2002            );
2003        }
2004
2005        let binary_schema: Schema = serde_json::from_value(json!({
2006            "type": "string",
2007            "format": "BINARY"
2008        }))
2009        .unwrap();
2010        assert_eq!(
2011            classify_response_media_type("application/x-custom", Some(&binary_schema)),
2012            ResponseMediaKind::Binary
2013        );
2014        assert_eq!(
2015            classify_response_media_type("text/plain", Some(&binary_schema)),
2016            ResponseMediaKind::Binary,
2017            "an explicit binary schema must prevent UTF-8 decoding"
2018        );
2019        assert!(is_binary_media_type(
2020            "application/x-custom",
2021            Some(&binary_schema)
2022        ));
2023    }
2024
2025    #[test]
2026    fn response_media_classifier_leaves_ambiguous_formats_unsupported() {
2027        let string_schema: Schema = serde_json::from_value(json!({ "type": "string" })).unwrap();
2028        for media_type in ["application/x-unknown", "not-a-media-type"] {
2029            assert_eq!(
2030                classify_response_media_type(media_type, Some(&string_schema)),
2031                ResponseMediaKind::Unsupported,
2032                "{media_type}"
2033            );
2034        }
2035        // PDF bodies are raw bytes; XML bodies are character data. Both are
2036        // pass-through lossless for a server that never parses the payload,
2037        // so they classify instead of failing generation.
2038        assert_eq!(
2039            classify_response_media_type("application/pdf", Some(&string_schema)),
2040            ResponseMediaKind::Binary
2041        );
2042        assert_eq!(
2043            classify_response_media_type("application/xml", Some(&string_schema)),
2044            ResponseMediaKind::Text
2045        );
2046        assert_eq!(
2047            classify_response_media_type("application/atom+xml", Some(&string_schema)),
2048            ResponseMediaKind::Text
2049        );
2050        // JWT compact serializations (RFC 7519) are ASCII text.
2051        assert_eq!(
2052            classify_response_media_type("application/jwt", Some(&string_schema)),
2053            ResponseMediaKind::Text
2054        );
2055        assert!(!is_binary_media_type("text/plain", None));
2056    }
2057
2058    #[test]
2059    fn request_body_json_schema_finds_vnd_api_plus_json() {
2060        // Mirrors Latitude.sh: request body declared under
2061        // application/vnd.api+json without a sibling application/json.
2062        let body_json = json!({
2063            "required": true,
2064            "content": {
2065                "application/vnd.api+json": {
2066                    "schema": {"$ref": "#/components/schemas/create_api_key"}
2067                }
2068            }
2069        });
2070
2071        let body: RequestBody = serde_json::from_value(body_json).unwrap();
2072        let schema = body.json_schema().expect("expected +json schema match");
2073        assert!(schema.is_reference());
2074    }
2075
2076    #[test]
2077    fn request_body_best_content_prefers_canonical_json_over_plus_json() {
2078        // When both are present (e.g. Latitude.sh's POST /auth/api_keys),
2079        // best_content should still pick application/json for backwards
2080        // compatibility with the existing snapshot suite.
2081        let body_json = json!({
2082            "required": true,
2083            "content": {
2084                "application/json": {
2085                    "schema": {"$ref": "#/components/schemas/A"}
2086                },
2087                "application/vnd.api+json": {
2088                    "schema": {"$ref": "#/components/schemas/B"}
2089                }
2090            }
2091        });
2092
2093        let body: RequestBody = serde_json::from_value(body_json).unwrap();
2094        let (ct, _) = body.best_content().expect("expected best_content");
2095        assert_eq!(ct, "application/json");
2096    }
2097
2098    #[test]
2099    fn request_body_best_content_falls_back_to_plus_json() {
2100        // When only the +json variant is declared, best_content returns
2101        // it instead of skipping straight to form-urlencoded.
2102        let body_json = json!({
2103            "required": true,
2104            "content": {
2105                "application/vnd.api+json": {
2106                    "schema": {"$ref": "#/components/schemas/B"}
2107                }
2108            }
2109        });
2110
2111        let body: RequestBody = serde_json::from_value(body_json).unwrap();
2112        let (ct, _) = body.best_content().expect("expected best_content");
2113        assert_eq!(ct, "application/vnd.api+json");
2114    }
2115
2116    #[test]
2117    fn request_body_best_content_does_not_select_wildcard_media_ranges() {
2118        let body: RequestBody = serde_json::from_value(json!({
2119            "required": true,
2120            "content": {
2121                "image/*": {
2122                    "schema": { "type": "string", "format": "binary" }
2123                },
2124                "*/*": {
2125                    "schema": { "type": "string", "format": "binary" }
2126                }
2127            }
2128        }))
2129        .unwrap();
2130
2131        assert!(
2132            body.best_content().is_none(),
2133            "request media ranges require a runtime concrete Content-Type"
2134        );
2135    }
2136
2137    #[test]
2138    fn request_body_best_content_matches_parameterized_text_plain_by_essence() {
2139        let body: RequestBody = serde_json::from_value(json!({
2140            "required": true,
2141            "content": {
2142                "Text/Plain; charset=utf-8": {
2143                    "schema": { "type": "string" }
2144                }
2145            }
2146        }))
2147        .unwrap();
2148
2149        let (media_type, _) = body.best_content().expect("parameterized text body");
2150        assert_eq!(media_type, "Text/Plain; charset=utf-8");
2151    }
2152
2153    #[test]
2154    fn response_json_schema_finds_vnd_api_plus_json() {
2155        // Mirrors every Latitude.sh response: schema lives under
2156        // application/vnd.api+json only.
2157        let resp_json = json!({
2158            "description": "OK",
2159            "content": {
2160                "application/vnd.api+json": {
2161                    "schema": {"$ref": "#/components/schemas/api_keys"}
2162                }
2163            }
2164        });
2165
2166        let resp: Response = serde_json::from_value(resp_json).unwrap();
2167        let schema = resp.json_schema().expect("expected +json schema match");
2168        assert!(schema.is_reference());
2169    }
2170}