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