1use crate::extensions::Extensions;
2use once_cell::sync::Lazy;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::BTreeMap;
6
7#[derive(Debug, Clone, Deserialize, Serialize)]
8pub struct OpenApiSpec {
9 pub openapi: String,
10 pub info: Info,
11 #[serde(rename = "jsonSchemaDialect", default)]
12 pub json_schema_dialect: Option<String>,
13 #[serde(default)]
14 pub servers: Option<Vec<Server>>,
15 #[serde(default, deserialize_with = "deserialize_lenient_path_map")]
16 pub paths: Option<BTreeMap<String, PathItem>>,
17 #[serde(default)]
18 pub webhooks: Option<BTreeMap<String, PathItem>>,
19 #[serde(default)]
20 pub components: Option<Components>,
21 #[serde(default)]
22 pub security: Option<Vec<BTreeMap<String, Vec<String>>>>,
23 #[serde(default)]
24 pub tags: Option<Vec<Tag>>,
25 #[serde(rename = "externalDocs", default)]
26 pub external_docs: Option<ExternalDocs>,
27 #[serde(rename = "$self", default)]
29 pub self_uri: Option<String>,
30 #[serde(flatten, default)]
31 pub extensions: Extensions,
32}
33
34fn deserialize_lenient_path_map<'de, D>(
40 deserializer: D,
41) -> Result<Option<BTreeMap<String, PathItem>>, D::Error>
42where
43 D: serde::Deserializer<'de>,
44{
45 let raw = Option::<BTreeMap<String, Value>>::deserialize(deserializer)?;
46 let Some(entries) = raw else {
47 return Ok(None);
48 };
49 let mut paths = BTreeMap::new();
50 for (key, value) in entries {
51 if value.is_object() || !key.starts_with("x-") {
52 let item =
53 serde_json::from_value::<PathItem>(value).map_err(serde::de::Error::custom)?;
54 paths.insert(key, item);
55 }
56 }
57 Ok(Some(paths))
58}
59
60#[derive(Debug, Clone, Deserialize, Serialize)]
61pub struct Info {
62 pub title: String,
63 #[serde(default)]
64 pub summary: Option<String>,
65 #[serde(default)]
66 pub description: Option<String>,
67 #[serde(rename = "termsOfService", default)]
68 pub terms_of_service: Option<String>,
69 #[serde(default)]
70 pub contact: Option<Value>,
71 #[serde(default)]
72 pub license: Option<Value>,
73 #[serde(default)]
74 pub version: Option<String>,
75 #[serde(flatten, default)]
76 pub extensions: Extensions,
77}
78
79#[derive(Debug, Clone, Deserialize, Serialize)]
80pub struct Components {
81 #[serde(default)]
82 pub schemas: Option<BTreeMap<String, Schema>>,
83 #[serde(default)]
84 pub responses: Option<BTreeMap<String, Response>>,
85 #[serde(default)]
86 pub parameters: Option<BTreeMap<String, Parameter>>,
87 #[serde(default)]
88 pub examples: Option<BTreeMap<String, Example>>,
89 #[serde(rename = "requestBodies", default)]
90 pub request_bodies: Option<BTreeMap<String, RequestBody>>,
91 #[serde(default)]
92 pub headers: Option<BTreeMap<String, Header>>,
93 #[serde(rename = "securitySchemes", default)]
94 pub security_schemes: Option<BTreeMap<String, SecurityScheme>>,
95 #[serde(default)]
96 pub links: Option<BTreeMap<String, Link>>,
97 #[serde(default)]
98 pub callbacks: Option<BTreeMap<String, Callback>>,
99 #[serde(rename = "pathItems", default)]
101 pub path_items: Option<BTreeMap<String, PathItem>>,
102 #[serde(rename = "mediaTypes", default)]
104 pub media_types: Option<BTreeMap<String, MediaType>>,
105 #[serde(flatten, default)]
106 pub extensions: Extensions,
107}
108
109#[derive(Debug, Clone, Deserialize, Serialize)]
110#[serde(untagged)]
111pub enum Schema {
112 Reference {
114 #[serde(rename = "$ref")]
115 reference: String,
116 #[serde(flatten)]
117 extra: BTreeMap<String, Value>,
118 },
119 RecursiveRef {
121 #[serde(rename = "$recursiveRef")]
122 recursive_ref: String,
123 #[serde(flatten)]
124 extra: BTreeMap<String, Value>,
125 },
126 DynamicRef {
132 #[serde(rename = "$dynamicRef")]
133 dynamic_ref: String,
134 #[serde(flatten)]
135 extra: BTreeMap<String, Value>,
136 },
137 OneOf {
139 #[serde(rename = "oneOf")]
140 one_of: Vec<Schema>,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 discriminator: Option<Discriminator>,
143 #[serde(flatten)]
144 details: SchemaDetails,
145 },
146 AnyOf {
148 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
149 schema_type: Option<SchemaType>,
150 #[serde(rename = "anyOf")]
151 any_of: Vec<Schema>,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 discriminator: Option<Discriminator>,
154 #[serde(flatten)]
155 details: SchemaDetails,
156 },
157 AllOf {
160 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
161 schema_type: Option<SchemaType>,
162 #[serde(rename = "allOf")]
163 all_of: Vec<Schema>,
164 #[serde(flatten)]
165 details: SchemaDetails,
166 },
167 TypedMulti {
172 #[serde(rename = "type")]
173 schema_types: Vec<SchemaType>,
174 #[serde(flatten)]
175 details: SchemaDetails,
176 },
177 Typed {
179 #[serde(rename = "type")]
180 schema_type: SchemaType,
181 #[serde(flatten)]
182 details: SchemaDetails,
183 },
184 Untyped {
186 #[serde(flatten)]
187 details: SchemaDetails,
188 },
189}
190
191#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
192#[serde(rename_all = "lowercase")]
193pub enum SchemaType {
194 String,
195 Integer,
196 Number,
197 Boolean,
198 Array,
199 Object,
200 #[serde(rename = "null")]
201 Null,
202}
203
204#[derive(Debug, Clone, Default, Deserialize, Serialize)]
205pub struct SchemaDetails {
206 #[serde(skip_serializing_if = "Option::is_none")]
207 pub description: Option<String>,
208 #[serde(skip_serializing_if = "Option::is_none")]
209 pub nullable: Option<bool>,
210
211 #[serde(rename = "$recursiveAnchor", skip_serializing_if = "Option::is_none")]
213 pub recursive_anchor: Option<bool>,
214
215 #[serde(rename = "$dynamicAnchor", skip_serializing_if = "Option::is_none")]
217 pub dynamic_anchor: Option<String>,
218 #[serde(rename = "$id", skip_serializing_if = "Option::is_none")]
219 pub schema_id: Option<String>,
220
221 #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
223 pub enum_values: Option<Vec<Value>>,
224 #[serde(skip_serializing_if = "Option::is_none")]
225 pub format: Option<String>,
226 #[serde(skip_serializing_if = "Option::is_none")]
227 pub default: Option<Value>,
228 #[serde(
229 rename = "const",
230 default,
231 deserialize_with = "deserialize_present_value",
232 skip_serializing_if = "Option::is_none"
233 )]
234 pub const_value: Option<Value>,
235
236 #[serde(skip_serializing_if = "Option::is_none")]
238 pub properties: Option<BTreeMap<String, Schema>>,
239 #[serde(skip_serializing_if = "Option::is_none")]
240 pub required: Option<Vec<String>>,
241 #[serde(
242 rename = "additionalProperties",
243 skip_serializing_if = "Option::is_none"
244 )]
245 pub additional_properties: Option<AdditionalProperties>,
246
247 #[serde(skip_serializing_if = "Option::is_none")]
249 pub items: Option<Box<Schema>>,
250
251 #[serde(skip_serializing_if = "Option::is_none")]
253 pub minimum: Option<f64>,
254 #[serde(skip_serializing_if = "Option::is_none")]
255 pub maximum: Option<f64>,
256
257 #[serde(rename = "minLength", skip_serializing_if = "Option::is_none")]
259 pub min_length: Option<u64>,
260 #[serde(rename = "maxLength", skip_serializing_if = "Option::is_none")]
261 pub max_length: Option<u64>,
262 #[serde(skip_serializing_if = "Option::is_none")]
263 pub pattern: Option<String>,
264 #[serde(rename = "exclusiveMinimum", skip_serializing_if = "Option::is_none")]
268 pub exclusive_minimum: Option<ExclusiveBound>,
269 #[serde(rename = "exclusiveMaximum", skip_serializing_if = "Option::is_none")]
270 pub exclusive_maximum: Option<ExclusiveBound>,
271 #[serde(rename = "multipleOf", skip_serializing_if = "Option::is_none")]
272 pub multiple_of: Option<f64>,
273 #[serde(rename = "minItems", skip_serializing_if = "Option::is_none")]
274 pub min_items: Option<u64>,
275 #[serde(rename = "maxItems", skip_serializing_if = "Option::is_none")]
276 pub max_items: Option<u64>,
277 #[serde(rename = "uniqueItems", skip_serializing_if = "Option::is_none")]
278 pub unique_items: Option<bool>,
279 #[serde(rename = "minProperties", skip_serializing_if = "Option::is_none")]
280 pub min_properties: Option<u64>,
281 #[serde(rename = "maxProperties", skip_serializing_if = "Option::is_none")]
282 pub max_properties: Option<u64>,
283
284 #[serde(rename = "prefixItems", skip_serializing_if = "Option::is_none")]
286 pub prefix_items: Option<Vec<Schema>>,
287 #[serde(skip_serializing_if = "Option::is_none")]
288 pub contains: Option<Box<Schema>>,
289 #[serde(rename = "minContains", skip_serializing_if = "Option::is_none")]
290 pub min_contains: Option<u64>,
291 #[serde(rename = "maxContains", skip_serializing_if = "Option::is_none")]
292 pub max_contains: Option<u64>,
293
294 #[serde(rename = "patternProperties", skip_serializing_if = "Option::is_none")]
296 pub pattern_properties: Option<BTreeMap<String, Schema>>,
297 #[serde(rename = "propertyNames", skip_serializing_if = "Option::is_none")]
298 pub property_names: Option<Box<Schema>>,
299 #[serde(
300 rename = "unevaluatedProperties",
301 skip_serializing_if = "Option::is_none"
302 )]
303 pub unevaluated_properties: Option<AdditionalProperties>,
304 #[serde(rename = "unevaluatedItems", skip_serializing_if = "Option::is_none")]
305 pub unevaluated_items: Option<AdditionalProperties>,
306 #[serde(rename = "dependentRequired", skip_serializing_if = "Option::is_none")]
307 pub dependent_required: Option<BTreeMap<String, Vec<String>>>,
308 #[serde(rename = "dependentSchemas", skip_serializing_if = "Option::is_none")]
309 pub dependent_schemas: Option<BTreeMap<String, Schema>>,
310
311 #[serde(rename = "contentEncoding", skip_serializing_if = "Option::is_none")]
313 pub content_encoding: Option<String>,
314 #[serde(rename = "contentMediaType", skip_serializing_if = "Option::is_none")]
315 pub content_media_type: Option<String>,
316 #[serde(rename = "contentSchema", skip_serializing_if = "Option::is_none")]
317 pub content_schema: Option<Box<Schema>>,
318
319 #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
321 pub if_schema: Option<Box<Schema>>,
322 #[serde(rename = "then", skip_serializing_if = "Option::is_none")]
323 pub then_schema: Option<Box<Schema>>,
324 #[serde(rename = "else", skip_serializing_if = "Option::is_none")]
325 pub else_schema: Option<Box<Schema>>,
326 #[serde(skip_serializing_if = "Option::is_none")]
327 pub not: Option<Box<Schema>>,
328
329 #[serde(skip_serializing_if = "Option::is_none")]
331 pub title: Option<String>,
332 #[serde(skip_serializing_if = "Option::is_none")]
333 pub deprecated: Option<bool>,
334 #[serde(rename = "readOnly", skip_serializing_if = "Option::is_none")]
335 pub read_only: Option<bool>,
336 #[serde(rename = "writeOnly", skip_serializing_if = "Option::is_none")]
337 pub write_only: Option<bool>,
338 #[serde(skip_serializing_if = "Option::is_none")]
339 pub examples: Option<Vec<Value>>,
340 #[serde(skip_serializing_if = "Option::is_none")]
341 pub example: Option<Value>,
342 #[serde(rename = "$comment", skip_serializing_if = "Option::is_none")]
344 pub comment: Option<String>,
345 #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
346 pub schema_keyword: Option<String>,
347 #[serde(rename = "$defs", skip_serializing_if = "Option::is_none")]
348 pub defs: Option<BTreeMap<String, Schema>>,
349
350 #[serde(flatten)]
353 pub extra: BTreeMap<String, Value>,
354}
355
356fn deserialize_present_value<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
357where
358 D: serde::Deserializer<'de>,
359{
360 Value::deserialize(deserializer).map(Some)
361}
362
363#[derive(Debug, Clone, Deserialize, Serialize)]
366#[serde(untagged)]
367pub enum ExclusiveBound {
368 Bool(bool),
369 Number(f64),
370}
371
372#[derive(Debug, Clone, Deserialize, Serialize)]
373#[serde(untagged)]
374pub enum AdditionalProperties {
375 Boolean(bool),
376 Schema(Box<Schema>),
377}
378
379#[derive(Debug, Clone, Deserialize, Serialize)]
381pub struct Example {
382 #[serde(default)]
383 pub summary: Option<String>,
384 #[serde(default)]
385 pub description: Option<String>,
386 #[serde(default)]
388 pub value: Option<Value>,
389 #[serde(rename = "externalValue", default)]
390 pub external_value: Option<String>,
391 #[serde(rename = "dataValue", default)]
393 pub data_value: Option<Value>,
394 #[serde(rename = "serializedValue", default)]
396 pub serialized_value: Option<String>,
397 #[serde(rename = "$ref", default)]
398 pub reference: Option<String>,
399 #[serde(flatten, default)]
400 pub extensions: Extensions,
401}
402
403#[derive(Debug, Clone, Deserialize, Serialize)]
405pub struct Link {
406 #[serde(rename = "operationRef", default)]
407 pub operation_ref: Option<String>,
408 #[serde(rename = "operationId", default)]
409 pub operation_id: Option<String>,
410 #[serde(default)]
411 pub parameters: Option<BTreeMap<String, Value>>,
412 #[serde(rename = "requestBody", default)]
413 pub request_body: Option<Value>,
414 #[serde(default)]
415 pub description: Option<String>,
416 #[serde(default)]
417 pub server: Option<Server>,
418 #[serde(rename = "$ref", default)]
419 pub reference: Option<String>,
420 #[serde(flatten, default)]
421 pub extensions: Extensions,
422}
423
424#[derive(Debug, Clone, Deserialize, Serialize)]
427#[serde(transparent)]
428pub struct Callback(pub BTreeMap<String, PathItem>);
429
430#[derive(Debug, Clone, Deserialize, Serialize)]
433pub struct Encoding {
434 #[serde(rename = "contentType", default)]
435 pub content_type: Option<String>,
436 #[serde(default)]
437 pub headers: Option<BTreeMap<String, Header>>,
438 #[serde(default)]
439 pub style: Option<String>,
440 #[serde(default)]
441 pub explode: Option<bool>,
442 #[serde(rename = "allowReserved", default)]
443 pub allow_reserved: Option<bool>,
444 #[serde(rename = "itemEncoding", default)]
446 pub item_encoding: Option<Box<Encoding>>,
447 #[serde(flatten, default)]
448 pub extensions: Extensions,
449}
450
451#[derive(Debug, Clone, Deserialize, Serialize)]
455pub struct Header {
456 #[serde(default)]
457 pub description: Option<String>,
458 #[serde(default)]
459 pub required: Option<bool>,
460 #[serde(default)]
461 pub deprecated: Option<bool>,
462 #[serde(rename = "allowEmptyValue", default)]
463 pub allow_empty_value: Option<bool>,
464 #[serde(default)]
465 pub style: Option<String>,
466 #[serde(default)]
467 pub explode: Option<bool>,
468 #[serde(rename = "allowReserved", default)]
469 pub allow_reserved: Option<bool>,
470 #[serde(default)]
471 pub schema: Option<Schema>,
472 #[serde(default)]
473 pub content: Option<BTreeMap<String, MediaType>>,
474 #[serde(default)]
475 pub example: Option<Value>,
476 #[serde(default)]
477 pub examples: Option<Value>,
478 #[serde(rename = "$ref", default)]
479 pub reference: Option<String>,
480 #[serde(flatten, default)]
481 pub extensions: Extensions,
482}
483
484#[derive(Debug, Clone, Deserialize, Serialize)]
488#[serde(tag = "type")]
489pub enum SecurityScheme {
490 #[serde(rename = "apiKey")]
491 ApiKey {
492 name: String,
493 #[serde(rename = "in")]
494 location: String, #[serde(default)]
496 description: Option<String>,
497 #[serde(default)]
499 deprecated: Option<bool>,
500 #[serde(flatten, default)]
501 extensions: Extensions,
502 },
503 #[serde(rename = "http")]
504 Http {
505 scheme: String, #[serde(rename = "bearerFormat", default)]
507 bearer_format: Option<String>,
508 #[serde(default)]
509 description: Option<String>,
510 #[serde(default)]
511 deprecated: Option<bool>,
512 #[serde(flatten, default)]
513 extensions: Extensions,
514 },
515 #[serde(rename = "mutualTLS")]
516 MutualTls {
517 #[serde(default)]
518 description: Option<String>,
519 #[serde(default)]
520 deprecated: Option<bool>,
521 #[serde(flatten, default)]
522 extensions: Extensions,
523 },
524 #[serde(rename = "oauth2")]
525 OAuth2 {
526 flows: Box<OAuthFlows>,
530 #[serde(default)]
531 description: Option<String>,
532 #[serde(rename = "oauth2MetadataUrl", default)]
534 oauth2_metadata_url: Option<String>,
535 #[serde(default)]
536 deprecated: Option<bool>,
537 #[serde(flatten, default)]
538 extensions: Extensions,
539 },
540 #[serde(rename = "openIdConnect")]
541 OpenIdConnect {
542 #[serde(rename = "openIdConnectUrl")]
543 open_id_connect_url: String,
544 #[serde(default)]
545 description: Option<String>,
546 #[serde(default)]
547 deprecated: Option<bool>,
548 #[serde(flatten, default)]
549 extensions: Extensions,
550 },
551}
552
553#[derive(Debug, Clone, Deserialize, Serialize)]
554pub struct OAuthFlows {
555 #[serde(default)]
556 pub implicit: Option<OAuthFlow>,
557 #[serde(default)]
558 pub password: Option<OAuthFlow>,
559 #[serde(rename = "clientCredentials", default)]
560 pub client_credentials: Option<OAuthFlow>,
561 #[serde(rename = "authorizationCode", default)]
562 pub authorization_code: Option<OAuthFlow>,
563 #[serde(rename = "deviceAuthorization", default)]
565 pub device_authorization: Option<OAuthFlow>,
566 #[serde(flatten, default)]
567 pub extensions: Extensions,
568}
569
570#[derive(Debug, Clone, Deserialize, Serialize)]
571pub struct OAuthFlow {
572 #[serde(rename = "authorizationUrl", default)]
573 pub authorization_url: Option<String>,
574 #[serde(rename = "tokenUrl", default)]
575 pub token_url: Option<String>,
576 #[serde(rename = "refreshUrl", default)]
577 pub refresh_url: Option<String>,
578 #[serde(rename = "deviceAuthorizationUrl", default)]
580 pub device_authorization_url: Option<String>,
581 pub scopes: BTreeMap<String, String>,
582 #[serde(flatten, default)]
583 pub extensions: Extensions,
584}
585
586#[derive(Debug, Clone, Deserialize, Serialize)]
588pub struct ExternalDocs {
589 pub url: String,
590 #[serde(default)]
591 pub description: Option<String>,
592 #[serde(flatten, default)]
593 pub extensions: Extensions,
594}
595
596#[derive(Debug, Clone, Deserialize, Serialize)]
598pub struct Tag {
599 pub name: String,
600 #[serde(default)]
602 pub summary: Option<String>,
603 #[serde(default)]
604 pub description: Option<String>,
605 #[serde(default)]
607 pub parent: Option<String>,
608 #[serde(default)]
612 pub kind: Option<String>,
613 #[serde(rename = "externalDocs", default)]
614 pub external_docs: Option<ExternalDocs>,
615 #[serde(flatten, default)]
616 pub extensions: Extensions,
617}
618
619#[derive(Debug, Clone, Deserialize, Serialize)]
622pub struct Server {
623 pub url: String,
624 #[serde(default)]
626 pub name: Option<String>,
627 #[serde(default)]
628 pub description: Option<String>,
629 #[serde(default)]
630 pub variables: Option<BTreeMap<String, ServerVariable>>,
631 #[serde(flatten, default)]
632 pub extensions: Extensions,
633}
634
635#[derive(Debug, Clone, Deserialize, Serialize)]
636pub struct ServerVariable {
637 #[serde(default)]
639 pub default: Option<String>,
640 #[serde(rename = "enum", default)]
641 pub enum_values: Option<Vec<String>>,
642 #[serde(default)]
643 pub description: Option<String>,
644 #[serde(flatten, default)]
645 pub extensions: Extensions,
646}
647
648#[derive(Debug, Clone, Deserialize, Serialize)]
649pub struct Discriminator {
650 #[serde(rename = "propertyName")]
651 pub property_name: String,
652 #[serde(default)]
653 pub mapping: Option<BTreeMap<String, String>>,
654 #[serde(rename = "defaultMapping", default)]
658 pub default_mapping: Option<String>,
659 #[serde(flatten, default)]
660 pub extensions: Extensions,
661}
662
663impl Schema {
664 pub fn schema_type(&self) -> Option<&SchemaType> {
671 match self {
672 Schema::Typed { schema_type, .. } => Some(schema_type),
673 Schema::TypedMulti { schema_types, .. } => schema_types
674 .iter()
675 .find(|t| **t != SchemaType::Null)
676 .or_else(|| schema_types.first()),
677 _ => None,
678 }
679 }
680
681 pub fn non_null_schema_types(&self) -> Option<Vec<SchemaType>> {
688 match self {
689 Schema::TypedMulti { schema_types, .. } => {
690 let mut non_null = Vec::new();
691 for t in schema_types {
692 if *t != SchemaType::Null && !non_null.contains(t) {
693 non_null.push(t.clone());
694 }
695 }
696 (non_null.len() > 1).then_some(non_null)
697 }
698 _ => None,
699 }
700 }
701
702 pub fn type_array_contains_null(&self) -> bool {
705 match self {
706 Schema::TypedMulti { schema_types, .. } => schema_types.contains(&SchemaType::Null),
707 _ => false,
708 }
709 }
710
711 pub fn is_nullable_any(&self) -> bool {
723 self.details().is_nullable()
724 || self.type_array_contains_null()
725 || self.is_nullable_pattern()
726 }
727
728 pub fn details(&self) -> &SchemaDetails {
730 static EMPTY_DETAILS: Lazy<SchemaDetails> = Lazy::new(SchemaDetails::default);
731 match self {
732 Schema::Typed { details, .. } => details,
733 Schema::TypedMulti { details, .. } => details,
734 Schema::Reference { .. } | Schema::RecursiveRef { .. } | Schema::DynamicRef { .. } => {
735 &EMPTY_DETAILS
736 }
737 Schema::OneOf { details, .. } => details,
738 Schema::AnyOf { details, .. } => details,
739 Schema::AllOf { details, .. } => details,
740 Schema::Untyped { details } => details,
741 }
742 }
743
744 pub fn details_mut(&mut self) -> &mut SchemaDetails {
746 match self {
747 Schema::Typed { details, .. } => details,
748 Schema::TypedMulti { details, .. } => details,
749 Schema::Reference { .. } => {
750 panic!("Cannot get mutable details for reference schema")
751 }
752 Schema::RecursiveRef { .. } => {
753 panic!("Cannot get mutable details for recursive reference schema")
754 }
755 Schema::DynamicRef { .. } => {
756 panic!("Cannot get mutable details for dynamic reference schema")
757 }
758 Schema::OneOf { details, .. } => details,
759 Schema::AnyOf { details, .. } => details,
760 Schema::AllOf { details, .. } => details,
761 Schema::Untyped { details } => details,
762 }
763 }
764
765 pub fn is_reference(&self) -> bool {
767 matches!(self, Schema::Reference { .. } | Schema::RecursiveRef { .. })
768 }
769
770 pub fn reference(&self) -> Option<&str> {
772 match self {
773 Schema::Reference { reference, .. } => Some(reference),
774 _ => None,
775 }
776 }
777
778 pub fn recursive_reference(&self) -> Option<&str> {
780 match self {
781 Schema::RecursiveRef { recursive_ref, .. } => Some(recursive_ref),
782 _ => None,
783 }
784 }
785
786 pub fn is_discriminated_union(&self) -> bool {
788 match self {
789 Schema::OneOf { discriminator, .. } => discriminator.is_some(),
790 Schema::AnyOf { discriminator, .. } => discriminator.is_some(),
791 _ => false,
792 }
793 }
794
795 pub fn discriminator(&self) -> Option<&Discriminator> {
797 match self {
798 Schema::OneOf { discriminator, .. } => discriminator.as_ref(),
799 Schema::AnyOf { discriminator, .. } => discriminator.as_ref(),
800 _ => None,
801 }
802 }
803
804 pub fn union_variants(&self) -> Option<&[Schema]> {
806 match self {
807 Schema::OneOf { one_of, .. } => Some(one_of),
808 Schema::AnyOf { any_of, .. } => Some(any_of),
809 _ => None,
810 }
811 }
812
813 pub fn is_nullable_pattern(&self) -> bool {
815 let variants = match self {
816 Schema::AnyOf { any_of, .. } => any_of,
817 Schema::OneOf { one_of, .. } => one_of,
818 _ => return false,
819 };
820 variants.len() == 2
821 && variants
822 .iter()
823 .any(|s| matches!(s.schema_type(), Some(SchemaType::Null)))
824 }
825
826 pub fn non_null_variant(&self) -> Option<&Schema> {
828 if !self.is_nullable_pattern() {
829 return None;
830 }
831 let variants = match self {
832 Schema::AnyOf { any_of, .. } => any_of,
833 Schema::OneOf { one_of, .. } => one_of,
834 _ => return None,
835 };
836 variants
837 .iter()
838 .find(|s| !matches!(s.schema_type(), Some(SchemaType::Null)))
839 }
840
841 pub fn inferred_type(&self) -> Option<SchemaType> {
843 match self {
844 Schema::Typed { schema_type, .. } => Some(schema_type.clone()),
845 Schema::TypedMulti { .. } => self.schema_type().cloned(),
846 Schema::Untyped { details } => {
847 if details.properties.is_some() {
849 Some(SchemaType::Object)
850 } else if details.items.is_some() {
851 Some(SchemaType::Array)
852 } else if details.enum_values.is_some() {
853 Some(SchemaType::String) } else {
855 None
856 }
857 }
858 _ => None,
859 }
860 }
861}
862
863impl SchemaDetails {
864 pub fn is_nullable(&self) -> bool {
866 self.nullable.unwrap_or(false)
867 }
868
869 pub fn is_string_enum(&self) -> bool {
875 self.enum_values.is_some() || self.const_string_value().is_some()
876 }
877
878 pub fn string_enum_values(&self) -> Option<Vec<String>> {
884 if let Some(values) = self.enum_values.as_ref() {
885 return Some(
890 values
891 .iter()
892 .map(|v| match v {
893 Value::String(s) => s.clone(),
894 Value::Number(n) => n.to_string(),
895 Value::Bool(b) => b.to_string(),
896 Value::Null => "null".to_string(),
897 _ => v.to_string(),
898 })
899 .collect(),
900 );
901 }
902 self.const_string_value().map(|s| vec![s])
903 }
904
905 fn const_string_value(&self) -> Option<String> {
906 self.const_value
907 .as_ref()
908 .and_then(|v| v.as_str())
909 .map(|s| s.to_string())
910 }
911
912 pub fn is_field_required(&self, field_name: &str) -> bool {
914 self.required
915 .as_ref()
916 .map(|req| req.contains(&field_name.to_string()))
917 .unwrap_or(false)
918 }
919}
920
921#[derive(Debug, Clone, Deserialize, Serialize)]
923pub struct PathItem {
924 #[serde(default)]
925 pub summary: Option<String>,
926 #[serde(default)]
927 pub description: Option<String>,
928 pub get: Option<Operation>,
929 pub put: Option<Operation>,
930 pub post: Option<Operation>,
931 pub delete: Option<Operation>,
932 pub options: Option<Operation>,
933 pub head: Option<Operation>,
934 pub patch: Option<Operation>,
935 pub trace: Option<Operation>,
936 pub query: Option<Operation>,
939 #[serde(rename = "additionalOperations", default)]
943 pub additional_operations: Option<BTreeMap<String, Operation>>,
944 pub parameters: Option<Vec<Parameter>>,
945 #[serde(default)]
946 pub servers: Option<Vec<Server>>,
947 #[serde(rename = "$ref", default)]
948 pub reference: Option<String>,
949 #[serde(flatten, default)]
950 pub extensions: Extensions,
951}
952
953impl PathItem {
954 pub fn operations(&self) -> Vec<(&str, &Operation)> {
957 let mut ops = Vec::new();
958 if let Some(ref op) = self.get {
959 ops.push(("get", op));
960 }
961 if let Some(ref op) = self.put {
962 ops.push(("put", op));
963 }
964 if let Some(ref op) = self.post {
965 ops.push(("post", op));
966 }
967 if let Some(ref op) = self.delete {
968 ops.push(("delete", op));
969 }
970 if let Some(ref op) = self.options {
971 ops.push(("options", op));
972 }
973 if let Some(ref op) = self.head {
974 ops.push(("head", op));
975 }
976 if let Some(ref op) = self.patch {
977 ops.push(("patch", op));
978 }
979 if let Some(ref op) = self.trace {
980 ops.push(("trace", op));
981 }
982 if let Some(ref op) = self.query {
983 ops.push(("query", op));
984 }
985 if let Some(map) = &self.additional_operations {
986 for (verb, op) in map {
987 ops.push((verb.as_str(), op));
988 }
989 }
990 ops
991 }
992}
993
994#[derive(Debug, Clone, Deserialize, Serialize)]
996pub struct Operation {
997 #[serde(rename = "operationId", default)]
998 pub operation_id: Option<String>,
999 #[serde(default)]
1000 pub summary: Option<String>,
1001 #[serde(default)]
1002 pub description: Option<String>,
1003 #[serde(default)]
1004 pub tags: Option<Vec<String>>,
1005 #[serde(default)]
1006 pub deprecated: Option<bool>,
1007 pub parameters: Option<Vec<Parameter>>,
1008 #[serde(rename = "requestBody")]
1009 pub request_body: Option<RequestBody>,
1010 pub responses: Option<BTreeMap<String, Response>>,
1011 #[serde(default)]
1012 pub callbacks: Option<BTreeMap<String, Callback>>,
1013 #[serde(default)]
1014 pub security: Option<Vec<BTreeMap<String, Vec<String>>>>,
1015 #[serde(default)]
1016 pub servers: Option<Vec<Server>>,
1017 #[serde(rename = "externalDocs", default)]
1018 pub external_docs: Option<ExternalDocs>,
1019 #[serde(flatten, default)]
1020 pub extensions: Extensions,
1021}
1022
1023#[derive(Debug, Clone, Deserialize, Serialize)]
1025pub struct Parameter {
1026 #[serde(default)]
1027 pub name: Option<String>,
1028 #[serde(rename = "in", default)]
1029 pub location: Option<String>,
1030 #[serde(default)]
1031 pub required: Option<bool>,
1032 #[serde(default)]
1033 pub deprecated: Option<bool>,
1034 #[serde(rename = "allowEmptyValue", default)]
1035 pub allow_empty_value: Option<bool>,
1036 #[serde(default)]
1037 pub style: Option<String>,
1038 #[serde(default)]
1039 pub explode: Option<bool>,
1040 #[serde(rename = "allowReserved", default)]
1041 pub allow_reserved: Option<bool>,
1042 #[serde(default)]
1043 pub schema: Option<Schema>,
1044 #[serde(default)]
1045 pub content: Option<BTreeMap<String, MediaType>>,
1046 #[serde(default)]
1047 pub example: Option<Value>,
1048 #[serde(default)]
1049 pub examples: Option<BTreeMap<String, Example>>,
1050 #[serde(default)]
1051 pub description: Option<String>,
1052 #[serde(rename = "$ref", default)]
1053 pub reference: Option<String>,
1054 #[serde(flatten, default)]
1055 pub extensions: Extensions,
1056}
1057
1058#[derive(Debug, Clone, Deserialize, Serialize)]
1060pub struct RequestBody {
1061 pub content: Option<BTreeMap<String, MediaType>>,
1062 #[serde(default)]
1063 pub description: Option<String>,
1064 #[serde(default)]
1065 pub required: Option<bool>,
1066 #[serde(rename = "$ref", default)]
1067 pub reference: Option<String>,
1068 #[serde(flatten, default)]
1069 pub extensions: Extensions,
1070}
1071
1072#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1078#[serde(rename_all = "snake_case")]
1079pub enum ResponseMediaKind {
1080 Json,
1081 EventStream,
1082 Text,
1083 Binary,
1084 Unsupported,
1085}
1086
1087pub fn media_type_essence(content_type: &str) -> &str {
1093 content_type
1094 .split(';')
1095 .next()
1096 .unwrap_or(content_type)
1097 .trim()
1098}
1099
1100pub fn is_json_media_type(ct: &str) -> bool {
1108 let essence = media_type_essence(ct).to_ascii_lowercase();
1109 if essence == "application/json" {
1110 return true;
1111 }
1112 if let Some(subtype) = essence.strip_prefix("application/") {
1113 return subtype.ends_with("+json");
1114 }
1115 false
1116}
1117
1118pub fn is_form_urlencoded_media_type(ct: &str) -> bool {
1121 let essence = media_type_essence(ct).to_ascii_lowercase();
1122 essence == "application/x-www-form-urlencoded"
1123}
1124
1125pub fn is_event_stream_media_type(ct: &str) -> bool {
1131 media_type_essence(ct).eq_ignore_ascii_case("text/event-stream")
1132}
1133
1134pub fn is_text_media_type(ct: &str) -> bool {
1142 let Some((top_level, subtype)) = media_type_essence(ct).split_once('/') else {
1143 return false;
1144 };
1145 if top_level.eq_ignore_ascii_case("text")
1146 && !subtype.is_empty()
1147 && !is_event_stream_media_type(ct)
1148 {
1149 return true;
1150 }
1151 top_level.eq_ignore_ascii_case("application")
1152 && (subtype.eq_ignore_ascii_case("xml")
1153 || subtype.to_ascii_lowercase().ends_with("+xml")
1154 || subtype.eq_ignore_ascii_case("jwt"))
1157}
1158
1159pub fn is_wildcard_media_type(ct: &str) -> bool {
1165 let Some((top_level, subtype)) = media_type_essence(ct).split_once('/') else {
1166 return false;
1167 };
1168 !top_level.is_empty() && subtype == "*"
1169}
1170
1171fn schema_has_binary_format(schema: Option<&Schema>) -> bool {
1172 schema.is_some_and(|schema| {
1173 schema
1174 .details()
1175 .format
1176 .as_deref()
1177 .is_some_and(|format| format.eq_ignore_ascii_case("binary"))
1178 })
1179}
1180
1181pub fn is_binary_media_type(ct: &str, schema: Option<&Schema>) -> bool {
1189 if schema_has_binary_format(schema) {
1190 return true;
1191 }
1192
1193 let essence = media_type_essence(ct);
1194 let Some((top_level, _)) = essence.split_once('/') else {
1195 return false;
1196 };
1197 if top_level.eq_ignore_ascii_case("image")
1198 || top_level.eq_ignore_ascii_case("audio")
1199 || top_level.eq_ignore_ascii_case("video")
1200 {
1201 return true;
1202 }
1203 if essence.eq_ignore_ascii_case("application/octet-stream")
1204 || essence.eq_ignore_ascii_case("application/zip")
1205 || essence.eq_ignore_ascii_case("application/pdf")
1206 {
1207 return true;
1208 }
1209
1210 !top_level.eq_ignore_ascii_case("text") && is_wildcard_media_type(ct)
1211}
1212
1213pub fn classify_response_media_type(ct: &str, schema: Option<&Schema>) -> ResponseMediaKind {
1218 if is_json_media_type(ct) {
1219 ResponseMediaKind::Json
1220 } else if is_event_stream_media_type(ct) {
1221 ResponseMediaKind::EventStream
1222 } else if schema_has_binary_format(schema) {
1223 ResponseMediaKind::Binary
1224 } else if is_text_media_type(ct) {
1225 if is_wildcard_media_type(ct) {
1226 ResponseMediaKind::Unsupported
1227 } else {
1228 ResponseMediaKind::Text
1229 }
1230 } else if is_binary_media_type(ct, schema) {
1231 ResponseMediaKind::Binary
1232 } else {
1233 ResponseMediaKind::Unsupported
1234 }
1235}
1236
1237fn find_json_content(content: &BTreeMap<String, MediaType>) -> Option<(&str, &MediaType)> {
1238 if let Some(mt) = content
1239 .get("application/json")
1240 .filter(|media_type| media_type.schema.is_some())
1241 {
1242 return Some(("application/json", mt));
1243 }
1244 content
1245 .iter()
1246 .find(|(ct, media_type)| is_json_media_type(ct) && media_type.schema.is_some())
1247 .map(|(ct, mt)| (ct.as_str(), mt))
1248 .or_else(|| {
1249 content
1250 .get("application/json")
1251 .map(|media_type| ("application/json", media_type))
1252 })
1253 .or_else(|| {
1254 content
1255 .iter()
1256 .find(|(ct, _)| is_json_media_type(ct))
1257 .map(|(ct, mt)| (ct.as_str(), mt))
1258 })
1259}
1260
1261impl RequestBody {
1262 pub fn json_schema(&self) -> Option<&Schema> {
1268 self.content
1269 .as_ref()
1270 .and_then(find_json_content)
1271 .and_then(|(_, media_type)| media_type.schema.as_ref())
1272 }
1273
1274 pub fn best_content(&self) -> Option<(&str, Option<&Schema>)> {
1276 let content = self.content.as_ref()?;
1277
1278 if let Some((ct, media_type)) = find_json_content(content) {
1279 return Some((ct, media_type.schema.as_ref()));
1280 }
1281
1282 const PRIORITY: &[&str] = &[
1283 "application/x-www-form-urlencoded",
1284 "multipart/form-data",
1285 "application/octet-stream",
1286 "text/plain",
1287 ];
1288 for preferred_essence in PRIORITY {
1289 if let Some((ct, media_type)) = content
1290 .iter()
1291 .find(|(ct, _)| media_type_essence(ct).eq_ignore_ascii_case(preferred_essence))
1292 {
1293 return Some((ct.as_str(), media_type.schema.as_ref()));
1294 }
1295 }
1296 if let Some((ct, media_type)) = content.iter().find(|(ct, _)| is_text_media_type(ct)) {
1299 return Some((ct.as_str(), media_type.schema.as_ref()));
1300 }
1301 content
1302 .iter()
1303 .find(|(ct, media_type)| {
1309 !is_wildcard_media_type(ct) && is_binary_media_type(ct, media_type.schema.as_ref())
1310 })
1311 .map(|(ct, media_type)| (ct.as_str(), media_type.schema.as_ref()))
1312 }
1313}
1314
1315#[derive(Debug, Clone, Deserialize, Serialize)]
1317pub struct Response {
1318 #[serde(default)]
1319 pub description: Option<String>,
1320 #[serde(default)]
1321 pub headers: Option<BTreeMap<String, Header>>,
1322 #[serde(default)]
1323 pub content: Option<BTreeMap<String, MediaType>>,
1324 #[serde(default)]
1325 pub links: Option<Value>,
1326 #[serde(rename = "$ref", default)]
1327 pub reference: Option<String>,
1328 #[serde(flatten, default)]
1329 pub extensions: Extensions,
1330}
1331
1332impl Response {
1333 pub fn json_schema(&self) -> Option<&Schema> {
1340 self.content
1341 .as_ref()
1342 .and_then(find_json_content)
1343 .and_then(|(_, media_type)| media_type.schema.as_ref())
1344 }
1345
1346 pub fn json_content(&self) -> Option<(&str, &Schema)> {
1348 self.content
1349 .as_ref()
1350 .and_then(find_json_content)
1351 .and_then(|(content_type, media_type)| {
1352 media_type
1353 .schema
1354 .as_ref()
1355 .map(|schema| (content_type, schema))
1356 })
1357 }
1358}
1359
1360#[derive(Debug, Clone, Deserialize, Serialize)]
1362pub struct MediaType {
1363 #[serde(default)]
1364 pub schema: Option<Schema>,
1365 #[serde(default)]
1366 pub example: Option<Value>,
1367 #[serde(default)]
1368 pub examples: Option<BTreeMap<String, Example>>,
1369 #[serde(default)]
1370 pub encoding: Option<BTreeMap<String, Encoding>>,
1371 #[serde(rename = "itemSchema", default)]
1374 pub item_schema: Option<Schema>,
1375 #[serde(rename = "prefixEncoding", default)]
1378 pub prefix_encoding: Option<Vec<Encoding>>,
1379 #[serde(rename = "itemEncoding", default)]
1382 pub item_encoding: Option<Encoding>,
1383 #[serde(rename = "$ref", default)]
1384 pub reference: Option<String>,
1385 #[serde(flatten, default)]
1386 pub extensions: Extensions,
1387}
1388
1389#[cfg(test)]
1390#[allow(clippy::unwrap_used, clippy::expect_used)]
1391mod tests {
1392 use super::*;
1393 use serde_json::json;
1394
1395 #[test]
1396 fn paths_map_skips_extension_scalars() {
1397 let spec: OpenApiSpec = serde_json::from_value(json!({
1400 "openapi": "3.0.0",
1401 "info": { "title": "lenient paths", "version": "1" },
1402 "paths": {
1403 "x-codegen-contextRoot": "/apis/registry/v2",
1404 "/items": {
1405 "get": {
1406 "operationId": "listItems",
1407 "responses": { "204": { "description": "ok" } }
1408 }
1409 }
1410 }
1411 }))
1412 .unwrap();
1413 let paths = spec.paths.unwrap();
1414 assert!(paths.contains_key("/items"));
1415 assert!(!paths.contains_key("x-codegen-contextRoot"));
1416 }
1417
1418 #[test]
1419 fn test_parse_simple_object_schema() {
1420 let schema_json = json!({
1421 "type": "object",
1422 "properties": {
1423 "name": {
1424 "type": "string",
1425 "description": "User name"
1426 },
1427 "age": {
1428 "type": "integer"
1429 }
1430 },
1431 "required": ["name"]
1432 });
1433
1434 let schema: Schema = serde_json::from_value(schema_json).unwrap();
1435
1436 match schema {
1437 Schema::Typed {
1438 schema_type: SchemaType::Object,
1439 details,
1440 } => {
1441 assert!(details.properties.is_some());
1442 assert_eq!(details.required, Some(vec!["name".to_string()]));
1443 assert!(details.is_field_required("name"));
1444 assert!(!details.is_field_required("age"));
1445 }
1446 _ => panic!("Expected object schema"),
1447 }
1448 }
1449
1450 #[test]
1451 fn test_parse_string_enum() {
1452 let schema_json = json!({
1453 "type": "string",
1454 "enum": ["active", "inactive", "pending"],
1455 "description": "User status"
1456 });
1457
1458 let schema: Schema = serde_json::from_value(schema_json).unwrap();
1459
1460 match schema {
1461 Schema::Typed {
1462 schema_type: SchemaType::String,
1463 details,
1464 } => {
1465 assert!(details.is_string_enum());
1466 let values = details.string_enum_values().unwrap();
1467 assert_eq!(values, vec!["active", "inactive", "pending"]);
1468 }
1469 _ => panic!("Expected string enum schema"),
1470 }
1471 }
1472
1473 #[test]
1474 fn test_parse_reference_schema() {
1475 let schema_json = json!({
1476 "$ref": "#/components/schemas/User"
1477 });
1478
1479 let schema: Schema = serde_json::from_value(schema_json).unwrap();
1480
1481 assert!(schema.is_reference());
1482 assert_eq!(schema.reference(), Some("#/components/schemas/User"));
1483 }
1484
1485 #[test]
1486 fn test_parse_discriminated_union() {
1487 let schema_json = json!({
1488 "oneOf": [
1489 {"$ref": "#/components/schemas/Dog"},
1490 {"$ref": "#/components/schemas/Cat"}
1491 ],
1492 "discriminator": {
1493 "propertyName": "petType"
1494 }
1495 });
1496
1497 let schema: Schema = serde_json::from_value(schema_json).unwrap();
1498
1499 assert!(schema.is_discriminated_union());
1500 let discriminator = schema.discriminator().unwrap();
1501 assert_eq!(discriminator.property_name, "petType");
1502 }
1503
1504 #[test]
1505 fn test_parse_nullable_pattern() {
1506 let schema_json = json!({
1507 "anyOf": [
1508 {"$ref": "#/components/schemas/User"},
1509 {"type": "null"}
1510 ]
1511 });
1512
1513 let schema: Schema = serde_json::from_value(schema_json).unwrap();
1514
1515 assert!(schema.is_nullable_pattern());
1516 let non_null = schema.non_null_variant().unwrap();
1517 assert!(non_null.is_reference());
1518 }
1519
1520 #[test]
1521 fn is_json_media_type_accepts_canonical_and_structured_suffix() {
1522 assert!(is_json_media_type("application/json"));
1524 assert!(is_json_media_type("application/json; charset=utf-8"));
1526 assert!(is_json_media_type("APPLICATION/JSON"));
1527 assert!(is_json_media_type("application/vnd.api+json"));
1529 assert!(is_json_media_type("application/hal+json"));
1530 assert!(is_json_media_type("application/problem+json"));
1531 assert!(is_json_media_type("application/ld+json"));
1532 assert!(is_json_media_type(
1533 "application/vnd.api+json; charset=utf-8"
1534 ));
1535 assert!(!is_json_media_type("application/xml"));
1537 assert!(!is_json_media_type("application/x-www-form-urlencoded"));
1538 assert!(!is_json_media_type("text/plain"));
1539 assert!(!is_json_media_type("application/jsonbutnotreally"));
1540 assert!(!is_json_media_type("text/something+json"));
1542 }
1543
1544 #[test]
1545 fn response_media_helpers_normalize_parameters_and_case() {
1546 assert_eq!(
1547 media_type_essence(" Text/Plain ; charset=utf-8 "),
1548 "Text/Plain"
1549 );
1550 assert!(is_text_media_type("TEXT/HTML; charset=UTF-8"));
1551 assert!(!is_text_media_type("Text/Event-Stream; charset=utf-8"));
1552 assert!(is_wildcard_media_type("*/*; q=0.8"));
1553 assert!(is_wildcard_media_type("IMAGE/*"));
1554 assert!(is_wildcard_media_type("text/*"));
1555 assert!(!is_wildcard_media_type("image/*+json"));
1556 assert!(!is_wildcard_media_type("application/json"));
1557 }
1558
1559 #[test]
1560 fn response_media_classifier_keeps_json_sse_and_text_distinct() {
1561 for media_type in [
1562 "application/json",
1563 "APPLICATION/PROBLEM+JSON; charset=utf-8",
1564 ] {
1565 assert_eq!(
1566 classify_response_media_type(media_type, None),
1567 ResponseMediaKind::Json,
1568 "{media_type}"
1569 );
1570 }
1571
1572 assert_eq!(
1573 classify_response_media_type("Text/Event-Stream; charset=utf-8", None),
1574 ResponseMediaKind::EventStream
1575 );
1576 for media_type in ["text/plain", "TEXT/HTML; charset=UTF-8"] {
1577 assert_eq!(
1578 classify_response_media_type(media_type, None),
1579 ResponseMediaKind::Text,
1580 "{media_type}"
1581 );
1582 }
1583 assert_eq!(
1584 classify_response_media_type("text/event-streaming", None),
1585 ResponseMediaKind::Text
1586 );
1587 assert_eq!(
1588 classify_response_media_type("text/*", None),
1589 ResponseMediaKind::Unsupported,
1590 "a media range is not a valid concrete response Content-Type"
1591 );
1592 }
1593
1594 #[test]
1595 fn response_json_content_skips_schema_less_canonical_entry() {
1596 let response: Response = serde_json::from_value(json!({
1597 "description": "mixed JSON",
1598 "content": {
1599 "application/json": {},
1600 "application/vnd.example+json": {
1601 "schema": { "type": "string" }
1602 }
1603 }
1604 }))
1605 .unwrap();
1606
1607 let (media_type, schema) = response.json_content().expect("schema-bearing JSON");
1608 assert_eq!(media_type, "application/vnd.example+json");
1609 assert!(matches!(schema.schema_type(), Some(SchemaType::String)));
1610 }
1611
1612 #[test]
1613 fn response_media_classifier_recognizes_binary_formats_and_wildcards() {
1614 for media_type in [
1615 "image/png",
1616 "IMAGE/*; version=1",
1617 "audio/mpeg",
1618 "video/mp4",
1619 "application/octet-stream",
1620 "APPLICATION/ZIP; version=1",
1621 "application/*",
1622 "*/*",
1623 ] {
1624 assert_eq!(
1625 classify_response_media_type(media_type, None),
1626 ResponseMediaKind::Binary,
1627 "{media_type}"
1628 );
1629 }
1630
1631 let binary_schema: Schema = serde_json::from_value(json!({
1632 "type": "string",
1633 "format": "BINARY"
1634 }))
1635 .unwrap();
1636 assert_eq!(
1637 classify_response_media_type("application/x-custom", Some(&binary_schema)),
1638 ResponseMediaKind::Binary
1639 );
1640 assert_eq!(
1641 classify_response_media_type("text/plain", Some(&binary_schema)),
1642 ResponseMediaKind::Binary,
1643 "an explicit binary schema must prevent UTF-8 decoding"
1644 );
1645 assert!(is_binary_media_type(
1646 "application/x-custom",
1647 Some(&binary_schema)
1648 ));
1649 }
1650
1651 #[test]
1652 fn response_media_classifier_leaves_ambiguous_formats_unsupported() {
1653 let string_schema: Schema = serde_json::from_value(json!({ "type": "string" })).unwrap();
1654 for media_type in ["application/x-unknown", "not-a-media-type"] {
1655 assert_eq!(
1656 classify_response_media_type(media_type, Some(&string_schema)),
1657 ResponseMediaKind::Unsupported,
1658 "{media_type}"
1659 );
1660 }
1661 assert_eq!(
1665 classify_response_media_type("application/pdf", Some(&string_schema)),
1666 ResponseMediaKind::Binary
1667 );
1668 assert_eq!(
1669 classify_response_media_type("application/xml", Some(&string_schema)),
1670 ResponseMediaKind::Text
1671 );
1672 assert_eq!(
1673 classify_response_media_type("application/atom+xml", Some(&string_schema)),
1674 ResponseMediaKind::Text
1675 );
1676 assert_eq!(
1678 classify_response_media_type("application/jwt", Some(&string_schema)),
1679 ResponseMediaKind::Text
1680 );
1681 assert!(!is_binary_media_type("text/plain", None));
1682 }
1683
1684 #[test]
1685 fn request_body_json_schema_finds_vnd_api_plus_json() {
1686 let body_json = json!({
1689 "required": true,
1690 "content": {
1691 "application/vnd.api+json": {
1692 "schema": {"$ref": "#/components/schemas/create_api_key"}
1693 }
1694 }
1695 });
1696
1697 let body: RequestBody = serde_json::from_value(body_json).unwrap();
1698 let schema = body.json_schema().expect("expected +json schema match");
1699 assert!(schema.is_reference());
1700 }
1701
1702 #[test]
1703 fn request_body_best_content_prefers_canonical_json_over_plus_json() {
1704 let body_json = json!({
1708 "required": true,
1709 "content": {
1710 "application/json": {
1711 "schema": {"$ref": "#/components/schemas/A"}
1712 },
1713 "application/vnd.api+json": {
1714 "schema": {"$ref": "#/components/schemas/B"}
1715 }
1716 }
1717 });
1718
1719 let body: RequestBody = serde_json::from_value(body_json).unwrap();
1720 let (ct, _) = body.best_content().expect("expected best_content");
1721 assert_eq!(ct, "application/json");
1722 }
1723
1724 #[test]
1725 fn request_body_best_content_falls_back_to_plus_json() {
1726 let body_json = json!({
1729 "required": true,
1730 "content": {
1731 "application/vnd.api+json": {
1732 "schema": {"$ref": "#/components/schemas/B"}
1733 }
1734 }
1735 });
1736
1737 let body: RequestBody = serde_json::from_value(body_json).unwrap();
1738 let (ct, _) = body.best_content().expect("expected best_content");
1739 assert_eq!(ct, "application/vnd.api+json");
1740 }
1741
1742 #[test]
1743 fn request_body_best_content_does_not_select_wildcard_media_ranges() {
1744 let body: RequestBody = serde_json::from_value(json!({
1745 "required": true,
1746 "content": {
1747 "image/*": {
1748 "schema": { "type": "string", "format": "binary" }
1749 },
1750 "*/*": {
1751 "schema": { "type": "string", "format": "binary" }
1752 }
1753 }
1754 }))
1755 .unwrap();
1756
1757 assert!(
1758 body.best_content().is_none(),
1759 "request media ranges require a runtime concrete Content-Type"
1760 );
1761 }
1762
1763 #[test]
1764 fn request_body_best_content_matches_parameterized_text_plain_by_essence() {
1765 let body: RequestBody = serde_json::from_value(json!({
1766 "required": true,
1767 "content": {
1768 "Text/Plain; charset=utf-8": {
1769 "schema": { "type": "string" }
1770 }
1771 }
1772 }))
1773 .unwrap();
1774
1775 let (media_type, _) = body.best_content().expect("parameterized text body");
1776 assert_eq!(media_type, "Text/Plain; charset=utf-8");
1777 }
1778
1779 #[test]
1780 fn response_json_schema_finds_vnd_api_plus_json() {
1781 let resp_json = json!({
1784 "description": "OK",
1785 "content": {
1786 "application/vnd.api+json": {
1787 "schema": {"$ref": "#/components/schemas/api_keys"}
1788 }
1789 }
1790 });
1791
1792 let resp: Response = serde_json::from_value(resp_json).unwrap();
1793 let schema = resp.json_schema().expect("expected +json schema match");
1794 assert!(schema.is_reference());
1795 }
1796}