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