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<Items>,
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)]
382#[serde(untagged)]
383pub enum Items {
384 Single(Box<Schema>),
386 Positional(Vec<Schema>),
388}
389
390#[derive(Debug, Clone, Deserialize, Serialize)]
391#[serde(untagged)]
392pub enum AdditionalProperties {
393 Boolean(bool),
394 Schema(Box<Schema>),
395}
396
397#[derive(Debug, Clone, Deserialize, Serialize)]
399pub struct Example {
400 #[serde(default)]
401 pub summary: Option<String>,
402 #[serde(default)]
403 pub description: Option<String>,
404 #[serde(default)]
406 pub value: Option<Value>,
407 #[serde(rename = "externalValue", default)]
408 pub external_value: Option<String>,
409 #[serde(rename = "dataValue", default)]
411 pub data_value: Option<Value>,
412 #[serde(rename = "serializedValue", default)]
414 pub serialized_value: Option<String>,
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)]
423pub struct Link {
424 #[serde(rename = "operationRef", default)]
425 pub operation_ref: Option<String>,
426 #[serde(rename = "operationId", default)]
427 pub operation_id: Option<String>,
428 #[serde(default)]
429 pub parameters: Option<BTreeMap<String, Value>>,
430 #[serde(rename = "requestBody", default)]
431 pub request_body: Option<Value>,
432 #[serde(default)]
433 pub description: Option<String>,
434 #[serde(default)]
435 pub server: Option<Server>,
436 #[serde(rename = "$ref", default)]
437 pub reference: Option<String>,
438 #[serde(flatten, default)]
439 pub extensions: Extensions,
440}
441
442#[derive(Debug, Clone, Deserialize, Serialize)]
445#[serde(transparent)]
446pub struct Callback(pub BTreeMap<String, PathItem>);
447
448#[derive(Debug, Clone, Deserialize, Serialize)]
451pub struct Encoding {
452 #[serde(rename = "contentType", default)]
453 pub content_type: Option<String>,
454 #[serde(default)]
455 pub headers: Option<BTreeMap<String, Header>>,
456 #[serde(default)]
457 pub style: Option<String>,
458 #[serde(default)]
459 pub explode: Option<bool>,
460 #[serde(rename = "allowReserved", default)]
461 pub allow_reserved: Option<bool>,
462 #[serde(rename = "itemEncoding", default)]
464 pub item_encoding: Option<Box<Encoding>>,
465 #[serde(flatten, default)]
466 pub extensions: Extensions,
467}
468
469#[derive(Debug, Clone, Deserialize, Serialize)]
473pub struct Header {
474 #[serde(default)]
475 pub description: Option<String>,
476 #[serde(default)]
477 pub required: Option<bool>,
478 #[serde(default)]
479 pub deprecated: Option<bool>,
480 #[serde(rename = "allowEmptyValue", default)]
481 pub allow_empty_value: Option<bool>,
482 #[serde(default)]
483 pub style: Option<String>,
484 #[serde(default)]
485 pub explode: Option<bool>,
486 #[serde(rename = "allowReserved", default)]
487 pub allow_reserved: Option<bool>,
488 #[serde(default)]
489 pub schema: Option<Schema>,
490 #[serde(default)]
491 pub content: Option<BTreeMap<String, MediaType>>,
492 #[serde(default)]
493 pub example: Option<Value>,
494 #[serde(default)]
495 pub examples: Option<Value>,
496 #[serde(rename = "$ref", default)]
497 pub reference: Option<String>,
498 #[serde(flatten, default)]
499 pub extensions: Extensions,
500}
501
502#[derive(Debug, Clone, Deserialize, Serialize)]
506#[serde(tag = "type")]
507pub enum SecurityScheme {
508 #[serde(rename = "apiKey")]
509 ApiKey {
510 name: String,
511 #[serde(rename = "in")]
512 location: String, #[serde(default)]
514 description: Option<String>,
515 #[serde(default)]
517 deprecated: Option<bool>,
518 #[serde(flatten, default)]
519 extensions: Extensions,
520 },
521 #[serde(rename = "http")]
522 Http {
523 scheme: String, #[serde(rename = "bearerFormat", default)]
525 bearer_format: Option<String>,
526 #[serde(default)]
527 description: Option<String>,
528 #[serde(default)]
529 deprecated: Option<bool>,
530 #[serde(flatten, default)]
531 extensions: Extensions,
532 },
533 #[serde(rename = "mutualTLS")]
534 MutualTls {
535 #[serde(default)]
536 description: Option<String>,
537 #[serde(default)]
538 deprecated: Option<bool>,
539 #[serde(flatten, default)]
540 extensions: Extensions,
541 },
542 #[serde(rename = "oauth2")]
543 OAuth2 {
544 flows: Box<OAuthFlows>,
548 #[serde(default)]
549 description: Option<String>,
550 #[serde(rename = "oauth2MetadataUrl", default)]
552 oauth2_metadata_url: Option<String>,
553 #[serde(default)]
554 deprecated: Option<bool>,
555 #[serde(flatten, default)]
556 extensions: Extensions,
557 },
558 #[serde(rename = "openIdConnect")]
559 OpenIdConnect {
560 #[serde(rename = "openIdConnectUrl")]
561 open_id_connect_url: String,
562 #[serde(default)]
563 description: Option<String>,
564 #[serde(default)]
565 deprecated: Option<bool>,
566 #[serde(flatten, default)]
567 extensions: Extensions,
568 },
569}
570
571#[derive(Debug, Clone, Deserialize, Serialize)]
572pub struct OAuthFlows {
573 #[serde(default)]
574 pub implicit: Option<OAuthFlow>,
575 #[serde(default)]
576 pub password: Option<OAuthFlow>,
577 #[serde(rename = "clientCredentials", default)]
578 pub client_credentials: Option<OAuthFlow>,
579 #[serde(rename = "authorizationCode", default)]
580 pub authorization_code: Option<OAuthFlow>,
581 #[serde(rename = "deviceAuthorization", default)]
583 pub device_authorization: Option<OAuthFlow>,
584 #[serde(flatten, default)]
585 pub extensions: Extensions,
586}
587
588#[derive(Debug, Clone, Deserialize, Serialize)]
589pub struct OAuthFlow {
590 #[serde(rename = "authorizationUrl", default)]
591 pub authorization_url: Option<String>,
592 #[serde(rename = "tokenUrl", default)]
593 pub token_url: Option<String>,
594 #[serde(rename = "refreshUrl", default)]
595 pub refresh_url: Option<String>,
596 #[serde(rename = "deviceAuthorizationUrl", default)]
598 pub device_authorization_url: Option<String>,
599 pub scopes: BTreeMap<String, String>,
600 #[serde(flatten, default)]
601 pub extensions: Extensions,
602}
603
604#[derive(Debug, Clone, Deserialize, Serialize)]
606pub struct ExternalDocs {
607 pub url: String,
608 #[serde(default)]
609 pub description: Option<String>,
610 #[serde(flatten, default)]
611 pub extensions: Extensions,
612}
613
614#[derive(Debug, Clone, Deserialize, Serialize)]
616pub struct Tag {
617 pub name: String,
618 #[serde(default)]
620 pub summary: Option<String>,
621 #[serde(default)]
622 pub description: Option<String>,
623 #[serde(default)]
625 pub parent: Option<String>,
626 #[serde(default)]
630 pub kind: Option<String>,
631 #[serde(rename = "externalDocs", default)]
632 pub external_docs: Option<ExternalDocs>,
633 #[serde(flatten, default)]
634 pub extensions: Extensions,
635}
636
637#[derive(Debug, Clone, Deserialize, Serialize)]
640pub struct Server {
641 pub url: String,
642 #[serde(default)]
644 pub name: Option<String>,
645 #[serde(default)]
646 pub description: Option<String>,
647 #[serde(default)]
648 pub variables: Option<BTreeMap<String, ServerVariable>>,
649 #[serde(flatten, default)]
650 pub extensions: Extensions,
651}
652
653#[derive(Debug, Clone, Deserialize, Serialize)]
654pub struct ServerVariable {
655 #[serde(default)]
657 pub default: Option<String>,
658 #[serde(rename = "enum", default)]
659 pub enum_values: Option<Vec<String>>,
660 #[serde(default)]
661 pub description: Option<String>,
662 #[serde(flatten, default)]
663 pub extensions: Extensions,
664}
665
666#[derive(Debug, Clone, Deserialize, Serialize)]
667pub struct Discriminator {
668 #[serde(rename = "propertyName")]
669 pub property_name: String,
670 #[serde(default)]
671 pub mapping: Option<BTreeMap<String, String>>,
672 #[serde(rename = "defaultMapping", default)]
676 pub default_mapping: Option<String>,
677 #[serde(flatten, default)]
678 pub extensions: Extensions,
679}
680
681impl Schema {
682 pub fn schema_type(&self) -> Option<&SchemaType> {
689 match self {
690 Schema::Typed { schema_type, .. } => Some(schema_type),
691 Schema::TypedMulti { schema_types, .. } => schema_types
692 .iter()
693 .find(|t| **t != SchemaType::Null)
694 .or_else(|| schema_types.first()),
695 _ => None,
696 }
697 }
698
699 pub fn non_null_schema_types(&self) -> Option<Vec<SchemaType>> {
706 match self {
707 Schema::TypedMulti { schema_types, .. } => {
708 let mut non_null = Vec::new();
709 for t in schema_types {
710 if *t != SchemaType::Null && !non_null.contains(t) {
711 non_null.push(t.clone());
712 }
713 }
714 (non_null.len() > 1).then_some(non_null)
715 }
716 _ => None,
717 }
718 }
719
720 pub fn type_array_contains_null(&self) -> bool {
723 match self {
724 Schema::TypedMulti { schema_types, .. } => schema_types.contains(&SchemaType::Null),
725 _ => false,
726 }
727 }
728
729 pub fn is_nullable_any(&self) -> bool {
741 self.details().is_nullable()
742 || self.type_array_contains_null()
743 || self.is_nullable_pattern()
744 }
745
746 pub fn details(&self) -> &SchemaDetails {
748 static EMPTY_DETAILS: Lazy<SchemaDetails> = Lazy::new(SchemaDetails::default);
749 match self {
750 Schema::Typed { details, .. } => details,
751 Schema::TypedMulti { details, .. } => details,
752 Schema::Reference { .. } | Schema::RecursiveRef { .. } | Schema::DynamicRef { .. } => {
753 &EMPTY_DETAILS
754 }
755 Schema::OneOf { details, .. } => details,
756 Schema::AnyOf { details, .. } => details,
757 Schema::AllOf { details, .. } => details,
758 Schema::Untyped { details } => details,
759 }
760 }
761
762 pub fn details_mut(&mut self) -> &mut SchemaDetails {
764 match self {
765 Schema::Typed { details, .. } => details,
766 Schema::TypedMulti { details, .. } => details,
767 Schema::Reference { .. } => {
768 panic!("Cannot get mutable details for reference schema")
769 }
770 Schema::RecursiveRef { .. } => {
771 panic!("Cannot get mutable details for recursive reference schema")
772 }
773 Schema::DynamicRef { .. } => {
774 panic!("Cannot get mutable details for dynamic reference schema")
775 }
776 Schema::OneOf { details, .. } => details,
777 Schema::AnyOf { details, .. } => details,
778 Schema::AllOf { details, .. } => details,
779 Schema::Untyped { details } => details,
780 }
781 }
782
783 pub fn is_reference(&self) -> bool {
785 matches!(self, Schema::Reference { .. } | Schema::RecursiveRef { .. })
786 }
787
788 pub fn reference(&self) -> Option<&str> {
790 match self {
791 Schema::Reference { reference, .. } => Some(reference),
792 _ => None,
793 }
794 }
795
796 pub fn recursive_reference(&self) -> Option<&str> {
798 match self {
799 Schema::RecursiveRef { recursive_ref, .. } => Some(recursive_ref),
800 _ => None,
801 }
802 }
803
804 pub fn is_discriminated_union(&self) -> bool {
806 match self {
807 Schema::OneOf { discriminator, .. } => discriminator.is_some(),
808 Schema::AnyOf { discriminator, .. } => discriminator.is_some(),
809 _ => false,
810 }
811 }
812
813 pub fn discriminator(&self) -> Option<&Discriminator> {
815 match self {
816 Schema::OneOf { discriminator, .. } => discriminator.as_ref(),
817 Schema::AnyOf { discriminator, .. } => discriminator.as_ref(),
818 _ => None,
819 }
820 }
821
822 pub fn union_variants(&self) -> Option<&[Schema]> {
824 match self {
825 Schema::OneOf { one_of, .. } => Some(one_of),
826 Schema::AnyOf { any_of, .. } => Some(any_of),
827 _ => None,
828 }
829 }
830
831 pub fn is_nullable_pattern(&self) -> bool {
833 let variants = match self {
834 Schema::AnyOf { any_of, .. } => any_of,
835 Schema::OneOf { one_of, .. } => one_of,
836 _ => return false,
837 };
838 variants.len() == 2
839 && variants
840 .iter()
841 .any(|s| matches!(s.schema_type(), Some(SchemaType::Null)))
842 }
843
844 pub fn non_null_variant(&self) -> Option<&Schema> {
846 if !self.is_nullable_pattern() {
847 return None;
848 }
849 let variants = match self {
850 Schema::AnyOf { any_of, .. } => any_of,
851 Schema::OneOf { one_of, .. } => one_of,
852 _ => return None,
853 };
854 variants
855 .iter()
856 .find(|s| !matches!(s.schema_type(), Some(SchemaType::Null)))
857 }
858
859 pub fn inferred_type(&self) -> Option<SchemaType> {
861 match self {
862 Schema::Typed { schema_type, .. } => Some(schema_type.clone()),
863 Schema::TypedMulti { .. } => self.schema_type().cloned(),
864 Schema::Untyped { details } => {
865 if details.properties.is_some() {
867 Some(SchemaType::Object)
868 } else if details.items.is_some() {
869 Some(SchemaType::Array)
870 } else if details.enum_values.is_some() {
871 Some(SchemaType::String) } else {
873 None
874 }
875 }
876 _ => None,
877 }
878 }
879}
880
881impl SchemaDetails {
882 pub fn item_schema(&self) -> Option<&Schema> {
887 match self.items.as_ref()? {
888 Items::Single(schema) => Some(schema),
889 Items::Positional(_) => None,
890 }
891 }
892
893 pub fn positional_items(&self) -> Option<&[Schema]> {
896 if let Some(prefix_items) = self.prefix_items.as_deref() {
897 return Some(prefix_items);
898 }
899 match self.items.as_ref()? {
900 Items::Positional(schemas) => Some(schemas),
901 Items::Single(_) => None,
902 }
903 }
904
905 pub fn is_nullable(&self) -> bool {
907 self.nullable.unwrap_or(false)
908 }
909
910 pub fn is_string_enum(&self) -> bool {
916 self.enum_values.is_some() || self.const_string_value().is_some()
917 }
918
919 pub fn string_enum_values(&self) -> Option<Vec<String>> {
925 if let Some(values) = self.enum_values.as_ref() {
926 return Some(
931 values
932 .iter()
933 .map(|v| match v {
934 Value::String(s) => s.clone(),
935 Value::Number(n) => n.to_string(),
936 Value::Bool(b) => b.to_string(),
937 Value::Null => "null".to_string(),
938 _ => v.to_string(),
939 })
940 .collect(),
941 );
942 }
943 self.const_string_value().map(|s| vec![s])
944 }
945
946 fn const_string_value(&self) -> Option<String> {
947 self.const_value
948 .as_ref()
949 .and_then(|v| v.as_str())
950 .map(|s| s.to_string())
951 }
952
953 pub fn is_field_required(&self, field_name: &str) -> bool {
955 self.required
956 .as_ref()
957 .map(|req| req.contains(&field_name.to_string()))
958 .unwrap_or(false)
959 }
960}
961
962#[derive(Debug, Clone, Deserialize, Serialize)]
964pub struct PathItem {
965 #[serde(default)]
966 pub summary: Option<String>,
967 #[serde(default)]
968 pub description: Option<String>,
969 pub get: Option<Operation>,
970 pub put: Option<Operation>,
971 pub post: Option<Operation>,
972 pub delete: Option<Operation>,
973 pub options: Option<Operation>,
974 pub head: Option<Operation>,
975 pub patch: Option<Operation>,
976 pub trace: Option<Operation>,
977 pub query: Option<Operation>,
980 #[serde(rename = "additionalOperations", default)]
984 pub additional_operations: Option<BTreeMap<String, Operation>>,
985 pub parameters: Option<Vec<Parameter>>,
986 #[serde(default)]
987 pub servers: Option<Vec<Server>>,
988 #[serde(rename = "$ref", default)]
989 pub reference: Option<String>,
990 #[serde(flatten, default)]
991 pub extensions: Extensions,
992}
993
994impl PathItem {
995 pub fn operations(&self) -> Vec<(&str, &Operation)> {
998 let mut ops = Vec::new();
999 if let Some(ref op) = self.get {
1000 ops.push(("get", op));
1001 }
1002 if let Some(ref op) = self.put {
1003 ops.push(("put", op));
1004 }
1005 if let Some(ref op) = self.post {
1006 ops.push(("post", op));
1007 }
1008 if let Some(ref op) = self.delete {
1009 ops.push(("delete", op));
1010 }
1011 if let Some(ref op) = self.options {
1012 ops.push(("options", op));
1013 }
1014 if let Some(ref op) = self.head {
1015 ops.push(("head", op));
1016 }
1017 if let Some(ref op) = self.patch {
1018 ops.push(("patch", op));
1019 }
1020 if let Some(ref op) = self.trace {
1021 ops.push(("trace", op));
1022 }
1023 if let Some(ref op) = self.query {
1024 ops.push(("query", op));
1025 }
1026 if let Some(map) = &self.additional_operations {
1027 for (verb, op) in map {
1028 ops.push((verb.as_str(), op));
1029 }
1030 }
1031 ops
1032 }
1033}
1034
1035#[derive(Debug, Clone, Deserialize, Serialize)]
1037pub struct Operation {
1038 #[serde(rename = "operationId", default)]
1039 pub operation_id: Option<String>,
1040 #[serde(default)]
1041 pub summary: Option<String>,
1042 #[serde(default)]
1043 pub description: Option<String>,
1044 #[serde(default)]
1045 pub tags: Option<Vec<String>>,
1046 #[serde(default)]
1047 pub deprecated: Option<bool>,
1048 pub parameters: Option<Vec<Parameter>>,
1049 #[serde(rename = "requestBody")]
1050 pub request_body: Option<RequestBody>,
1051 pub responses: Option<BTreeMap<String, Response>>,
1052 #[serde(default)]
1053 pub callbacks: Option<BTreeMap<String, Callback>>,
1054 #[serde(default)]
1055 pub security: Option<Vec<BTreeMap<String, Vec<String>>>>,
1056 #[serde(default)]
1057 pub servers: Option<Vec<Server>>,
1058 #[serde(rename = "externalDocs", default)]
1059 pub external_docs: Option<ExternalDocs>,
1060 #[serde(flatten, default)]
1061 pub extensions: Extensions,
1062}
1063
1064#[derive(Debug, Clone, Deserialize, Serialize)]
1066pub struct Parameter {
1067 #[serde(default)]
1068 pub name: Option<String>,
1069 #[serde(rename = "in", default)]
1070 pub location: Option<String>,
1071 #[serde(default)]
1072 pub required: Option<bool>,
1073 #[serde(default)]
1074 pub deprecated: Option<bool>,
1075 #[serde(rename = "allowEmptyValue", default)]
1076 pub allow_empty_value: Option<bool>,
1077 #[serde(default)]
1078 pub style: Option<String>,
1079 #[serde(default)]
1080 pub explode: Option<bool>,
1081 #[serde(rename = "allowReserved", default)]
1082 pub allow_reserved: Option<bool>,
1083 #[serde(default)]
1084 pub schema: Option<Schema>,
1085 #[serde(default)]
1086 pub content: Option<BTreeMap<String, MediaType>>,
1087 #[serde(default)]
1088 pub example: Option<Value>,
1089 #[serde(default)]
1090 pub examples: Option<BTreeMap<String, Example>>,
1091 #[serde(default)]
1092 pub description: Option<String>,
1093 #[serde(rename = "$ref", default)]
1094 pub reference: Option<String>,
1095 #[serde(flatten, default)]
1096 pub extensions: Extensions,
1097}
1098
1099#[derive(Debug, Clone, Deserialize, Serialize)]
1101pub struct RequestBody {
1102 pub content: Option<BTreeMap<String, MediaType>>,
1103 #[serde(default)]
1104 pub description: Option<String>,
1105 #[serde(default)]
1106 pub required: Option<bool>,
1107 #[serde(rename = "$ref", default)]
1108 pub reference: Option<String>,
1109 #[serde(flatten, default)]
1110 pub extensions: Extensions,
1111}
1112
1113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1119#[serde(rename_all = "snake_case")]
1120pub enum ResponseMediaKind {
1121 Json,
1122 EventStream,
1123 Text,
1124 Binary,
1125 Unsupported,
1126}
1127
1128pub fn media_type_essence(content_type: &str) -> &str {
1134 content_type
1135 .split(';')
1136 .next()
1137 .unwrap_or(content_type)
1138 .trim()
1139}
1140
1141pub fn is_json_media_type(ct: &str) -> bool {
1149 let essence = media_type_essence(ct).to_ascii_lowercase();
1150 if essence == "application/json" {
1151 return true;
1152 }
1153 if let Some(subtype) = essence.strip_prefix("application/") {
1154 return subtype.ends_with("+json");
1155 }
1156 false
1157}
1158
1159pub fn is_form_urlencoded_media_type(ct: &str) -> bool {
1162 let essence = media_type_essence(ct).to_ascii_lowercase();
1163 essence == "application/x-www-form-urlencoded"
1164}
1165
1166pub fn is_event_stream_media_type(ct: &str) -> bool {
1172 media_type_essence(ct).eq_ignore_ascii_case("text/event-stream")
1173}
1174
1175pub fn is_text_media_type(ct: &str) -> bool {
1183 let Some((top_level, subtype)) = media_type_essence(ct).split_once('/') else {
1184 return false;
1185 };
1186 if top_level.eq_ignore_ascii_case("text")
1187 && !subtype.is_empty()
1188 && !is_event_stream_media_type(ct)
1189 {
1190 return true;
1191 }
1192 top_level.eq_ignore_ascii_case("application")
1193 && (subtype.eq_ignore_ascii_case("xml")
1194 || subtype.to_ascii_lowercase().ends_with("+xml")
1195 || subtype.eq_ignore_ascii_case("jwt"))
1198}
1199
1200pub fn is_wildcard_media_type(ct: &str) -> bool {
1206 let Some((top_level, subtype)) = media_type_essence(ct).split_once('/') else {
1207 return false;
1208 };
1209 !top_level.is_empty() && subtype == "*"
1210}
1211
1212fn schema_has_binary_format(schema: Option<&Schema>) -> bool {
1213 schema.is_some_and(|schema| {
1214 schema
1215 .details()
1216 .format
1217 .as_deref()
1218 .is_some_and(|format| format.eq_ignore_ascii_case("binary"))
1219 })
1220}
1221
1222pub fn is_binary_media_type(ct: &str, schema: Option<&Schema>) -> bool {
1230 if schema_has_binary_format(schema) {
1231 return true;
1232 }
1233
1234 let essence = media_type_essence(ct);
1235 let Some((top_level, _)) = essence.split_once('/') else {
1236 return false;
1237 };
1238 if top_level.eq_ignore_ascii_case("image")
1239 || top_level.eq_ignore_ascii_case("audio")
1240 || top_level.eq_ignore_ascii_case("video")
1241 {
1242 return true;
1243 }
1244 if essence.eq_ignore_ascii_case("application/octet-stream")
1245 || essence.eq_ignore_ascii_case("application/zip")
1246 || essence.eq_ignore_ascii_case("application/pdf")
1247 {
1248 return true;
1249 }
1250
1251 !top_level.eq_ignore_ascii_case("text") && is_wildcard_media_type(ct)
1252}
1253
1254pub fn classify_response_media_type(ct: &str, schema: Option<&Schema>) -> ResponseMediaKind {
1259 if is_json_media_type(ct) {
1260 ResponseMediaKind::Json
1261 } else if is_event_stream_media_type(ct) {
1262 ResponseMediaKind::EventStream
1263 } else if schema_has_binary_format(schema) {
1264 ResponseMediaKind::Binary
1265 } else if is_text_media_type(ct) {
1266 if is_wildcard_media_type(ct) {
1267 ResponseMediaKind::Unsupported
1268 } else {
1269 ResponseMediaKind::Text
1270 }
1271 } else if is_binary_media_type(ct, schema) {
1272 ResponseMediaKind::Binary
1273 } else {
1274 ResponseMediaKind::Unsupported
1275 }
1276}
1277
1278fn find_json_content(content: &BTreeMap<String, MediaType>) -> Option<(&str, &MediaType)> {
1279 if let Some(mt) = content
1280 .get("application/json")
1281 .filter(|media_type| media_type.schema.is_some())
1282 {
1283 return Some(("application/json", mt));
1284 }
1285 content
1286 .iter()
1287 .find(|(ct, media_type)| is_json_media_type(ct) && media_type.schema.is_some())
1288 .map(|(ct, mt)| (ct.as_str(), mt))
1289 .or_else(|| {
1290 content
1291 .get("application/json")
1292 .map(|media_type| ("application/json", media_type))
1293 })
1294 .or_else(|| {
1295 content
1296 .iter()
1297 .find(|(ct, _)| is_json_media_type(ct))
1298 .map(|(ct, mt)| (ct.as_str(), mt))
1299 })
1300}
1301
1302impl RequestBody {
1303 pub fn json_schema(&self) -> Option<&Schema> {
1309 self.content
1310 .as_ref()
1311 .and_then(find_json_content)
1312 .and_then(|(_, media_type)| media_type.schema.as_ref())
1313 }
1314
1315 pub fn best_content(&self) -> Option<(&str, Option<&Schema>)> {
1317 let content = self.content.as_ref()?;
1318
1319 if let Some((ct, media_type)) = find_json_content(content) {
1320 return Some((ct, media_type.schema.as_ref()));
1321 }
1322
1323 const PRIORITY: &[&str] = &[
1324 "application/x-www-form-urlencoded",
1325 "multipart/form-data",
1326 "application/octet-stream",
1327 "text/plain",
1328 ];
1329 for preferred_essence in PRIORITY {
1330 if let Some((ct, media_type)) = content
1331 .iter()
1332 .find(|(ct, _)| media_type_essence(ct).eq_ignore_ascii_case(preferred_essence))
1333 {
1334 return Some((ct.as_str(), media_type.schema.as_ref()));
1335 }
1336 }
1337 if let Some((ct, media_type)) = content.iter().find(|(ct, _)| is_text_media_type(ct)) {
1340 return Some((ct.as_str(), media_type.schema.as_ref()));
1341 }
1342 content
1343 .iter()
1344 .find(|(ct, media_type)| {
1350 !is_wildcard_media_type(ct) && is_binary_media_type(ct, media_type.schema.as_ref())
1351 })
1352 .map(|(ct, media_type)| (ct.as_str(), media_type.schema.as_ref()))
1353 }
1354}
1355
1356#[derive(Debug, Clone, Deserialize, Serialize)]
1358pub struct Response {
1359 #[serde(default)]
1360 pub description: Option<String>,
1361 #[serde(default)]
1362 pub headers: Option<BTreeMap<String, Header>>,
1363 #[serde(default)]
1364 pub content: Option<BTreeMap<String, MediaType>>,
1365 #[serde(default)]
1366 pub links: Option<Value>,
1367 #[serde(rename = "$ref", default)]
1368 pub reference: Option<String>,
1369 #[serde(flatten, default)]
1370 pub extensions: Extensions,
1371}
1372
1373impl Response {
1374 pub fn json_schema(&self) -> Option<&Schema> {
1381 self.content
1382 .as_ref()
1383 .and_then(find_json_content)
1384 .and_then(|(_, media_type)| media_type.schema.as_ref())
1385 }
1386
1387 pub fn json_content(&self) -> Option<(&str, &Schema)> {
1389 self.content
1390 .as_ref()
1391 .and_then(find_json_content)
1392 .and_then(|(content_type, media_type)| {
1393 media_type
1394 .schema
1395 .as_ref()
1396 .map(|schema| (content_type, schema))
1397 })
1398 }
1399}
1400
1401#[derive(Debug, Clone, Deserialize, Serialize)]
1403pub struct MediaType {
1404 #[serde(default)]
1405 pub schema: Option<Schema>,
1406 #[serde(default)]
1407 pub example: Option<Value>,
1408 #[serde(default)]
1409 pub examples: Option<BTreeMap<String, Example>>,
1410 #[serde(default)]
1411 pub encoding: Option<BTreeMap<String, Encoding>>,
1412 #[serde(rename = "itemSchema", default)]
1415 pub item_schema: Option<Schema>,
1416 #[serde(rename = "prefixEncoding", default)]
1419 pub prefix_encoding: Option<Vec<Encoding>>,
1420 #[serde(rename = "itemEncoding", default)]
1423 pub item_encoding: Option<Encoding>,
1424 #[serde(rename = "$ref", default)]
1425 pub reference: Option<String>,
1426 #[serde(flatten, default)]
1427 pub extensions: Extensions,
1428}
1429
1430#[cfg(test)]
1431#[allow(clippy::unwrap_used, clippy::expect_used)]
1432mod tests {
1433 use super::*;
1434 use serde_json::json;
1435
1436 #[test]
1437 fn paths_map_skips_extension_scalars() {
1438 let spec: OpenApiSpec = serde_json::from_value(json!({
1441 "openapi": "3.0.0",
1442 "info": { "title": "lenient paths", "version": "1" },
1443 "paths": {
1444 "x-codegen-contextRoot": "/apis/registry/v2",
1445 "/items": {
1446 "get": {
1447 "operationId": "listItems",
1448 "responses": { "204": { "description": "ok" } }
1449 }
1450 }
1451 }
1452 }))
1453 .unwrap();
1454 let paths = spec.paths.unwrap();
1455 assert!(paths.contains_key("/items"));
1456 assert!(!paths.contains_key("x-codegen-contextRoot"));
1457 }
1458
1459 #[test]
1460 fn test_parse_simple_object_schema() {
1461 let schema_json = json!({
1462 "type": "object",
1463 "properties": {
1464 "name": {
1465 "type": "string",
1466 "description": "User name"
1467 },
1468 "age": {
1469 "type": "integer"
1470 }
1471 },
1472 "required": ["name"]
1473 });
1474
1475 let schema: Schema = serde_json::from_value(schema_json).unwrap();
1476
1477 match schema {
1478 Schema::Typed {
1479 schema_type: SchemaType::Object,
1480 details,
1481 } => {
1482 assert!(details.properties.is_some());
1483 assert_eq!(details.required, Some(vec!["name".to_string()]));
1484 assert!(details.is_field_required("name"));
1485 assert!(!details.is_field_required("age"));
1486 }
1487 _ => panic!("Expected object schema"),
1488 }
1489 }
1490
1491 #[test]
1492 fn test_parse_string_enum() {
1493 let schema_json = json!({
1494 "type": "string",
1495 "enum": ["active", "inactive", "pending"],
1496 "description": "User status"
1497 });
1498
1499 let schema: Schema = serde_json::from_value(schema_json).unwrap();
1500
1501 match schema {
1502 Schema::Typed {
1503 schema_type: SchemaType::String,
1504 details,
1505 } => {
1506 assert!(details.is_string_enum());
1507 let values = details.string_enum_values().unwrap();
1508 assert_eq!(values, vec!["active", "inactive", "pending"]);
1509 }
1510 _ => panic!("Expected string enum schema"),
1511 }
1512 }
1513
1514 #[test]
1515 fn test_parse_reference_schema() {
1516 let schema_json = json!({
1517 "$ref": "#/components/schemas/User"
1518 });
1519
1520 let schema: Schema = serde_json::from_value(schema_json).unwrap();
1521
1522 assert!(schema.is_reference());
1523 assert_eq!(schema.reference(), Some("#/components/schemas/User"));
1524 }
1525
1526 #[test]
1527 fn test_parse_discriminated_union() {
1528 let schema_json = json!({
1529 "oneOf": [
1530 {"$ref": "#/components/schemas/Dog"},
1531 {"$ref": "#/components/schemas/Cat"}
1532 ],
1533 "discriminator": {
1534 "propertyName": "petType"
1535 }
1536 });
1537
1538 let schema: Schema = serde_json::from_value(schema_json).unwrap();
1539
1540 assert!(schema.is_discriminated_union());
1541 let discriminator = schema.discriminator().unwrap();
1542 assert_eq!(discriminator.property_name, "petType");
1543 }
1544
1545 #[test]
1546 fn test_parse_nullable_pattern() {
1547 let schema_json = json!({
1548 "anyOf": [
1549 {"$ref": "#/components/schemas/User"},
1550 {"type": "null"}
1551 ]
1552 });
1553
1554 let schema: Schema = serde_json::from_value(schema_json).unwrap();
1555
1556 assert!(schema.is_nullable_pattern());
1557 let non_null = schema.non_null_variant().unwrap();
1558 assert!(non_null.is_reference());
1559 }
1560
1561 #[test]
1562 fn is_json_media_type_accepts_canonical_and_structured_suffix() {
1563 assert!(is_json_media_type("application/json"));
1565 assert!(is_json_media_type("application/json; charset=utf-8"));
1567 assert!(is_json_media_type("APPLICATION/JSON"));
1568 assert!(is_json_media_type("application/vnd.api+json"));
1570 assert!(is_json_media_type("application/hal+json"));
1571 assert!(is_json_media_type("application/problem+json"));
1572 assert!(is_json_media_type("application/ld+json"));
1573 assert!(is_json_media_type(
1574 "application/vnd.api+json; charset=utf-8"
1575 ));
1576 assert!(!is_json_media_type("application/xml"));
1578 assert!(!is_json_media_type("application/x-www-form-urlencoded"));
1579 assert!(!is_json_media_type("text/plain"));
1580 assert!(!is_json_media_type("application/jsonbutnotreally"));
1581 assert!(!is_json_media_type("text/something+json"));
1583 }
1584
1585 #[test]
1586 fn response_media_helpers_normalize_parameters_and_case() {
1587 assert_eq!(
1588 media_type_essence(" Text/Plain ; charset=utf-8 "),
1589 "Text/Plain"
1590 );
1591 assert!(is_text_media_type("TEXT/HTML; charset=UTF-8"));
1592 assert!(!is_text_media_type("Text/Event-Stream; charset=utf-8"));
1593 assert!(is_wildcard_media_type("*/*; q=0.8"));
1594 assert!(is_wildcard_media_type("IMAGE/*"));
1595 assert!(is_wildcard_media_type("text/*"));
1596 assert!(!is_wildcard_media_type("image/*+json"));
1597 assert!(!is_wildcard_media_type("application/json"));
1598 }
1599
1600 #[test]
1601 fn response_media_classifier_keeps_json_sse_and_text_distinct() {
1602 for media_type in [
1603 "application/json",
1604 "APPLICATION/PROBLEM+JSON; charset=utf-8",
1605 ] {
1606 assert_eq!(
1607 classify_response_media_type(media_type, None),
1608 ResponseMediaKind::Json,
1609 "{media_type}"
1610 );
1611 }
1612
1613 assert_eq!(
1614 classify_response_media_type("Text/Event-Stream; charset=utf-8", None),
1615 ResponseMediaKind::EventStream
1616 );
1617 for media_type in ["text/plain", "TEXT/HTML; charset=UTF-8"] {
1618 assert_eq!(
1619 classify_response_media_type(media_type, None),
1620 ResponseMediaKind::Text,
1621 "{media_type}"
1622 );
1623 }
1624 assert_eq!(
1625 classify_response_media_type("text/event-streaming", None),
1626 ResponseMediaKind::Text
1627 );
1628 assert_eq!(
1629 classify_response_media_type("text/*", None),
1630 ResponseMediaKind::Unsupported,
1631 "a media range is not a valid concrete response Content-Type"
1632 );
1633 }
1634
1635 #[test]
1636 fn response_json_content_skips_schema_less_canonical_entry() {
1637 let response: Response = serde_json::from_value(json!({
1638 "description": "mixed JSON",
1639 "content": {
1640 "application/json": {},
1641 "application/vnd.example+json": {
1642 "schema": { "type": "string" }
1643 }
1644 }
1645 }))
1646 .unwrap();
1647
1648 let (media_type, schema) = response.json_content().expect("schema-bearing JSON");
1649 assert_eq!(media_type, "application/vnd.example+json");
1650 assert!(matches!(schema.schema_type(), Some(SchemaType::String)));
1651 }
1652
1653 #[test]
1654 fn response_media_classifier_recognizes_binary_formats_and_wildcards() {
1655 for media_type in [
1656 "image/png",
1657 "IMAGE/*; version=1",
1658 "audio/mpeg",
1659 "video/mp4",
1660 "application/octet-stream",
1661 "APPLICATION/ZIP; version=1",
1662 "application/*",
1663 "*/*",
1664 ] {
1665 assert_eq!(
1666 classify_response_media_type(media_type, None),
1667 ResponseMediaKind::Binary,
1668 "{media_type}"
1669 );
1670 }
1671
1672 let binary_schema: Schema = serde_json::from_value(json!({
1673 "type": "string",
1674 "format": "BINARY"
1675 }))
1676 .unwrap();
1677 assert_eq!(
1678 classify_response_media_type("application/x-custom", Some(&binary_schema)),
1679 ResponseMediaKind::Binary
1680 );
1681 assert_eq!(
1682 classify_response_media_type("text/plain", Some(&binary_schema)),
1683 ResponseMediaKind::Binary,
1684 "an explicit binary schema must prevent UTF-8 decoding"
1685 );
1686 assert!(is_binary_media_type(
1687 "application/x-custom",
1688 Some(&binary_schema)
1689 ));
1690 }
1691
1692 #[test]
1693 fn response_media_classifier_leaves_ambiguous_formats_unsupported() {
1694 let string_schema: Schema = serde_json::from_value(json!({ "type": "string" })).unwrap();
1695 for media_type in ["application/x-unknown", "not-a-media-type"] {
1696 assert_eq!(
1697 classify_response_media_type(media_type, Some(&string_schema)),
1698 ResponseMediaKind::Unsupported,
1699 "{media_type}"
1700 );
1701 }
1702 assert_eq!(
1706 classify_response_media_type("application/pdf", Some(&string_schema)),
1707 ResponseMediaKind::Binary
1708 );
1709 assert_eq!(
1710 classify_response_media_type("application/xml", Some(&string_schema)),
1711 ResponseMediaKind::Text
1712 );
1713 assert_eq!(
1714 classify_response_media_type("application/atom+xml", Some(&string_schema)),
1715 ResponseMediaKind::Text
1716 );
1717 assert_eq!(
1719 classify_response_media_type("application/jwt", Some(&string_schema)),
1720 ResponseMediaKind::Text
1721 );
1722 assert!(!is_binary_media_type("text/plain", None));
1723 }
1724
1725 #[test]
1726 fn request_body_json_schema_finds_vnd_api_plus_json() {
1727 let body_json = json!({
1730 "required": true,
1731 "content": {
1732 "application/vnd.api+json": {
1733 "schema": {"$ref": "#/components/schemas/create_api_key"}
1734 }
1735 }
1736 });
1737
1738 let body: RequestBody = serde_json::from_value(body_json).unwrap();
1739 let schema = body.json_schema().expect("expected +json schema match");
1740 assert!(schema.is_reference());
1741 }
1742
1743 #[test]
1744 fn request_body_best_content_prefers_canonical_json_over_plus_json() {
1745 let body_json = json!({
1749 "required": true,
1750 "content": {
1751 "application/json": {
1752 "schema": {"$ref": "#/components/schemas/A"}
1753 },
1754 "application/vnd.api+json": {
1755 "schema": {"$ref": "#/components/schemas/B"}
1756 }
1757 }
1758 });
1759
1760 let body: RequestBody = serde_json::from_value(body_json).unwrap();
1761 let (ct, _) = body.best_content().expect("expected best_content");
1762 assert_eq!(ct, "application/json");
1763 }
1764
1765 #[test]
1766 fn request_body_best_content_falls_back_to_plus_json() {
1767 let body_json = json!({
1770 "required": true,
1771 "content": {
1772 "application/vnd.api+json": {
1773 "schema": {"$ref": "#/components/schemas/B"}
1774 }
1775 }
1776 });
1777
1778 let body: RequestBody = serde_json::from_value(body_json).unwrap();
1779 let (ct, _) = body.best_content().expect("expected best_content");
1780 assert_eq!(ct, "application/vnd.api+json");
1781 }
1782
1783 #[test]
1784 fn request_body_best_content_does_not_select_wildcard_media_ranges() {
1785 let body: RequestBody = serde_json::from_value(json!({
1786 "required": true,
1787 "content": {
1788 "image/*": {
1789 "schema": { "type": "string", "format": "binary" }
1790 },
1791 "*/*": {
1792 "schema": { "type": "string", "format": "binary" }
1793 }
1794 }
1795 }))
1796 .unwrap();
1797
1798 assert!(
1799 body.best_content().is_none(),
1800 "request media ranges require a runtime concrete Content-Type"
1801 );
1802 }
1803
1804 #[test]
1805 fn request_body_best_content_matches_parameterized_text_plain_by_essence() {
1806 let body: RequestBody = serde_json::from_value(json!({
1807 "required": true,
1808 "content": {
1809 "Text/Plain; charset=utf-8": {
1810 "schema": { "type": "string" }
1811 }
1812 }
1813 }))
1814 .unwrap();
1815
1816 let (media_type, _) = body.best_content().expect("parameterized text body");
1817 assert_eq!(media_type, "Text/Plain; charset=utf-8");
1818 }
1819
1820 #[test]
1821 fn response_json_schema_finds_vnd_api_plus_json() {
1822 let resp_json = json!({
1825 "description": "OK",
1826 "content": {
1827 "application/vnd.api+json": {
1828 "schema": {"$ref": "#/components/schemas/api_keys"}
1829 }
1830 }
1831 });
1832
1833 let resp: Response = serde_json::from_value(resp_json).unwrap();
1834 let schema = resp.json_schema().expect("expected +json schema match");
1835 assert!(schema.is_reference());
1836 }
1837}