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    pub fn schema_type(&self) -> Option<&SchemaType> {
668        match self {
669            Schema::Typed { schema_type, .. } => Some(schema_type),
670            Schema::TypedMulti { schema_types, .. } => schema_types
671                .iter()
672                .find(|t| **t != SchemaType::Null)
673                .or_else(|| schema_types.first()),
674            _ => None,
675        }
676    }
677
678    /// True when the schema's type set explicitly contains `null`.
679    /// (3.1 canonical nullability via `type: ["X", "null"]`.)
680    pub fn type_array_contains_null(&self) -> bool {
681        match self {
682            Schema::TypedMulti { schema_types, .. } => schema_types.contains(&SchemaType::Null),
683            _ => false,
684        }
685    }
686
687    /// True when the schema is nullable in any form OpenAPI allows:
688    /// 3.0's `nullable: true`, 3.1's `type: ["X", "null"]`, or an
689    /// `anyOf`/`oneOf` carrying a `null` branch.
690    ///
691    /// Property nullability must be decided through this, not through any
692    /// single one of the three checks. Each form was added separately and each
693    /// time a call site was missed: `nullable: true` first, then the
694    /// `anyOf`-with-null shape (openapi-generator-bgo), leaving the 3.1
695    /// canonical type-array form unhandled on properties
696    /// (openapi-generator-dsu) — which silently generated non-`Option` fields
697    /// for values the API really does send as `null`.
698    pub fn is_nullable_any(&self) -> bool {
699        self.details().is_nullable()
700            || self.type_array_contains_null()
701            || self.is_nullable_pattern()
702    }
703
704    /// Get schema details
705    pub fn details(&self) -> &SchemaDetails {
706        static EMPTY_DETAILS: Lazy<SchemaDetails> = Lazy::new(SchemaDetails::default);
707        match self {
708            Schema::Typed { details, .. } => details,
709            Schema::TypedMulti { details, .. } => details,
710            Schema::Reference { .. } | Schema::RecursiveRef { .. } | Schema::DynamicRef { .. } => {
711                &EMPTY_DETAILS
712            }
713            Schema::OneOf { details, .. } => details,
714            Schema::AnyOf { details, .. } => details,
715            Schema::AllOf { details, .. } => details,
716            Schema::Untyped { details } => details,
717        }
718    }
719
720    /// Get mutable schema details
721    pub fn details_mut(&mut self) -> &mut SchemaDetails {
722        match self {
723            Schema::Typed { details, .. } => details,
724            Schema::TypedMulti { details, .. } => details,
725            Schema::Reference { .. } => {
726                panic!("Cannot get mutable details for reference schema")
727            }
728            Schema::RecursiveRef { .. } => {
729                panic!("Cannot get mutable details for recursive reference schema")
730            }
731            Schema::DynamicRef { .. } => {
732                panic!("Cannot get mutable details for dynamic reference schema")
733            }
734            Schema::OneOf { details, .. } => details,
735            Schema::AnyOf { details, .. } => details,
736            Schema::AllOf { details, .. } => details,
737            Schema::Untyped { details } => details,
738        }
739    }
740
741    /// Check if this is any kind of reference (regular or recursive)
742    pub fn is_reference(&self) -> bool {
743        matches!(self, Schema::Reference { .. } | Schema::RecursiveRef { .. })
744    }
745
746    /// Get reference string if this is a reference
747    pub fn reference(&self) -> Option<&str> {
748        match self {
749            Schema::Reference { reference, .. } => Some(reference),
750            _ => None,
751        }
752    }
753
754    /// Get recursive reference string if this is a recursive reference
755    pub fn recursive_reference(&self) -> Option<&str> {
756        match self {
757            Schema::RecursiveRef { recursive_ref, .. } => Some(recursive_ref),
758            _ => None,
759        }
760    }
761
762    /// Check if this is a discriminated union
763    pub fn is_discriminated_union(&self) -> bool {
764        match self {
765            Schema::OneOf { discriminator, .. } => discriminator.is_some(),
766            Schema::AnyOf { discriminator, .. } => discriminator.is_some(),
767            _ => false,
768        }
769    }
770
771    /// Get discriminator if this is a discriminated union
772    pub fn discriminator(&self) -> Option<&Discriminator> {
773        match self {
774            Schema::OneOf { discriminator, .. } => discriminator.as_ref(),
775            Schema::AnyOf { discriminator, .. } => discriminator.as_ref(),
776            _ => None,
777        }
778    }
779
780    /// Get union variants
781    pub fn union_variants(&self) -> Option<&[Schema]> {
782        match self {
783            Schema::OneOf { one_of, .. } => Some(one_of),
784            Schema::AnyOf { any_of, .. } => Some(any_of),
785            _ => None,
786        }
787    }
788
789    /// Check if this appears to be a nullable pattern (anyOf or oneOf with null)
790    pub fn is_nullable_pattern(&self) -> bool {
791        let variants = match self {
792            Schema::AnyOf { any_of, .. } => any_of,
793            Schema::OneOf { one_of, .. } => one_of,
794            _ => return false,
795        };
796        variants.len() == 2
797            && variants
798                .iter()
799                .any(|s| matches!(s.schema_type(), Some(SchemaType::Null)))
800    }
801
802    /// Get the non-null variant from a nullable pattern
803    pub fn non_null_variant(&self) -> Option<&Schema> {
804        if !self.is_nullable_pattern() {
805            return None;
806        }
807        let variants = match self {
808            Schema::AnyOf { any_of, .. } => any_of,
809            Schema::OneOf { one_of, .. } => one_of,
810            _ => return None,
811        };
812        variants
813            .iter()
814            .find(|s| !matches!(s.schema_type(), Some(SchemaType::Null)))
815    }
816
817    /// Infer schema type from structure if not explicitly set
818    pub fn inferred_type(&self) -> Option<SchemaType> {
819        match self {
820            Schema::Typed { schema_type, .. } => Some(schema_type.clone()),
821            Schema::TypedMulti { .. } => self.schema_type().cloned(),
822            Schema::Untyped { details } => {
823                // Infer from structure
824                if details.properties.is_some() {
825                    Some(SchemaType::Object)
826                } else if details.items.is_some() {
827                    Some(SchemaType::Array)
828                } else if details.enum_values.is_some() {
829                    Some(SchemaType::String) // Assume string enum
830                } else {
831                    None
832                }
833            }
834            _ => None,
835        }
836    }
837}
838
839impl SchemaDetails {
840    /// Check if this schema is nullable
841    pub fn is_nullable(&self) -> bool {
842        self.nullable.unwrap_or(false)
843    }
844
845    /// Check if this is a string enum
846    ///
847    /// A standalone string `const` (no `enum` array) is treated as a
848    /// degenerate single-value enum so the generator emits a tightly-typed
849    /// single-variant enum instead of a bare `String`. See issue #10.
850    pub fn is_string_enum(&self) -> bool {
851        self.enum_values.is_some() || self.const_string_value().is_some()
852    }
853
854    /// Get enum values as strings if this is a string enum.
855    ///
856    /// Falls back to `[const_value]` when `enum` is absent but `const` is a
857    /// string, so a property like `{ "type": "string", "const": "X" }`
858    /// produces a single-variant enum.
859    pub fn string_enum_values(&self) -> Option<Vec<String>> {
860        if let Some(values) = self.enum_values.as_ref() {
861            // Tolerate non-string scalars in `enum` for `type: string` schemas
862            // (gitpod has `enum: [2000, 5000, ...]` on a string-typed field).
863            // Without this, `filter_map(.as_str())` produced an empty Vec
864            // and we emitted an empty enum that fails to compile.
865            return Some(
866                values
867                    .iter()
868                    .map(|v| match v {
869                        Value::String(s) => s.clone(),
870                        Value::Number(n) => n.to_string(),
871                        Value::Bool(b) => b.to_string(),
872                        Value::Null => "null".to_string(),
873                        _ => v.to_string(),
874                    })
875                    .collect(),
876            );
877        }
878        self.const_string_value().map(|s| vec![s])
879    }
880
881    fn const_string_value(&self) -> Option<String> {
882        self.const_value
883            .as_ref()
884            .and_then(|v| v.as_str())
885            .map(|s| s.to_string())
886    }
887
888    /// Check if a field is required
889    pub fn is_field_required(&self, field_name: &str) -> bool {
890        self.required
891            .as_ref()
892            .map(|req| req.contains(&field_name.to_string()))
893            .unwrap_or(false)
894    }
895}
896
897/// OpenAPI Path Item Object
898#[derive(Debug, Clone, Deserialize, Serialize)]
899pub struct PathItem {
900    #[serde(default)]
901    pub summary: Option<String>,
902    #[serde(default)]
903    pub description: Option<String>,
904    pub get: Option<Operation>,
905    pub put: Option<Operation>,
906    pub post: Option<Operation>,
907    pub delete: Option<Operation>,
908    pub options: Option<Operation>,
909    pub head: Option<Operation>,
910    pub patch: Option<Operation>,
911    pub trace: Option<Operation>,
912    /// 3.2 §"Path Item Object" — `QUERY` HTTP method (D1). Originally
913    /// proposed for safe, idempotent reads with a body.
914    pub query: Option<Operation>,
915    /// 3.2 §"Path Item Object" — extension map for HTTP methods beyond the
916    /// well-known ones (e.g. WebDAV's PROPFIND, SEARCH; LINK/UNLINK). Keys
917    /// are uppercase method names (D1).
918    #[serde(rename = "additionalOperations", default)]
919    pub additional_operations: Option<BTreeMap<String, Operation>>,
920    pub parameters: Option<Vec<Parameter>>,
921    #[serde(default)]
922    pub servers: Option<Vec<Server>>,
923    #[serde(rename = "$ref", default)]
924    pub reference: Option<String>,
925    #[serde(flatten, default)]
926    pub extensions: Extensions,
927}
928
929impl PathItem {
930    /// Get all operations in this path item, including 3.2's `query`
931    /// (D1) and any custom verbs declared in `additionalOperations`.
932    pub fn operations(&self) -> Vec<(&str, &Operation)> {
933        let mut ops = Vec::new();
934        if let Some(ref op) = self.get {
935            ops.push(("get", op));
936        }
937        if let Some(ref op) = self.put {
938            ops.push(("put", op));
939        }
940        if let Some(ref op) = self.post {
941            ops.push(("post", op));
942        }
943        if let Some(ref op) = self.delete {
944            ops.push(("delete", op));
945        }
946        if let Some(ref op) = self.options {
947            ops.push(("options", op));
948        }
949        if let Some(ref op) = self.head {
950            ops.push(("head", op));
951        }
952        if let Some(ref op) = self.patch {
953            ops.push(("patch", op));
954        }
955        if let Some(ref op) = self.trace {
956            ops.push(("trace", op));
957        }
958        if let Some(ref op) = self.query {
959            ops.push(("query", op));
960        }
961        if let Some(map) = &self.additional_operations {
962            for (verb, op) in map {
963                ops.push((verb.as_str(), op));
964            }
965        }
966        ops
967    }
968}
969
970/// OpenAPI Operation Object
971#[derive(Debug, Clone, Deserialize, Serialize)]
972pub struct Operation {
973    #[serde(rename = "operationId", default)]
974    pub operation_id: Option<String>,
975    #[serde(default)]
976    pub summary: Option<String>,
977    #[serde(default)]
978    pub description: Option<String>,
979    #[serde(default)]
980    pub tags: Option<Vec<String>>,
981    #[serde(default)]
982    pub deprecated: Option<bool>,
983    pub parameters: Option<Vec<Parameter>>,
984    #[serde(rename = "requestBody")]
985    pub request_body: Option<RequestBody>,
986    pub responses: Option<BTreeMap<String, Response>>,
987    #[serde(default)]
988    pub callbacks: Option<BTreeMap<String, Callback>>,
989    #[serde(default)]
990    pub security: Option<Vec<BTreeMap<String, Vec<String>>>>,
991    #[serde(default)]
992    pub servers: Option<Vec<Server>>,
993    #[serde(rename = "externalDocs", default)]
994    pub external_docs: Option<ExternalDocs>,
995    #[serde(flatten, default)]
996    pub extensions: Extensions,
997}
998
999/// OpenAPI Parameter Object
1000#[derive(Debug, Clone, Deserialize, Serialize)]
1001pub struct Parameter {
1002    #[serde(default)]
1003    pub name: Option<String>,
1004    #[serde(rename = "in", default)]
1005    pub location: Option<String>,
1006    #[serde(default)]
1007    pub required: Option<bool>,
1008    #[serde(default)]
1009    pub deprecated: Option<bool>,
1010    #[serde(rename = "allowEmptyValue", default)]
1011    pub allow_empty_value: Option<bool>,
1012    #[serde(default)]
1013    pub style: Option<String>,
1014    #[serde(default)]
1015    pub explode: Option<bool>,
1016    #[serde(rename = "allowReserved", default)]
1017    pub allow_reserved: Option<bool>,
1018    #[serde(default)]
1019    pub schema: Option<Schema>,
1020    #[serde(default)]
1021    pub content: Option<BTreeMap<String, MediaType>>,
1022    #[serde(default)]
1023    pub example: Option<Value>,
1024    #[serde(default)]
1025    pub examples: Option<BTreeMap<String, Example>>,
1026    #[serde(default)]
1027    pub description: Option<String>,
1028    #[serde(rename = "$ref", default)]
1029    pub reference: Option<String>,
1030    #[serde(flatten, default)]
1031    pub extensions: Extensions,
1032}
1033
1034/// OpenAPI Request Body Object
1035#[derive(Debug, Clone, Deserialize, Serialize)]
1036pub struct RequestBody {
1037    pub content: Option<BTreeMap<String, MediaType>>,
1038    #[serde(default)]
1039    pub description: Option<String>,
1040    #[serde(default)]
1041    pub required: Option<bool>,
1042    #[serde(rename = "$ref", default)]
1043    pub reference: Option<String>,
1044    #[serde(flatten, default)]
1045    pub extensions: Extensions,
1046}
1047
1048/// Semantic representation used for a response media entry.
1049///
1050/// This deliberately keeps server-sent events separate from ordinary text:
1051/// although `text/event-stream` belongs to the `text` top-level type, callers
1052/// must stream it rather than buffer and UTF-8 decode it like `text/plain`.
1053#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1054#[serde(rename_all = "snake_case")]
1055pub enum ResponseMediaKind {
1056    Json,
1057    EventStream,
1058    Text,
1059    Binary,
1060    Unsupported,
1061}
1062
1063/// Return the media type essence, excluding parameters and surrounding space.
1064///
1065/// Media type comparisons remain ASCII-case-insensitive at their call sites;
1066/// this helper only provides one consistent way to discard parameters such as
1067/// `charset=utf-8` without allocating.
1068pub fn media_type_essence(content_type: &str) -> &str {
1069    content_type
1070        .split(';')
1071        .next()
1072        .unwrap_or(content_type)
1073        .trim()
1074}
1075
1076/// Returns true for media types whose payload is JSON.
1077///
1078/// Matches `application/json` exactly, plus any RFC 6839 structured-syntax
1079/// suffix variant of the form `application/<subtype>+json`
1080/// (e.g. `application/vnd.api+json`, `application/hal+json`,
1081/// `application/problem+json`). Trailing parameters such as
1082/// `; charset=utf-8` are tolerated.
1083pub fn is_json_media_type(ct: &str) -> bool {
1084    let essence = media_type_essence(ct).to_ascii_lowercase();
1085    if essence == "application/json" {
1086        return true;
1087    }
1088    if let Some(subtype) = essence.strip_prefix("application/") {
1089        return subtype.ends_with("+json");
1090    }
1091    false
1092}
1093
1094/// Returns true for `application/x-www-form-urlencoded` (with optional
1095/// parameters).
1096pub fn is_form_urlencoded_media_type(ct: &str) -> bool {
1097    let essence = media_type_essence(ct).to_ascii_lowercase();
1098    essence == "application/x-www-form-urlencoded"
1099}
1100
1101/// Returns true only for the `text/event-stream` media type essence.
1102///
1103/// Media type names are ASCII-case-insensitive and parameters do not change
1104/// the essence, so values such as `Text/Event-Stream; charset=utf-8` match,
1105/// while similarly prefixed subtypes such as `text/event-streaming` do not.
1106pub fn is_event_stream_media_type(ct: &str) -> bool {
1107    media_type_essence(ct).eq_ignore_ascii_case("text/event-stream")
1108}
1109
1110/// Returns true for non-SSE media types in the `text` top-level family.
1111///
1112/// Structured text formats in the `application` family whose instances are
1113/// UTF-8/UTF-16 character data — XML and its `+xml` suffix variants (RFC 7303,
1114/// RFC 6839) — are buffered and emitted as text as well; bytes are never
1115/// XML-parsed by the generated server, so a plain `String` body preserves
1116/// the payload losslessly.
1117pub fn is_text_media_type(ct: &str) -> bool {
1118    let Some((top_level, subtype)) = media_type_essence(ct).split_once('/') else {
1119        return false;
1120    };
1121    if top_level.eq_ignore_ascii_case("text")
1122        && !subtype.is_empty()
1123        && !is_event_stream_media_type(ct)
1124    {
1125        return true;
1126    }
1127    top_level.eq_ignore_ascii_case("application")
1128        && (subtype.eq_ignore_ascii_case("xml")
1129            || subtype.to_ascii_lowercase().ends_with("+xml")
1130            // JWT (RFC 7519) compact serializations are ASCII text: three
1131            // base64url segments joined by dots.
1132            || subtype.eq_ignore_ascii_case("jwt"))
1133}
1134
1135/// Returns true for OpenAPI media ranges with a wildcard subtype.
1136///
1137/// This recognizes both `*/*` and type-specific ranges such as `image/*`.
1138/// A wildcard is meaningful only as the complete subtype, so values such as
1139/// `image/*+json` do not match.
1140pub fn is_wildcard_media_type(ct: &str) -> bool {
1141    let Some((top_level, subtype)) = media_type_essence(ct).split_once('/') else {
1142        return false;
1143    };
1144    !top_level.is_empty() && subtype == "*"
1145}
1146
1147fn schema_has_binary_format(schema: Option<&Schema>) -> bool {
1148    schema.is_some_and(|schema| {
1149        schema
1150            .details()
1151            .format
1152            .as_deref()
1153            .is_some_and(|format| format.eq_ignore_ascii_case("binary"))
1154    })
1155}
1156
1157/// Returns true when a response representation must be handled as raw bytes.
1158///
1159/// An explicit schema `format: binary` takes precedence over a textual-looking
1160/// media type. Without that schema signal, known binary families and formats
1161/// are recognized, as are non-text OpenAPI wildcard media ranges. Text media
1162/// ranges cannot be emitted as a concrete response `Content-Type` and remain
1163/// unsupported unless their schema explicitly declares the binary format.
1164pub fn is_binary_media_type(ct: &str, schema: Option<&Schema>) -> bool {
1165    if schema_has_binary_format(schema) {
1166        return true;
1167    }
1168
1169    let essence = media_type_essence(ct);
1170    let Some((top_level, _)) = essence.split_once('/') else {
1171        return false;
1172    };
1173    if top_level.eq_ignore_ascii_case("image")
1174        || top_level.eq_ignore_ascii_case("audio")
1175        || top_level.eq_ignore_ascii_case("video")
1176    {
1177        return true;
1178    }
1179    if essence.eq_ignore_ascii_case("application/octet-stream")
1180        || essence.eq_ignore_ascii_case("application/zip")
1181        || essence.eq_ignore_ascii_case("application/pdf")
1182    {
1183        return true;
1184    }
1185
1186    !top_level.eq_ignore_ascii_case("text") && is_wildcard_media_type(ct)
1187}
1188
1189/// Classify one declared response representation for client/server analysis.
1190///
1191/// JSON and exact SSE retain their established behavior. A binary schema wins
1192/// over the media family so bytes are never accidentally UTF-8 decoded.
1193pub fn classify_response_media_type(ct: &str, schema: Option<&Schema>) -> ResponseMediaKind {
1194    if is_json_media_type(ct) {
1195        ResponseMediaKind::Json
1196    } else if is_event_stream_media_type(ct) {
1197        ResponseMediaKind::EventStream
1198    } else if schema_has_binary_format(schema) {
1199        ResponseMediaKind::Binary
1200    } else if is_text_media_type(ct) {
1201        if is_wildcard_media_type(ct) {
1202            ResponseMediaKind::Unsupported
1203        } else {
1204            ResponseMediaKind::Text
1205        }
1206    } else if is_binary_media_type(ct, schema) {
1207        ResponseMediaKind::Binary
1208    } else {
1209        ResponseMediaKind::Unsupported
1210    }
1211}
1212
1213fn find_json_content(content: &BTreeMap<String, MediaType>) -> Option<(&str, &MediaType)> {
1214    if let Some(mt) = content
1215        .get("application/json")
1216        .filter(|media_type| media_type.schema.is_some())
1217    {
1218        return Some(("application/json", mt));
1219    }
1220    content
1221        .iter()
1222        .find(|(ct, media_type)| is_json_media_type(ct) && media_type.schema.is_some())
1223        .map(|(ct, mt)| (ct.as_str(), mt))
1224        .or_else(|| {
1225            content
1226                .get("application/json")
1227                .map(|media_type| ("application/json", media_type))
1228        })
1229        .or_else(|| {
1230            content
1231                .iter()
1232                .find(|(ct, _)| is_json_media_type(ct))
1233                .map(|(ct, mt)| (ct.as_str(), mt))
1234        })
1235}
1236
1237impl RequestBody {
1238    /// Get schema for any JSON content type
1239    ///
1240    /// Prefers the canonical `application/json` entry, then falls back to
1241    /// any `application/*+json` variant (RFC 6839) such as
1242    /// `application/vnd.api+json` or `application/hal+json`.
1243    pub fn json_schema(&self) -> Option<&Schema> {
1244        self.content
1245            .as_ref()
1246            .and_then(find_json_content)
1247            .and_then(|(_, media_type)| media_type.schema.as_ref())
1248    }
1249
1250    /// Get the best content type and its schema, preferring JSON over others
1251    pub fn best_content(&self) -> Option<(&str, Option<&Schema>)> {
1252        let content = self.content.as_ref()?;
1253
1254        if let Some((ct, media_type)) = find_json_content(content) {
1255            return Some((ct, media_type.schema.as_ref()));
1256        }
1257
1258        const PRIORITY: &[&str] = &[
1259            "application/x-www-form-urlencoded",
1260            "multipart/form-data",
1261            "application/octet-stream",
1262            "text/plain",
1263        ];
1264        for preferred_essence in PRIORITY {
1265            if let Some((ct, media_type)) = content
1266                .iter()
1267                .find(|(ct, _)| media_type_essence(ct).eq_ignore_ascii_case(preferred_essence))
1268            {
1269                return Some((ct.as_str(), media_type.schema.as_ref()));
1270            }
1271        }
1272        // Character-data fallbacks (text/xml, application/xml, +xml suffixed)
1273        // are buffered as UTF-8 text like text/plain.
1274        if let Some((ct, media_type)) = content.iter().find(|(ct, _)| is_text_media_type(ct)) {
1275            return Some((ct.as_str(), media_type.schema.as_ref()));
1276        }
1277        content
1278            .iter()
1279            // A request media range is not a concrete Content-Type value. The
1280            // generated client cannot send `image/*` or `*/*`, and the server
1281            // cannot compare either range to one exact request representation,
1282            // so leave wildcard request content unsupported instead of
1283            // emitting a contract that always fails at runtime.
1284            .find(|(ct, media_type)| {
1285                !is_wildcard_media_type(ct) && is_binary_media_type(ct, media_type.schema.as_ref())
1286            })
1287            .map(|(ct, media_type)| (ct.as_str(), media_type.schema.as_ref()))
1288    }
1289}
1290
1291/// OpenAPI Response Object
1292#[derive(Debug, Clone, Deserialize, Serialize)]
1293pub struct Response {
1294    #[serde(default)]
1295    pub description: Option<String>,
1296    #[serde(default)]
1297    pub headers: Option<BTreeMap<String, Header>>,
1298    #[serde(default)]
1299    pub content: Option<BTreeMap<String, MediaType>>,
1300    #[serde(default)]
1301    pub links: Option<Value>,
1302    #[serde(rename = "$ref", default)]
1303    pub reference: Option<String>,
1304    #[serde(flatten, default)]
1305    pub extensions: Extensions,
1306}
1307
1308impl Response {
1309    /// Get schema for any JSON content type
1310    ///
1311    /// Prefers the canonical `application/json` entry, then falls back to
1312    /// any `application/*+json` variant (RFC 6839) such as
1313    /// `application/vnd.api+json`, `application/hal+json`, or
1314    /// `application/problem+json`.
1315    pub fn json_schema(&self) -> Option<&Schema> {
1316        self.content
1317            .as_ref()
1318            .and_then(find_json_content)
1319            .and_then(|(_, media_type)| media_type.schema.as_ref())
1320    }
1321
1322    /// Get the preferred JSON-compatible media type and its schema.
1323    pub fn json_content(&self) -> Option<(&str, &Schema)> {
1324        self.content
1325            .as_ref()
1326            .and_then(find_json_content)
1327            .and_then(|(content_type, media_type)| {
1328                media_type
1329                    .schema
1330                    .as_ref()
1331                    .map(|schema| (content_type, schema))
1332            })
1333    }
1334}
1335
1336/// OpenAPI Media Type Object
1337#[derive(Debug, Clone, Deserialize, Serialize)]
1338pub struct MediaType {
1339    #[serde(default)]
1340    pub schema: Option<Schema>,
1341    #[serde(default)]
1342    pub example: Option<Value>,
1343    #[serde(default)]
1344    pub examples: Option<BTreeMap<String, Example>>,
1345    #[serde(default)]
1346    pub encoding: Option<BTreeMap<String, Encoding>>,
1347    /// 3.2 §"Media Type Object" — schema for each item when streaming
1348    /// (D3). Common in `text/event-stream` and JSON-lines payloads.
1349    #[serde(rename = "itemSchema", default)]
1350    pub item_schema: Option<Schema>,
1351    /// 3.2 §"Media Type Object" — encoding for the leading prefix of a
1352    /// streamed body (D3).
1353    #[serde(rename = "prefixEncoding", default)]
1354    pub prefix_encoding: Option<Vec<Encoding>>,
1355    /// 3.2 §"Media Type Object" — encoding applied to each streamed item
1356    /// (D3).
1357    #[serde(rename = "itemEncoding", default)]
1358    pub item_encoding: Option<Encoding>,
1359    #[serde(rename = "$ref", default)]
1360    pub reference: Option<String>,
1361    #[serde(flatten, default)]
1362    pub extensions: Extensions,
1363}
1364
1365#[cfg(test)]
1366#[allow(clippy::unwrap_used, clippy::expect_used)]
1367mod tests {
1368    use super::*;
1369    use serde_json::json;
1370
1371    #[test]
1372    fn paths_map_skips_extension_scalars() {
1373        // apicurio registry: an `x-codegen-contextRoot` scalar sits inside
1374        // `paths`; the document must still parse with the extension dropped.
1375        let spec: OpenApiSpec = serde_json::from_value(json!({
1376            "openapi": "3.0.0",
1377            "info": { "title": "lenient paths", "version": "1" },
1378            "paths": {
1379                "x-codegen-contextRoot": "/apis/registry/v2",
1380                "/items": {
1381                    "get": {
1382                        "operationId": "listItems",
1383                        "responses": { "204": { "description": "ok" } }
1384                    }
1385                }
1386            }
1387        }))
1388        .unwrap();
1389        let paths = spec.paths.unwrap();
1390        assert!(paths.contains_key("/items"));
1391        assert!(!paths.contains_key("x-codegen-contextRoot"));
1392    }
1393
1394    #[test]
1395    fn test_parse_simple_object_schema() {
1396        let schema_json = json!({
1397            "type": "object",
1398            "properties": {
1399                "name": {
1400                    "type": "string",
1401                    "description": "User name"
1402                },
1403                "age": {
1404                    "type": "integer"
1405                }
1406            },
1407            "required": ["name"]
1408        });
1409
1410        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1411
1412        match schema {
1413            Schema::Typed {
1414                schema_type: SchemaType::Object,
1415                details,
1416            } => {
1417                assert!(details.properties.is_some());
1418                assert_eq!(details.required, Some(vec!["name".to_string()]));
1419                assert!(details.is_field_required("name"));
1420                assert!(!details.is_field_required("age"));
1421            }
1422            _ => panic!("Expected object schema"),
1423        }
1424    }
1425
1426    #[test]
1427    fn test_parse_string_enum() {
1428        let schema_json = json!({
1429            "type": "string",
1430            "enum": ["active", "inactive", "pending"],
1431            "description": "User status"
1432        });
1433
1434        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1435
1436        match schema {
1437            Schema::Typed {
1438                schema_type: SchemaType::String,
1439                details,
1440            } => {
1441                assert!(details.is_string_enum());
1442                let values = details.string_enum_values().unwrap();
1443                assert_eq!(values, vec!["active", "inactive", "pending"]);
1444            }
1445            _ => panic!("Expected string enum schema"),
1446        }
1447    }
1448
1449    #[test]
1450    fn test_parse_reference_schema() {
1451        let schema_json = json!({
1452            "$ref": "#/components/schemas/User"
1453        });
1454
1455        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1456
1457        assert!(schema.is_reference());
1458        assert_eq!(schema.reference(), Some("#/components/schemas/User"));
1459    }
1460
1461    #[test]
1462    fn test_parse_discriminated_union() {
1463        let schema_json = json!({
1464            "oneOf": [
1465                {"$ref": "#/components/schemas/Dog"},
1466                {"$ref": "#/components/schemas/Cat"}
1467            ],
1468            "discriminator": {
1469                "propertyName": "petType"
1470            }
1471        });
1472
1473        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1474
1475        assert!(schema.is_discriminated_union());
1476        let discriminator = schema.discriminator().unwrap();
1477        assert_eq!(discriminator.property_name, "petType");
1478    }
1479
1480    #[test]
1481    fn test_parse_nullable_pattern() {
1482        let schema_json = json!({
1483            "anyOf": [
1484                {"$ref": "#/components/schemas/User"},
1485                {"type": "null"}
1486            ]
1487        });
1488
1489        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1490
1491        assert!(schema.is_nullable_pattern());
1492        let non_null = schema.non_null_variant().unwrap();
1493        assert!(non_null.is_reference());
1494    }
1495
1496    #[test]
1497    fn is_json_media_type_accepts_canonical_and_structured_suffix() {
1498        // Canonical
1499        assert!(is_json_media_type("application/json"));
1500        // Parameters tolerated (RFC 7231 §3.1.1.1)
1501        assert!(is_json_media_type("application/json; charset=utf-8"));
1502        assert!(is_json_media_type("APPLICATION/JSON"));
1503        // RFC 6839 +json structured-syntax suffix
1504        assert!(is_json_media_type("application/vnd.api+json"));
1505        assert!(is_json_media_type("application/hal+json"));
1506        assert!(is_json_media_type("application/problem+json"));
1507        assert!(is_json_media_type("application/ld+json"));
1508        assert!(is_json_media_type(
1509            "application/vnd.api+json; charset=utf-8"
1510        ));
1511        // Negatives
1512        assert!(!is_json_media_type("application/xml"));
1513        assert!(!is_json_media_type("application/x-www-form-urlencoded"));
1514        assert!(!is_json_media_type("text/plain"));
1515        assert!(!is_json_media_type("application/jsonbutnotreally"));
1516        // +json suffix only applies to application/* per RFC 6839
1517        assert!(!is_json_media_type("text/something+json"));
1518    }
1519
1520    #[test]
1521    fn response_media_helpers_normalize_parameters_and_case() {
1522        assert_eq!(
1523            media_type_essence("  Text/Plain ; charset=utf-8  "),
1524            "Text/Plain"
1525        );
1526        assert!(is_text_media_type("TEXT/HTML; charset=UTF-8"));
1527        assert!(!is_text_media_type("Text/Event-Stream; charset=utf-8"));
1528        assert!(is_wildcard_media_type("*/*; q=0.8"));
1529        assert!(is_wildcard_media_type("IMAGE/*"));
1530        assert!(is_wildcard_media_type("text/*"));
1531        assert!(!is_wildcard_media_type("image/*+json"));
1532        assert!(!is_wildcard_media_type("application/json"));
1533    }
1534
1535    #[test]
1536    fn response_media_classifier_keeps_json_sse_and_text_distinct() {
1537        for media_type in [
1538            "application/json",
1539            "APPLICATION/PROBLEM+JSON; charset=utf-8",
1540        ] {
1541            assert_eq!(
1542                classify_response_media_type(media_type, None),
1543                ResponseMediaKind::Json,
1544                "{media_type}"
1545            );
1546        }
1547
1548        assert_eq!(
1549            classify_response_media_type("Text/Event-Stream; charset=utf-8", None),
1550            ResponseMediaKind::EventStream
1551        );
1552        for media_type in ["text/plain", "TEXT/HTML; charset=UTF-8"] {
1553            assert_eq!(
1554                classify_response_media_type(media_type, None),
1555                ResponseMediaKind::Text,
1556                "{media_type}"
1557            );
1558        }
1559        assert_eq!(
1560            classify_response_media_type("text/event-streaming", None),
1561            ResponseMediaKind::Text
1562        );
1563        assert_eq!(
1564            classify_response_media_type("text/*", None),
1565            ResponseMediaKind::Unsupported,
1566            "a media range is not a valid concrete response Content-Type"
1567        );
1568    }
1569
1570    #[test]
1571    fn response_json_content_skips_schema_less_canonical_entry() {
1572        let response: Response = serde_json::from_value(json!({
1573            "description": "mixed JSON",
1574            "content": {
1575                "application/json": {},
1576                "application/vnd.example+json": {
1577                    "schema": { "type": "string" }
1578                }
1579            }
1580        }))
1581        .unwrap();
1582
1583        let (media_type, schema) = response.json_content().expect("schema-bearing JSON");
1584        assert_eq!(media_type, "application/vnd.example+json");
1585        assert!(matches!(schema.schema_type(), Some(SchemaType::String)));
1586    }
1587
1588    #[test]
1589    fn response_media_classifier_recognizes_binary_formats_and_wildcards() {
1590        for media_type in [
1591            "image/png",
1592            "IMAGE/*; version=1",
1593            "audio/mpeg",
1594            "video/mp4",
1595            "application/octet-stream",
1596            "APPLICATION/ZIP; version=1",
1597            "application/*",
1598            "*/*",
1599        ] {
1600            assert_eq!(
1601                classify_response_media_type(media_type, None),
1602                ResponseMediaKind::Binary,
1603                "{media_type}"
1604            );
1605        }
1606
1607        let binary_schema: Schema = serde_json::from_value(json!({
1608            "type": "string",
1609            "format": "BINARY"
1610        }))
1611        .unwrap();
1612        assert_eq!(
1613            classify_response_media_type("application/x-custom", Some(&binary_schema)),
1614            ResponseMediaKind::Binary
1615        );
1616        assert_eq!(
1617            classify_response_media_type("text/plain", Some(&binary_schema)),
1618            ResponseMediaKind::Binary,
1619            "an explicit binary schema must prevent UTF-8 decoding"
1620        );
1621        assert!(is_binary_media_type(
1622            "application/x-custom",
1623            Some(&binary_schema)
1624        ));
1625    }
1626
1627    #[test]
1628    fn response_media_classifier_leaves_ambiguous_formats_unsupported() {
1629        let string_schema: Schema = serde_json::from_value(json!({ "type": "string" })).unwrap();
1630        for media_type in ["application/x-unknown", "not-a-media-type"] {
1631            assert_eq!(
1632                classify_response_media_type(media_type, Some(&string_schema)),
1633                ResponseMediaKind::Unsupported,
1634                "{media_type}"
1635            );
1636        }
1637        // PDF bodies are raw bytes; XML bodies are character data. Both are
1638        // pass-through lossless for a server that never parses the payload,
1639        // so they classify instead of failing generation.
1640        assert_eq!(
1641            classify_response_media_type("application/pdf", Some(&string_schema)),
1642            ResponseMediaKind::Binary
1643        );
1644        assert_eq!(
1645            classify_response_media_type("application/xml", Some(&string_schema)),
1646            ResponseMediaKind::Text
1647        );
1648        assert_eq!(
1649            classify_response_media_type("application/atom+xml", Some(&string_schema)),
1650            ResponseMediaKind::Text
1651        );
1652        // JWT compact serializations (RFC 7519) are ASCII text.
1653        assert_eq!(
1654            classify_response_media_type("application/jwt", Some(&string_schema)),
1655            ResponseMediaKind::Text
1656        );
1657        assert!(!is_binary_media_type("text/plain", None));
1658    }
1659
1660    #[test]
1661    fn request_body_json_schema_finds_vnd_api_plus_json() {
1662        // Mirrors Latitude.sh: request body declared under
1663        // application/vnd.api+json without a sibling application/json.
1664        let body_json = json!({
1665            "required": true,
1666            "content": {
1667                "application/vnd.api+json": {
1668                    "schema": {"$ref": "#/components/schemas/create_api_key"}
1669                }
1670            }
1671        });
1672
1673        let body: RequestBody = serde_json::from_value(body_json).unwrap();
1674        let schema = body.json_schema().expect("expected +json schema match");
1675        assert!(schema.is_reference());
1676    }
1677
1678    #[test]
1679    fn request_body_best_content_prefers_canonical_json_over_plus_json() {
1680        // When both are present (e.g. Latitude.sh's POST /auth/api_keys),
1681        // best_content should still pick application/json for backwards
1682        // compatibility with the existing snapshot suite.
1683        let body_json = json!({
1684            "required": true,
1685            "content": {
1686                "application/json": {
1687                    "schema": {"$ref": "#/components/schemas/A"}
1688                },
1689                "application/vnd.api+json": {
1690                    "schema": {"$ref": "#/components/schemas/B"}
1691                }
1692            }
1693        });
1694
1695        let body: RequestBody = serde_json::from_value(body_json).unwrap();
1696        let (ct, _) = body.best_content().expect("expected best_content");
1697        assert_eq!(ct, "application/json");
1698    }
1699
1700    #[test]
1701    fn request_body_best_content_falls_back_to_plus_json() {
1702        // When only the +json variant is declared, best_content returns
1703        // it instead of skipping straight to form-urlencoded.
1704        let body_json = json!({
1705            "required": true,
1706            "content": {
1707                "application/vnd.api+json": {
1708                    "schema": {"$ref": "#/components/schemas/B"}
1709                }
1710            }
1711        });
1712
1713        let body: RequestBody = serde_json::from_value(body_json).unwrap();
1714        let (ct, _) = body.best_content().expect("expected best_content");
1715        assert_eq!(ct, "application/vnd.api+json");
1716    }
1717
1718    #[test]
1719    fn request_body_best_content_does_not_select_wildcard_media_ranges() {
1720        let body: RequestBody = serde_json::from_value(json!({
1721            "required": true,
1722            "content": {
1723                "image/*": {
1724                    "schema": { "type": "string", "format": "binary" }
1725                },
1726                "*/*": {
1727                    "schema": { "type": "string", "format": "binary" }
1728                }
1729            }
1730        }))
1731        .unwrap();
1732
1733        assert!(
1734            body.best_content().is_none(),
1735            "request media ranges require a runtime concrete Content-Type"
1736        );
1737    }
1738
1739    #[test]
1740    fn request_body_best_content_matches_parameterized_text_plain_by_essence() {
1741        let body: RequestBody = serde_json::from_value(json!({
1742            "required": true,
1743            "content": {
1744                "Text/Plain; charset=utf-8": {
1745                    "schema": { "type": "string" }
1746                }
1747            }
1748        }))
1749        .unwrap();
1750
1751        let (media_type, _) = body.best_content().expect("parameterized text body");
1752        assert_eq!(media_type, "Text/Plain; charset=utf-8");
1753    }
1754
1755    #[test]
1756    fn response_json_schema_finds_vnd_api_plus_json() {
1757        // Mirrors every Latitude.sh response: schema lives under
1758        // application/vnd.api+json only.
1759        let resp_json = json!({
1760            "description": "OK",
1761            "content": {
1762                "application/vnd.api+json": {
1763                    "schema": {"$ref": "#/components/schemas/api_keys"}
1764                }
1765            }
1766        });
1767
1768        let resp: Response = serde_json::from_value(resp_json).unwrap();
1769        let schema = resp.json_schema().expect("expected +json schema match");
1770        assert!(schema.is_reference());
1771    }
1772}