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    /// Get schema details
659    pub fn details(&self) -> &SchemaDetails {
660        static EMPTY_DETAILS: Lazy<SchemaDetails> = Lazy::new(SchemaDetails::default);
661        match self {
662            Schema::Typed { details, .. } => details,
663            Schema::TypedMulti { details, .. } => details,
664            Schema::Reference { .. } | Schema::RecursiveRef { .. } | Schema::DynamicRef { .. } => {
665                &EMPTY_DETAILS
666            }
667            Schema::OneOf { details, .. } => details,
668            Schema::AnyOf { details, .. } => details,
669            Schema::AllOf { details, .. } => details,
670            Schema::Untyped { details } => details,
671        }
672    }
673
674    /// Get mutable schema details
675    pub fn details_mut(&mut self) -> &mut SchemaDetails {
676        match self {
677            Schema::Typed { details, .. } => details,
678            Schema::TypedMulti { details, .. } => details,
679            Schema::Reference { .. } => {
680                panic!("Cannot get mutable details for reference schema")
681            }
682            Schema::RecursiveRef { .. } => {
683                panic!("Cannot get mutable details for recursive reference schema")
684            }
685            Schema::DynamicRef { .. } => {
686                panic!("Cannot get mutable details for dynamic reference schema")
687            }
688            Schema::OneOf { details, .. } => details,
689            Schema::AnyOf { details, .. } => details,
690            Schema::AllOf { details, .. } => details,
691            Schema::Untyped { details } => details,
692        }
693    }
694
695    /// Check if this is any kind of reference (regular or recursive)
696    pub fn is_reference(&self) -> bool {
697        matches!(self, Schema::Reference { .. } | Schema::RecursiveRef { .. })
698    }
699
700    /// Get reference string if this is a reference
701    pub fn reference(&self) -> Option<&str> {
702        match self {
703            Schema::Reference { reference, .. } => Some(reference),
704            _ => None,
705        }
706    }
707
708    /// Get recursive reference string if this is a recursive reference
709    pub fn recursive_reference(&self) -> Option<&str> {
710        match self {
711            Schema::RecursiveRef { recursive_ref, .. } => Some(recursive_ref),
712            _ => None,
713        }
714    }
715
716    /// Check if this is a discriminated union
717    pub fn is_discriminated_union(&self) -> bool {
718        match self {
719            Schema::OneOf { discriminator, .. } => discriminator.is_some(),
720            Schema::AnyOf { discriminator, .. } => discriminator.is_some(),
721            _ => false,
722        }
723    }
724
725    /// Get discriminator if this is a discriminated union
726    pub fn discriminator(&self) -> Option<&Discriminator> {
727        match self {
728            Schema::OneOf { discriminator, .. } => discriminator.as_ref(),
729            Schema::AnyOf { discriminator, .. } => discriminator.as_ref(),
730            _ => None,
731        }
732    }
733
734    /// Get union variants
735    pub fn union_variants(&self) -> Option<&[Schema]> {
736        match self {
737            Schema::OneOf { one_of, .. } => Some(one_of),
738            Schema::AnyOf { any_of, .. } => Some(any_of),
739            _ => None,
740        }
741    }
742
743    /// Check if this appears to be a nullable pattern (anyOf or oneOf with null)
744    pub fn is_nullable_pattern(&self) -> bool {
745        let variants = match self {
746            Schema::AnyOf { any_of, .. } => any_of,
747            Schema::OneOf { one_of, .. } => one_of,
748            _ => return false,
749        };
750        variants.len() == 2
751            && variants
752                .iter()
753                .any(|s| matches!(s.schema_type(), Some(SchemaType::Null)))
754    }
755
756    /// Get the non-null variant from a nullable pattern
757    pub fn non_null_variant(&self) -> Option<&Schema> {
758        if !self.is_nullable_pattern() {
759            return None;
760        }
761        let variants = match self {
762            Schema::AnyOf { any_of, .. } => any_of,
763            Schema::OneOf { one_of, .. } => one_of,
764            _ => return None,
765        };
766        variants
767            .iter()
768            .find(|s| !matches!(s.schema_type(), Some(SchemaType::Null)))
769    }
770
771    /// Infer schema type from structure if not explicitly set
772    pub fn inferred_type(&self) -> Option<SchemaType> {
773        match self {
774            Schema::Typed { schema_type, .. } => Some(schema_type.clone()),
775            Schema::TypedMulti { .. } => self.schema_type().cloned(),
776            Schema::Untyped { details } => {
777                // Infer from structure
778                if details.properties.is_some() {
779                    Some(SchemaType::Object)
780                } else if details.items.is_some() {
781                    Some(SchemaType::Array)
782                } else if details.enum_values.is_some() {
783                    Some(SchemaType::String) // Assume string enum
784                } else {
785                    None
786                }
787            }
788            _ => None,
789        }
790    }
791}
792
793impl SchemaDetails {
794    /// Check if this schema is nullable
795    pub fn is_nullable(&self) -> bool {
796        self.nullable.unwrap_or(false)
797    }
798
799    /// Check if this is a string enum
800    ///
801    /// A standalone string `const` (no `enum` array) is treated as a
802    /// degenerate single-value enum so the generator emits a tightly-typed
803    /// single-variant enum instead of a bare `String`. See issue #10.
804    pub fn is_string_enum(&self) -> bool {
805        self.enum_values.is_some() || self.const_string_value().is_some()
806    }
807
808    /// Get enum values as strings if this is a string enum.
809    ///
810    /// Falls back to `[const_value]` when `enum` is absent but `const` is a
811    /// string, so a property like `{ "type": "string", "const": "X" }`
812    /// produces a single-variant enum.
813    pub fn string_enum_values(&self) -> Option<Vec<String>> {
814        if let Some(values) = self.enum_values.as_ref() {
815            // Tolerate non-string scalars in `enum` for `type: string` schemas
816            // (gitpod has `enum: [2000, 5000, ...]` on a string-typed field).
817            // Without this, `filter_map(.as_str())` produced an empty Vec
818            // and we emitted an empty enum that fails to compile.
819            return Some(
820                values
821                    .iter()
822                    .map(|v| match v {
823                        Value::String(s) => s.clone(),
824                        Value::Number(n) => n.to_string(),
825                        Value::Bool(b) => b.to_string(),
826                        Value::Null => "null".to_string(),
827                        _ => v.to_string(),
828                    })
829                    .collect(),
830            );
831        }
832        self.const_string_value().map(|s| vec![s])
833    }
834
835    fn const_string_value(&self) -> Option<String> {
836        self.const_value
837            .as_ref()
838            .and_then(|v| v.as_str())
839            .map(|s| s.to_string())
840    }
841
842    /// Check if a field is required
843    pub fn is_field_required(&self, field_name: &str) -> bool {
844        self.required
845            .as_ref()
846            .map(|req| req.contains(&field_name.to_string()))
847            .unwrap_or(false)
848    }
849}
850
851/// OpenAPI Path Item Object
852#[derive(Debug, Clone, Deserialize, Serialize)]
853pub struct PathItem {
854    #[serde(default)]
855    pub summary: Option<String>,
856    #[serde(default)]
857    pub description: Option<String>,
858    pub get: Option<Operation>,
859    pub put: Option<Operation>,
860    pub post: Option<Operation>,
861    pub delete: Option<Operation>,
862    pub options: Option<Operation>,
863    pub head: Option<Operation>,
864    pub patch: Option<Operation>,
865    pub trace: Option<Operation>,
866    /// 3.2 §"Path Item Object" — `QUERY` HTTP method (D1). Originally
867    /// proposed for safe, idempotent reads with a body.
868    pub query: Option<Operation>,
869    /// 3.2 §"Path Item Object" — extension map for HTTP methods beyond the
870    /// well-known ones (e.g. WebDAV's PROPFIND, SEARCH; LINK/UNLINK). Keys
871    /// are uppercase method names (D1).
872    #[serde(rename = "additionalOperations", default)]
873    pub additional_operations: Option<BTreeMap<String, Operation>>,
874    pub parameters: Option<Vec<Parameter>>,
875    #[serde(default)]
876    pub servers: Option<Vec<Server>>,
877    #[serde(rename = "$ref", default)]
878    pub reference: Option<String>,
879    #[serde(flatten, default)]
880    pub extensions: Extensions,
881}
882
883impl PathItem {
884    /// Get all operations in this path item, including 3.2's `query`
885    /// (D1) and any custom verbs declared in `additionalOperations`.
886    pub fn operations(&self) -> Vec<(&str, &Operation)> {
887        let mut ops = Vec::new();
888        if let Some(ref op) = self.get {
889            ops.push(("get", op));
890        }
891        if let Some(ref op) = self.put {
892            ops.push(("put", op));
893        }
894        if let Some(ref op) = self.post {
895            ops.push(("post", op));
896        }
897        if let Some(ref op) = self.delete {
898            ops.push(("delete", op));
899        }
900        if let Some(ref op) = self.options {
901            ops.push(("options", op));
902        }
903        if let Some(ref op) = self.head {
904            ops.push(("head", op));
905        }
906        if let Some(ref op) = self.patch {
907            ops.push(("patch", op));
908        }
909        if let Some(ref op) = self.trace {
910            ops.push(("trace", op));
911        }
912        if let Some(ref op) = self.query {
913            ops.push(("query", op));
914        }
915        if let Some(map) = &self.additional_operations {
916            for (verb, op) in map {
917                ops.push((verb.as_str(), op));
918            }
919        }
920        ops
921    }
922}
923
924/// OpenAPI Operation Object
925#[derive(Debug, Clone, Deserialize, Serialize)]
926pub struct Operation {
927    #[serde(rename = "operationId", default)]
928    pub operation_id: Option<String>,
929    #[serde(default)]
930    pub summary: Option<String>,
931    #[serde(default)]
932    pub description: Option<String>,
933    #[serde(default)]
934    pub tags: Option<Vec<String>>,
935    #[serde(default)]
936    pub deprecated: Option<bool>,
937    pub parameters: Option<Vec<Parameter>>,
938    #[serde(rename = "requestBody")]
939    pub request_body: Option<RequestBody>,
940    pub responses: Option<BTreeMap<String, Response>>,
941    #[serde(default)]
942    pub callbacks: Option<BTreeMap<String, Callback>>,
943    #[serde(default)]
944    pub security: Option<Vec<BTreeMap<String, Vec<String>>>>,
945    #[serde(default)]
946    pub servers: Option<Vec<Server>>,
947    #[serde(rename = "externalDocs", default)]
948    pub external_docs: Option<ExternalDocs>,
949    #[serde(flatten, default)]
950    pub extensions: Extensions,
951}
952
953/// OpenAPI Parameter Object
954#[derive(Debug, Clone, Deserialize, Serialize)]
955pub struct Parameter {
956    #[serde(default)]
957    pub name: Option<String>,
958    #[serde(rename = "in", default)]
959    pub location: Option<String>,
960    #[serde(default)]
961    pub required: Option<bool>,
962    #[serde(default)]
963    pub deprecated: Option<bool>,
964    #[serde(rename = "allowEmptyValue", default)]
965    pub allow_empty_value: Option<bool>,
966    #[serde(default)]
967    pub style: Option<String>,
968    #[serde(default)]
969    pub explode: Option<bool>,
970    #[serde(rename = "allowReserved", default)]
971    pub allow_reserved: Option<bool>,
972    #[serde(default)]
973    pub schema: Option<Schema>,
974    #[serde(default)]
975    pub content: Option<BTreeMap<String, MediaType>>,
976    #[serde(default)]
977    pub example: Option<Value>,
978    #[serde(default)]
979    pub examples: Option<BTreeMap<String, Example>>,
980    #[serde(default)]
981    pub description: Option<String>,
982    #[serde(rename = "$ref", default)]
983    pub reference: Option<String>,
984    #[serde(flatten, default)]
985    pub extensions: Extensions,
986}
987
988/// OpenAPI Request Body Object
989#[derive(Debug, Clone, Deserialize, Serialize)]
990pub struct RequestBody {
991    pub content: Option<BTreeMap<String, MediaType>>,
992    #[serde(default)]
993    pub description: Option<String>,
994    #[serde(default)]
995    pub required: Option<bool>,
996    #[serde(rename = "$ref", default)]
997    pub reference: Option<String>,
998    #[serde(flatten, default)]
999    pub extensions: Extensions,
1000}
1001
1002/// Returns true for media types whose payload is JSON.
1003///
1004/// Matches `application/json` exactly, plus any RFC 6839 structured-syntax
1005/// suffix variant of the form `application/<subtype>+json`
1006/// (e.g. `application/vnd.api+json`, `application/hal+json`,
1007/// `application/problem+json`). Trailing parameters such as
1008/// `; charset=utf-8` are tolerated.
1009pub fn is_json_media_type(ct: &str) -> bool {
1010    let essence = ct
1011        .split(';')
1012        .next()
1013        .unwrap_or(ct)
1014        .trim()
1015        .to_ascii_lowercase();
1016    if essence == "application/json" {
1017        return true;
1018    }
1019    if let Some(subtype) = essence.strip_prefix("application/") {
1020        return subtype.ends_with("+json");
1021    }
1022    false
1023}
1024
1025/// Returns true for `application/x-www-form-urlencoded` (with optional
1026/// parameters).
1027pub fn is_form_urlencoded_media_type(ct: &str) -> bool {
1028    let essence = ct
1029        .split(';')
1030        .next()
1031        .unwrap_or(ct)
1032        .trim()
1033        .to_ascii_lowercase();
1034    essence == "application/x-www-form-urlencoded"
1035}
1036
1037/// Returns true only for the `text/event-stream` media type essence.
1038///
1039/// Media type names are ASCII-case-insensitive and parameters do not change
1040/// the essence, so values such as `Text/Event-Stream; charset=utf-8` match,
1041/// while similarly prefixed subtypes such as `text/event-streaming` do not.
1042pub fn is_event_stream_media_type(ct: &str) -> bool {
1043    ct.split(';')
1044        .next()
1045        .unwrap_or(ct)
1046        .trim()
1047        .eq_ignore_ascii_case("text/event-stream")
1048}
1049
1050fn find_json_content(content: &BTreeMap<String, MediaType>) -> Option<(&str, &MediaType)> {
1051    if let Some(mt) = content.get("application/json") {
1052        return Some(("application/json", mt));
1053    }
1054    content
1055        .iter()
1056        .find(|(ct, _)| is_json_media_type(ct))
1057        .map(|(ct, mt)| (ct.as_str(), mt))
1058}
1059
1060impl RequestBody {
1061    /// Get schema for any JSON content type
1062    ///
1063    /// Prefers the canonical `application/json` entry, then falls back to
1064    /// any `application/*+json` variant (RFC 6839) such as
1065    /// `application/vnd.api+json` or `application/hal+json`.
1066    pub fn json_schema(&self) -> Option<&Schema> {
1067        self.content
1068            .as_ref()
1069            .and_then(find_json_content)
1070            .and_then(|(_, media_type)| media_type.schema.as_ref())
1071    }
1072
1073    /// Get the best content type and its schema, preferring JSON over others
1074    pub fn best_content(&self) -> Option<(&str, Option<&Schema>)> {
1075        let content = self.content.as_ref()?;
1076
1077        if let Some((ct, media_type)) = find_json_content(content) {
1078            return Some((ct, media_type.schema.as_ref()));
1079        }
1080
1081        const PRIORITY: &[&str] = &[
1082            "application/x-www-form-urlencoded",
1083            "multipart/form-data",
1084            "application/octet-stream",
1085            "text/plain",
1086        ];
1087        for ct in PRIORITY {
1088            if let Some(media_type) = content.get(*ct) {
1089                return Some((*ct, media_type.schema.as_ref()));
1090            }
1091        }
1092        None
1093    }
1094}
1095
1096/// OpenAPI Response Object
1097#[derive(Debug, Clone, Deserialize, Serialize)]
1098pub struct Response {
1099    #[serde(default)]
1100    pub description: Option<String>,
1101    #[serde(default)]
1102    pub headers: Option<BTreeMap<String, Header>>,
1103    #[serde(default)]
1104    pub content: Option<BTreeMap<String, MediaType>>,
1105    #[serde(default)]
1106    pub links: Option<Value>,
1107    #[serde(rename = "$ref", default)]
1108    pub reference: Option<String>,
1109    #[serde(flatten, default)]
1110    pub extensions: Extensions,
1111}
1112
1113impl Response {
1114    /// Get schema for any JSON content type
1115    ///
1116    /// Prefers the canonical `application/json` entry, then falls back to
1117    /// any `application/*+json` variant (RFC 6839) such as
1118    /// `application/vnd.api+json`, `application/hal+json`, or
1119    /// `application/problem+json`.
1120    pub fn json_schema(&self) -> Option<&Schema> {
1121        self.content
1122            .as_ref()
1123            .and_then(find_json_content)
1124            .and_then(|(_, media_type)| media_type.schema.as_ref())
1125    }
1126
1127    /// Get the preferred JSON-compatible media type and its schema.
1128    pub fn json_content(&self) -> Option<(&str, &Schema)> {
1129        self.content
1130            .as_ref()
1131            .and_then(find_json_content)
1132            .and_then(|(content_type, media_type)| {
1133                media_type
1134                    .schema
1135                    .as_ref()
1136                    .map(|schema| (content_type, schema))
1137            })
1138    }
1139}
1140
1141/// OpenAPI Media Type Object
1142#[derive(Debug, Clone, Deserialize, Serialize)]
1143pub struct MediaType {
1144    #[serde(default)]
1145    pub schema: Option<Schema>,
1146    #[serde(default)]
1147    pub example: Option<Value>,
1148    #[serde(default)]
1149    pub examples: Option<BTreeMap<String, Example>>,
1150    #[serde(default)]
1151    pub encoding: Option<BTreeMap<String, Encoding>>,
1152    /// 3.2 §"Media Type Object" — schema for each item when streaming
1153    /// (D3). Common in `text/event-stream` and JSON-lines payloads.
1154    #[serde(rename = "itemSchema", default)]
1155    pub item_schema: Option<Schema>,
1156    /// 3.2 §"Media Type Object" — encoding for the leading prefix of a
1157    /// streamed body (D3).
1158    #[serde(rename = "prefixEncoding", default)]
1159    pub prefix_encoding: Option<Vec<Encoding>>,
1160    /// 3.2 §"Media Type Object" — encoding applied to each streamed item
1161    /// (D3).
1162    #[serde(rename = "itemEncoding", default)]
1163    pub item_encoding: Option<Encoding>,
1164    #[serde(rename = "$ref", default)]
1165    pub reference: Option<String>,
1166    #[serde(flatten, default)]
1167    pub extensions: Extensions,
1168}
1169
1170#[cfg(test)]
1171#[allow(clippy::unwrap_used, clippy::expect_used)]
1172mod tests {
1173    use super::*;
1174    use serde_json::json;
1175
1176    #[test]
1177    fn test_parse_simple_object_schema() {
1178        let schema_json = json!({
1179            "type": "object",
1180            "properties": {
1181                "name": {
1182                    "type": "string",
1183                    "description": "User name"
1184                },
1185                "age": {
1186                    "type": "integer"
1187                }
1188            },
1189            "required": ["name"]
1190        });
1191
1192        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1193
1194        match schema {
1195            Schema::Typed {
1196                schema_type: SchemaType::Object,
1197                details,
1198            } => {
1199                assert!(details.properties.is_some());
1200                assert_eq!(details.required, Some(vec!["name".to_string()]));
1201                assert!(details.is_field_required("name"));
1202                assert!(!details.is_field_required("age"));
1203            }
1204            _ => panic!("Expected object schema"),
1205        }
1206    }
1207
1208    #[test]
1209    fn test_parse_string_enum() {
1210        let schema_json = json!({
1211            "type": "string",
1212            "enum": ["active", "inactive", "pending"],
1213            "description": "User status"
1214        });
1215
1216        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1217
1218        match schema {
1219            Schema::Typed {
1220                schema_type: SchemaType::String,
1221                details,
1222            } => {
1223                assert!(details.is_string_enum());
1224                let values = details.string_enum_values().unwrap();
1225                assert_eq!(values, vec!["active", "inactive", "pending"]);
1226            }
1227            _ => panic!("Expected string enum schema"),
1228        }
1229    }
1230
1231    #[test]
1232    fn test_parse_reference_schema() {
1233        let schema_json = json!({
1234            "$ref": "#/components/schemas/User"
1235        });
1236
1237        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1238
1239        assert!(schema.is_reference());
1240        assert_eq!(schema.reference(), Some("#/components/schemas/User"));
1241    }
1242
1243    #[test]
1244    fn test_parse_discriminated_union() {
1245        let schema_json = json!({
1246            "oneOf": [
1247                {"$ref": "#/components/schemas/Dog"},
1248                {"$ref": "#/components/schemas/Cat"}
1249            ],
1250            "discriminator": {
1251                "propertyName": "petType"
1252            }
1253        });
1254
1255        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1256
1257        assert!(schema.is_discriminated_union());
1258        let discriminator = schema.discriminator().unwrap();
1259        assert_eq!(discriminator.property_name, "petType");
1260    }
1261
1262    #[test]
1263    fn test_parse_nullable_pattern() {
1264        let schema_json = json!({
1265            "anyOf": [
1266                {"$ref": "#/components/schemas/User"},
1267                {"type": "null"}
1268            ]
1269        });
1270
1271        let schema: Schema = serde_json::from_value(schema_json).unwrap();
1272
1273        assert!(schema.is_nullable_pattern());
1274        let non_null = schema.non_null_variant().unwrap();
1275        assert!(non_null.is_reference());
1276    }
1277
1278    #[test]
1279    fn is_json_media_type_accepts_canonical_and_structured_suffix() {
1280        // Canonical
1281        assert!(is_json_media_type("application/json"));
1282        // Parameters tolerated (RFC 7231 §3.1.1.1)
1283        assert!(is_json_media_type("application/json; charset=utf-8"));
1284        assert!(is_json_media_type("APPLICATION/JSON"));
1285        // RFC 6839 +json structured-syntax suffix
1286        assert!(is_json_media_type("application/vnd.api+json"));
1287        assert!(is_json_media_type("application/hal+json"));
1288        assert!(is_json_media_type("application/problem+json"));
1289        assert!(is_json_media_type("application/ld+json"));
1290        assert!(is_json_media_type(
1291            "application/vnd.api+json; charset=utf-8"
1292        ));
1293        // Negatives
1294        assert!(!is_json_media_type("application/xml"));
1295        assert!(!is_json_media_type("application/x-www-form-urlencoded"));
1296        assert!(!is_json_media_type("text/plain"));
1297        assert!(!is_json_media_type("application/jsonbutnotreally"));
1298        // +json suffix only applies to application/* per RFC 6839
1299        assert!(!is_json_media_type("text/something+json"));
1300    }
1301
1302    #[test]
1303    fn request_body_json_schema_finds_vnd_api_plus_json() {
1304        // Mirrors Latitude.sh: request body declared under
1305        // application/vnd.api+json without a sibling application/json.
1306        let body_json = json!({
1307            "required": true,
1308            "content": {
1309                "application/vnd.api+json": {
1310                    "schema": {"$ref": "#/components/schemas/create_api_key"}
1311                }
1312            }
1313        });
1314
1315        let body: RequestBody = serde_json::from_value(body_json).unwrap();
1316        let schema = body.json_schema().expect("expected +json schema match");
1317        assert!(schema.is_reference());
1318    }
1319
1320    #[test]
1321    fn request_body_best_content_prefers_canonical_json_over_plus_json() {
1322        // When both are present (e.g. Latitude.sh's POST /auth/api_keys),
1323        // best_content should still pick application/json for backwards
1324        // compatibility with the existing snapshot suite.
1325        let body_json = json!({
1326            "required": true,
1327            "content": {
1328                "application/json": {
1329                    "schema": {"$ref": "#/components/schemas/A"}
1330                },
1331                "application/vnd.api+json": {
1332                    "schema": {"$ref": "#/components/schemas/B"}
1333                }
1334            }
1335        });
1336
1337        let body: RequestBody = serde_json::from_value(body_json).unwrap();
1338        let (ct, _) = body.best_content().expect("expected best_content");
1339        assert_eq!(ct, "application/json");
1340    }
1341
1342    #[test]
1343    fn request_body_best_content_falls_back_to_plus_json() {
1344        // When only the +json variant is declared, best_content returns
1345        // it instead of skipping straight to form-urlencoded.
1346        let body_json = json!({
1347            "required": true,
1348            "content": {
1349                "application/vnd.api+json": {
1350                    "schema": {"$ref": "#/components/schemas/B"}
1351                }
1352            }
1353        });
1354
1355        let body: RequestBody = serde_json::from_value(body_json).unwrap();
1356        let (ct, _) = body.best_content().expect("expected best_content");
1357        assert_eq!(ct, "application/vnd.api+json");
1358    }
1359
1360    #[test]
1361    fn response_json_schema_finds_vnd_api_plus_json() {
1362        // Mirrors every Latitude.sh response: schema lives under
1363        // application/vnd.api+json only.
1364        let resp_json = json!({
1365            "description": "OK",
1366            "content": {
1367                "application/vnd.api+json": {
1368                    "schema": {"$ref": "#/components/schemas/api_keys"}
1369                }
1370            }
1371        });
1372
1373        let resp: Response = serde_json::from_value(resp_json).unwrap();
1374        let schema = resp.json_schema().expect("expected +json schema match");
1375        assert!(schema.is_reference());
1376    }
1377}