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 TypedMulti {
162 #[serde(rename = "type")]
163 schema_types: Vec<SchemaType>,
164 #[serde(flatten)]
165 details: SchemaDetails,
166 },
167 Typed {
169 #[serde(rename = "type")]
170 schema_type: SchemaType,
171 #[serde(flatten)]
172 details: SchemaDetails,
173 },
174 AllOf {
176 #[serde(rename = "allOf")]
177 all_of: Vec<Schema>,
178 #[serde(flatten)]
179 details: SchemaDetails,
180 },
181 Untyped {
183 #[serde(flatten)]
184 details: SchemaDetails,
185 },
186}
187
188#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
189#[serde(rename_all = "lowercase")]
190pub enum SchemaType {
191 String,
192 Integer,
193 Number,
194 Boolean,
195 Array,
196 Object,
197 #[serde(rename = "null")]
198 Null,
199}
200
201#[derive(Debug, Clone, Default, Deserialize, Serialize)]
202pub struct SchemaDetails {
203 #[serde(skip_serializing_if = "Option::is_none")]
204 pub description: Option<String>,
205 #[serde(skip_serializing_if = "Option::is_none")]
206 pub nullable: Option<bool>,
207
208 #[serde(rename = "$recursiveAnchor", skip_serializing_if = "Option::is_none")]
210 pub recursive_anchor: Option<bool>,
211
212 #[serde(rename = "$dynamicAnchor", skip_serializing_if = "Option::is_none")]
214 pub dynamic_anchor: Option<String>,
215 #[serde(rename = "$id", skip_serializing_if = "Option::is_none")]
216 pub schema_id: Option<String>,
217
218 #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
220 pub enum_values: Option<Vec<Value>>,
221 #[serde(skip_serializing_if = "Option::is_none")]
222 pub format: Option<String>,
223 #[serde(skip_serializing_if = "Option::is_none")]
224 pub default: Option<Value>,
225 #[serde(
226 rename = "const",
227 default,
228 deserialize_with = "deserialize_present_value",
229 skip_serializing_if = "Option::is_none"
230 )]
231 pub const_value: Option<Value>,
232
233 #[serde(skip_serializing_if = "Option::is_none")]
235 pub properties: Option<BTreeMap<String, Schema>>,
236 #[serde(skip_serializing_if = "Option::is_none")]
237 pub required: Option<Vec<String>>,
238 #[serde(
239 rename = "additionalProperties",
240 skip_serializing_if = "Option::is_none"
241 )]
242 pub additional_properties: Option<AdditionalProperties>,
243
244 #[serde(skip_serializing_if = "Option::is_none")]
246 pub items: Option<Box<Schema>>,
247
248 #[serde(skip_serializing_if = "Option::is_none")]
250 pub minimum: Option<f64>,
251 #[serde(skip_serializing_if = "Option::is_none")]
252 pub maximum: Option<f64>,
253
254 #[serde(rename = "minLength", skip_serializing_if = "Option::is_none")]
256 pub min_length: Option<u64>,
257 #[serde(rename = "maxLength", skip_serializing_if = "Option::is_none")]
258 pub max_length: Option<u64>,
259 #[serde(skip_serializing_if = "Option::is_none")]
260 pub pattern: Option<String>,
261 #[serde(rename = "exclusiveMinimum", skip_serializing_if = "Option::is_none")]
265 pub exclusive_minimum: Option<ExclusiveBound>,
266 #[serde(rename = "exclusiveMaximum", skip_serializing_if = "Option::is_none")]
267 pub exclusive_maximum: Option<ExclusiveBound>,
268 #[serde(rename = "multipleOf", skip_serializing_if = "Option::is_none")]
269 pub multiple_of: Option<f64>,
270 #[serde(rename = "minItems", skip_serializing_if = "Option::is_none")]
271 pub min_items: Option<u64>,
272 #[serde(rename = "maxItems", skip_serializing_if = "Option::is_none")]
273 pub max_items: Option<u64>,
274 #[serde(rename = "uniqueItems", skip_serializing_if = "Option::is_none")]
275 pub unique_items: Option<bool>,
276 #[serde(rename = "minProperties", skip_serializing_if = "Option::is_none")]
277 pub min_properties: Option<u64>,
278 #[serde(rename = "maxProperties", skip_serializing_if = "Option::is_none")]
279 pub max_properties: Option<u64>,
280
281 #[serde(rename = "prefixItems", skip_serializing_if = "Option::is_none")]
283 pub prefix_items: Option<Vec<Schema>>,
284 #[serde(skip_serializing_if = "Option::is_none")]
285 pub contains: Option<Box<Schema>>,
286 #[serde(rename = "minContains", skip_serializing_if = "Option::is_none")]
287 pub min_contains: Option<u64>,
288 #[serde(rename = "maxContains", skip_serializing_if = "Option::is_none")]
289 pub max_contains: Option<u64>,
290
291 #[serde(rename = "patternProperties", skip_serializing_if = "Option::is_none")]
293 pub pattern_properties: Option<BTreeMap<String, Schema>>,
294 #[serde(rename = "propertyNames", skip_serializing_if = "Option::is_none")]
295 pub property_names: Option<Box<Schema>>,
296 #[serde(
297 rename = "unevaluatedProperties",
298 skip_serializing_if = "Option::is_none"
299 )]
300 pub unevaluated_properties: Option<AdditionalProperties>,
301 #[serde(rename = "unevaluatedItems", skip_serializing_if = "Option::is_none")]
302 pub unevaluated_items: Option<AdditionalProperties>,
303 #[serde(rename = "dependentRequired", skip_serializing_if = "Option::is_none")]
304 pub dependent_required: Option<BTreeMap<String, Vec<String>>>,
305 #[serde(rename = "dependentSchemas", skip_serializing_if = "Option::is_none")]
306 pub dependent_schemas: Option<BTreeMap<String, Schema>>,
307
308 #[serde(rename = "contentEncoding", skip_serializing_if = "Option::is_none")]
310 pub content_encoding: Option<String>,
311 #[serde(rename = "contentMediaType", skip_serializing_if = "Option::is_none")]
312 pub content_media_type: Option<String>,
313 #[serde(rename = "contentSchema", skip_serializing_if = "Option::is_none")]
314 pub content_schema: Option<Box<Schema>>,
315
316 #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
318 pub if_schema: Option<Box<Schema>>,
319 #[serde(rename = "then", skip_serializing_if = "Option::is_none")]
320 pub then_schema: Option<Box<Schema>>,
321 #[serde(rename = "else", skip_serializing_if = "Option::is_none")]
322 pub else_schema: Option<Box<Schema>>,
323 #[serde(skip_serializing_if = "Option::is_none")]
324 pub not: Option<Box<Schema>>,
325
326 #[serde(skip_serializing_if = "Option::is_none")]
328 pub title: Option<String>,
329 #[serde(skip_serializing_if = "Option::is_none")]
330 pub deprecated: Option<bool>,
331 #[serde(rename = "readOnly", skip_serializing_if = "Option::is_none")]
332 pub read_only: Option<bool>,
333 #[serde(rename = "writeOnly", skip_serializing_if = "Option::is_none")]
334 pub write_only: Option<bool>,
335 #[serde(skip_serializing_if = "Option::is_none")]
336 pub examples: Option<Vec<Value>>,
337 #[serde(skip_serializing_if = "Option::is_none")]
338 pub example: Option<Value>,
339 #[serde(rename = "$comment", skip_serializing_if = "Option::is_none")]
341 pub comment: Option<String>,
342 #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
343 pub schema_keyword: Option<String>,
344 #[serde(rename = "$defs", skip_serializing_if = "Option::is_none")]
345 pub defs: Option<BTreeMap<String, Schema>>,
346
347 #[serde(flatten)]
350 pub extra: BTreeMap<String, Value>,
351}
352
353fn deserialize_present_value<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
354where
355 D: serde::Deserializer<'de>,
356{
357 Value::deserialize(deserializer).map(Some)
358}
359
360#[derive(Debug, Clone, Deserialize, Serialize)]
363#[serde(untagged)]
364pub enum ExclusiveBound {
365 Bool(bool),
366 Number(f64),
367}
368
369#[derive(Debug, Clone, Deserialize, Serialize)]
370#[serde(untagged)]
371pub enum AdditionalProperties {
372 Boolean(bool),
373 Schema(Box<Schema>),
374}
375
376#[derive(Debug, Clone, Deserialize, Serialize)]
378pub struct Example {
379 #[serde(default)]
380 pub summary: Option<String>,
381 #[serde(default)]
382 pub description: Option<String>,
383 #[serde(default)]
385 pub value: Option<Value>,
386 #[serde(rename = "externalValue", default)]
387 pub external_value: Option<String>,
388 #[serde(rename = "dataValue", default)]
390 pub data_value: Option<Value>,
391 #[serde(rename = "serializedValue", default)]
393 pub serialized_value: Option<String>,
394 #[serde(rename = "$ref", default)]
395 pub reference: Option<String>,
396 #[serde(flatten, default)]
397 pub extensions: Extensions,
398}
399
400#[derive(Debug, Clone, Deserialize, Serialize)]
402pub struct Link {
403 #[serde(rename = "operationRef", default)]
404 pub operation_ref: Option<String>,
405 #[serde(rename = "operationId", default)]
406 pub operation_id: Option<String>,
407 #[serde(default)]
408 pub parameters: Option<BTreeMap<String, Value>>,
409 #[serde(rename = "requestBody", default)]
410 pub request_body: Option<Value>,
411 #[serde(default)]
412 pub description: Option<String>,
413 #[serde(default)]
414 pub server: Option<Server>,
415 #[serde(rename = "$ref", default)]
416 pub reference: Option<String>,
417 #[serde(flatten, default)]
418 pub extensions: Extensions,
419}
420
421#[derive(Debug, Clone, Deserialize, Serialize)]
424#[serde(transparent)]
425pub struct Callback(pub BTreeMap<String, PathItem>);
426
427#[derive(Debug, Clone, Deserialize, Serialize)]
430pub struct Encoding {
431 #[serde(rename = "contentType", default)]
432 pub content_type: Option<String>,
433 #[serde(default)]
434 pub headers: Option<BTreeMap<String, Header>>,
435 #[serde(default)]
436 pub style: Option<String>,
437 #[serde(default)]
438 pub explode: Option<bool>,
439 #[serde(rename = "allowReserved", default)]
440 pub allow_reserved: Option<bool>,
441 #[serde(rename = "itemEncoding", default)]
443 pub item_encoding: Option<Box<Encoding>>,
444 #[serde(flatten, default)]
445 pub extensions: Extensions,
446}
447
448#[derive(Debug, Clone, Deserialize, Serialize)]
452pub struct Header {
453 #[serde(default)]
454 pub description: Option<String>,
455 #[serde(default)]
456 pub required: Option<bool>,
457 #[serde(default)]
458 pub deprecated: Option<bool>,
459 #[serde(rename = "allowEmptyValue", default)]
460 pub allow_empty_value: Option<bool>,
461 #[serde(default)]
462 pub style: Option<String>,
463 #[serde(default)]
464 pub explode: Option<bool>,
465 #[serde(rename = "allowReserved", default)]
466 pub allow_reserved: Option<bool>,
467 #[serde(default)]
468 pub schema: Option<Schema>,
469 #[serde(default)]
470 pub content: Option<BTreeMap<String, MediaType>>,
471 #[serde(default)]
472 pub example: Option<Value>,
473 #[serde(default)]
474 pub examples: Option<Value>,
475 #[serde(rename = "$ref", default)]
476 pub reference: Option<String>,
477 #[serde(flatten, default)]
478 pub extensions: Extensions,
479}
480
481#[derive(Debug, Clone, Deserialize, Serialize)]
485#[serde(tag = "type")]
486pub enum SecurityScheme {
487 #[serde(rename = "apiKey")]
488 ApiKey {
489 name: String,
490 #[serde(rename = "in")]
491 location: String, #[serde(default)]
493 description: Option<String>,
494 #[serde(default)]
496 deprecated: Option<bool>,
497 #[serde(flatten, default)]
498 extensions: Extensions,
499 },
500 #[serde(rename = "http")]
501 Http {
502 scheme: String, #[serde(rename = "bearerFormat", default)]
504 bearer_format: Option<String>,
505 #[serde(default)]
506 description: Option<String>,
507 #[serde(default)]
508 deprecated: Option<bool>,
509 #[serde(flatten, default)]
510 extensions: Extensions,
511 },
512 #[serde(rename = "mutualTLS")]
513 MutualTls {
514 #[serde(default)]
515 description: Option<String>,
516 #[serde(default)]
517 deprecated: Option<bool>,
518 #[serde(flatten, default)]
519 extensions: Extensions,
520 },
521 #[serde(rename = "oauth2")]
522 OAuth2 {
523 flows: Box<OAuthFlows>,
527 #[serde(default)]
528 description: Option<String>,
529 #[serde(rename = "oauth2MetadataUrl", default)]
531 oauth2_metadata_url: Option<String>,
532 #[serde(default)]
533 deprecated: Option<bool>,
534 #[serde(flatten, default)]
535 extensions: Extensions,
536 },
537 #[serde(rename = "openIdConnect")]
538 OpenIdConnect {
539 #[serde(rename = "openIdConnectUrl")]
540 open_id_connect_url: String,
541 #[serde(default)]
542 description: Option<String>,
543 #[serde(default)]
544 deprecated: Option<bool>,
545 #[serde(flatten, default)]
546 extensions: Extensions,
547 },
548}
549
550#[derive(Debug, Clone, Deserialize, Serialize)]
551pub struct OAuthFlows {
552 #[serde(default)]
553 pub implicit: Option<OAuthFlow>,
554 #[serde(default)]
555 pub password: Option<OAuthFlow>,
556 #[serde(rename = "clientCredentials", default)]
557 pub client_credentials: Option<OAuthFlow>,
558 #[serde(rename = "authorizationCode", default)]
559 pub authorization_code: Option<OAuthFlow>,
560 #[serde(rename = "deviceAuthorization", default)]
562 pub device_authorization: Option<OAuthFlow>,
563 #[serde(flatten, default)]
564 pub extensions: Extensions,
565}
566
567#[derive(Debug, Clone, Deserialize, Serialize)]
568pub struct OAuthFlow {
569 #[serde(rename = "authorizationUrl", default)]
570 pub authorization_url: Option<String>,
571 #[serde(rename = "tokenUrl", default)]
572 pub token_url: Option<String>,
573 #[serde(rename = "refreshUrl", default)]
574 pub refresh_url: Option<String>,
575 #[serde(rename = "deviceAuthorizationUrl", default)]
577 pub device_authorization_url: Option<String>,
578 pub scopes: BTreeMap<String, String>,
579 #[serde(flatten, default)]
580 pub extensions: Extensions,
581}
582
583#[derive(Debug, Clone, Deserialize, Serialize)]
585pub struct ExternalDocs {
586 pub url: String,
587 #[serde(default)]
588 pub description: Option<String>,
589 #[serde(flatten, default)]
590 pub extensions: Extensions,
591}
592
593#[derive(Debug, Clone, Deserialize, Serialize)]
595pub struct Tag {
596 pub name: String,
597 #[serde(default)]
599 pub summary: Option<String>,
600 #[serde(default)]
601 pub description: Option<String>,
602 #[serde(default)]
604 pub parent: Option<String>,
605 #[serde(default)]
609 pub kind: Option<String>,
610 #[serde(rename = "externalDocs", default)]
611 pub external_docs: Option<ExternalDocs>,
612 #[serde(flatten, default)]
613 pub extensions: Extensions,
614}
615
616#[derive(Debug, Clone, Deserialize, Serialize)]
619pub struct Server {
620 pub url: String,
621 #[serde(default)]
623 pub name: Option<String>,
624 #[serde(default)]
625 pub description: Option<String>,
626 #[serde(default)]
627 pub variables: Option<BTreeMap<String, ServerVariable>>,
628 #[serde(flatten, default)]
629 pub extensions: Extensions,
630}
631
632#[derive(Debug, Clone, Deserialize, Serialize)]
633pub struct ServerVariable {
634 #[serde(default)]
636 pub default: Option<String>,
637 #[serde(rename = "enum", default)]
638 pub enum_values: Option<Vec<String>>,
639 #[serde(default)]
640 pub description: Option<String>,
641 #[serde(flatten, default)]
642 pub extensions: Extensions,
643}
644
645#[derive(Debug, Clone, Deserialize, Serialize)]
646pub struct Discriminator {
647 #[serde(rename = "propertyName")]
648 pub property_name: String,
649 #[serde(default)]
650 pub mapping: Option<BTreeMap<String, String>>,
651 #[serde(rename = "defaultMapping", default)]
655 pub default_mapping: Option<String>,
656 #[serde(flatten, default)]
657 pub extensions: Extensions,
658}
659
660impl Schema {
661 pub fn schema_type(&self) -> Option<&SchemaType> {
665 match self {
666 Schema::Typed { schema_type, .. } => Some(schema_type),
667 Schema::TypedMulti { schema_types, .. } => schema_types
668 .iter()
669 .find(|t| **t != SchemaType::Null)
670 .or_else(|| schema_types.first()),
671 _ => None,
672 }
673 }
674
675 pub fn type_array_contains_null(&self) -> bool {
678 match self {
679 Schema::TypedMulti { schema_types, .. } => schema_types.contains(&SchemaType::Null),
680 _ => false,
681 }
682 }
683
684 pub fn is_nullable_any(&self) -> bool {
696 self.details().is_nullable()
697 || self.type_array_contains_null()
698 || self.is_nullable_pattern()
699 }
700
701 pub fn details(&self) -> &SchemaDetails {
703 static EMPTY_DETAILS: Lazy<SchemaDetails> = Lazy::new(SchemaDetails::default);
704 match self {
705 Schema::Typed { details, .. } => details,
706 Schema::TypedMulti { details, .. } => details,
707 Schema::Reference { .. } | Schema::RecursiveRef { .. } | Schema::DynamicRef { .. } => {
708 &EMPTY_DETAILS
709 }
710 Schema::OneOf { details, .. } => details,
711 Schema::AnyOf { details, .. } => details,
712 Schema::AllOf { details, .. } => details,
713 Schema::Untyped { details } => details,
714 }
715 }
716
717 pub fn details_mut(&mut self) -> &mut SchemaDetails {
719 match self {
720 Schema::Typed { details, .. } => details,
721 Schema::TypedMulti { details, .. } => details,
722 Schema::Reference { .. } => {
723 panic!("Cannot get mutable details for reference schema")
724 }
725 Schema::RecursiveRef { .. } => {
726 panic!("Cannot get mutable details for recursive reference schema")
727 }
728 Schema::DynamicRef { .. } => {
729 panic!("Cannot get mutable details for dynamic reference schema")
730 }
731 Schema::OneOf { details, .. } => details,
732 Schema::AnyOf { details, .. } => details,
733 Schema::AllOf { details, .. } => details,
734 Schema::Untyped { details } => details,
735 }
736 }
737
738 pub fn is_reference(&self) -> bool {
740 matches!(self, Schema::Reference { .. } | Schema::RecursiveRef { .. })
741 }
742
743 pub fn reference(&self) -> Option<&str> {
745 match self {
746 Schema::Reference { reference, .. } => Some(reference),
747 _ => None,
748 }
749 }
750
751 pub fn recursive_reference(&self) -> Option<&str> {
753 match self {
754 Schema::RecursiveRef { recursive_ref, .. } => Some(recursive_ref),
755 _ => None,
756 }
757 }
758
759 pub fn is_discriminated_union(&self) -> bool {
761 match self {
762 Schema::OneOf { discriminator, .. } => discriminator.is_some(),
763 Schema::AnyOf { discriminator, .. } => discriminator.is_some(),
764 _ => false,
765 }
766 }
767
768 pub fn discriminator(&self) -> Option<&Discriminator> {
770 match self {
771 Schema::OneOf { discriminator, .. } => discriminator.as_ref(),
772 Schema::AnyOf { discriminator, .. } => discriminator.as_ref(),
773 _ => None,
774 }
775 }
776
777 pub fn union_variants(&self) -> Option<&[Schema]> {
779 match self {
780 Schema::OneOf { one_of, .. } => Some(one_of),
781 Schema::AnyOf { any_of, .. } => Some(any_of),
782 _ => None,
783 }
784 }
785
786 pub fn is_nullable_pattern(&self) -> bool {
788 let variants = match self {
789 Schema::AnyOf { any_of, .. } => any_of,
790 Schema::OneOf { one_of, .. } => one_of,
791 _ => return false,
792 };
793 variants.len() == 2
794 && variants
795 .iter()
796 .any(|s| matches!(s.schema_type(), Some(SchemaType::Null)))
797 }
798
799 pub fn non_null_variant(&self) -> Option<&Schema> {
801 if !self.is_nullable_pattern() {
802 return None;
803 }
804 let variants = match self {
805 Schema::AnyOf { any_of, .. } => any_of,
806 Schema::OneOf { one_of, .. } => one_of,
807 _ => return None,
808 };
809 variants
810 .iter()
811 .find(|s| !matches!(s.schema_type(), Some(SchemaType::Null)))
812 }
813
814 pub fn inferred_type(&self) -> Option<SchemaType> {
816 match self {
817 Schema::Typed { schema_type, .. } => Some(schema_type.clone()),
818 Schema::TypedMulti { .. } => self.schema_type().cloned(),
819 Schema::Untyped { details } => {
820 if details.properties.is_some() {
822 Some(SchemaType::Object)
823 } else if details.items.is_some() {
824 Some(SchemaType::Array)
825 } else if details.enum_values.is_some() {
826 Some(SchemaType::String) } else {
828 None
829 }
830 }
831 _ => None,
832 }
833 }
834}
835
836impl SchemaDetails {
837 pub fn is_nullable(&self) -> bool {
839 self.nullable.unwrap_or(false)
840 }
841
842 pub fn is_string_enum(&self) -> bool {
848 self.enum_values.is_some() || self.const_string_value().is_some()
849 }
850
851 pub fn string_enum_values(&self) -> Option<Vec<String>> {
857 if let Some(values) = self.enum_values.as_ref() {
858 return Some(
863 values
864 .iter()
865 .map(|v| match v {
866 Value::String(s) => s.clone(),
867 Value::Number(n) => n.to_string(),
868 Value::Bool(b) => b.to_string(),
869 Value::Null => "null".to_string(),
870 _ => v.to_string(),
871 })
872 .collect(),
873 );
874 }
875 self.const_string_value().map(|s| vec![s])
876 }
877
878 fn const_string_value(&self) -> Option<String> {
879 self.const_value
880 .as_ref()
881 .and_then(|v| v.as_str())
882 .map(|s| s.to_string())
883 }
884
885 pub fn is_field_required(&self, field_name: &str) -> bool {
887 self.required
888 .as_ref()
889 .map(|req| req.contains(&field_name.to_string()))
890 .unwrap_or(false)
891 }
892}
893
894#[derive(Debug, Clone, Deserialize, Serialize)]
896pub struct PathItem {
897 #[serde(default)]
898 pub summary: Option<String>,
899 #[serde(default)]
900 pub description: Option<String>,
901 pub get: Option<Operation>,
902 pub put: Option<Operation>,
903 pub post: Option<Operation>,
904 pub delete: Option<Operation>,
905 pub options: Option<Operation>,
906 pub head: Option<Operation>,
907 pub patch: Option<Operation>,
908 pub trace: Option<Operation>,
909 pub query: Option<Operation>,
912 #[serde(rename = "additionalOperations", default)]
916 pub additional_operations: Option<BTreeMap<String, Operation>>,
917 pub parameters: Option<Vec<Parameter>>,
918 #[serde(default)]
919 pub servers: Option<Vec<Server>>,
920 #[serde(rename = "$ref", default)]
921 pub reference: Option<String>,
922 #[serde(flatten, default)]
923 pub extensions: Extensions,
924}
925
926impl PathItem {
927 pub fn operations(&self) -> Vec<(&str, &Operation)> {
930 let mut ops = Vec::new();
931 if let Some(ref op) = self.get {
932 ops.push(("get", op));
933 }
934 if let Some(ref op) = self.put {
935 ops.push(("put", op));
936 }
937 if let Some(ref op) = self.post {
938 ops.push(("post", op));
939 }
940 if let Some(ref op) = self.delete {
941 ops.push(("delete", op));
942 }
943 if let Some(ref op) = self.options {
944 ops.push(("options", op));
945 }
946 if let Some(ref op) = self.head {
947 ops.push(("head", op));
948 }
949 if let Some(ref op) = self.patch {
950 ops.push(("patch", op));
951 }
952 if let Some(ref op) = self.trace {
953 ops.push(("trace", op));
954 }
955 if let Some(ref op) = self.query {
956 ops.push(("query", op));
957 }
958 if let Some(map) = &self.additional_operations {
959 for (verb, op) in map {
960 ops.push((verb.as_str(), op));
961 }
962 }
963 ops
964 }
965}
966
967#[derive(Debug, Clone, Deserialize, Serialize)]
969pub struct Operation {
970 #[serde(rename = "operationId", default)]
971 pub operation_id: Option<String>,
972 #[serde(default)]
973 pub summary: Option<String>,
974 #[serde(default)]
975 pub description: Option<String>,
976 #[serde(default)]
977 pub tags: Option<Vec<String>>,
978 #[serde(default)]
979 pub deprecated: Option<bool>,
980 pub parameters: Option<Vec<Parameter>>,
981 #[serde(rename = "requestBody")]
982 pub request_body: Option<RequestBody>,
983 pub responses: Option<BTreeMap<String, Response>>,
984 #[serde(default)]
985 pub callbacks: Option<BTreeMap<String, Callback>>,
986 #[serde(default)]
987 pub security: Option<Vec<BTreeMap<String, Vec<String>>>>,
988 #[serde(default)]
989 pub servers: Option<Vec<Server>>,
990 #[serde(rename = "externalDocs", default)]
991 pub external_docs: Option<ExternalDocs>,
992 #[serde(flatten, default)]
993 pub extensions: Extensions,
994}
995
996#[derive(Debug, Clone, Deserialize, Serialize)]
998pub struct Parameter {
999 #[serde(default)]
1000 pub name: Option<String>,
1001 #[serde(rename = "in", default)]
1002 pub location: Option<String>,
1003 #[serde(default)]
1004 pub required: Option<bool>,
1005 #[serde(default)]
1006 pub deprecated: Option<bool>,
1007 #[serde(rename = "allowEmptyValue", default)]
1008 pub allow_empty_value: Option<bool>,
1009 #[serde(default)]
1010 pub style: Option<String>,
1011 #[serde(default)]
1012 pub explode: Option<bool>,
1013 #[serde(rename = "allowReserved", default)]
1014 pub allow_reserved: Option<bool>,
1015 #[serde(default)]
1016 pub schema: Option<Schema>,
1017 #[serde(default)]
1018 pub content: Option<BTreeMap<String, MediaType>>,
1019 #[serde(default)]
1020 pub example: Option<Value>,
1021 #[serde(default)]
1022 pub examples: Option<BTreeMap<String, Example>>,
1023 #[serde(default)]
1024 pub description: Option<String>,
1025 #[serde(rename = "$ref", default)]
1026 pub reference: Option<String>,
1027 #[serde(flatten, default)]
1028 pub extensions: Extensions,
1029}
1030
1031#[derive(Debug, Clone, Deserialize, Serialize)]
1033pub struct RequestBody {
1034 pub content: Option<BTreeMap<String, MediaType>>,
1035 #[serde(default)]
1036 pub description: Option<String>,
1037 #[serde(default)]
1038 pub required: Option<bool>,
1039 #[serde(rename = "$ref", default)]
1040 pub reference: Option<String>,
1041 #[serde(flatten, default)]
1042 pub extensions: Extensions,
1043}
1044
1045#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1051#[serde(rename_all = "snake_case")]
1052pub enum ResponseMediaKind {
1053 Json,
1054 EventStream,
1055 Text,
1056 Binary,
1057 Unsupported,
1058}
1059
1060pub fn media_type_essence(content_type: &str) -> &str {
1066 content_type
1067 .split(';')
1068 .next()
1069 .unwrap_or(content_type)
1070 .trim()
1071}
1072
1073pub fn is_json_media_type(ct: &str) -> bool {
1081 let essence = media_type_essence(ct).to_ascii_lowercase();
1082 if essence == "application/json" {
1083 return true;
1084 }
1085 if let Some(subtype) = essence.strip_prefix("application/") {
1086 return subtype.ends_with("+json");
1087 }
1088 false
1089}
1090
1091pub fn is_form_urlencoded_media_type(ct: &str) -> bool {
1094 let essence = media_type_essence(ct).to_ascii_lowercase();
1095 essence == "application/x-www-form-urlencoded"
1096}
1097
1098pub fn is_event_stream_media_type(ct: &str) -> bool {
1104 media_type_essence(ct).eq_ignore_ascii_case("text/event-stream")
1105}
1106
1107pub fn is_text_media_type(ct: &str) -> bool {
1115 let Some((top_level, subtype)) = media_type_essence(ct).split_once('/') else {
1116 return false;
1117 };
1118 if top_level.eq_ignore_ascii_case("text")
1119 && !subtype.is_empty()
1120 && !is_event_stream_media_type(ct)
1121 {
1122 return true;
1123 }
1124 top_level.eq_ignore_ascii_case("application")
1125 && (subtype.eq_ignore_ascii_case("xml")
1126 || subtype.to_ascii_lowercase().ends_with("+xml")
1127 || subtype.eq_ignore_ascii_case("jwt"))
1130}
1131
1132pub fn is_wildcard_media_type(ct: &str) -> bool {
1138 let Some((top_level, subtype)) = media_type_essence(ct).split_once('/') else {
1139 return false;
1140 };
1141 !top_level.is_empty() && subtype == "*"
1142}
1143
1144fn schema_has_binary_format(schema: Option<&Schema>) -> bool {
1145 schema.is_some_and(|schema| {
1146 schema
1147 .details()
1148 .format
1149 .as_deref()
1150 .is_some_and(|format| format.eq_ignore_ascii_case("binary"))
1151 })
1152}
1153
1154pub fn is_binary_media_type(ct: &str, schema: Option<&Schema>) -> bool {
1162 if schema_has_binary_format(schema) {
1163 return true;
1164 }
1165
1166 let essence = media_type_essence(ct);
1167 let Some((top_level, _)) = essence.split_once('/') else {
1168 return false;
1169 };
1170 if top_level.eq_ignore_ascii_case("image")
1171 || top_level.eq_ignore_ascii_case("audio")
1172 || top_level.eq_ignore_ascii_case("video")
1173 {
1174 return true;
1175 }
1176 if essence.eq_ignore_ascii_case("application/octet-stream")
1177 || essence.eq_ignore_ascii_case("application/zip")
1178 || essence.eq_ignore_ascii_case("application/pdf")
1179 {
1180 return true;
1181 }
1182
1183 !top_level.eq_ignore_ascii_case("text") && is_wildcard_media_type(ct)
1184}
1185
1186pub fn classify_response_media_type(ct: &str, schema: Option<&Schema>) -> ResponseMediaKind {
1191 if is_json_media_type(ct) {
1192 ResponseMediaKind::Json
1193 } else if is_event_stream_media_type(ct) {
1194 ResponseMediaKind::EventStream
1195 } else if schema_has_binary_format(schema) {
1196 ResponseMediaKind::Binary
1197 } else if is_text_media_type(ct) {
1198 if is_wildcard_media_type(ct) {
1199 ResponseMediaKind::Unsupported
1200 } else {
1201 ResponseMediaKind::Text
1202 }
1203 } else if is_binary_media_type(ct, schema) {
1204 ResponseMediaKind::Binary
1205 } else {
1206 ResponseMediaKind::Unsupported
1207 }
1208}
1209
1210fn find_json_content(content: &BTreeMap<String, MediaType>) -> Option<(&str, &MediaType)> {
1211 if let Some(mt) = content
1212 .get("application/json")
1213 .filter(|media_type| media_type.schema.is_some())
1214 {
1215 return Some(("application/json", mt));
1216 }
1217 content
1218 .iter()
1219 .find(|(ct, media_type)| is_json_media_type(ct) && media_type.schema.is_some())
1220 .map(|(ct, mt)| (ct.as_str(), mt))
1221 .or_else(|| {
1222 content
1223 .get("application/json")
1224 .map(|media_type| ("application/json", media_type))
1225 })
1226 .or_else(|| {
1227 content
1228 .iter()
1229 .find(|(ct, _)| is_json_media_type(ct))
1230 .map(|(ct, mt)| (ct.as_str(), mt))
1231 })
1232}
1233
1234impl RequestBody {
1235 pub fn json_schema(&self) -> Option<&Schema> {
1241 self.content
1242 .as_ref()
1243 .and_then(find_json_content)
1244 .and_then(|(_, media_type)| media_type.schema.as_ref())
1245 }
1246
1247 pub fn best_content(&self) -> Option<(&str, Option<&Schema>)> {
1249 let content = self.content.as_ref()?;
1250
1251 if let Some((ct, media_type)) = find_json_content(content) {
1252 return Some((ct, media_type.schema.as_ref()));
1253 }
1254
1255 const PRIORITY: &[&str] = &[
1256 "application/x-www-form-urlencoded",
1257 "multipart/form-data",
1258 "application/octet-stream",
1259 "text/plain",
1260 ];
1261 for preferred_essence in PRIORITY {
1262 if let Some((ct, media_type)) = content
1263 .iter()
1264 .find(|(ct, _)| media_type_essence(ct).eq_ignore_ascii_case(preferred_essence))
1265 {
1266 return Some((ct.as_str(), media_type.schema.as_ref()));
1267 }
1268 }
1269 if let Some((ct, media_type)) = content.iter().find(|(ct, _)| is_text_media_type(ct)) {
1272 return Some((ct.as_str(), media_type.schema.as_ref()));
1273 }
1274 content
1275 .iter()
1276 .find(|(ct, media_type)| {
1282 !is_wildcard_media_type(ct) && is_binary_media_type(ct, media_type.schema.as_ref())
1283 })
1284 .map(|(ct, media_type)| (ct.as_str(), media_type.schema.as_ref()))
1285 }
1286}
1287
1288#[derive(Debug, Clone, Deserialize, Serialize)]
1290pub struct Response {
1291 #[serde(default)]
1292 pub description: Option<String>,
1293 #[serde(default)]
1294 pub headers: Option<BTreeMap<String, Header>>,
1295 #[serde(default)]
1296 pub content: Option<BTreeMap<String, MediaType>>,
1297 #[serde(default)]
1298 pub links: Option<Value>,
1299 #[serde(rename = "$ref", default)]
1300 pub reference: Option<String>,
1301 #[serde(flatten, default)]
1302 pub extensions: Extensions,
1303}
1304
1305impl Response {
1306 pub fn json_schema(&self) -> Option<&Schema> {
1313 self.content
1314 .as_ref()
1315 .and_then(find_json_content)
1316 .and_then(|(_, media_type)| media_type.schema.as_ref())
1317 }
1318
1319 pub fn json_content(&self) -> Option<(&str, &Schema)> {
1321 self.content
1322 .as_ref()
1323 .and_then(find_json_content)
1324 .and_then(|(content_type, media_type)| {
1325 media_type
1326 .schema
1327 .as_ref()
1328 .map(|schema| (content_type, schema))
1329 })
1330 }
1331}
1332
1333#[derive(Debug, Clone, Deserialize, Serialize)]
1335pub struct MediaType {
1336 #[serde(default)]
1337 pub schema: Option<Schema>,
1338 #[serde(default)]
1339 pub example: Option<Value>,
1340 #[serde(default)]
1341 pub examples: Option<BTreeMap<String, Example>>,
1342 #[serde(default)]
1343 pub encoding: Option<BTreeMap<String, Encoding>>,
1344 #[serde(rename = "itemSchema", default)]
1347 pub item_schema: Option<Schema>,
1348 #[serde(rename = "prefixEncoding", default)]
1351 pub prefix_encoding: Option<Vec<Encoding>>,
1352 #[serde(rename = "itemEncoding", default)]
1355 pub item_encoding: Option<Encoding>,
1356 #[serde(rename = "$ref", default)]
1357 pub reference: Option<String>,
1358 #[serde(flatten, default)]
1359 pub extensions: Extensions,
1360}
1361
1362#[cfg(test)]
1363#[allow(clippy::unwrap_used, clippy::expect_used)]
1364mod tests {
1365 use super::*;
1366 use serde_json::json;
1367
1368 #[test]
1369 fn paths_map_skips_extension_scalars() {
1370 let spec: OpenApiSpec = serde_json::from_value(json!({
1373 "openapi": "3.0.0",
1374 "info": { "title": "lenient paths", "version": "1" },
1375 "paths": {
1376 "x-codegen-contextRoot": "/apis/registry/v2",
1377 "/items": {
1378 "get": {
1379 "operationId": "listItems",
1380 "responses": { "204": { "description": "ok" } }
1381 }
1382 }
1383 }
1384 }))
1385 .unwrap();
1386 let paths = spec.paths.unwrap();
1387 assert!(paths.contains_key("/items"));
1388 assert!(!paths.contains_key("x-codegen-contextRoot"));
1389 }
1390
1391 #[test]
1392 fn test_parse_simple_object_schema() {
1393 let schema_json = json!({
1394 "type": "object",
1395 "properties": {
1396 "name": {
1397 "type": "string",
1398 "description": "User name"
1399 },
1400 "age": {
1401 "type": "integer"
1402 }
1403 },
1404 "required": ["name"]
1405 });
1406
1407 let schema: Schema = serde_json::from_value(schema_json).unwrap();
1408
1409 match schema {
1410 Schema::Typed {
1411 schema_type: SchemaType::Object,
1412 details,
1413 } => {
1414 assert!(details.properties.is_some());
1415 assert_eq!(details.required, Some(vec!["name".to_string()]));
1416 assert!(details.is_field_required("name"));
1417 assert!(!details.is_field_required("age"));
1418 }
1419 _ => panic!("Expected object schema"),
1420 }
1421 }
1422
1423 #[test]
1424 fn test_parse_string_enum() {
1425 let schema_json = json!({
1426 "type": "string",
1427 "enum": ["active", "inactive", "pending"],
1428 "description": "User status"
1429 });
1430
1431 let schema: Schema = serde_json::from_value(schema_json).unwrap();
1432
1433 match schema {
1434 Schema::Typed {
1435 schema_type: SchemaType::String,
1436 details,
1437 } => {
1438 assert!(details.is_string_enum());
1439 let values = details.string_enum_values().unwrap();
1440 assert_eq!(values, vec!["active", "inactive", "pending"]);
1441 }
1442 _ => panic!("Expected string enum schema"),
1443 }
1444 }
1445
1446 #[test]
1447 fn test_parse_reference_schema() {
1448 let schema_json = json!({
1449 "$ref": "#/components/schemas/User"
1450 });
1451
1452 let schema: Schema = serde_json::from_value(schema_json).unwrap();
1453
1454 assert!(schema.is_reference());
1455 assert_eq!(schema.reference(), Some("#/components/schemas/User"));
1456 }
1457
1458 #[test]
1459 fn test_parse_discriminated_union() {
1460 let schema_json = json!({
1461 "oneOf": [
1462 {"$ref": "#/components/schemas/Dog"},
1463 {"$ref": "#/components/schemas/Cat"}
1464 ],
1465 "discriminator": {
1466 "propertyName": "petType"
1467 }
1468 });
1469
1470 let schema: Schema = serde_json::from_value(schema_json).unwrap();
1471
1472 assert!(schema.is_discriminated_union());
1473 let discriminator = schema.discriminator().unwrap();
1474 assert_eq!(discriminator.property_name, "petType");
1475 }
1476
1477 #[test]
1478 fn test_parse_nullable_pattern() {
1479 let schema_json = json!({
1480 "anyOf": [
1481 {"$ref": "#/components/schemas/User"},
1482 {"type": "null"}
1483 ]
1484 });
1485
1486 let schema: Schema = serde_json::from_value(schema_json).unwrap();
1487
1488 assert!(schema.is_nullable_pattern());
1489 let non_null = schema.non_null_variant().unwrap();
1490 assert!(non_null.is_reference());
1491 }
1492
1493 #[test]
1494 fn is_json_media_type_accepts_canonical_and_structured_suffix() {
1495 assert!(is_json_media_type("application/json"));
1497 assert!(is_json_media_type("application/json; charset=utf-8"));
1499 assert!(is_json_media_type("APPLICATION/JSON"));
1500 assert!(is_json_media_type("application/vnd.api+json"));
1502 assert!(is_json_media_type("application/hal+json"));
1503 assert!(is_json_media_type("application/problem+json"));
1504 assert!(is_json_media_type("application/ld+json"));
1505 assert!(is_json_media_type(
1506 "application/vnd.api+json; charset=utf-8"
1507 ));
1508 assert!(!is_json_media_type("application/xml"));
1510 assert!(!is_json_media_type("application/x-www-form-urlencoded"));
1511 assert!(!is_json_media_type("text/plain"));
1512 assert!(!is_json_media_type("application/jsonbutnotreally"));
1513 assert!(!is_json_media_type("text/something+json"));
1515 }
1516
1517 #[test]
1518 fn response_media_helpers_normalize_parameters_and_case() {
1519 assert_eq!(
1520 media_type_essence(" Text/Plain ; charset=utf-8 "),
1521 "Text/Plain"
1522 );
1523 assert!(is_text_media_type("TEXT/HTML; charset=UTF-8"));
1524 assert!(!is_text_media_type("Text/Event-Stream; charset=utf-8"));
1525 assert!(is_wildcard_media_type("*/*; q=0.8"));
1526 assert!(is_wildcard_media_type("IMAGE/*"));
1527 assert!(is_wildcard_media_type("text/*"));
1528 assert!(!is_wildcard_media_type("image/*+json"));
1529 assert!(!is_wildcard_media_type("application/json"));
1530 }
1531
1532 #[test]
1533 fn response_media_classifier_keeps_json_sse_and_text_distinct() {
1534 for media_type in [
1535 "application/json",
1536 "APPLICATION/PROBLEM+JSON; charset=utf-8",
1537 ] {
1538 assert_eq!(
1539 classify_response_media_type(media_type, None),
1540 ResponseMediaKind::Json,
1541 "{media_type}"
1542 );
1543 }
1544
1545 assert_eq!(
1546 classify_response_media_type("Text/Event-Stream; charset=utf-8", None),
1547 ResponseMediaKind::EventStream
1548 );
1549 for media_type in ["text/plain", "TEXT/HTML; charset=UTF-8"] {
1550 assert_eq!(
1551 classify_response_media_type(media_type, None),
1552 ResponseMediaKind::Text,
1553 "{media_type}"
1554 );
1555 }
1556 assert_eq!(
1557 classify_response_media_type("text/event-streaming", None),
1558 ResponseMediaKind::Text
1559 );
1560 assert_eq!(
1561 classify_response_media_type("text/*", None),
1562 ResponseMediaKind::Unsupported,
1563 "a media range is not a valid concrete response Content-Type"
1564 );
1565 }
1566
1567 #[test]
1568 fn response_json_content_skips_schema_less_canonical_entry() {
1569 let response: Response = serde_json::from_value(json!({
1570 "description": "mixed JSON",
1571 "content": {
1572 "application/json": {},
1573 "application/vnd.example+json": {
1574 "schema": { "type": "string" }
1575 }
1576 }
1577 }))
1578 .unwrap();
1579
1580 let (media_type, schema) = response.json_content().expect("schema-bearing JSON");
1581 assert_eq!(media_type, "application/vnd.example+json");
1582 assert!(matches!(schema.schema_type(), Some(SchemaType::String)));
1583 }
1584
1585 #[test]
1586 fn response_media_classifier_recognizes_binary_formats_and_wildcards() {
1587 for media_type in [
1588 "image/png",
1589 "IMAGE/*; version=1",
1590 "audio/mpeg",
1591 "video/mp4",
1592 "application/octet-stream",
1593 "APPLICATION/ZIP; version=1",
1594 "application/*",
1595 "*/*",
1596 ] {
1597 assert_eq!(
1598 classify_response_media_type(media_type, None),
1599 ResponseMediaKind::Binary,
1600 "{media_type}"
1601 );
1602 }
1603
1604 let binary_schema: Schema = serde_json::from_value(json!({
1605 "type": "string",
1606 "format": "BINARY"
1607 }))
1608 .unwrap();
1609 assert_eq!(
1610 classify_response_media_type("application/x-custom", Some(&binary_schema)),
1611 ResponseMediaKind::Binary
1612 );
1613 assert_eq!(
1614 classify_response_media_type("text/plain", Some(&binary_schema)),
1615 ResponseMediaKind::Binary,
1616 "an explicit binary schema must prevent UTF-8 decoding"
1617 );
1618 assert!(is_binary_media_type(
1619 "application/x-custom",
1620 Some(&binary_schema)
1621 ));
1622 }
1623
1624 #[test]
1625 fn response_media_classifier_leaves_ambiguous_formats_unsupported() {
1626 let string_schema: Schema = serde_json::from_value(json!({ "type": "string" })).unwrap();
1627 for media_type in ["application/x-unknown", "not-a-media-type"] {
1628 assert_eq!(
1629 classify_response_media_type(media_type, Some(&string_schema)),
1630 ResponseMediaKind::Unsupported,
1631 "{media_type}"
1632 );
1633 }
1634 assert_eq!(
1638 classify_response_media_type("application/pdf", Some(&string_schema)),
1639 ResponseMediaKind::Binary
1640 );
1641 assert_eq!(
1642 classify_response_media_type("application/xml", Some(&string_schema)),
1643 ResponseMediaKind::Text
1644 );
1645 assert_eq!(
1646 classify_response_media_type("application/atom+xml", Some(&string_schema)),
1647 ResponseMediaKind::Text
1648 );
1649 assert_eq!(
1651 classify_response_media_type("application/jwt", Some(&string_schema)),
1652 ResponseMediaKind::Text
1653 );
1654 assert!(!is_binary_media_type("text/plain", None));
1655 }
1656
1657 #[test]
1658 fn request_body_json_schema_finds_vnd_api_plus_json() {
1659 let body_json = json!({
1662 "required": true,
1663 "content": {
1664 "application/vnd.api+json": {
1665 "schema": {"$ref": "#/components/schemas/create_api_key"}
1666 }
1667 }
1668 });
1669
1670 let body: RequestBody = serde_json::from_value(body_json).unwrap();
1671 let schema = body.json_schema().expect("expected +json schema match");
1672 assert!(schema.is_reference());
1673 }
1674
1675 #[test]
1676 fn request_body_best_content_prefers_canonical_json_over_plus_json() {
1677 let body_json = json!({
1681 "required": true,
1682 "content": {
1683 "application/json": {
1684 "schema": {"$ref": "#/components/schemas/A"}
1685 },
1686 "application/vnd.api+json": {
1687 "schema": {"$ref": "#/components/schemas/B"}
1688 }
1689 }
1690 });
1691
1692 let body: RequestBody = serde_json::from_value(body_json).unwrap();
1693 let (ct, _) = body.best_content().expect("expected best_content");
1694 assert_eq!(ct, "application/json");
1695 }
1696
1697 #[test]
1698 fn request_body_best_content_falls_back_to_plus_json() {
1699 let body_json = json!({
1702 "required": true,
1703 "content": {
1704 "application/vnd.api+json": {
1705 "schema": {"$ref": "#/components/schemas/B"}
1706 }
1707 }
1708 });
1709
1710 let body: RequestBody = serde_json::from_value(body_json).unwrap();
1711 let (ct, _) = body.best_content().expect("expected best_content");
1712 assert_eq!(ct, "application/vnd.api+json");
1713 }
1714
1715 #[test]
1716 fn request_body_best_content_does_not_select_wildcard_media_ranges() {
1717 let body: RequestBody = serde_json::from_value(json!({
1718 "required": true,
1719 "content": {
1720 "image/*": {
1721 "schema": { "type": "string", "format": "binary" }
1722 },
1723 "*/*": {
1724 "schema": { "type": "string", "format": "binary" }
1725 }
1726 }
1727 }))
1728 .unwrap();
1729
1730 assert!(
1731 body.best_content().is_none(),
1732 "request media ranges require a runtime concrete Content-Type"
1733 );
1734 }
1735
1736 #[test]
1737 fn request_body_best_content_matches_parameterized_text_plain_by_essence() {
1738 let body: RequestBody = serde_json::from_value(json!({
1739 "required": true,
1740 "content": {
1741 "Text/Plain; charset=utf-8": {
1742 "schema": { "type": "string" }
1743 }
1744 }
1745 }))
1746 .unwrap();
1747
1748 let (media_type, _) = body.best_content().expect("parameterized text body");
1749 assert_eq!(media_type, "Text/Plain; charset=utf-8");
1750 }
1751
1752 #[test]
1753 fn response_json_schema_finds_vnd_api_plus_json() {
1754 let resp_json = json!({
1757 "description": "OK",
1758 "content": {
1759 "application/vnd.api+json": {
1760 "schema": {"$ref": "#/components/schemas/api_keys"}
1761 }
1762 }
1763 });
1764
1765 let resp: Response = serde_json::from_value(resp_json).unwrap();
1766 let schema = resp.json_schema().expect("expected +json schema match");
1767 assert!(schema.is_reference());
1768 }
1769}