1use serde::{
4 Deserialize, Deserializer, Serialize,
5 de::{DeserializeSeed, Error as DeError, IntoDeserializer, MapAccess, SeqAccess, Visitor},
6};
7use serde_json::{Map, Number, Value, json};
8use std::{
9 cmp::Ordering,
10 collections::{BTreeMap, BTreeSet},
11};
12
13#[derive(Clone, Debug, Serialize, Deserialize)]
15pub struct OpenApiSchema {
16 #[serde(rename = "$ref", skip_serializing_if = "Option::is_none")]
17 pub(super) schema_ref: Option<String>,
18
19 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
20 pub(super) schema_type: Option<String>,
21
22 #[serde(skip_serializing_if = "Option::is_none")]
23 pub(super) format: Option<String>,
24
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub(super) title: Option<String>,
27
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub(super) properties: Option<BTreeMap<String, OpenApiSchema>>,
30
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub(super) required: Option<Vec<String>>,
33
34 #[serde(
35 rename = "additionalProperties",
36 skip_serializing_if = "Option::is_none"
37 )]
38 pub(super) additional_properties: Option<bool>,
39
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub(super) items: Option<Box<OpenApiSchema>>,
42
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub(super) nullable: Option<bool>,
45
46 #[serde(rename = "minLength", skip_serializing_if = "Option::is_none")]
47 pub(super) min_length: Option<usize>,
48
49 #[serde(rename = "maxLength", skip_serializing_if = "Option::is_none")]
50 pub(super) max_length: Option<usize>,
51
52 #[serde(rename = "minItems", skip_serializing_if = "Option::is_none")]
53 pub(super) min_items: Option<usize>,
54
55 #[serde(rename = "maxItems", skip_serializing_if = "Option::is_none")]
56 pub(super) max_items: Option<usize>,
57
58 #[serde(rename = "minProperties", skip_serializing_if = "Option::is_none")]
59 pub(super) min_properties: Option<usize>,
60
61 #[serde(rename = "maxProperties", skip_serializing_if = "Option::is_none")]
62 pub(super) max_properties: Option<usize>,
63
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub(super) minimum: Option<Number>,
66
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub(super) maximum: Option<Number>,
69}
70
71#[derive(Clone, Debug, PartialEq)]
73pub struct FieldConstraint {
74 pub field: String,
76
77 pub constraint: SchemaConstraint,
79}
80
81impl FieldConstraint {
82 pub fn new(field: impl Into<String>, constraint: SchemaConstraint) -> Self {
84 Self {
85 field: field.into(),
86 constraint,
87 }
88 }
89}
90
91#[derive(Clone, Debug, PartialEq)]
93#[non_exhaustive]
94pub enum SchemaConstraint {
95 MinSize(usize),
100 MaxSize(usize),
102 Minimum(Number),
104 Maximum(Number),
106 Nested(Vec<FieldConstraint>),
108 Each(Vec<FieldConstraint>),
110}
111
112fn keep_greatest(slot: &mut Option<usize>, value: usize) {
117 *slot = Some(match *slot {
118 Some(current) => current.max(value),
119 None => value,
120 });
121}
122
123fn keep_least(slot: &mut Option<usize>, value: usize) {
125 *slot = Some(match *slot {
126 Some(current) => current.min(value),
127 None => value,
128 });
129}
130
131fn keep_greatest_number(slot: &mut Option<Number>, value: &Number) {
133 let replace = match slot.as_ref() {
134 Some(current) => compare_numbers(value, current) == Some(Ordering::Greater),
135 None => true,
136 };
137 if replace {
138 *slot = Some(value.clone());
139 }
140}
141
142fn keep_least_number(slot: &mut Option<Number>, value: &Number) {
144 let replace = match slot.as_ref() {
145 Some(current) => compare_numbers(value, current) == Some(Ordering::Less),
146 None => true,
147 };
148 if replace {
149 *slot = Some(value.clone());
150 }
151}
152
153fn compare_numbers(left: &Number, right: &Number) -> Option<Ordering> {
155 if let (Some(left), Some(right)) = (left.as_u64(), right.as_u64()) {
156 return Some(left.cmp(&right));
157 }
158 if let (Some(left), Some(right)) = (left.as_i64(), right.as_i64()) {
159 return Some(left.cmp(&right));
160 }
161 left.as_f64()?.partial_cmp(&right.as_f64()?)
162}
163
164const FLOAT_FORMAT: &str = "float";
166
167const DOUBLE_FORMAT: &str = "double";
169
170enum SizeKeyword {
172 Length,
174 Items,
176 Properties,
178}
179
180impl OpenApiSchema {
181 fn empty() -> Self {
183 Self {
184 schema_ref: None,
185 schema_type: None,
186 format: None,
187 title: None,
188 properties: None,
189 required: None,
190 additional_properties: None,
191 items: None,
192 nullable: None,
193 min_length: None,
194 max_length: None,
195 min_items: None,
196 max_items: None,
197 min_properties: None,
198 max_properties: None,
199 minimum: None,
200 maximum: None,
201 }
202 }
203
204 pub fn with_constraint(mut self, constraint: SchemaConstraint) -> Self {
206 self.apply_constraint(&constraint);
207 self
208 }
209
210 pub(super) fn apply_constraint(&mut self, constraint: &SchemaConstraint) {
215 match constraint {
216 SchemaConstraint::MinSize(value) => match self.size_keyword() {
217 SizeKeyword::Items => keep_greatest(&mut self.min_items, *value),
218 SizeKeyword::Properties => keep_greatest(&mut self.min_properties, *value),
219 SizeKeyword::Length => keep_greatest(&mut self.min_length, *value),
220 },
221 SchemaConstraint::MaxSize(value) => match self.size_keyword() {
222 SizeKeyword::Items => keep_least(&mut self.max_items, *value),
223 SizeKeyword::Properties => keep_least(&mut self.max_properties, *value),
224 SizeKeyword::Length => keep_least(&mut self.max_length, *value),
225 },
226 SchemaConstraint::Minimum(value) => {
227 let value = self.narrow(value);
228 keep_greatest_number(&mut self.minimum, &value);
229 }
230 SchemaConstraint::Maximum(value) => {
231 let value = self.narrow(value);
232 keep_least_number(&mut self.maximum, &value);
233 }
234 SchemaConstraint::Nested(fields) => self.apply_field_constraints(fields),
235 SchemaConstraint::Each(fields) => {
236 if let Some(items) = self.items.as_mut() {
237 items.apply_field_constraints(fields);
238 }
239 }
240 }
241 }
242
243 fn narrow(&self, value: &Number) -> Number {
250 let narrowed = match self.format.as_deref() {
254 Some(FLOAT_FORMAT) => value.as_f64().map(|value| f64::from(value as f32)),
255 _ => None,
256 };
257
258 narrowed
259 .and_then(Number::from_f64)
260 .unwrap_or_else(|| value.clone())
261 }
262
263 fn size_keyword(&self) -> SizeKeyword {
268 match self.schema_type.as_deref() {
269 Some("array") => SizeKeyword::Items,
270 Some("object") => SizeKeyword::Properties,
271 _ => SizeKeyword::Length,
272 }
273 }
274
275 pub(super) fn apply_field_constraints(&mut self, constraints: &[FieldConstraint]) {
279 let Some(properties) = self.properties.as_mut() else {
280 return;
281 };
282 for constraint in constraints {
283 if let Some(property) = properties.get_mut(&constraint.field) {
284 property.apply_constraint(&constraint.constraint);
285 }
286 }
287 }
288
289 pub fn object() -> Self {
291 Self {
292 schema_type: Some("object".to_string()),
293 ..Self::empty()
294 }
295 }
296
297 pub fn string() -> Self {
299 Self {
300 schema_type: Some("string".to_string()),
301 ..Self::empty()
302 }
303 }
304
305 pub fn integer() -> Self {
307 Self {
308 schema_type: Some("integer".to_string()),
309 ..Self::empty()
310 }
311 }
312
313 pub fn number() -> Self {
315 Self {
316 schema_type: Some("number".to_string()),
317 ..Self::empty()
318 }
319 }
320
321 pub fn boolean() -> Self {
323 Self {
324 schema_type: Some("boolean".to_string()),
325 ..Self::empty()
326 }
327 }
328
329 pub fn binary() -> Self {
331 Self {
332 schema_type: Some("string".to_string()),
333 format: Some("binary".to_string()),
334 ..Self::empty()
335 }
336 }
337
338 pub fn multipart() -> Self {
340 Self::object()
341 .with_property("file", OpenApiSchema::binary())
342 .with_property("meta", OpenApiSchema::string())
343 .with_required(["file"])
344 }
345
346 pub fn array(items: OpenApiSchema) -> Self {
348 Self {
349 schema_type: Some("array".to_string()),
350 items: Some(Box::new(items)),
351 ..Self::empty()
352 }
353 }
354
355 pub fn reference(name: &str) -> Self {
357 Self {
358 schema_ref: Some(format!("#/components/schemas/{name}")),
359 ..Self::empty()
360 }
361 }
362
363 pub fn from_example(example: &Value) -> Self {
365 match example {
366 Value::Null => OpenApiSchema::object().nullable(),
367 Value::Bool(_) => OpenApiSchema::boolean(),
368 Value::Number(number) => {
369 if number.is_i64() || number.is_u64() {
370 OpenApiSchema::integer()
371 } else {
372 OpenApiSchema::number()
373 }
374 }
375 Value::String(_) => OpenApiSchema::string(),
376 Value::Array(items) => {
377 let item_schema = items
378 .first()
379 .map(OpenApiSchema::from_example)
380 .unwrap_or_else(OpenApiSchema::object);
381 OpenApiSchema::array(item_schema)
382 }
383 Value::Object(map) => {
384 let mut schema = OpenApiSchema::object();
385 let mut required = Vec::new();
386 for (key, value) in map {
387 schema = schema.with_property(key.clone(), OpenApiSchema::from_example(value));
388 required.push(key.clone());
389 }
390 schema.with_required(required)
391 }
392 }
393 }
394
395 pub fn nullable(mut self) -> Self {
397 self.nullable = Some(true);
398 self
399 }
400
401 pub fn with_title(mut self, title: impl Into<String>) -> Self {
403 self.title = Some(title.into());
404 self
405 }
406
407 pub fn with_property(mut self, name: impl Into<String>, schema: OpenApiSchema) -> Self {
409 self.properties
410 .get_or_insert_with(BTreeMap::new)
411 .insert(name.into(), schema);
412 self
413 }
414
415 pub fn with_format(mut self, fmt: impl Into<String>) -> Self {
417 self.format = Some(fmt.into());
418 self
419 }
420
421 pub fn with_required<I, T>(mut self, required: I) -> Self
423 where
424 I: IntoIterator<Item = T>,
425 T: Into<String>,
426 {
427 self.required = Some(required.into_iter().map(Into::into).collect());
428 self
429 }
430
431 pub fn is_ref(&self) -> bool {
433 self.schema_ref.is_some()
434 }
435
436 pub(super) fn collect_component_refs(&self, out: &mut BTreeSet<String>) {
437 if let Some(name) = self.component_ref_name()
438 && out.insert(name.clone())
439 {
440 }
442
443 if let Some(properties) = &self.properties {
444 for schema in properties.values() {
445 schema.collect_component_refs(out);
446 }
447 }
448
449 if let Some(items) = &self.items {
450 items.collect_component_refs(out);
451 }
452 }
453
454 pub(super) fn component_ref_name(&self) -> Option<String> {
455 const PREFIX: &str = "#/components/schemas/";
456 self.schema_ref
457 .as_deref()
458 .and_then(|r| r.strip_prefix(PREFIX))
459 .map(str::to_string)
460 }
461}
462
463impl Default for OpenApiSchema {
464 fn default() -> Self {
465 Self::object()
466 }
467}
468
469#[derive(Debug)]
470pub(super) struct ProbeError(String);
471
472impl std::fmt::Display for ProbeError {
473 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
474 f.write_str(&self.0)
475 }
476}
477impl std::error::Error for ProbeError {}
478impl DeError for ProbeError {
479 fn custom<M: std::fmt::Display>(msg: M) -> Self {
480 ProbeError(msg.to_string())
481 }
482}
483
484pub(super) struct Probe {
485 root: Option<(OpenApiSchema, Value)>,
486}
487
488impl Probe {
489 pub(super) fn new() -> Self {
490 Self { root: None }
491 }
492
493 pub(super) fn finish(self) -> Option<(OpenApiSchema, Value)> {
494 self.root
495 }
496}
497
498impl<'de> Deserializer<'de> for &mut Probe {
499 type Error = ProbeError;
500
501 fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
502 where
503 V: Visitor<'de>,
504 {
505 visitor.visit_unit()
506 }
507
508 fn deserialize_bool<V>(self, visitor: V) -> Result<V::Value, Self::Error>
509 where
510 V: Visitor<'de>,
511 {
512 self.root = Some((OpenApiSchema::boolean(), Value::Bool(false)));
513 visitor.visit_bool(false)
514 }
515
516 fn deserialize_i8<V>(self, visitor: V) -> Result<V::Value, Self::Error>
517 where
518 V: Visitor<'de>,
519 {
520 self.root = Some((OpenApiSchema::integer(), Value::Number(0.into())));
521 visitor.visit_i8(0)
522 }
523
524 fn deserialize_u8<V>(self, visitor: V) -> Result<V::Value, Self::Error>
525 where
526 V: Visitor<'de>,
527 {
528 self.root = Some((OpenApiSchema::integer(), Value::Number(0.into())));
529 visitor.visit_u8(0)
530 }
531
532 fn deserialize_i16<V>(self, visitor: V) -> Result<V::Value, Self::Error>
533 where
534 V: Visitor<'de>,
535 {
536 self.root = Some((OpenApiSchema::integer(), Value::Number(0.into())));
537 visitor.visit_i16(0)
538 }
539
540 fn deserialize_u16<V>(self, visitor: V) -> Result<V::Value, Self::Error>
541 where
542 V: Visitor<'de>,
543 {
544 self.root = Some((OpenApiSchema::integer(), Value::Number(0.into())));
545 visitor.visit_u16(0)
546 }
547
548 fn deserialize_i32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
549 where
550 V: Visitor<'de>,
551 {
552 self.root = Some((OpenApiSchema::integer(), Value::Number(0.into())));
553 visitor.visit_i32(0)
554 }
555
556 fn deserialize_u32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
557 where
558 V: Visitor<'de>,
559 {
560 self.root = Some((OpenApiSchema::integer(), Value::Number(0.into())));
561 visitor.visit_u32(0)
562 }
563
564 fn deserialize_f32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
565 where
566 V: Visitor<'de>,
567 {
568 self.root = Some((
569 OpenApiSchema::number().with_format(FLOAT_FORMAT),
570 json!(0.0),
571 ));
572 visitor.visit_f32(0.0)
573 }
574
575 fn deserialize_i64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
576 where
577 V: Visitor<'de>,
578 {
579 self.root = Some((OpenApiSchema::integer(), Value::Number(0.into())));
580 visitor.visit_i64(0)
581 }
582
583 fn deserialize_u64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
584 where
585 V: Visitor<'de>,
586 {
587 self.root = Some((OpenApiSchema::integer(), Value::Number(0.into())));
588 visitor.visit_u64(0)
589 }
590
591 fn deserialize_i128<V>(self, visitor: V) -> Result<V::Value, Self::Error>
592 where
593 V: Visitor<'de>,
594 {
595 self.root = Some((OpenApiSchema::integer(), Value::Number(0.into())));
596 visitor.visit_i128(0)
597 }
598
599 fn deserialize_u128<V>(self, visitor: V) -> Result<V::Value, Self::Error>
600 where
601 V: Visitor<'de>,
602 {
603 self.root = Some((OpenApiSchema::integer(), Value::Number(0.into())));
604 visitor.visit_u128(0)
605 }
606
607 fn deserialize_f64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
608 where
609 V: Visitor<'de>,
610 {
611 self.root = Some((
612 OpenApiSchema::number().with_format(DOUBLE_FORMAT),
613 json!(0.0),
614 ));
615 visitor.visit_f64(0.0)
616 }
617
618 fn deserialize_str<V>(self, visitor: V) -> Result<V::Value, Self::Error>
619 where
620 V: Visitor<'de>,
621 {
622 self.root = Some((OpenApiSchema::string(), Value::String(String::new())));
623 visitor.visit_borrowed_str("")
624 }
625
626 fn deserialize_string<V>(self, visitor: V) -> Result<V::Value, Self::Error>
627 where
628 V: Visitor<'de>,
629 {
630 self.root = Some((OpenApiSchema::string(), Value::String(String::new())));
631 visitor.visit_string(String::new())
632 }
633
634 fn deserialize_option<V>(self, visitor: V) -> Result<V::Value, Self::Error>
635 where
636 V: Visitor<'de>,
637 {
638 let out = visitor.visit_some(&mut *self)?;
642
643 if let Some((schema, example)) = self.root.take() {
644 self.root = Some((schema.nullable(), example));
645 } else {
646 self.root = Some((OpenApiSchema::object().nullable(), Value::Null));
647 }
648
649 Ok(out)
650 }
651
652 fn deserialize_seq<V>(self, visitor: V) -> Result<V::Value, Self::Error>
653 where
654 V: Visitor<'de>,
655 {
656 visitor.visit_seq(SeqProbeAccess {
657 probe: self,
658 yielded: false,
659 })
660 }
661
662 fn deserialize_map<V>(self, visitor: V) -> Result<V::Value, Self::Error>
663 where
664 V: Visitor<'de>,
665 {
666 self.root = Some((OpenApiSchema::object(), Value::Object(Map::new())));
667 visitor.visit_map(EmptyMapAccess)
668 }
669
670 fn deserialize_struct<V>(
671 self,
672 _name: &'static str,
673 fields: &'static [&'static str],
674 visitor: V,
675 ) -> Result<V::Value, Self::Error>
676 where
677 V: Visitor<'de>,
678 {
679 let access = StructProbeAccess {
680 fields,
681 idx: 0,
682 parent: self,
683 obj_schema: OpenApiSchema::object(),
684 example: Map::new(),
685 required: Vec::new(),
686 };
687
688 visitor.visit_map(access)
689 }
690
691 fn deserialize_enum<V>(
692 self,
693 _name: &'static str,
694 _variants: &'static [&'static str],
695 _visitor: V,
696 ) -> Result<V::Value, Self::Error>
697 where
698 V: Visitor<'de>,
699 {
700 Err(ProbeError(
701 "enums are not supported for automatic schema inference".into(),
702 ))
703 }
704
705 serde::forward_to_deserialize_any! {
706 char bytes byte_buf unit unit_struct
707 newtype_struct tuple tuple_struct identifier ignored_any
708 }
709}
710
711struct SeqProbeAccess<'a> {
712 probe: &'a mut Probe,
713 yielded: bool,
714}
715
716impl<'de, 'a> SeqAccess<'de> for SeqProbeAccess<'a> {
717 type Error = ProbeError;
718
719 fn next_element_seed<T>(&mut self, seed: T) -> Result<Option<T::Value>, Self::Error>
720 where
721 T: DeserializeSeed<'de>,
722 {
723 if self.yielded {
724 if let Some((item_schema, item_example)) = self.probe.root.take() {
725 self.probe.root = Some((
726 OpenApiSchema::array(item_schema),
727 Value::Array(vec![item_example]),
728 ));
729 } else {
730 self.probe.root = Some((
731 OpenApiSchema::array(OpenApiSchema::object()),
732 Value::Array(vec![]),
733 ));
734 }
735 return Ok(None);
736 }
737
738 self.yielded = true;
739 let v = seed.deserialize(&mut *self.probe)?;
740 Ok(Some(v))
741 }
742}
743
744struct StructProbeAccess<'a> {
745 fields: &'static [&'static str],
746 idx: usize,
747 parent: &'a mut Probe,
748
749 obj_schema: OpenApiSchema,
750 example: Map<String, Value>,
751 required: Vec<String>,
752}
753
754impl<'de, 'a> MapAccess<'de> for StructProbeAccess<'a> {
755 type Error = ProbeError;
756
757 fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, Self::Error>
758 where
759 K: DeserializeSeed<'de>,
760 {
761 if self.idx >= self.fields.len() {
762 let schema = self.obj_schema.clone().with_required(self.required.clone());
763 let ex = Value::Object(std::mem::take(&mut self.example));
764 self.parent.root = Some((schema, ex));
765 return Ok(None);
766 }
767
768 let key = self.fields[self.idx];
769 let key_de = key.into_deserializer();
770 let k = seed.deserialize(key_de)?;
771 Ok(Some(k))
772 }
773
774 fn next_value_seed<V>(&mut self, seed: V) -> Result<V::Value, Self::Error>
775 where
776 V: DeserializeSeed<'de>,
777 {
778 let field = self.fields[self.idx];
779 self.idx += 1;
780
781 let v = seed.deserialize(&mut *self.parent)?;
782
783 if let Some((field_schema, field_example)) = self.parent.root.take() {
784 let is_required = field_schema.nullable != Some(true) && field_example != Value::Null;
785 self.obj_schema = self
786 .obj_schema
787 .clone()
788 .with_property(field.to_string(), field_schema);
789
790 self.example.insert(field.to_string(), field_example);
791 if is_required {
792 self.required.push(field.to_string());
793 }
794 } else {
795 self.obj_schema = self
796 .obj_schema
797 .clone()
798 .with_property(field.to_string(), OpenApiSchema::object());
799
800 self.example.insert(field.to_string(), Value::Null);
801 }
802
803 Ok(v)
804 }
805}
806
807struct EmptyMapAccess;
808impl<'de> MapAccess<'de> for EmptyMapAccess {
809 type Error = ProbeError;
810
811 fn next_key_seed<K>(&mut self, _seed: K) -> Result<Option<K::Value>, Self::Error>
812 where
813 K: DeserializeSeed<'de>,
814 {
815 Ok(None)
816 }
817
818 fn next_value_seed<V>(&mut self, _seed: V) -> Result<V::Value, Self::Error>
819 where
820 V: DeserializeSeed<'de>,
821 {
822 Err(ProbeError("no values".into()))
823 }
824}
825
826#[cfg(test)]
827mod tests {
828 use super::*;
829 use std::collections::BTreeSet;
830
831 #[test]
832 fn schema_from_example_object_keeps_properties() {
833 let schema = OpenApiSchema::from_example(&serde_json::json!({
834 "name": "Alice",
835 "age": 42
836 }));
837
838 let props = schema.properties.expect("schema properties");
839 assert_eq!(
840 props.get("name").expect("name").schema_type.as_deref(),
841 Some("string")
842 );
843 assert_eq!(
844 props.get("age").expect("age").schema_type.as_deref(),
845 Some("integer")
846 );
847 }
848
849 #[test]
850 fn schema_from_example_handles_null_and_arrays() {
851 let null_schema = OpenApiSchema::from_example(&Value::Null);
852 assert_eq!(null_schema.schema_type.as_deref(), Some("object"));
853 assert_eq!(null_schema.nullable, Some(true));
854
855 let array_schema = OpenApiSchema::from_example(&serde_json::json!([1, 2, 3]));
856 assert_eq!(array_schema.schema_type.as_deref(), Some("array"));
857 assert_eq!(
858 array_schema
859 .items
860 .expect("array items")
861 .schema_type
862 .as_deref(),
863 Some("integer")
864 );
865 }
866
867 #[test]
868 fn multipart_schema_contains_expected_fields() {
869 let schema = OpenApiSchema::multipart();
870 let props = schema.properties.expect("properties");
871
872 assert!(props.contains_key("file"));
873 assert!(props.contains_key("meta"));
874 assert_eq!(schema.required.expect("required"), vec!["file"]);
875 }
876
877 #[test]
878 fn schema_reference_marks_ref_and_path() {
879 let schema = OpenApiSchema::reference("Payload");
880 assert!(schema.is_ref());
881 assert_eq!(
882 schema.schema_ref.as_deref(),
883 Some("#/components/schemas/Payload")
884 );
885 }
886
887 #[test]
888 fn number_and_boolean_builders_set_expected_types() {
889 assert_eq!(
890 OpenApiSchema::number().schema_type.as_deref(),
891 Some("number")
892 );
893 assert_eq!(
894 OpenApiSchema::boolean().schema_type.as_deref(),
895 Some("boolean")
896 );
897 }
898
899 #[test]
900 fn with_format_sets_schema_format() {
901 let schema = OpenApiSchema::string().with_format("uuid");
902 assert_eq!(schema.format.as_deref(), Some("uuid"));
903 }
904
905 #[test]
906 fn default_schema_is_object() {
907 let schema = OpenApiSchema::default();
908 assert_eq!(schema.schema_type.as_deref(), Some("object"));
909 }
910
911 #[test]
912 fn probe_does_not_mark_option_fields_as_required() {
913 #[derive(Deserialize)]
914 #[allow(dead_code)]
915 struct Input {
916 required_name: String,
917 optional_age: Option<()>,
918 }
919
920 let mut probe = Probe::new();
921 let _ = Input::deserialize(&mut probe);
922 let (schema, _) = probe.finish().expect("schema should be produced");
923
924 let required = schema.required.expect("required list");
925 assert!(required.contains(&"required_name".to_string()));
926 assert!(!required.contains(&"optional_age".to_string()));
927 }
928
929 #[test]
930 fn probe_supports_i32_fields_for_schema_inference() {
931 #[derive(Deserialize)]
932 #[allow(dead_code)]
933 struct NumericInput {
934 value: i32,
935 }
936
937 let mut probe = Probe::new();
938 let _ = NumericInput::deserialize(&mut probe);
939 let (schema, _) = probe.finish().expect("schema should be produced");
940
941 let props = schema.properties.expect("properties");
942 assert_eq!(props["value"].schema_type.as_deref(), Some("integer"));
943 }
944
945 #[test]
946 fn probe_supports_u32_fields_for_schema_inference() {
947 #[derive(Deserialize)]
948 #[allow(dead_code)]
949 struct NumericInput {
950 value: u32,
951 }
952
953 let mut probe = Probe::new();
954 let _ = NumericInput::deserialize(&mut probe);
955 let (schema, _) = probe.finish().expect("schema should be produced");
956
957 let props = schema.properties.expect("properties");
958 assert_eq!(props["value"].schema_type.as_deref(), Some("integer"));
959 }
960
961 #[test]
962 fn probe_supports_f32_fields_for_schema_inference() {
963 #[derive(Deserialize)]
964 #[allow(dead_code)]
965 struct NumericInput {
966 value: f32,
967 }
968
969 let mut probe = Probe::new();
970 let _ = NumericInput::deserialize(&mut probe);
971 let (schema, _) = probe.finish().expect("schema should be produced");
972
973 let props = schema.properties.expect("properties");
974 assert_eq!(props["value"].schema_type.as_deref(), Some("number"));
975 }
976
977 #[test]
978 fn probe_supports_i128_fields_for_schema_inference() {
979 #[derive(Deserialize)]
980 #[allow(dead_code)]
981 struct NumericInput {
982 value: i128,
983 }
984
985 let mut probe = Probe::new();
986 let _ = NumericInput::deserialize(&mut probe);
987 let (schema, _) = probe.finish().expect("schema should be produced");
988
989 let props = schema.properties.expect("properties");
990 assert_eq!(props["value"].schema_type.as_deref(), Some("integer"));
991 }
992
993 #[test]
994 fn probe_supports_u128_fields_for_schema_inference() {
995 #[derive(Deserialize)]
996 #[allow(dead_code)]
997 struct NumericInput {
998 value: u128,
999 }
1000
1001 let mut probe = Probe::new();
1002 let _ = NumericInput::deserialize(&mut probe);
1003 let (schema, _) = probe.finish().expect("schema should be produced");
1004
1005 let props = schema.properties.expect("properties");
1006 assert_eq!(props["value"].schema_type.as_deref(), Some("integer"));
1007 }
1008
1009 #[test]
1010 fn probe_supports_i16_fields_for_schema_inference() {
1011 #[derive(Deserialize)]
1012 #[allow(dead_code)]
1013 struct NumericInput {
1014 value: i16,
1015 }
1016
1017 let mut probe = Probe::new();
1018 let _ = NumericInput::deserialize(&mut probe);
1019 let (schema, _) = probe.finish().expect("schema should be produced");
1020
1021 let props = schema.properties.expect("properties");
1022 assert_eq!(props["value"].schema_type.as_deref(), Some("integer"));
1023 }
1024
1025 #[test]
1026 fn probe_supports_u16_fields_for_schema_inference() {
1027 #[derive(Deserialize)]
1028 #[allow(dead_code)]
1029 struct NumericInput {
1030 value: u16,
1031 }
1032
1033 let mut probe = Probe::new();
1034 let _ = NumericInput::deserialize(&mut probe);
1035 let (schema, _) = probe.finish().expect("schema should be produced");
1036
1037 let props = schema.properties.expect("properties");
1038 assert_eq!(props["value"].schema_type.as_deref(), Some("integer"));
1039 }
1040
1041 #[test]
1042 fn probe_supports_i8_fields_for_schema_inference() {
1043 #[derive(Deserialize)]
1044 #[allow(dead_code)]
1045 struct NumericInput {
1046 value: i8,
1047 }
1048
1049 let mut probe = Probe::new();
1050 let _ = NumericInput::deserialize(&mut probe);
1051 let (schema, _) = probe.finish().expect("schema should be produced");
1052
1053 let props = schema.properties.expect("properties");
1054 assert_eq!(props["value"].schema_type.as_deref(), Some("integer"));
1055 }
1056
1057 #[test]
1058 fn probe_supports_u8_fields_for_schema_inference() {
1059 #[derive(Deserialize)]
1060 #[allow(dead_code)]
1061 struct NumericInput {
1062 value: u8,
1063 }
1064
1065 let mut probe = Probe::new();
1066 let _ = NumericInput::deserialize(&mut probe);
1067 let (schema, _) = probe.finish().expect("schema should be produced");
1068
1069 let props = schema.properties.expect("properties");
1070 assert_eq!(props["value"].schema_type.as_deref(), Some("integer"));
1071 }
1072
1073 #[test]
1074 fn collect_component_refs_walks_nested_refs() {
1075 let schema = OpenApiSchema::object()
1076 .with_property("user", OpenApiSchema::reference("User"))
1077 .with_property(
1078 "items",
1079 OpenApiSchema::array(OpenApiSchema::reference("Item")),
1080 );
1081
1082 let mut refs = BTreeSet::new();
1083 schema.collect_component_refs(&mut refs);
1084
1085 assert!(refs.contains("User"));
1086 assert!(refs.contains("Item"));
1087 assert_eq!(refs.len(), 2);
1088 }
1089}