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    pub fn is_string_enum(&self) -> bool {
348        self.enum_values.is_some()
349    }
350
351    /// Get enum values as strings if this is a string enum
352    pub fn string_enum_values(&self) -> Option<Vec<String>> {
353        self.enum_values.as_ref().map(|values| {
354            values
355                .iter()
356                .filter_map(|v| v.as_str())
357                .map(|s| s.to_string())
358                .collect()
359        })
360    }
361
362    /// Check if a field is required
363    pub fn is_field_required(&self, field_name: &str) -> bool {
364        self.required
365            .as_ref()
366            .map(|req| req.contains(&field_name.to_string()))
367            .unwrap_or(false)
368    }
369}
370
371/// OpenAPI Path Item Object  
372#[derive(Debug, Clone, Deserialize, Serialize)]
373pub struct PathItem {
374    #[serde(rename = "get")]
375    pub get: Option<Operation>,
376    #[serde(rename = "put")]
377    pub put: Option<Operation>,
378    #[serde(rename = "post")]
379    pub post: Option<Operation>,
380    #[serde(rename = "delete")]
381    pub delete: Option<Operation>,
382    #[serde(rename = "options")]
383    pub options: Option<Operation>,
384    #[serde(rename = "head")]
385    pub head: Option<Operation>,
386    #[serde(rename = "patch")]
387    pub patch: Option<Operation>,
388    #[serde(rename = "trace")]
389    pub trace: Option<Operation>,
390    pub parameters: Option<Vec<Parameter>>,
391    #[serde(flatten)]
392    pub extra: BTreeMap<String, Value>,
393}
394
395impl PathItem {
396    /// Get all operations in this path item
397    pub fn operations(&self) -> Vec<(&str, &Operation)> {
398        let mut ops = Vec::new();
399        if let Some(ref op) = self.get {
400            ops.push(("get", op));
401        }
402        if let Some(ref op) = self.put {
403            ops.push(("put", op));
404        }
405        if let Some(ref op) = self.post {
406            ops.push(("post", op));
407        }
408        if let Some(ref op) = self.delete {
409            ops.push(("delete", op));
410        }
411        if let Some(ref op) = self.options {
412            ops.push(("options", op));
413        }
414        if let Some(ref op) = self.head {
415            ops.push(("head", op));
416        }
417        if let Some(ref op) = self.patch {
418            ops.push(("patch", op));
419        }
420        if let Some(ref op) = self.trace {
421            ops.push(("trace", op));
422        }
423        ops
424    }
425}
426
427/// OpenAPI Operation Object
428#[derive(Debug, Clone, Deserialize, Serialize)]
429pub struct Operation {
430    #[serde(rename = "operationId")]
431    pub operation_id: Option<String>,
432    pub summary: Option<String>,
433    pub description: Option<String>,
434    pub parameters: Option<Vec<Parameter>>,
435    #[serde(rename = "requestBody")]
436    pub request_body: Option<RequestBody>,
437    pub responses: Option<BTreeMap<String, Response>>,
438    #[serde(flatten)]
439    pub extra: BTreeMap<String, Value>,
440}
441
442/// OpenAPI Parameter Object
443#[derive(Debug, Clone, Deserialize, Serialize)]
444pub struct Parameter {
445    pub name: Option<String>,
446    #[serde(rename = "in")]
447    pub location: Option<String>,
448    pub required: Option<bool>,
449    pub schema: Option<Schema>,
450    pub description: Option<String>,
451    #[serde(flatten)]
452    pub extra: BTreeMap<String, Value>,
453}
454
455/// OpenAPI Request Body Object
456#[derive(Debug, Clone, Deserialize, Serialize)]
457pub struct RequestBody {
458    pub content: Option<BTreeMap<String, MediaType>>,
459    pub description: Option<String>,
460    pub required: Option<bool>,
461    #[serde(flatten)]
462    pub extra: BTreeMap<String, Value>,
463}
464
465/// Returns true for media types whose payload is JSON.
466///
467/// Matches `application/json` exactly, plus any RFC 6839 structured-syntax
468/// suffix variant of the form `application/<subtype>+json`
469/// (e.g. `application/vnd.api+json`, `application/hal+json`,
470/// `application/problem+json`). Trailing parameters such as
471/// `; charset=utf-8` are tolerated.
472pub fn is_json_media_type(ct: &str) -> bool {
473    let essence = ct
474        .split(';')
475        .next()
476        .unwrap_or(ct)
477        .trim()
478        .to_ascii_lowercase();
479    if essence == "application/json" {
480        return true;
481    }
482    if let Some(subtype) = essence.strip_prefix("application/") {
483        return subtype.ends_with("+json");
484    }
485    false
486}
487
488/// Returns true for `application/x-www-form-urlencoded` (with optional
489/// parameters).
490pub fn is_form_urlencoded_media_type(ct: &str) -> bool {
491    let essence = ct
492        .split(';')
493        .next()
494        .unwrap_or(ct)
495        .trim()
496        .to_ascii_lowercase();
497    essence == "application/x-www-form-urlencoded"
498}
499
500fn find_json_content(content: &BTreeMap<String, MediaType>) -> Option<(&str, &MediaType)> {
501    if let Some(mt) = content.get("application/json") {
502        return Some(("application/json", mt));
503    }
504    content
505        .iter()
506        .find(|(ct, _)| is_json_media_type(ct))
507        .map(|(ct, mt)| (ct.as_str(), mt))
508}
509
510impl RequestBody {
511    /// Get schema for any JSON content type
512    ///
513    /// Prefers the canonical `application/json` entry, then falls back to
514    /// any `application/*+json` variant (RFC 6839) such as
515    /// `application/vnd.api+json` or `application/hal+json`.
516    pub fn json_schema(&self) -> Option<&Schema> {
517        self.content
518            .as_ref()
519            .and_then(find_json_content)
520            .and_then(|(_, media_type)| media_type.schema.as_ref())
521    }
522
523    /// Get the best content type and its schema, preferring JSON over others
524    pub fn best_content(&self) -> Option<(&str, Option<&Schema>)> {
525        let content = self.content.as_ref()?;
526
527        if let Some((ct, media_type)) = find_json_content(content) {
528            return Some((ct, media_type.schema.as_ref()));
529        }
530
531        const PRIORITY: &[&str] = &[
532            "application/x-www-form-urlencoded",
533            "multipart/form-data",
534            "application/octet-stream",
535            "text/plain",
536        ];
537        for ct in PRIORITY {
538            if let Some(media_type) = content.get(*ct) {
539                return Some((*ct, media_type.schema.as_ref()));
540            }
541        }
542        None
543    }
544}
545
546/// OpenAPI Response Object
547#[derive(Debug, Clone, Deserialize, Serialize)]
548pub struct Response {
549    pub description: Option<String>,
550    pub content: Option<BTreeMap<String, MediaType>>,
551    #[serde(flatten)]
552    pub extra: BTreeMap<String, Value>,
553}
554
555impl Response {
556    /// Get schema for any JSON content type
557    ///
558    /// Prefers the canonical `application/json` entry, then falls back to
559    /// any `application/*+json` variant (RFC 6839) such as
560    /// `application/vnd.api+json`, `application/hal+json`, or
561    /// `application/problem+json`.
562    pub fn json_schema(&self) -> Option<&Schema> {
563        self.content
564            .as_ref()
565            .and_then(find_json_content)
566            .and_then(|(_, media_type)| media_type.schema.as_ref())
567    }
568}
569
570/// OpenAPI Media Type Object
571#[derive(Debug, Clone, Deserialize, Serialize)]
572pub struct MediaType {
573    pub schema: Option<Schema>,
574    #[serde(flatten)]
575    pub extra: BTreeMap<String, Value>,
576}
577
578#[cfg(test)]
579#[allow(clippy::unwrap_used, clippy::expect_used)]
580mod tests {
581    use super::*;
582    use serde_json::json;
583
584    #[test]
585    fn test_parse_simple_object_schema() {
586        let schema_json = json!({
587            "type": "object",
588            "properties": {
589                "name": {
590                    "type": "string",
591                    "description": "User name"
592                },
593                "age": {
594                    "type": "integer"
595                }
596            },
597            "required": ["name"]
598        });
599
600        let schema: Schema = serde_json::from_value(schema_json).unwrap();
601
602        match schema {
603            Schema::Typed {
604                schema_type: SchemaType::Object,
605                details,
606            } => {
607                assert!(details.properties.is_some());
608                assert_eq!(details.required, Some(vec!["name".to_string()]));
609                assert!(details.is_field_required("name"));
610                assert!(!details.is_field_required("age"));
611            }
612            _ => panic!("Expected object schema"),
613        }
614    }
615
616    #[test]
617    fn test_parse_string_enum() {
618        let schema_json = json!({
619            "type": "string",
620            "enum": ["active", "inactive", "pending"],
621            "description": "User status"
622        });
623
624        let schema: Schema = serde_json::from_value(schema_json).unwrap();
625
626        match schema {
627            Schema::Typed {
628                schema_type: SchemaType::String,
629                details,
630            } => {
631                assert!(details.is_string_enum());
632                let values = details.string_enum_values().unwrap();
633                assert_eq!(values, vec!["active", "inactive", "pending"]);
634            }
635            _ => panic!("Expected string enum schema"),
636        }
637    }
638
639    #[test]
640    fn test_parse_reference_schema() {
641        let schema_json = json!({
642            "$ref": "#/components/schemas/User"
643        });
644
645        let schema: Schema = serde_json::from_value(schema_json).unwrap();
646
647        assert!(schema.is_reference());
648        assert_eq!(schema.reference(), Some("#/components/schemas/User"));
649    }
650
651    #[test]
652    fn test_parse_discriminated_union() {
653        let schema_json = json!({
654            "oneOf": [
655                {"$ref": "#/components/schemas/Dog"},
656                {"$ref": "#/components/schemas/Cat"}
657            ],
658            "discriminator": {
659                "propertyName": "petType"
660            }
661        });
662
663        let schema: Schema = serde_json::from_value(schema_json).unwrap();
664
665        assert!(schema.is_discriminated_union());
666        let discriminator = schema.discriminator().unwrap();
667        assert_eq!(discriminator.property_name, "petType");
668    }
669
670    #[test]
671    fn test_parse_nullable_pattern() {
672        let schema_json = json!({
673            "anyOf": [
674                {"$ref": "#/components/schemas/User"},
675                {"type": "null"}
676            ]
677        });
678
679        let schema: Schema = serde_json::from_value(schema_json).unwrap();
680
681        assert!(schema.is_nullable_pattern());
682        let non_null = schema.non_null_variant().unwrap();
683        assert!(non_null.is_reference());
684    }
685
686    #[test]
687    fn is_json_media_type_accepts_canonical_and_structured_suffix() {
688        // Canonical
689        assert!(is_json_media_type("application/json"));
690        // Parameters tolerated (RFC 7231 ยง3.1.1.1)
691        assert!(is_json_media_type("application/json; charset=utf-8"));
692        assert!(is_json_media_type("APPLICATION/JSON"));
693        // RFC 6839 +json structured-syntax suffix
694        assert!(is_json_media_type("application/vnd.api+json"));
695        assert!(is_json_media_type("application/hal+json"));
696        assert!(is_json_media_type("application/problem+json"));
697        assert!(is_json_media_type("application/ld+json"));
698        assert!(is_json_media_type(
699            "application/vnd.api+json; charset=utf-8"
700        ));
701        // Negatives
702        assert!(!is_json_media_type("application/xml"));
703        assert!(!is_json_media_type("application/x-www-form-urlencoded"));
704        assert!(!is_json_media_type("text/plain"));
705        assert!(!is_json_media_type("application/jsonbutnotreally"));
706        // +json suffix only applies to application/* per RFC 6839
707        assert!(!is_json_media_type("text/something+json"));
708    }
709
710    #[test]
711    fn request_body_json_schema_finds_vnd_api_plus_json() {
712        // Mirrors Latitude.sh: request body declared under
713        // application/vnd.api+json without a sibling application/json.
714        let body_json = json!({
715            "required": true,
716            "content": {
717                "application/vnd.api+json": {
718                    "schema": {"$ref": "#/components/schemas/create_api_key"}
719                }
720            }
721        });
722
723        let body: RequestBody = serde_json::from_value(body_json).unwrap();
724        let schema = body.json_schema().expect("expected +json schema match");
725        assert!(schema.is_reference());
726    }
727
728    #[test]
729    fn request_body_best_content_prefers_canonical_json_over_plus_json() {
730        // When both are present (e.g. Latitude.sh's POST /auth/api_keys),
731        // best_content should still pick application/json for backwards
732        // compatibility with the existing snapshot suite.
733        let body_json = json!({
734            "required": true,
735            "content": {
736                "application/json": {
737                    "schema": {"$ref": "#/components/schemas/A"}
738                },
739                "application/vnd.api+json": {
740                    "schema": {"$ref": "#/components/schemas/B"}
741                }
742            }
743        });
744
745        let body: RequestBody = serde_json::from_value(body_json).unwrap();
746        let (ct, _) = body.best_content().expect("expected best_content");
747        assert_eq!(ct, "application/json");
748    }
749
750    #[test]
751    fn request_body_best_content_falls_back_to_plus_json() {
752        // When only the +json variant is declared, best_content returns
753        // it instead of skipping straight to form-urlencoded.
754        let body_json = json!({
755            "required": true,
756            "content": {
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/vnd.api+json");
766    }
767
768    #[test]
769    fn response_json_schema_finds_vnd_api_plus_json() {
770        // Mirrors every Latitude.sh response: schema lives under
771        // application/vnd.api+json only.
772        let resp_json = json!({
773            "description": "OK",
774            "content": {
775                "application/vnd.api+json": {
776                    "schema": {"$ref": "#/components/schemas/api_keys"}
777                }
778            }
779        });
780
781        let resp: Response = serde_json::from_value(resp_json).unwrap();
782        let schema = resp.json_schema().expect("expected +json schema match");
783        assert!(schema.is_reference());
784    }
785}