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