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