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