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