Skip to main content

openapi_to_rust/
openapi.rs

1use once_cell::sync::Lazy;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::BTreeMap;
5
6#[derive(Debug, Clone, Deserialize, Serialize)]
7pub struct OpenApiSpec {
8    pub openapi: String,
9    pub info: Info,
10    pub paths: Option<BTreeMap<String, PathItem>>,
11    pub components: Option<Components>,
12    #[serde(flatten)]
13    pub extra: BTreeMap<String, Value>,
14}
15
16#[derive(Debug, Clone, Deserialize, Serialize)]
17pub struct Info {
18    pub title: String,
19    #[serde(default)]
20    pub version: Option<String>,
21    #[serde(flatten)]
22    pub extra: BTreeMap<String, Value>,
23}
24
25#[derive(Debug, Clone, Deserialize, Serialize)]
26pub struct Components {
27    pub schemas: Option<BTreeMap<String, Schema>>,
28    pub parameters: Option<BTreeMap<String, Parameter>>,
29    #[serde(flatten)]
30    pub extra: BTreeMap<String, Value>,
31}
32
33#[derive(Debug, Clone, Deserialize, Serialize)]
34#[serde(untagged)]
35pub enum Schema {
36    /// Schema reference
37    Reference {
38        #[serde(rename = "$ref")]
39        reference: String,
40        #[serde(flatten)]
41        extra: BTreeMap<String, Value>,
42    },
43    /// Recursive reference (OpenAPI 3.1)
44    RecursiveRef {
45        #[serde(rename = "$recursiveRef")]
46        recursive_ref: String,
47        #[serde(flatten)]
48        extra: BTreeMap<String, Value>,
49    },
50    /// OneOf union
51    OneOf {
52        #[serde(rename = "oneOf")]
53        one_of: Vec<Schema>,
54        discriminator: Option<Discriminator>,
55        #[serde(flatten)]
56        details: SchemaDetails,
57    },
58    /// AnyOf union (must come before Typed to handle type + anyOf patterns)
59    AnyOf {
60        #[serde(rename = "type")]
61        schema_type: Option<SchemaType>,
62        #[serde(rename = "anyOf")]
63        any_of: Vec<Schema>,
64        discriminator: Option<Discriminator>,
65        #[serde(flatten)]
66        details: SchemaDetails,
67    },
68    /// Schema with explicit type
69    Typed {
70        #[serde(rename = "type")]
71        schema_type: SchemaType,
72        #[serde(flatten)]
73        details: SchemaDetails,
74    },
75    /// AllOf composition
76    AllOf {
77        #[serde(rename = "allOf")]
78        all_of: Vec<Schema>,
79        #[serde(flatten)]
80        details: SchemaDetails,
81    },
82    /// Schema without explicit type (inferred from other fields)
83    Untyped {
84        #[serde(flatten)]
85        details: SchemaDetails,
86    },
87}
88
89#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
90#[serde(rename_all = "lowercase")]
91pub enum SchemaType {
92    String,
93    Integer,
94    Number,
95    Boolean,
96    Array,
97    Object,
98    #[serde(rename = "null")]
99    Null,
100}
101
102#[derive(Debug, Clone, Deserialize, Serialize)]
103pub struct SchemaDetails {
104    pub description: Option<String>,
105    pub nullable: Option<bool>,
106
107    // OpenAPI 3.1 recursive support
108    #[serde(rename = "$recursiveAnchor")]
109    pub recursive_anchor: Option<bool>,
110
111    // String-specific
112    #[serde(rename = "enum")]
113    pub enum_values: Option<Vec<Value>>,
114    pub format: Option<String>,
115    pub default: Option<Value>,
116    #[serde(rename = "const")]
117    pub const_value: Option<Value>,
118
119    // Object-specific
120    pub properties: Option<BTreeMap<String, Schema>>,
121    pub required: Option<Vec<String>>,
122    #[serde(rename = "additionalProperties")]
123    pub additional_properties: Option<AdditionalProperties>,
124
125    // Array-specific
126    pub items: Option<Box<Schema>>,
127
128    // Number-specific
129    pub minimum: Option<f64>,
130    pub maximum: Option<f64>,
131
132    // Validation
133    #[serde(rename = "minLength")]
134    pub min_length: Option<u64>,
135    #[serde(rename = "maxLength")]
136    pub max_length: Option<u64>,
137    pub pattern: Option<String>,
138
139    // Extensions and unknown fields
140    #[serde(flatten)]
141    pub extra: BTreeMap<String, Value>,
142}
143
144#[derive(Debug, Clone, Deserialize, Serialize)]
145#[serde(untagged)]
146pub enum AdditionalProperties {
147    Boolean(bool),
148    Schema(Box<Schema>),
149}
150
151#[derive(Debug, Clone, Deserialize, Serialize)]
152pub struct Discriminator {
153    #[serde(rename = "propertyName")]
154    pub property_name: String,
155    pub mapping: Option<BTreeMap<String, String>>,
156    #[serde(flatten)]
157    pub extra: BTreeMap<String, Value>,
158}
159
160impl Schema {
161    /// Get the schema type if explicitly set
162    pub fn schema_type(&self) -> Option<&SchemaType> {
163        match self {
164            Schema::Typed { schema_type, .. } => Some(schema_type),
165            _ => None,
166        }
167    }
168
169    /// Get schema details
170    pub fn details(&self) -> &SchemaDetails {
171        match self {
172            Schema::Typed { details, .. } => details,
173            Schema::Reference { .. } => {
174                static EMPTY_DETAILS: Lazy<SchemaDetails> = Lazy::new(|| SchemaDetails {
175                    description: None,
176                    nullable: None,
177                    recursive_anchor: None,
178                    enum_values: None,
179                    format: None,
180                    default: None,
181                    const_value: None,
182                    properties: None,
183                    required: None,
184                    additional_properties: None,
185                    items: None,
186                    minimum: None,
187                    maximum: None,
188                    min_length: None,
189                    max_length: None,
190                    pattern: None,
191                    extra: BTreeMap::new(),
192                });
193                &EMPTY_DETAILS
194            }
195            Schema::RecursiveRef { .. } => {
196                static EMPTY_DETAILS_RECURSIVE: Lazy<SchemaDetails> = Lazy::new(|| SchemaDetails {
197                    description: None,
198                    nullable: None,
199                    recursive_anchor: None,
200                    enum_values: None,
201                    format: None,
202                    default: None,
203                    const_value: None,
204                    properties: None,
205                    required: None,
206                    additional_properties: None,
207                    items: None,
208                    minimum: None,
209                    maximum: None,
210                    min_length: None,
211                    max_length: None,
212                    pattern: None,
213                    extra: BTreeMap::new(),
214                });
215                &EMPTY_DETAILS_RECURSIVE
216            }
217            Schema::OneOf { details, .. } => details,
218            Schema::AnyOf { details, .. } => details,
219            Schema::AllOf { details, .. } => details,
220            Schema::Untyped { details } => details,
221        }
222    }
223
224    /// Get mutable schema details
225    pub fn details_mut(&mut self) -> &mut SchemaDetails {
226        match self {
227            Schema::Typed { details, .. } => details,
228            Schema::Reference { .. } => {
229                // Cannot mutate reference details
230                panic!("Cannot get mutable details for reference schema")
231            }
232            Schema::RecursiveRef { .. } => {
233                // Cannot mutate recursive reference details
234                panic!("Cannot get mutable details for recursive reference schema")
235            }
236            Schema::OneOf { details, .. } => details,
237            Schema::AnyOf { details, .. } => details,
238            Schema::AllOf { details, .. } => details,
239            Schema::Untyped { details } => details,
240        }
241    }
242
243    /// Check if this is any kind of reference (regular or recursive)
244    pub fn is_reference(&self) -> bool {
245        matches!(self, Schema::Reference { .. } | Schema::RecursiveRef { .. })
246    }
247
248    /// Get reference string if this is a reference
249    pub fn reference(&self) -> Option<&str> {
250        match self {
251            Schema::Reference { reference, .. } => Some(reference),
252            _ => None,
253        }
254    }
255
256    /// Get recursive reference string if this is a recursive reference
257    pub fn recursive_reference(&self) -> Option<&str> {
258        match self {
259            Schema::RecursiveRef { recursive_ref, .. } => Some(recursive_ref),
260            _ => None,
261        }
262    }
263
264    /// Check if this is a discriminated union
265    pub fn is_discriminated_union(&self) -> bool {
266        match self {
267            Schema::OneOf { discriminator, .. } => discriminator.is_some(),
268            Schema::AnyOf { discriminator, .. } => discriminator.is_some(),
269            _ => false,
270        }
271    }
272
273    /// Get discriminator if this is a discriminated union
274    pub fn discriminator(&self) -> Option<&Discriminator> {
275        match self {
276            Schema::OneOf { discriminator, .. } => discriminator.as_ref(),
277            Schema::AnyOf { discriminator, .. } => discriminator.as_ref(),
278            _ => None,
279        }
280    }
281
282    /// Get union variants
283    pub fn union_variants(&self) -> Option<&[Schema]> {
284        match self {
285            Schema::OneOf { one_of, .. } => Some(one_of),
286            Schema::AnyOf { any_of, .. } => Some(any_of),
287            _ => None,
288        }
289    }
290
291    /// Check if this appears to be a nullable pattern (anyOf or oneOf with null)
292    pub fn is_nullable_pattern(&self) -> bool {
293        let variants = match self {
294            Schema::AnyOf { any_of, .. } => any_of,
295            Schema::OneOf { one_of, .. } => one_of,
296            _ => return false,
297        };
298        variants.len() == 2
299            && variants
300                .iter()
301                .any(|s| matches!(s.schema_type(), Some(SchemaType::Null)))
302    }
303
304    /// Get the non-null variant from a nullable pattern
305    pub fn non_null_variant(&self) -> Option<&Schema> {
306        if !self.is_nullable_pattern() {
307            return None;
308        }
309        let variants = match self {
310            Schema::AnyOf { any_of, .. } => any_of,
311            Schema::OneOf { one_of, .. } => one_of,
312            _ => return None,
313        };
314        variants
315            .iter()
316            .find(|s| !matches!(s.schema_type(), Some(SchemaType::Null)))
317    }
318
319    /// Infer schema type from structure if not explicitly set
320    pub fn inferred_type(&self) -> Option<SchemaType> {
321        match self {
322            Schema::Typed { schema_type, .. } => Some(schema_type.clone()),
323            Schema::Untyped { details } => {
324                // Infer from structure
325                if details.properties.is_some() {
326                    Some(SchemaType::Object)
327                } else if details.items.is_some() {
328                    Some(SchemaType::Array)
329                } else if details.enum_values.is_some() {
330                    Some(SchemaType::String) // Assume string enum
331                } else {
332                    None
333                }
334            }
335            _ => None,
336        }
337    }
338}
339
340impl SchemaDetails {
341    /// Check if this schema is nullable
342    pub fn is_nullable(&self) -> bool {
343        self.nullable.unwrap_or(false)
344    }
345
346    /// Check if this is a string enum
347    ///
348    /// A standalone string `const` (no `enum` array) is treated as a
349    /// degenerate single-value enum so the generator emits a tightly-typed
350    /// single-variant enum instead of a bare `String`. See issue #10.
351    pub fn is_string_enum(&self) -> bool {
352        self.enum_values.is_some() || self.const_string_value().is_some()
353    }
354
355    /// Get enum values as strings if this is a string enum.
356    ///
357    /// Falls back to `[const_value]` when `enum` is absent but `const` is a
358    /// string, so a property like `{ "type": "string", "const": "X" }`
359    /// produces a single-variant enum.
360    pub fn string_enum_values(&self) -> Option<Vec<String>> {
361        if let Some(values) = self.enum_values.as_ref() {
362            return Some(
363                values
364                    .iter()
365                    .filter_map(|v| v.as_str())
366                    .map(|s| s.to_string())
367                    .collect(),
368            );
369        }
370        self.const_string_value().map(|s| vec![s])
371    }
372
373    fn const_string_value(&self) -> Option<String> {
374        self.const_value
375            .as_ref()
376            .and_then(|v| v.as_str())
377            .map(|s| s.to_string())
378    }
379
380    /// Check if a field is required
381    pub fn is_field_required(&self, field_name: &str) -> bool {
382        self.required
383            .as_ref()
384            .map(|req| req.contains(&field_name.to_string()))
385            .unwrap_or(false)
386    }
387}
388
389/// OpenAPI Path Item Object  
390#[derive(Debug, Clone, Deserialize, Serialize)]
391pub struct PathItem {
392    #[serde(rename = "get")]
393    pub get: Option<Operation>,
394    #[serde(rename = "put")]
395    pub put: Option<Operation>,
396    #[serde(rename = "post")]
397    pub post: Option<Operation>,
398    #[serde(rename = "delete")]
399    pub delete: Option<Operation>,
400    #[serde(rename = "options")]
401    pub options: Option<Operation>,
402    #[serde(rename = "head")]
403    pub head: Option<Operation>,
404    #[serde(rename = "patch")]
405    pub patch: Option<Operation>,
406    #[serde(rename = "trace")]
407    pub trace: Option<Operation>,
408    pub parameters: Option<Vec<Parameter>>,
409    #[serde(flatten)]
410    pub extra: BTreeMap<String, Value>,
411}
412
413impl PathItem {
414    /// Get all operations in this path item
415    pub fn operations(&self) -> Vec<(&str, &Operation)> {
416        let mut ops = Vec::new();
417        if let Some(ref op) = self.get {
418            ops.push(("get", op));
419        }
420        if let Some(ref op) = self.put {
421            ops.push(("put", op));
422        }
423        if let Some(ref op) = self.post {
424            ops.push(("post", op));
425        }
426        if let Some(ref op) = self.delete {
427            ops.push(("delete", op));
428        }
429        if let Some(ref op) = self.options {
430            ops.push(("options", op));
431        }
432        if let Some(ref op) = self.head {
433            ops.push(("head", op));
434        }
435        if let Some(ref op) = self.patch {
436            ops.push(("patch", op));
437        }
438        if let Some(ref op) = self.trace {
439            ops.push(("trace", op));
440        }
441        ops
442    }
443}
444
445/// OpenAPI Operation Object
446#[derive(Debug, Clone, Deserialize, Serialize)]
447pub struct Operation {
448    #[serde(rename = "operationId")]
449    pub operation_id: Option<String>,
450    pub summary: Option<String>,
451    pub description: Option<String>,
452    pub parameters: Option<Vec<Parameter>>,
453    #[serde(rename = "requestBody")]
454    pub request_body: Option<RequestBody>,
455    pub responses: Option<BTreeMap<String, Response>>,
456    #[serde(flatten)]
457    pub extra: BTreeMap<String, Value>,
458}
459
460/// OpenAPI Parameter Object
461#[derive(Debug, Clone, Deserialize, Serialize)]
462pub struct Parameter {
463    pub name: Option<String>,
464    #[serde(rename = "in")]
465    pub location: Option<String>,
466    pub required: Option<bool>,
467    pub schema: Option<Schema>,
468    pub description: Option<String>,
469    #[serde(flatten)]
470    pub extra: BTreeMap<String, Value>,
471}
472
473/// OpenAPI Request Body Object
474#[derive(Debug, Clone, Deserialize, Serialize)]
475pub struct RequestBody {
476    pub content: Option<BTreeMap<String, MediaType>>,
477    pub description: Option<String>,
478    pub required: Option<bool>,
479    #[serde(flatten)]
480    pub extra: BTreeMap<String, Value>,
481}
482
483/// Returns true for media types whose payload is JSON.
484///
485/// Matches `application/json` exactly, plus any RFC 6839 structured-syntax
486/// suffix variant of the form `application/<subtype>+json`
487/// (e.g. `application/vnd.api+json`, `application/hal+json`,
488/// `application/problem+json`). Trailing parameters such as
489/// `; charset=utf-8` are tolerated.
490pub fn is_json_media_type(ct: &str) -> bool {
491    let essence = ct
492        .split(';')
493        .next()
494        .unwrap_or(ct)
495        .trim()
496        .to_ascii_lowercase();
497    if essence == "application/json" {
498        return true;
499    }
500    if let Some(subtype) = essence.strip_prefix("application/") {
501        return subtype.ends_with("+json");
502    }
503    false
504}
505
506/// Returns true for `application/x-www-form-urlencoded` (with optional
507/// parameters).
508pub fn is_form_urlencoded_media_type(ct: &str) -> bool {
509    let essence = ct
510        .split(';')
511        .next()
512        .unwrap_or(ct)
513        .trim()
514        .to_ascii_lowercase();
515    essence == "application/x-www-form-urlencoded"
516}
517
518fn find_json_content(content: &BTreeMap<String, MediaType>) -> Option<(&str, &MediaType)> {
519    if let Some(mt) = content.get("application/json") {
520        return Some(("application/json", mt));
521    }
522    content
523        .iter()
524        .find(|(ct, _)| is_json_media_type(ct))
525        .map(|(ct, mt)| (ct.as_str(), mt))
526}
527
528impl RequestBody {
529    /// Get schema for any JSON content type
530    ///
531    /// Prefers the canonical `application/json` entry, then falls back to
532    /// any `application/*+json` variant (RFC 6839) such as
533    /// `application/vnd.api+json` or `application/hal+json`.
534    pub fn json_schema(&self) -> Option<&Schema> {
535        self.content
536            .as_ref()
537            .and_then(find_json_content)
538            .and_then(|(_, media_type)| media_type.schema.as_ref())
539    }
540
541    /// Get the best content type and its schema, preferring JSON over others
542    pub fn best_content(&self) -> Option<(&str, Option<&Schema>)> {
543        let content = self.content.as_ref()?;
544
545        if let Some((ct, media_type)) = find_json_content(content) {
546            return Some((ct, media_type.schema.as_ref()));
547        }
548
549        const PRIORITY: &[&str] = &[
550            "application/x-www-form-urlencoded",
551            "multipart/form-data",
552            "application/octet-stream",
553            "text/plain",
554        ];
555        for ct in PRIORITY {
556            if let Some(media_type) = content.get(*ct) {
557                return Some((*ct, media_type.schema.as_ref()));
558            }
559        }
560        None
561    }
562}
563
564/// OpenAPI Response Object
565#[derive(Debug, Clone, Deserialize, Serialize)]
566pub struct Response {
567    pub description: Option<String>,
568    pub content: Option<BTreeMap<String, MediaType>>,
569    #[serde(flatten)]
570    pub extra: BTreeMap<String, Value>,
571}
572
573impl Response {
574    /// Get schema for any JSON content type
575    ///
576    /// Prefers the canonical `application/json` entry, then falls back to
577    /// any `application/*+json` variant (RFC 6839) such as
578    /// `application/vnd.api+json`, `application/hal+json`, or
579    /// `application/problem+json`.
580    pub fn json_schema(&self) -> Option<&Schema> {
581        self.content
582            .as_ref()
583            .and_then(find_json_content)
584            .and_then(|(_, media_type)| media_type.schema.as_ref())
585    }
586}
587
588/// OpenAPI Media Type Object
589#[derive(Debug, Clone, Deserialize, Serialize)]
590pub struct MediaType {
591    pub schema: Option<Schema>,
592    #[serde(flatten)]
593    pub extra: BTreeMap<String, Value>,
594}
595
596#[cfg(test)]
597#[allow(clippy::unwrap_used, clippy::expect_used)]
598mod tests {
599    use super::*;
600    use serde_json::json;
601
602    #[test]
603    fn test_parse_simple_object_schema() {
604        let schema_json = json!({
605            "type": "object",
606            "properties": {
607                "name": {
608                    "type": "string",
609                    "description": "User name"
610                },
611                "age": {
612                    "type": "integer"
613                }
614            },
615            "required": ["name"]
616        });
617
618        let schema: Schema = serde_json::from_value(schema_json).unwrap();
619
620        match schema {
621            Schema::Typed {
622                schema_type: SchemaType::Object,
623                details,
624            } => {
625                assert!(details.properties.is_some());
626                assert_eq!(details.required, Some(vec!["name".to_string()]));
627                assert!(details.is_field_required("name"));
628                assert!(!details.is_field_required("age"));
629            }
630            _ => panic!("Expected object schema"),
631        }
632    }
633
634    #[test]
635    fn test_parse_string_enum() {
636        let schema_json = json!({
637            "type": "string",
638            "enum": ["active", "inactive", "pending"],
639            "description": "User status"
640        });
641
642        let schema: Schema = serde_json::from_value(schema_json).unwrap();
643
644        match schema {
645            Schema::Typed {
646                schema_type: SchemaType::String,
647                details,
648            } => {
649                assert!(details.is_string_enum());
650                let values = details.string_enum_values().unwrap();
651                assert_eq!(values, vec!["active", "inactive", "pending"]);
652            }
653            _ => panic!("Expected string enum schema"),
654        }
655    }
656
657    #[test]
658    fn test_parse_reference_schema() {
659        let schema_json = json!({
660            "$ref": "#/components/schemas/User"
661        });
662
663        let schema: Schema = serde_json::from_value(schema_json).unwrap();
664
665        assert!(schema.is_reference());
666        assert_eq!(schema.reference(), Some("#/components/schemas/User"));
667    }
668
669    #[test]
670    fn test_parse_discriminated_union() {
671        let schema_json = json!({
672            "oneOf": [
673                {"$ref": "#/components/schemas/Dog"},
674                {"$ref": "#/components/schemas/Cat"}
675            ],
676            "discriminator": {
677                "propertyName": "petType"
678            }
679        });
680
681        let schema: Schema = serde_json::from_value(schema_json).unwrap();
682
683        assert!(schema.is_discriminated_union());
684        let discriminator = schema.discriminator().unwrap();
685        assert_eq!(discriminator.property_name, "petType");
686    }
687
688    #[test]
689    fn test_parse_nullable_pattern() {
690        let schema_json = json!({
691            "anyOf": [
692                {"$ref": "#/components/schemas/User"},
693                {"type": "null"}
694            ]
695        });
696
697        let schema: Schema = serde_json::from_value(schema_json).unwrap();
698
699        assert!(schema.is_nullable_pattern());
700        let non_null = schema.non_null_variant().unwrap();
701        assert!(non_null.is_reference());
702    }
703
704    #[test]
705    fn is_json_media_type_accepts_canonical_and_structured_suffix() {
706        // Canonical
707        assert!(is_json_media_type("application/json"));
708        // Parameters tolerated (RFC 7231 ยง3.1.1.1)
709        assert!(is_json_media_type("application/json; charset=utf-8"));
710        assert!(is_json_media_type("APPLICATION/JSON"));
711        // RFC 6839 +json structured-syntax suffix
712        assert!(is_json_media_type("application/vnd.api+json"));
713        assert!(is_json_media_type("application/hal+json"));
714        assert!(is_json_media_type("application/problem+json"));
715        assert!(is_json_media_type("application/ld+json"));
716        assert!(is_json_media_type(
717            "application/vnd.api+json; charset=utf-8"
718        ));
719        // Negatives
720        assert!(!is_json_media_type("application/xml"));
721        assert!(!is_json_media_type("application/x-www-form-urlencoded"));
722        assert!(!is_json_media_type("text/plain"));
723        assert!(!is_json_media_type("application/jsonbutnotreally"));
724        // +json suffix only applies to application/* per RFC 6839
725        assert!(!is_json_media_type("text/something+json"));
726    }
727
728    #[test]
729    fn request_body_json_schema_finds_vnd_api_plus_json() {
730        // Mirrors Latitude.sh: request body declared under
731        // application/vnd.api+json without a sibling application/json.
732        let body_json = json!({
733            "required": true,
734            "content": {
735                "application/vnd.api+json": {
736                    "schema": {"$ref": "#/components/schemas/create_api_key"}
737                }
738            }
739        });
740
741        let body: RequestBody = serde_json::from_value(body_json).unwrap();
742        let schema = body.json_schema().expect("expected +json schema match");
743        assert!(schema.is_reference());
744    }
745
746    #[test]
747    fn request_body_best_content_prefers_canonical_json_over_plus_json() {
748        // When both are present (e.g. Latitude.sh's POST /auth/api_keys),
749        // best_content should still pick application/json for backwards
750        // compatibility with the existing snapshot suite.
751        let body_json = json!({
752            "required": true,
753            "content": {
754                "application/json": {
755                    "schema": {"$ref": "#/components/schemas/A"}
756                },
757                "application/vnd.api+json": {
758                    "schema": {"$ref": "#/components/schemas/B"}
759                }
760            }
761        });
762
763        let body: RequestBody = serde_json::from_value(body_json).unwrap();
764        let (ct, _) = body.best_content().expect("expected best_content");
765        assert_eq!(ct, "application/json");
766    }
767
768    #[test]
769    fn request_body_best_content_falls_back_to_plus_json() {
770        // When only the +json variant is declared, best_content returns
771        // it instead of skipping straight to form-urlencoded.
772        let body_json = json!({
773            "required": true,
774            "content": {
775                "application/vnd.api+json": {
776                    "schema": {"$ref": "#/components/schemas/B"}
777                }
778            }
779        });
780
781        let body: RequestBody = serde_json::from_value(body_json).unwrap();
782        let (ct, _) = body.best_content().expect("expected best_content");
783        assert_eq!(ct, "application/vnd.api+json");
784    }
785
786    #[test]
787    fn response_json_schema_finds_vnd_api_plus_json() {
788        // Mirrors every Latitude.sh response: schema lives under
789        // application/vnd.api+json only.
790        let resp_json = json!({
791            "description": "OK",
792            "content": {
793                "application/vnd.api+json": {
794                    "schema": {"$ref": "#/components/schemas/api_keys"}
795                }
796            }
797        });
798
799        let resp: Response = serde_json::from_value(resp_json).unwrap();
800        let schema = resp.json_schema().expect("expected +json schema match");
801        assert!(schema.is_reference());
802    }
803}