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 match self {
294 Schema::AnyOf { any_of, .. } => {
295 any_of.len() == 2
296 && any_of
297 .iter()
298 .any(|s| matches!(s.schema_type(), Some(SchemaType::Null)))
299 }
300 _ => false,
301 }
302 }
303
304 pub fn non_null_variant(&self) -> Option<&Schema> {
306 if self.is_nullable_pattern() {
307 if let Schema::AnyOf { any_of, .. } = self {
308 return any_of
309 .iter()
310 .find(|s| !matches!(s.schema_type(), Some(SchemaType::Null)));
311 }
312 }
313 None
314 }
315
316 pub fn inferred_type(&self) -> Option<SchemaType> {
318 match self {
319 Schema::Typed { schema_type, .. } => Some(schema_type.clone()),
320 Schema::Untyped { details } => {
321 if details.properties.is_some() {
323 Some(SchemaType::Object)
324 } else if details.items.is_some() {
325 Some(SchemaType::Array)
326 } else if details.enum_values.is_some() {
327 Some(SchemaType::String) } else {
329 None
330 }
331 }
332 _ => None,
333 }
334 }
335}
336
337impl SchemaDetails {
338 pub fn is_nullable(&self) -> bool {
340 self.nullable.unwrap_or(false)
341 }
342
343 pub fn is_string_enum(&self) -> bool {
345 self.enum_values.is_some()
346 }
347
348 pub fn string_enum_values(&self) -> Option<Vec<String>> {
350 self.enum_values.as_ref().map(|values| {
351 values
352 .iter()
353 .filter_map(|v| v.as_str())
354 .map(|s| s.to_string())
355 .collect()
356 })
357 }
358
359 pub fn is_field_required(&self, field_name: &str) -> bool {
361 self.required
362 .as_ref()
363 .map(|req| req.contains(&field_name.to_string()))
364 .unwrap_or(false)
365 }
366}
367
368#[derive(Debug, Clone, Deserialize, Serialize)]
370pub struct PathItem {
371 #[serde(rename = "get")]
372 pub get: Option<Operation>,
373 #[serde(rename = "put")]
374 pub put: Option<Operation>,
375 #[serde(rename = "post")]
376 pub post: Option<Operation>,
377 #[serde(rename = "delete")]
378 pub delete: Option<Operation>,
379 #[serde(rename = "options")]
380 pub options: Option<Operation>,
381 #[serde(rename = "head")]
382 pub head: Option<Operation>,
383 #[serde(rename = "patch")]
384 pub patch: Option<Operation>,
385 #[serde(rename = "trace")]
386 pub trace: Option<Operation>,
387 pub parameters: Option<Vec<Parameter>>,
388 #[serde(flatten)]
389 pub extra: BTreeMap<String, Value>,
390}
391
392impl PathItem {
393 pub fn operations(&self) -> Vec<(&str, &Operation)> {
395 let mut ops = Vec::new();
396 if let Some(ref op) = self.get {
397 ops.push(("get", op));
398 }
399 if let Some(ref op) = self.put {
400 ops.push(("put", op));
401 }
402 if let Some(ref op) = self.post {
403 ops.push(("post", op));
404 }
405 if let Some(ref op) = self.delete {
406 ops.push(("delete", op));
407 }
408 if let Some(ref op) = self.options {
409 ops.push(("options", op));
410 }
411 if let Some(ref op) = self.head {
412 ops.push(("head", op));
413 }
414 if let Some(ref op) = self.patch {
415 ops.push(("patch", op));
416 }
417 if let Some(ref op) = self.trace {
418 ops.push(("trace", op));
419 }
420 ops
421 }
422}
423
424#[derive(Debug, Clone, Deserialize, Serialize)]
426pub struct Operation {
427 #[serde(rename = "operationId")]
428 pub operation_id: Option<String>,
429 pub summary: Option<String>,
430 pub description: Option<String>,
431 pub parameters: Option<Vec<Parameter>>,
432 #[serde(rename = "requestBody")]
433 pub request_body: Option<RequestBody>,
434 pub responses: Option<BTreeMap<String, Response>>,
435 #[serde(flatten)]
436 pub extra: BTreeMap<String, Value>,
437}
438
439#[derive(Debug, Clone, Deserialize, Serialize)]
441pub struct Parameter {
442 pub name: Option<String>,
443 #[serde(rename = "in")]
444 pub location: Option<String>,
445 pub required: Option<bool>,
446 pub schema: Option<Schema>,
447 pub description: Option<String>,
448 #[serde(flatten)]
449 pub extra: BTreeMap<String, Value>,
450}
451
452#[derive(Debug, Clone, Deserialize, Serialize)]
454pub struct RequestBody {
455 pub content: Option<BTreeMap<String, MediaType>>,
456 pub description: Option<String>,
457 pub required: Option<bool>,
458 #[serde(flatten)]
459 pub extra: BTreeMap<String, Value>,
460}
461
462pub fn is_json_media_type(ct: &str) -> bool {
470 let essence = ct
471 .split(';')
472 .next()
473 .unwrap_or(ct)
474 .trim()
475 .to_ascii_lowercase();
476 if essence == "application/json" {
477 return true;
478 }
479 if let Some(subtype) = essence.strip_prefix("application/") {
480 return subtype.ends_with("+json");
481 }
482 false
483}
484
485pub fn is_form_urlencoded_media_type(ct: &str) -> bool {
488 let essence = ct
489 .split(';')
490 .next()
491 .unwrap_or(ct)
492 .trim()
493 .to_ascii_lowercase();
494 essence == "application/x-www-form-urlencoded"
495}
496
497fn find_json_content(content: &BTreeMap<String, MediaType>) -> Option<(&str, &MediaType)> {
498 if let Some(mt) = content.get("application/json") {
499 return Some(("application/json", mt));
500 }
501 content
502 .iter()
503 .find(|(ct, _)| is_json_media_type(ct))
504 .map(|(ct, mt)| (ct.as_str(), mt))
505}
506
507impl RequestBody {
508 pub fn json_schema(&self) -> Option<&Schema> {
514 self.content
515 .as_ref()
516 .and_then(find_json_content)
517 .and_then(|(_, media_type)| media_type.schema.as_ref())
518 }
519
520 pub fn best_content(&self) -> Option<(&str, Option<&Schema>)> {
522 let content = self.content.as_ref()?;
523
524 if let Some((ct, media_type)) = find_json_content(content) {
525 return Some((ct, media_type.schema.as_ref()));
526 }
527
528 const PRIORITY: &[&str] = &[
529 "application/x-www-form-urlencoded",
530 "multipart/form-data",
531 "application/octet-stream",
532 "text/plain",
533 ];
534 for ct in PRIORITY {
535 if let Some(media_type) = content.get(*ct) {
536 return Some((*ct, media_type.schema.as_ref()));
537 }
538 }
539 None
540 }
541}
542
543#[derive(Debug, Clone, Deserialize, Serialize)]
545pub struct Response {
546 pub description: Option<String>,
547 pub content: Option<BTreeMap<String, MediaType>>,
548 #[serde(flatten)]
549 pub extra: BTreeMap<String, Value>,
550}
551
552impl Response {
553 pub fn json_schema(&self) -> Option<&Schema> {
560 self.content
561 .as_ref()
562 .and_then(find_json_content)
563 .and_then(|(_, media_type)| media_type.schema.as_ref())
564 }
565}
566
567#[derive(Debug, Clone, Deserialize, Serialize)]
569pub struct MediaType {
570 pub schema: Option<Schema>,
571 #[serde(flatten)]
572 pub extra: BTreeMap<String, Value>,
573}
574
575#[cfg(test)]
576#[allow(clippy::unwrap_used, clippy::expect_used)]
577mod tests {
578 use super::*;
579 use serde_json::json;
580
581 #[test]
582 fn test_parse_simple_object_schema() {
583 let schema_json = json!({
584 "type": "object",
585 "properties": {
586 "name": {
587 "type": "string",
588 "description": "User name"
589 },
590 "age": {
591 "type": "integer"
592 }
593 },
594 "required": ["name"]
595 });
596
597 let schema: Schema = serde_json::from_value(schema_json).unwrap();
598
599 match schema {
600 Schema::Typed {
601 schema_type: SchemaType::Object,
602 details,
603 } => {
604 assert!(details.properties.is_some());
605 assert_eq!(details.required, Some(vec!["name".to_string()]));
606 assert!(details.is_field_required("name"));
607 assert!(!details.is_field_required("age"));
608 }
609 _ => panic!("Expected object schema"),
610 }
611 }
612
613 #[test]
614 fn test_parse_string_enum() {
615 let schema_json = json!({
616 "type": "string",
617 "enum": ["active", "inactive", "pending"],
618 "description": "User status"
619 });
620
621 let schema: Schema = serde_json::from_value(schema_json).unwrap();
622
623 match schema {
624 Schema::Typed {
625 schema_type: SchemaType::String,
626 details,
627 } => {
628 assert!(details.is_string_enum());
629 let values = details.string_enum_values().unwrap();
630 assert_eq!(values, vec!["active", "inactive", "pending"]);
631 }
632 _ => panic!("Expected string enum schema"),
633 }
634 }
635
636 #[test]
637 fn test_parse_reference_schema() {
638 let schema_json = json!({
639 "$ref": "#/components/schemas/User"
640 });
641
642 let schema: Schema = serde_json::from_value(schema_json).unwrap();
643
644 assert!(schema.is_reference());
645 assert_eq!(schema.reference(), Some("#/components/schemas/User"));
646 }
647
648 #[test]
649 fn test_parse_discriminated_union() {
650 let schema_json = json!({
651 "oneOf": [
652 {"$ref": "#/components/schemas/Dog"},
653 {"$ref": "#/components/schemas/Cat"}
654 ],
655 "discriminator": {
656 "propertyName": "petType"
657 }
658 });
659
660 let schema: Schema = serde_json::from_value(schema_json).unwrap();
661
662 assert!(schema.is_discriminated_union());
663 let discriminator = schema.discriminator().unwrap();
664 assert_eq!(discriminator.property_name, "petType");
665 }
666
667 #[test]
668 fn test_parse_nullable_pattern() {
669 let schema_json = json!({
670 "anyOf": [
671 {"$ref": "#/components/schemas/User"},
672 {"type": "null"}
673 ]
674 });
675
676 let schema: Schema = serde_json::from_value(schema_json).unwrap();
677
678 assert!(schema.is_nullable_pattern());
679 let non_null = schema.non_null_variant().unwrap();
680 assert!(non_null.is_reference());
681 }
682
683 #[test]
684 fn is_json_media_type_accepts_canonical_and_structured_suffix() {
685 assert!(is_json_media_type("application/json"));
687 assert!(is_json_media_type("application/json; charset=utf-8"));
689 assert!(is_json_media_type("APPLICATION/JSON"));
690 assert!(is_json_media_type("application/vnd.api+json"));
692 assert!(is_json_media_type("application/hal+json"));
693 assert!(is_json_media_type("application/problem+json"));
694 assert!(is_json_media_type("application/ld+json"));
695 assert!(is_json_media_type(
696 "application/vnd.api+json; charset=utf-8"
697 ));
698 assert!(!is_json_media_type("application/xml"));
700 assert!(!is_json_media_type("application/x-www-form-urlencoded"));
701 assert!(!is_json_media_type("text/plain"));
702 assert!(!is_json_media_type("application/jsonbutnotreally"));
703 assert!(!is_json_media_type("text/something+json"));
705 }
706
707 #[test]
708 fn request_body_json_schema_finds_vnd_api_plus_json() {
709 let body_json = json!({
712 "required": true,
713 "content": {
714 "application/vnd.api+json": {
715 "schema": {"$ref": "#/components/schemas/create_api_key"}
716 }
717 }
718 });
719
720 let body: RequestBody = serde_json::from_value(body_json).unwrap();
721 let schema = body.json_schema().expect("expected +json schema match");
722 assert!(schema.is_reference());
723 }
724
725 #[test]
726 fn request_body_best_content_prefers_canonical_json_over_plus_json() {
727 let body_json = json!({
731 "required": true,
732 "content": {
733 "application/json": {
734 "schema": {"$ref": "#/components/schemas/A"}
735 },
736 "application/vnd.api+json": {
737 "schema": {"$ref": "#/components/schemas/B"}
738 }
739 }
740 });
741
742 let body: RequestBody = serde_json::from_value(body_json).unwrap();
743 let (ct, _) = body.best_content().expect("expected best_content");
744 assert_eq!(ct, "application/json");
745 }
746
747 #[test]
748 fn request_body_best_content_falls_back_to_plus_json() {
749 let body_json = json!({
752 "required": true,
753 "content": {
754 "application/vnd.api+json": {
755 "schema": {"$ref": "#/components/schemas/B"}
756 }
757 }
758 });
759
760 let body: RequestBody = serde_json::from_value(body_json).unwrap();
761 let (ct, _) = body.best_content().expect("expected best_content");
762 assert_eq!(ct, "application/vnd.api+json");
763 }
764
765 #[test]
766 fn response_json_schema_finds_vnd_api_plus_json() {
767 let resp_json = json!({
770 "description": "OK",
771 "content": {
772 "application/vnd.api+json": {
773 "schema": {"$ref": "#/components/schemas/api_keys"}
774 }
775 }
776 });
777
778 let resp: Response = serde_json::from_value(resp_json).unwrap();
779 let schema = resp.json_schema().expect("expected +json schema match");
780 assert!(schema.is_reference());
781 }
782}