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)]
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#[derive(Debug, Clone, Deserialize, Serialize)]
35pub struct Info {
36    pub title: String,
37    #[serde(default)]
38    pub summary: Option<String>,
39    #[serde(default)]
40    pub description: Option<String>,
41    #[serde(rename = "termsOfService", default)]
42    pub terms_of_service: Option<String>,
43    #[serde(default)]
44    pub contact: Option<Value>,
45    #[serde(default)]
46    pub license: Option<Value>,
47    #[serde(default)]
48    pub version: Option<String>,
49    #[serde(flatten, default)]
50    pub extensions: Extensions,
51}
52
53#[derive(Debug, Clone, Deserialize, Serialize)]
54pub struct Components {
55    #[serde(default)]
56    pub schemas: Option<BTreeMap<String, Schema>>,
57    #[serde(default)]
58    pub responses: Option<BTreeMap<String, Response>>,
59    #[serde(default)]
60    pub parameters: Option<BTreeMap<String, Parameter>>,
61    #[serde(default)]
62    pub examples: Option<BTreeMap<String, Example>>,
63    #[serde(rename = "requestBodies", default)]
64    pub request_bodies: Option<BTreeMap<String, RequestBody>>,
65    #[serde(default)]
66    pub headers: Option<BTreeMap<String, Header>>,
67    #[serde(rename = "securitySchemes", default)]
68    pub security_schemes: Option<BTreeMap<String, SecurityScheme>>,
69    #[serde(default)]
70    pub links: Option<BTreeMap<String, Link>>,
71    #[serde(default)]
72    pub callbacks: Option<BTreeMap<String, Callback>>,
73    /// 3.1+ §Components — reusable Path Items.
74    #[serde(rename = "pathItems", default)]
75    pub path_items: Option<BTreeMap<String, PathItem>>,
76    /// 3.2 §Components — reusable Media Types.
77    #[serde(rename = "mediaTypes", default)]
78    pub media_types: Option<BTreeMap<String, MediaType>>,
79    #[serde(flatten, default)]
80    pub extensions: Extensions,
81}
82
83#[derive(Debug, Clone, Deserialize, Serialize)]
84#[serde(untagged)]
85pub enum Schema {
86    /// Schema reference
87    Reference {
88        #[serde(rename = "$ref")]
89        reference: String,
90        #[serde(flatten)]
91        extra: BTreeMap<String, Value>,
92    },
93    /// Recursive reference (older draft, kept for OAS 3.0 compatibility)
94    RecursiveRef {
95        #[serde(rename = "$recursiveRef")]
96        recursive_ref: String,
97        #[serde(flatten)]
98        extra: BTreeMap<String, Value>,
99    },
100    /// Dynamic reference per JSON Schema 2020-12 (OAS 3.1+).
101    /// `$dynamicRef` resolves against the nearest enclosing `$dynamicAnchor`.
102    /// J1: modeled today; full dynamic resolution at analysis time is a
103    /// follow-up. Self-references via `$dynamicRef: "#x"` are treated as
104    /// recursive references to the schema bearing `$dynamicAnchor: "x"`.
105    DynamicRef {
106        #[serde(rename = "$dynamicRef")]
107        dynamic_ref: String,
108        #[serde(flatten)]
109        extra: BTreeMap<String, Value>,
110    },
111    /// OneOf union
112    OneOf {
113        #[serde(rename = "oneOf")]
114        one_of: Vec<Schema>,
115        #[serde(skip_serializing_if = "Option::is_none")]
116        discriminator: Option<Discriminator>,
117        #[serde(flatten)]
118        details: SchemaDetails,
119    },
120    /// AnyOf union (must come before Typed to handle type + anyOf patterns)
121    AnyOf {
122        #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
123        schema_type: Option<SchemaType>,
124        #[serde(rename = "anyOf")]
125        any_of: Vec<Schema>,
126        #[serde(skip_serializing_if = "Option::is_none")]
127        discriminator: Option<Discriminator>,
128        #[serde(flatten)]
129        details: SchemaDetails,
130    },
131    /// Schema with `type` as an array (OpenAPI 3.1 / JSON Schema 2020-12).
132    /// The canonical 3.1 way to express a nullable type is
133    /// `type: ["string", "null"]`. Listed before `Typed` so the array form
134    /// matches first.
135    TypedMulti {
136        #[serde(rename = "type")]
137        schema_types: Vec<SchemaType>,
138        #[serde(flatten)]
139        details: SchemaDetails,
140    },
141    /// Schema with a single explicit type
142    Typed {
143        #[serde(rename = "type")]
144        schema_type: SchemaType,
145        #[serde(flatten)]
146        details: SchemaDetails,
147    },
148    /// AllOf composition
149    AllOf {
150        #[serde(rename = "allOf")]
151        all_of: Vec<Schema>,
152        #[serde(flatten)]
153        details: SchemaDetails,
154    },
155    /// Schema without explicit type (inferred from other fields)
156    Untyped {
157        #[serde(flatten)]
158        details: SchemaDetails,
159    },
160}
161
162#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
163#[serde(rename_all = "lowercase")]
164pub enum SchemaType {
165    String,
166    Integer,
167    Number,
168    Boolean,
169    Array,
170    Object,
171    #[serde(rename = "null")]
172    Null,
173}
174
175#[derive(Debug, Clone, Default, Deserialize, Serialize)]
176pub struct SchemaDetails {
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub description: Option<String>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub nullable: Option<bool>,
181
182    // OpenAPI 3.0 recursive support (obsoleted by JSON Schema 2020-12).
183    #[serde(rename = "$recursiveAnchor", skip_serializing_if = "Option::is_none")]
184    pub recursive_anchor: Option<bool>,
185
186    // JSON Schema 2020-12 dynamic anchors (J1).
187    #[serde(rename = "$dynamicAnchor", skip_serializing_if = "Option::is_none")]
188    pub dynamic_anchor: Option<String>,
189    #[serde(rename = "$id", skip_serializing_if = "Option::is_none")]
190    pub schema_id: Option<String>,
191
192    // String-specific
193    #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
194    pub enum_values: Option<Vec<Value>>,
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub format: Option<String>,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub default: Option<Value>,
199    #[serde(
200        rename = "const",
201        default,
202        deserialize_with = "deserialize_present_value",
203        skip_serializing_if = "Option::is_none"
204    )]
205    pub const_value: Option<Value>,
206
207    // Object-specific
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub properties: Option<BTreeMap<String, Schema>>,
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub required: Option<Vec<String>>,
212    #[serde(
213        rename = "additionalProperties",
214        skip_serializing_if = "Option::is_none"
215    )]
216    pub additional_properties: Option<AdditionalProperties>,
217
218    // Array-specific
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub items: Option<Box<Schema>>,
221
222    // Number-specific
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub minimum: Option<f64>,
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub maximum: Option<f64>,
227
228    // Validation
229    #[serde(rename = "minLength", skip_serializing_if = "Option::is_none")]
230    pub min_length: Option<u64>,
231    #[serde(rename = "maxLength", skip_serializing_if = "Option::is_none")]
232    pub max_length: Option<u64>,
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub pattern: Option<String>,
235    /// In 3.0/Swagger this was a `bool` flag relative to `minimum`; in 3.1
236    /// (JSON Schema 2020-12) it's a number. Accept either to round-trip
237    /// real-world specs. (Tracked under J3 — proper validation lowering.)
238    #[serde(rename = "exclusiveMinimum", skip_serializing_if = "Option::is_none")]
239    pub exclusive_minimum: Option<ExclusiveBound>,
240    #[serde(rename = "exclusiveMaximum", skip_serializing_if = "Option::is_none")]
241    pub exclusive_maximum: Option<ExclusiveBound>,
242    #[serde(rename = "multipleOf", skip_serializing_if = "Option::is_none")]
243    pub multiple_of: Option<f64>,
244    #[serde(rename = "minItems", skip_serializing_if = "Option::is_none")]
245    pub min_items: Option<u64>,
246    #[serde(rename = "maxItems", skip_serializing_if = "Option::is_none")]
247    pub max_items: Option<u64>,
248    #[serde(rename = "uniqueItems", skip_serializing_if = "Option::is_none")]
249    pub unique_items: Option<bool>,
250    #[serde(rename = "minProperties", skip_serializing_if = "Option::is_none")]
251    pub min_properties: Option<u64>,
252    #[serde(rename = "maxProperties", skip_serializing_if = "Option::is_none")]
253    pub max_properties: Option<u64>,
254
255    // JSON Schema 2020-12 array keywords (J4, J8).
256    #[serde(rename = "prefixItems", skip_serializing_if = "Option::is_none")]
257    pub prefix_items: Option<Vec<Schema>>,
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub contains: Option<Box<Schema>>,
260    #[serde(rename = "minContains", skip_serializing_if = "Option::is_none")]
261    pub min_contains: Option<u64>,
262    #[serde(rename = "maxContains", skip_serializing_if = "Option::is_none")]
263    pub max_contains: Option<u64>,
264
265    // JSON Schema 2020-12 object keywords (J5, J6, J7).
266    #[serde(rename = "patternProperties", skip_serializing_if = "Option::is_none")]
267    pub pattern_properties: Option<BTreeMap<String, Schema>>,
268    #[serde(rename = "propertyNames", skip_serializing_if = "Option::is_none")]
269    pub property_names: Option<Box<Schema>>,
270    #[serde(
271        rename = "unevaluatedProperties",
272        skip_serializing_if = "Option::is_none"
273    )]
274    pub unevaluated_properties: Option<AdditionalProperties>,
275    #[serde(rename = "unevaluatedItems", skip_serializing_if = "Option::is_none")]
276    pub unevaluated_items: Option<AdditionalProperties>,
277    #[serde(rename = "dependentRequired", skip_serializing_if = "Option::is_none")]
278    pub dependent_required: Option<BTreeMap<String, Vec<String>>>,
279    #[serde(rename = "dependentSchemas", skip_serializing_if = "Option::is_none")]
280    pub dependent_schemas: Option<BTreeMap<String, Schema>>,
281
282    // JSON Schema 2020-12 content keywords (J8).
283    #[serde(rename = "contentEncoding", skip_serializing_if = "Option::is_none")]
284    pub content_encoding: Option<String>,
285    #[serde(rename = "contentMediaType", skip_serializing_if = "Option::is_none")]
286    pub content_media_type: Option<String>,
287    #[serde(rename = "contentSchema", skip_serializing_if = "Option::is_none")]
288    pub content_schema: Option<Box<Schema>>,
289
290    // JSON Schema 2020-12 conditional keywords.
291    #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
292    pub if_schema: Option<Box<Schema>>,
293    #[serde(rename = "then", skip_serializing_if = "Option::is_none")]
294    pub then_schema: Option<Box<Schema>>,
295    #[serde(rename = "else", skip_serializing_if = "Option::is_none")]
296    pub else_schema: Option<Box<Schema>>,
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub not: Option<Box<Schema>>,
299
300    // 3.0 deprecated annotations now first-class (kept since openai-responses fixture is OAS 3.0).
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub title: Option<String>,
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub deprecated: Option<bool>,
305    #[serde(rename = "readOnly", skip_serializing_if = "Option::is_none")]
306    pub read_only: Option<bool>,
307    #[serde(rename = "writeOnly", skip_serializing_if = "Option::is_none")]
308    pub write_only: Option<bool>,
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub examples: Option<Vec<Value>>,
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub example: Option<Value>,
313    /// JSON Schema annotation `$comment`.
314    #[serde(rename = "$comment", skip_serializing_if = "Option::is_none")]
315    pub comment: Option<String>,
316    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
317    pub schema_keyword: Option<String>,
318    #[serde(rename = "$defs", skip_serializing_if = "Option::is_none")]
319    pub defs: Option<BTreeMap<String, Schema>>,
320
321    // Extensions and unknown fields. After J5–J8 above this should be x-*-only
322    // for well-formed OAS 3.1+ specs.
323    #[serde(flatten)]
324    pub extra: BTreeMap<String, Value>,
325}
326
327fn deserialize_present_value<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
328where
329    D: serde::Deserializer<'de>,
330{
331    Value::deserialize(deserializer).map(Some)
332}
333
334/// 3.0 used `exclusiveMinimum: true` as a bool flag against `minimum`;
335/// 3.1 (JSON Schema 2020-12) uses `exclusiveMinimum: <number>`.
336#[derive(Debug, Clone, Deserialize, Serialize)]
337#[serde(untagged)]
338pub enum ExclusiveBound {
339    Bool(bool),
340    Number(f64),
341}
342
343#[derive(Debug, Clone, Deserialize, Serialize)]
344#[serde(untagged)]
345pub enum AdditionalProperties {
346    Boolean(bool),
347    Schema(Box<Schema>),
348}
349
350/// OpenAPI Example Object (H6).
351#[derive(Debug, Clone, Deserialize, Serialize)]
352pub struct Example {
353    #[serde(default)]
354    pub summary: Option<String>,
355    #[serde(default)]
356    pub description: Option<String>,
357    /// Singular embedded value. Mutually exclusive with `external_value`.
358    #[serde(default)]
359    pub value: Option<Value>,
360    #[serde(rename = "externalValue", default)]
361    pub external_value: Option<String>,
362    /// 3.2 §"Example Object" — typed pre-serialization data.
363    #[serde(rename = "dataValue", default)]
364    pub data_value: Option<Value>,
365    /// 3.2 §"Example Object" — already-serialized form.
366    #[serde(rename = "serializedValue", default)]
367    pub serialized_value: Option<String>,
368    #[serde(rename = "$ref", default)]
369    pub reference: Option<String>,
370    #[serde(flatten, default)]
371    pub extensions: Extensions,
372}
373
374/// OpenAPI Link Object (H7).
375#[derive(Debug, Clone, Deserialize, Serialize)]
376pub struct Link {
377    #[serde(rename = "operationRef", default)]
378    pub operation_ref: Option<String>,
379    #[serde(rename = "operationId", default)]
380    pub operation_id: Option<String>,
381    #[serde(default)]
382    pub parameters: Option<BTreeMap<String, Value>>,
383    #[serde(rename = "requestBody", default)]
384    pub request_body: Option<Value>,
385    #[serde(default)]
386    pub description: Option<String>,
387    #[serde(default)]
388    pub server: Option<Server>,
389    #[serde(rename = "$ref", default)]
390    pub reference: Option<String>,
391    #[serde(flatten, default)]
392    pub extensions: Extensions,
393}
394
395/// OpenAPI Callback Object (H8). A map keyed by runtime-expression URL
396/// templates, with Path Item values.
397#[derive(Debug, Clone, Deserialize, Serialize)]
398#[serde(transparent)]
399pub struct Callback(pub BTreeMap<String, PathItem>);
400
401/// OpenAPI Encoding Object (H4). Used inside `multipart/form-data` and
402/// `application/x-www-form-urlencoded` Media Type bodies.
403#[derive(Debug, Clone, Deserialize, Serialize)]
404pub struct Encoding {
405    #[serde(rename = "contentType", default)]
406    pub content_type: Option<String>,
407    #[serde(default)]
408    pub headers: Option<BTreeMap<String, Header>>,
409    #[serde(default)]
410    pub style: Option<String>,
411    #[serde(default)]
412    pub explode: Option<bool>,
413    #[serde(rename = "allowReserved", default)]
414    pub allow_reserved: Option<bool>,
415    /// 3.2 §"Encoding Object" — nested encoding for arrays of items.
416    #[serde(rename = "itemEncoding", default)]
417    pub item_encoding: Option<Box<Encoding>>,
418    #[serde(flatten, default)]
419    pub extensions: Extensions,
420}
421
422/// OpenAPI Header Object (H5). Structurally a Parameter minus the `name`
423/// and `in` fields. Used in Response.headers, Encoding.headers, and
424/// Components.headers.
425#[derive(Debug, Clone, Deserialize, Serialize)]
426pub struct Header {
427    #[serde(default)]
428    pub description: Option<String>,
429    #[serde(default)]
430    pub required: Option<bool>,
431    #[serde(default)]
432    pub deprecated: Option<bool>,
433    #[serde(rename = "allowEmptyValue", default)]
434    pub allow_empty_value: Option<bool>,
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    #[serde(default)]
442    pub schema: Option<Schema>,
443    #[serde(default)]
444    pub content: Option<BTreeMap<String, MediaType>>,
445    #[serde(default)]
446    pub example: Option<Value>,
447    #[serde(default)]
448    pub examples: Option<Value>,
449    #[serde(rename = "$ref", default)]
450    pub reference: Option<String>,
451    #[serde(flatten, default)]
452    pub extensions: Extensions,
453}
454
455/// OpenAPI Security Scheme Object (H2). Covers all 3.x scheme types:
456/// apiKey, http (basic/bearer/digest), oauth2 (with flows), openIdConnect,
457/// and 3.1+ mutualTLS.
458#[derive(Debug, Clone, Deserialize, Serialize)]
459#[serde(tag = "type")]
460pub enum SecurityScheme {
461    #[serde(rename = "apiKey")]
462    ApiKey {
463        name: String,
464        #[serde(rename = "in")]
465        location: String, // "query" | "header" | "cookie"
466        #[serde(default)]
467        description: Option<String>,
468        /// 3.2 §"Security Scheme Object" — D10.
469        #[serde(default)]
470        deprecated: Option<bool>,
471        #[serde(flatten, default)]
472        extensions: Extensions,
473    },
474    #[serde(rename = "http")]
475    Http {
476        scheme: String, // "basic" | "bearer" | "digest" | …
477        #[serde(rename = "bearerFormat", default)]
478        bearer_format: Option<String>,
479        #[serde(default)]
480        description: Option<String>,
481        #[serde(default)]
482        deprecated: Option<bool>,
483        #[serde(flatten, default)]
484        extensions: Extensions,
485    },
486    #[serde(rename = "mutualTLS")]
487    MutualTls {
488        #[serde(default)]
489        description: Option<String>,
490        #[serde(default)]
491        deprecated: Option<bool>,
492        #[serde(flatten, default)]
493        extensions: Extensions,
494    },
495    #[serde(rename = "oauth2")]
496    OAuth2 {
497        // Boxed to keep the SecurityScheme enum's variants similarly sized
498        // (the OAuthFlows tree is ~800 bytes; clippy::large_enum_variant
499        // flagged the disparity).
500        flows: Box<OAuthFlows>,
501        #[serde(default)]
502        description: Option<String>,
503        /// 3.2 §"Security Scheme Object" — well-known metadata URL (D4).
504        #[serde(rename = "oauth2MetadataUrl", default)]
505        oauth2_metadata_url: Option<String>,
506        #[serde(default)]
507        deprecated: Option<bool>,
508        #[serde(flatten, default)]
509        extensions: Extensions,
510    },
511    #[serde(rename = "openIdConnect")]
512    OpenIdConnect {
513        #[serde(rename = "openIdConnectUrl")]
514        open_id_connect_url: String,
515        #[serde(default)]
516        description: Option<String>,
517        #[serde(default)]
518        deprecated: Option<bool>,
519        #[serde(flatten, default)]
520        extensions: Extensions,
521    },
522}
523
524#[derive(Debug, Clone, Deserialize, Serialize)]
525pub struct OAuthFlows {
526    #[serde(default)]
527    pub implicit: Option<OAuthFlow>,
528    #[serde(default)]
529    pub password: Option<OAuthFlow>,
530    #[serde(rename = "clientCredentials", default)]
531    pub client_credentials: Option<OAuthFlow>,
532    #[serde(rename = "authorizationCode", default)]
533    pub authorization_code: Option<OAuthFlow>,
534    /// 3.2 §"OAuth Flows Object" — device authorization flow (D4).
535    #[serde(rename = "deviceAuthorization", default)]
536    pub device_authorization: Option<OAuthFlow>,
537    #[serde(flatten, default)]
538    pub extensions: Extensions,
539}
540
541#[derive(Debug, Clone, Deserialize, Serialize)]
542pub struct OAuthFlow {
543    #[serde(rename = "authorizationUrl", default)]
544    pub authorization_url: Option<String>,
545    #[serde(rename = "tokenUrl", default)]
546    pub token_url: Option<String>,
547    #[serde(rename = "refreshUrl", default)]
548    pub refresh_url: Option<String>,
549    /// 3.2 §"OAuth Flow Object" — required for `deviceAuthorization` (D4).
550    #[serde(rename = "deviceAuthorizationUrl", default)]
551    pub device_authorization_url: Option<String>,
552    pub scopes: BTreeMap<String, String>,
553    #[serde(flatten, default)]
554    pub extensions: Extensions,
555}
556
557/// OpenAPI External Documentation Object (H10).
558#[derive(Debug, Clone, Deserialize, Serialize)]
559pub struct ExternalDocs {
560    pub url: String,
561    #[serde(default)]
562    pub description: Option<String>,
563    #[serde(flatten, default)]
564    pub extensions: Extensions,
565}
566
567/// OpenAPI Tag Object (H9 + D5 — 3.2 added summary/parent/kind).
568#[derive(Debug, Clone, Deserialize, Serialize)]
569pub struct Tag {
570    pub name: String,
571    /// 3.2 §"Tag Object" — short summary of the tag.
572    #[serde(default)]
573    pub summary: Option<String>,
574    #[serde(default)]
575    pub description: Option<String>,
576    /// 3.2 §"Tag Object" — name of a parent tag for hierarchical organisation.
577    #[serde(default)]
578    pub parent: Option<String>,
579    /// 3.2 §"Tag Object" — categorisation hint (e.g. "feature", "audience",
580    /// "compliance"). Free-form string; consumers MAY define their own
581    /// vocabulary.
582    #[serde(default)]
583    pub kind: Option<String>,
584    #[serde(rename = "externalDocs", default)]
585    pub external_docs: Option<ExternalDocs>,
586    #[serde(flatten, default)]
587    pub extensions: Extensions,
588}
589
590/// OpenAPI Server Object (H1). Multiple servers, server variables, and
591/// 3.2's `name` field are all modeled.
592#[derive(Debug, Clone, Deserialize, Serialize)]
593pub struct Server {
594    pub url: String,
595    /// 3.2 §"Server Object" — server identifier for runtime selection (D8).
596    #[serde(default)]
597    pub name: Option<String>,
598    #[serde(default)]
599    pub description: Option<String>,
600    #[serde(default)]
601    pub variables: Option<BTreeMap<String, ServerVariable>>,
602    #[serde(flatten, default)]
603    pub extensions: Extensions,
604}
605
606#[derive(Debug, Clone, Deserialize, Serialize)]
607pub struct ServerVariable {
608    /// REQUIRED in 3.0/3.1. In 3.2 this MAY be omitted when `enum` is present.
609    #[serde(default)]
610    pub default: Option<String>,
611    #[serde(rename = "enum", default)]
612    pub enum_values: Option<Vec<String>>,
613    #[serde(default)]
614    pub description: Option<String>,
615    #[serde(flatten, default)]
616    pub extensions: Extensions,
617}
618
619#[derive(Debug, Clone, Deserialize, Serialize)]
620pub struct Discriminator {
621    #[serde(rename = "propertyName")]
622    pub property_name: String,
623    #[serde(default)]
624    pub mapping: Option<BTreeMap<String, String>>,
625    /// 3.2 §"Discriminator Object" — fallback mapping target when the
626    /// discriminator value is unknown (D9). Captured today; a future bead
627    /// will emit a `_Other(Value)` enum variant when this is set.
628    #[serde(rename = "defaultMapping", default)]
629    pub default_mapping: Option<String>,
630    #[serde(flatten, default)]
631    pub extensions: Extensions,
632}
633
634impl Schema {
635    /// Get the schema type if explicitly set. For `Schema::TypedMulti` the
636    /// "primary" non-null type is returned; if the array contained only `null`
637    /// then `Some(&SchemaType::Null)` is returned.
638    pub fn schema_type(&self) -> Option<&SchemaType> {
639        match self {
640            Schema::Typed { schema_type, .. } => Some(schema_type),
641            Schema::TypedMulti { schema_types, .. } => schema_types
642                .iter()
643                .find(|t| **t != SchemaType::Null)
644                .or_else(|| schema_types.first()),
645            _ => None,
646        }
647    }
648
649    /// True when the schema's type set explicitly contains `null`.
650    /// (3.1 canonical nullability via `type: ["X", "null"]`.)
651    pub fn type_array_contains_null(&self) -> bool {
652        match self {
653            Schema::TypedMulti { schema_types, .. } => schema_types.contains(&SchemaType::Null),
654            _ => false,
655        }
656    }
657
658    /// True when the schema is nullable in any form OpenAPI allows:
659    /// 3.0's `nullable: true`, 3.1's `type: ["X", "null"]`, or an
660    /// `anyOf`/`oneOf` carrying a `null` branch.
661    ///
662    /// Property nullability must be decided through this, not through any
663    /// single one of the three checks. Each form was added separately and each
664    /// time a call site was missed: `nullable: true` first, then the
665    /// `anyOf`-with-null shape (openapi-generator-bgo), leaving the 3.1
666    /// canonical type-array form unhandled on properties
667    /// (openapi-generator-dsu) — which silently generated non-`Option` fields
668    /// for values the API really does send as `null`.
669    pub fn is_nullable_any(&self) -> bool {
670        self.details().is_nullable()
671            || self.type_array_contains_null()
672            || self.is_nullable_pattern()
673    }
674
675    /// Get schema details
676    pub fn details(&self) -> &SchemaDetails {
677        static EMPTY_DETAILS: Lazy<SchemaDetails> = Lazy::new(SchemaDetails::default);
678        match self {
679            Schema::Typed { details, .. } => details,
680            Schema::TypedMulti { details, .. } => details,
681            Schema::Reference { .. } | Schema::RecursiveRef { .. } | Schema::DynamicRef { .. } => {
682                &EMPTY_DETAILS
683            }
684            Schema::OneOf { details, .. } => details,
685            Schema::AnyOf { details, .. } => details,
686            Schema::AllOf { details, .. } => details,
687            Schema::Untyped { details } => details,
688        }
689    }
690
691    /// Get mutable schema details
692    pub fn details_mut(&mut self) -> &mut SchemaDetails {
693        match self {
694            Schema::Typed { details, .. } => details,
695            Schema::TypedMulti { details, .. } => details,
696            Schema::Reference { .. } => {
697                panic!("Cannot get mutable details for reference schema")
698            }
699            Schema::RecursiveRef { .. } => {
700                panic!("Cannot get mutable details for recursive reference schema")
701            }
702            Schema::DynamicRef { .. } => {
703                panic!("Cannot get mutable details for dynamic reference schema")
704            }
705            Schema::OneOf { details, .. } => details,
706            Schema::AnyOf { details, .. } => details,
707            Schema::AllOf { details, .. } => details,
708            Schema::Untyped { details } => details,
709        }
710    }
711
712    /// Check if this is any kind of reference (regular or recursive)
713    pub fn is_reference(&self) -> bool {
714        matches!(self, Schema::Reference { .. } | Schema::RecursiveRef { .. })
715    }
716
717    /// Get reference string if this is a reference
718    pub fn reference(&self) -> Option<&str> {
719        match self {
720            Schema::Reference { reference, .. } => Some(reference),
721            _ => None,
722        }
723    }
724
725    /// Get recursive reference string if this is a recursive reference
726    pub fn recursive_reference(&self) -> Option<&str> {
727        match self {
728            Schema::RecursiveRef { recursive_ref, .. } => Some(recursive_ref),
729            _ => None,
730        }
731    }
732
733    /// Check if this is a discriminated union
734    pub fn is_discriminated_union(&self) -> bool {
735        match self {
736            Schema::OneOf { discriminator, .. } => discriminator.is_some(),
737            Schema::AnyOf { discriminator, .. } => discriminator.is_some(),
738            _ => false,
739        }
740    }
741
742    /// Get discriminator if this is a discriminated union
743    pub fn discriminator(&self) -> Option<&Discriminator> {
744        match self {
745            Schema::OneOf { discriminator, .. } => discriminator.as_ref(),
746            Schema::AnyOf { discriminator, .. } => discriminator.as_ref(),
747            _ => None,
748        }
749    }
750
751    /// Get union variants
752    pub fn union_variants(&self) -> Option<&[Schema]> {
753        match self {
754            Schema::OneOf { one_of, .. } => Some(one_of),
755            Schema::AnyOf { any_of, .. } => Some(any_of),
756            _ => None,
757        }
758    }
759
760    /// Check if this appears to be a nullable pattern (anyOf or oneOf with null)
761    pub fn is_nullable_pattern(&self) -> bool {
762        let variants = match self {
763            Schema::AnyOf { any_of, .. } => any_of,
764            Schema::OneOf { one_of, .. } => one_of,
765            _ => return false,
766        };
767        variants.len() == 2
768            && variants
769                .iter()
770                .any(|s| matches!(s.schema_type(), Some(SchemaType::Null)))
771    }
772
773    /// Get the non-null variant from a nullable pattern
774    pub fn non_null_variant(&self) -> Option<&Schema> {
775        if !self.is_nullable_pattern() {
776            return None;
777        }
778        let variants = match self {
779            Schema::AnyOf { any_of, .. } => any_of,
780            Schema::OneOf { one_of, .. } => one_of,
781            _ => return None,
782        };
783        variants
784            .iter()
785            .find(|s| !matches!(s.schema_type(), Some(SchemaType::Null)))
786    }
787
788    /// Infer schema type from structure if not explicitly set
789    pub fn inferred_type(&self) -> Option<SchemaType> {
790        match self {
791            Schema::Typed { schema_type, .. } => Some(schema_type.clone()),
792            Schema::TypedMulti { .. } => self.schema_type().cloned(),
793            Schema::Untyped { details } => {
794                // Infer from structure
795                if details.properties.is_some() {
796                    Some(SchemaType::Object)
797                } else if details.items.is_some() {
798                    Some(SchemaType::Array)
799                } else if details.enum_values.is_some() {
800                    Some(SchemaType::String) // Assume string enum
801                } else {
802                    None
803                }
804            }
805            _ => None,
806        }
807    }
808}
809
810impl SchemaDetails {
811    /// Check if this schema is nullable
812    pub fn is_nullable(&self) -> bool {
813        self.nullable.unwrap_or(false)
814    }
815
816    /// Check if this is a string enum
817    ///
818    /// A standalone string `const` (no `enum` array) is treated as a
819    /// degenerate single-value enum so the generator emits a tightly-typed
820    /// single-variant enum instead of a bare `String`. See issue #10.
821    pub fn is_string_enum(&self) -> bool {
822        self.enum_values.is_some() || self.const_string_value().is_some()
823    }
824
825    /// Get enum values as strings if this is a string enum.
826    ///
827    /// Falls back to `[const_value]` when `enum` is absent but `const` is a
828    /// string, so a property like `{ "type": "string", "const": "X" }`
829    /// produces a single-variant enum.
830    pub fn string_enum_values(&self) -> Option<Vec<String>> {
831        if let Some(values) = self.enum_values.as_ref() {
832            // Tolerate non-string scalars in `enum` for `type: string` schemas
833            // (gitpod has `enum: [2000, 5000, ...]` on a string-typed field).
834            // Without this, `filter_map(.as_str())` produced an empty Vec
835            // and we emitted an empty enum that fails to compile.
836            return Some(
837                values
838                    .iter()
839                    .map(|v| match v {
840                        Value::String(s) => s.clone(),
841                        Value::Number(n) => n.to_string(),
842                        Value::Bool(b) => b.to_string(),
843                        Value::Null => "null".to_string(),
844                        _ => v.to_string(),
845                    })
846                    .collect(),
847            );
848        }
849        self.const_string_value().map(|s| vec![s])
850    }
851
852    fn const_string_value(&self) -> Option<String> {
853        self.const_value
854            .as_ref()
855            .and_then(|v| v.as_str())
856            .map(|s| s.to_string())
857    }
858
859    /// Check if a field is required
860    pub fn is_field_required(&self, field_name: &str) -> bool {
861        self.required
862            .as_ref()
863            .map(|req| req.contains(&field_name.to_string()))
864            .unwrap_or(false)
865    }
866}
867
868/// OpenAPI Path Item Object
869#[derive(Debug, Clone, Deserialize, Serialize)]
870pub struct PathItem {
871    #[serde(default)]
872    pub summary: Option<String>,
873    #[serde(default)]
874    pub description: Option<String>,
875    pub get: Option<Operation>,
876    pub put: Option<Operation>,
877    pub post: Option<Operation>,
878    pub delete: Option<Operation>,
879    pub options: Option<Operation>,
880    pub head: Option<Operation>,
881    pub patch: Option<Operation>,
882    pub trace: Option<Operation>,
883    /// 3.2 §"Path Item Object" — `QUERY` HTTP method (D1). Originally
884    /// proposed for safe, idempotent reads with a body.
885    pub query: Option<Operation>,
886    /// 3.2 §"Path Item Object" — extension map for HTTP methods beyond the
887    /// well-known ones (e.g. WebDAV's PROPFIND, SEARCH; LINK/UNLINK). Keys
888    /// are uppercase method names (D1).
889    #[serde(rename = "additionalOperations", default)]
890    pub additional_operations: Option<BTreeMap<String, Operation>>,
891    pub parameters: Option<Vec<Parameter>>,
892    #[serde(default)]
893    pub servers: Option<Vec<Server>>,
894    #[serde(rename = "$ref", default)]
895    pub reference: Option<String>,
896    #[serde(flatten, default)]
897    pub extensions: Extensions,
898}
899
900impl PathItem {
901    /// Get all operations in this path item, including 3.2's `query`
902    /// (D1) and any custom verbs declared in `additionalOperations`.
903    pub fn operations(&self) -> Vec<(&str, &Operation)> {
904        let mut ops = Vec::new();
905        if let Some(ref op) = self.get {
906            ops.push(("get", op));
907        }
908        if let Some(ref op) = self.put {
909            ops.push(("put", op));
910        }
911        if let Some(ref op) = self.post {
912            ops.push(("post", op));
913        }
914        if let Some(ref op) = self.delete {
915            ops.push(("delete", op));
916        }
917        if let Some(ref op) = self.options {
918            ops.push(("options", op));
919        }
920        if let Some(ref op) = self.head {
921            ops.push(("head", op));
922        }
923        if let Some(ref op) = self.patch {
924            ops.push(("patch", op));
925        }
926        if let Some(ref op) = self.trace {
927            ops.push(("trace", op));
928        }
929        if let Some(ref op) = self.query {
930            ops.push(("query", op));
931        }
932        if let Some(map) = &self.additional_operations {
933            for (verb, op) in map {
934                ops.push((verb.as_str(), op));
935            }
936        }
937        ops
938    }
939}
940
941/// OpenAPI Operation Object
942#[derive(Debug, Clone, Deserialize, Serialize)]
943pub struct Operation {
944    #[serde(rename = "operationId", default)]
945    pub operation_id: Option<String>,
946    #[serde(default)]
947    pub summary: Option<String>,
948    #[serde(default)]
949    pub description: Option<String>,
950    #[serde(default)]
951    pub tags: Option<Vec<String>>,
952    #[serde(default)]
953    pub deprecated: Option<bool>,
954    pub parameters: Option<Vec<Parameter>>,
955    #[serde(rename = "requestBody")]
956    pub request_body: Option<RequestBody>,
957    pub responses: Option<BTreeMap<String, Response>>,
958    #[serde(default)]
959    pub callbacks: Option<BTreeMap<String, Callback>>,
960    #[serde(default)]
961    pub security: Option<Vec<BTreeMap<String, Vec<String>>>>,
962    #[serde(default)]
963    pub servers: Option<Vec<Server>>,
964    #[serde(rename = "externalDocs", default)]
965    pub external_docs: Option<ExternalDocs>,
966    #[serde(flatten, default)]
967    pub extensions: Extensions,
968}
969
970/// OpenAPI Parameter Object
971#[derive(Debug, Clone, Deserialize, Serialize)]
972pub struct Parameter {
973    #[serde(default)]
974    pub name: Option<String>,
975    #[serde(rename = "in", default)]
976    pub location: Option<String>,
977    #[serde(default)]
978    pub required: Option<bool>,
979    #[serde(default)]
980    pub deprecated: Option<bool>,
981    #[serde(rename = "allowEmptyValue", default)]
982    pub allow_empty_value: Option<bool>,
983    #[serde(default)]
984    pub style: Option<String>,
985    #[serde(default)]
986    pub explode: Option<bool>,
987    #[serde(rename = "allowReserved", default)]
988    pub allow_reserved: Option<bool>,
989    #[serde(default)]
990    pub schema: Option<Schema>,
991    #[serde(default)]
992    pub content: Option<BTreeMap<String, MediaType>>,
993    #[serde(default)]
994    pub example: Option<Value>,
995    #[serde(default)]
996    pub examples: Option<BTreeMap<String, Example>>,
997    #[serde(default)]
998    pub description: Option<String>,
999    #[serde(rename = "$ref", default)]
1000    pub reference: Option<String>,
1001    #[serde(flatten, default)]
1002    pub extensions: Extensions,
1003}
1004
1005/// OpenAPI Request Body Object
1006#[derive(Debug, Clone, Deserialize, Serialize)]
1007pub struct RequestBody {
1008    pub content: Option<BTreeMap<String, MediaType>>,
1009    #[serde(default)]
1010    pub description: Option<String>,
1011    #[serde(default)]
1012    pub required: Option<bool>,
1013    #[serde(rename = "$ref", default)]
1014    pub reference: Option<String>,
1015    #[serde(flatten, default)]
1016    pub extensions: Extensions,
1017}
1018
1019/// Returns true for media types whose payload is JSON.
1020///
1021/// Matches `application/json` exactly, plus any RFC 6839 structured-syntax
1022/// suffix variant of the form `application/<subtype>+json`
1023/// (e.g. `application/vnd.api+json`, `application/hal+json`,
1024/// `application/problem+json`). Trailing parameters such as
1025/// `; charset=utf-8` are tolerated.
1026pub fn is_json_media_type(ct: &str) -> bool {
1027    let essence = ct
1028        .split(';')
1029        .next()
1030        .unwrap_or(ct)
1031        .trim()
1032        .to_ascii_lowercase();
1033    if essence == "application/json" {
1034        return true;
1035    }
1036    if let Some(subtype) = essence.strip_prefix("application/") {
1037        return subtype.ends_with("+json");
1038    }
1039    false
1040}
1041
1042/// Returns true for `application/x-www-form-urlencoded` (with optional
1043/// parameters).
1044pub fn is_form_urlencoded_media_type(ct: &str) -> bool {
1045    let essence = ct
1046        .split(';')
1047        .next()
1048        .unwrap_or(ct)
1049        .trim()
1050        .to_ascii_lowercase();
1051    essence == "application/x-www-form-urlencoded"
1052}
1053
1054/// Returns true only for the `text/event-stream` media type essence.
1055///
1056/// Media type names are ASCII-case-insensitive and parameters do not change
1057/// the essence, so values such as `Text/Event-Stream; charset=utf-8` match,
1058/// while similarly prefixed subtypes such as `text/event-streaming` do not.
1059pub fn is_event_stream_media_type(ct: &str) -> bool {
1060    ct.split(';')
1061        .next()
1062        .unwrap_or(ct)
1063        .trim()
1064        .eq_ignore_ascii_case("text/event-stream")
1065}
1066
1067fn find_json_content(content: &BTreeMap<String, MediaType>) -> Option<(&str, &MediaType)> {
1068    if let Some(mt) = content.get("application/json") {
1069        return Some(("application/json", mt));
1070    }
1071    content
1072        .iter()
1073        .find(|(ct, _)| is_json_media_type(ct))
1074        .map(|(ct, mt)| (ct.as_str(), mt))
1075}
1076
1077impl RequestBody {
1078    /// Get schema for any JSON content type
1079    ///
1080    /// Prefers the canonical `application/json` entry, then falls back to
1081    /// any `application/*+json` variant (RFC 6839) such as
1082    /// `application/vnd.api+json` or `application/hal+json`.
1083    pub fn json_schema(&self) -> Option<&Schema> {
1084        self.content
1085            .as_ref()
1086            .and_then(find_json_content)
1087            .and_then(|(_, media_type)| media_type.schema.as_ref())
1088    }
1089
1090    /// Get the best content type and its schema, preferring JSON over others
1091    pub fn best_content(&self) -> Option<(&str, Option<&Schema>)> {
1092        let content = self.content.as_ref()?;
1093
1094        if let Some((ct, media_type)) = find_json_content(content) {
1095            return Some((ct, media_type.schema.as_ref()));
1096        }
1097
1098        const PRIORITY: &[&str] = &[
1099            "application/x-www-form-urlencoded",
1100            "multipart/form-data",
1101            "application/octet-stream",
1102            "text/plain",
1103        ];
1104        for ct in PRIORITY {
1105            if let Some(media_type) = content.get(*ct) {
1106                return Some((*ct, media_type.schema.as_ref()));
1107            }
1108        }
1109        None
1110    }
1111}
1112
1113/// OpenAPI Response Object
1114#[derive(Debug, Clone, Deserialize, Serialize)]
1115pub struct Response {
1116    #[serde(default)]
1117    pub description: Option<String>,
1118    #[serde(default)]
1119    pub headers: Option<BTreeMap<String, Header>>,
1120    #[serde(default)]
1121    pub content: Option<BTreeMap<String, MediaType>>,
1122    #[serde(default)]
1123    pub links: Option<Value>,
1124    #[serde(rename = "$ref", default)]
1125    pub reference: Option<String>,
1126    #[serde(flatten, default)]
1127    pub extensions: Extensions,
1128}
1129
1130impl Response {
1131    /// Get schema for any JSON content type
1132    ///
1133    /// Prefers the canonical `application/json` entry, then falls back to
1134    /// any `application/*+json` variant (RFC 6839) such as
1135    /// `application/vnd.api+json`, `application/hal+json`, or
1136    /// `application/problem+json`.
1137    pub fn json_schema(&self) -> Option<&Schema> {
1138        self.content
1139            .as_ref()
1140            .and_then(find_json_content)
1141            .and_then(|(_, media_type)| media_type.schema.as_ref())
1142    }
1143
1144    /// Get the preferred JSON-compatible media type and its schema.
1145    pub fn json_content(&self) -> Option<(&str, &Schema)> {
1146        self.content
1147            .as_ref()
1148            .and_then(find_json_content)
1149            .and_then(|(content_type, media_type)| {
1150                media_type
1151                    .schema
1152                    .as_ref()
1153                    .map(|schema| (content_type, schema))
1154            })
1155    }
1156}
1157
1158/// OpenAPI Media Type Object
1159#[derive(Debug, Clone, Deserialize, Serialize)]
1160pub struct MediaType {
1161    #[serde(default)]
1162    pub schema: Option<Schema>,
1163    #[serde(default)]
1164    pub example: Option<Value>,
1165    #[serde(default)]
1166    pub examples: Option<BTreeMap<String, Example>>,
1167    #[serde(default)]
1168    pub encoding: Option<BTreeMap<String, Encoding>>,
1169    /// 3.2 §"Media Type Object" — schema for each item when streaming
1170    /// (D3). Common in `text/event-stream` and JSON-lines payloads.
1171    #[serde(rename = "itemSchema", default)]
1172    pub item_schema: Option<Schema>,
1173    /// 3.2 §"Media Type Object" — encoding for the leading prefix of a
1174    /// streamed body (D3).
1175    #[serde(rename = "prefixEncoding", default)]
1176    pub prefix_encoding: Option<Vec<Encoding>>,
1177    /// 3.2 §"Media Type Object" — encoding applied to each streamed item
1178    /// (D3).
1179    #[serde(rename = "itemEncoding", default)]
1180    pub item_encoding: Option<Encoding>,
1181    #[serde(rename = "$ref", default)]
1182    pub reference: Option<String>,
1183    #[serde(flatten, default)]
1184    pub extensions: Extensions,
1185}
1186
1187#[cfg(test)]
1188#[allow(clippy::unwrap_used, clippy::expect_used)]
1189mod tests {
1190    use super::*;
1191    use serde_json::json;
1192
1193    #[test]
1194    fn test_parse_simple_object_schema() {
1195        let schema_json = json!({
1196            "type": "object",
1197            "properties": {
1198                "name": {
1199                    "type": "string",
1200                    "description": "User name"
1201                },
1202                "age": {
1203                    "type": "integer"
1204                }
1205            },
1206            "required": ["name"]
1207        });
1208
1209        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1210
1211        match schema {
1212            Schema::Typed {
1213                schema_type: SchemaType::Object,
1214                details,
1215            } => {
1216                assert!(details.properties.is_some());
1217                assert_eq!(details.required, Some(vec!["name".to_string()]));
1218                assert!(details.is_field_required("name"));
1219                assert!(!details.is_field_required("age"));
1220            }
1221            _ => panic!("Expected object schema"),
1222        }
1223    }
1224
1225    #[test]
1226    fn test_parse_string_enum() {
1227        let schema_json = json!({
1228            "type": "string",
1229            "enum": ["active", "inactive", "pending"],
1230            "description": "User status"
1231        });
1232
1233        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1234
1235        match schema {
1236            Schema::Typed {
1237                schema_type: SchemaType::String,
1238                details,
1239            } => {
1240                assert!(details.is_string_enum());
1241                let values = details.string_enum_values().unwrap();
1242                assert_eq!(values, vec!["active", "inactive", "pending"]);
1243            }
1244            _ => panic!("Expected string enum schema"),
1245        }
1246    }
1247
1248    #[test]
1249    fn test_parse_reference_schema() {
1250        let schema_json = json!({
1251            "$ref": "#/components/schemas/User"
1252        });
1253
1254        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1255
1256        assert!(schema.is_reference());
1257        assert_eq!(schema.reference(), Some("#/components/schemas/User"));
1258    }
1259
1260    #[test]
1261    fn test_parse_discriminated_union() {
1262        let schema_json = json!({
1263            "oneOf": [
1264                {"$ref": "#/components/schemas/Dog"},
1265                {"$ref": "#/components/schemas/Cat"}
1266            ],
1267            "discriminator": {
1268                "propertyName": "petType"
1269            }
1270        });
1271
1272        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1273
1274        assert!(schema.is_discriminated_union());
1275        let discriminator = schema.discriminator().unwrap();
1276        assert_eq!(discriminator.property_name, "petType");
1277    }
1278
1279    #[test]
1280    fn test_parse_nullable_pattern() {
1281        let schema_json = json!({
1282            "anyOf": [
1283                {"$ref": "#/components/schemas/User"},
1284                {"type": "null"}
1285            ]
1286        });
1287
1288        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1289
1290        assert!(schema.is_nullable_pattern());
1291        let non_null = schema.non_null_variant().unwrap();
1292        assert!(non_null.is_reference());
1293    }
1294
1295    #[test]
1296    fn is_json_media_type_accepts_canonical_and_structured_suffix() {
1297        // Canonical
1298        assert!(is_json_media_type("application/json"));
1299        // Parameters tolerated (RFC 7231 §3.1.1.1)
1300        assert!(is_json_media_type("application/json; charset=utf-8"));
1301        assert!(is_json_media_type("APPLICATION/JSON"));
1302        // RFC 6839 +json structured-syntax suffix
1303        assert!(is_json_media_type("application/vnd.api+json"));
1304        assert!(is_json_media_type("application/hal+json"));
1305        assert!(is_json_media_type("application/problem+json"));
1306        assert!(is_json_media_type("application/ld+json"));
1307        assert!(is_json_media_type(
1308            "application/vnd.api+json; charset=utf-8"
1309        ));
1310        // Negatives
1311        assert!(!is_json_media_type("application/xml"));
1312        assert!(!is_json_media_type("application/x-www-form-urlencoded"));
1313        assert!(!is_json_media_type("text/plain"));
1314        assert!(!is_json_media_type("application/jsonbutnotreally"));
1315        // +json suffix only applies to application/* per RFC 6839
1316        assert!(!is_json_media_type("text/something+json"));
1317    }
1318
1319    #[test]
1320    fn request_body_json_schema_finds_vnd_api_plus_json() {
1321        // Mirrors Latitude.sh: request body declared under
1322        // application/vnd.api+json without a sibling application/json.
1323        let body_json = json!({
1324            "required": true,
1325            "content": {
1326                "application/vnd.api+json": {
1327                    "schema": {"$ref": "#/components/schemas/create_api_key"}
1328                }
1329            }
1330        });
1331
1332        let body: RequestBody = serde_json::from_value(body_json).unwrap();
1333        let schema = body.json_schema().expect("expected +json schema match");
1334        assert!(schema.is_reference());
1335    }
1336
1337    #[test]
1338    fn request_body_best_content_prefers_canonical_json_over_plus_json() {
1339        // When both are present (e.g. Latitude.sh's POST /auth/api_keys),
1340        // best_content should still pick application/json for backwards
1341        // compatibility with the existing snapshot suite.
1342        let body_json = json!({
1343            "required": true,
1344            "content": {
1345                "application/json": {
1346                    "schema": {"$ref": "#/components/schemas/A"}
1347                },
1348                "application/vnd.api+json": {
1349                    "schema": {"$ref": "#/components/schemas/B"}
1350                }
1351            }
1352        });
1353
1354        let body: RequestBody = serde_json::from_value(body_json).unwrap();
1355        let (ct, _) = body.best_content().expect("expected best_content");
1356        assert_eq!(ct, "application/json");
1357    }
1358
1359    #[test]
1360    fn request_body_best_content_falls_back_to_plus_json() {
1361        // When only the +json variant is declared, best_content returns
1362        // it instead of skipping straight to form-urlencoded.
1363        let body_json = json!({
1364            "required": true,
1365            "content": {
1366                "application/vnd.api+json": {
1367                    "schema": {"$ref": "#/components/schemas/B"}
1368                }
1369            }
1370        });
1371
1372        let body: RequestBody = serde_json::from_value(body_json).unwrap();
1373        let (ct, _) = body.best_content().expect("expected best_content");
1374        assert_eq!(ct, "application/vnd.api+json");
1375    }
1376
1377    #[test]
1378    fn response_json_schema_finds_vnd_api_plus_json() {
1379        // Mirrors every Latitude.sh response: schema lives under
1380        // application/vnd.api+json only.
1381        let resp_json = json!({
1382            "description": "OK",
1383            "content": {
1384                "application/vnd.api+json": {
1385                    "schema": {"$ref": "#/components/schemas/api_keys"}
1386                }
1387            }
1388        });
1389
1390        let resp: Response = serde_json::from_value(resp_json).unwrap();
1391        let schema = resp.json_schema().expect("expected +json schema match");
1392        assert!(schema.is_reference());
1393    }
1394}