use serde::{
Deserialize, Deserializer, Serialize,
de::{
DeserializeSeed, Error as DeError, Expected, IntoDeserializer, MapAccess, SeqAccess,
Visitor,
},
};
use serde_json::{Map, Number, Value, json};
use std::{
cmp::Ordering,
collections::{BTreeMap, BTreeSet},
};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct OpenApiSchema {
#[serde(rename = "$ref", skip_serializing_if = "Option::is_none")]
pub(super) schema_ref: Option<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub(super) schema_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) properties: Option<BTreeMap<String, OpenApiSchema>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) required: Option<Vec<String>>,
#[serde(
rename = "additionalProperties",
skip_serializing_if = "Option::is_none"
)]
pub(super) additional_properties: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) items: Option<Box<OpenApiSchema>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) nullable: Option<bool>,
#[serde(rename = "minLength", skip_serializing_if = "Option::is_none")]
pub(super) min_length: Option<usize>,
#[serde(rename = "maxLength", skip_serializing_if = "Option::is_none")]
pub(super) max_length: Option<usize>,
#[serde(rename = "minItems", skip_serializing_if = "Option::is_none")]
pub(super) min_items: Option<usize>,
#[serde(rename = "maxItems", skip_serializing_if = "Option::is_none")]
pub(super) max_items: Option<usize>,
#[serde(rename = "minProperties", skip_serializing_if = "Option::is_none")]
pub(super) min_properties: Option<usize>,
#[serde(rename = "maxProperties", skip_serializing_if = "Option::is_none")]
pub(super) max_properties: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) minimum: Option<Number>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) maximum: Option<Number>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct FieldConstraint {
pub field: String,
pub constraint: SchemaConstraint,
}
impl FieldConstraint {
pub fn new(field: impl Into<String>, constraint: SchemaConstraint) -> Self {
Self {
field: field.into(),
constraint,
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum SchemaConstraint {
MinSize(usize),
MaxSize(usize),
Minimum(Number),
Maximum(Number),
Nested(Vec<FieldConstraint>),
Each(Vec<FieldConstraint>),
}
fn keep_greatest(slot: &mut Option<usize>, value: usize) {
*slot = Some(match *slot {
Some(current) => current.max(value),
None => value,
});
}
fn keep_least(slot: &mut Option<usize>, value: usize) {
*slot = Some(match *slot {
Some(current) => current.min(value),
None => value,
});
}
fn keep_greatest_number(slot: &mut Option<Number>, value: &Number) {
let replace = match slot.as_ref() {
Some(current) => compare_numbers(value, current) == Some(Ordering::Greater),
None => true,
};
if replace {
*slot = Some(value.clone());
}
}
fn keep_least_number(slot: &mut Option<Number>, value: &Number) {
let replace = match slot.as_ref() {
Some(current) => compare_numbers(value, current) == Some(Ordering::Less),
None => true,
};
if replace {
*slot = Some(value.clone());
}
}
fn compare_numbers(left: &Number, right: &Number) -> Option<Ordering> {
if let (Some(left), Some(right)) = (left.as_u64(), right.as_u64()) {
return Some(left.cmp(&right));
}
if let (Some(left), Some(right)) = (left.as_i64(), right.as_i64()) {
return Some(left.cmp(&right));
}
left.as_f64()?.partial_cmp(&right.as_f64()?)
}
const FLOAT_FORMAT: &str = "float";
const DOUBLE_FORMAT: &str = "double";
enum SizeKeyword {
Length,
Items,
Properties,
}
impl OpenApiSchema {
pub(super) fn empty() -> Self {
Self {
schema_ref: None,
schema_type: None,
format: None,
title: None,
properties: None,
required: None,
additional_properties: None,
items: None,
nullable: None,
min_length: None,
max_length: None,
min_items: None,
max_items: None,
min_properties: None,
max_properties: None,
minimum: None,
maximum: None,
}
}
pub fn with_constraint(mut self, constraint: SchemaConstraint) -> Self {
self.apply_constraint(&constraint);
self
}
pub(super) fn apply_constraint(&mut self, constraint: &SchemaConstraint) {
match constraint {
SchemaConstraint::MinSize(value) => match self.size_keyword() {
SizeKeyword::Items => keep_greatest(&mut self.min_items, *value),
SizeKeyword::Properties => keep_greatest(&mut self.min_properties, *value),
SizeKeyword::Length => keep_greatest(&mut self.min_length, *value),
},
SchemaConstraint::MaxSize(value) => match self.size_keyword() {
SizeKeyword::Items => keep_least(&mut self.max_items, *value),
SizeKeyword::Properties => keep_least(&mut self.max_properties, *value),
SizeKeyword::Length => keep_least(&mut self.max_length, *value),
},
SchemaConstraint::Minimum(value) => {
let value = self.narrow(value);
keep_greatest_number(&mut self.minimum, &value);
}
SchemaConstraint::Maximum(value) => {
let value = self.narrow(value);
keep_least_number(&mut self.maximum, &value);
}
SchemaConstraint::Nested(fields) => self.apply_field_constraints(fields),
SchemaConstraint::Each(fields) => {
if let Some(items) = self.items.as_mut() {
items.apply_field_constraints(fields);
}
}
}
}
fn narrow(&self, value: &Number) -> Number {
let narrowed = match self.format.as_deref() {
Some(FLOAT_FORMAT) => value.as_f64().map(|value| f64::from(value as f32)),
_ => None,
};
narrowed
.and_then(Number::from_f64)
.unwrap_or_else(|| value.clone())
}
fn size_keyword(&self) -> SizeKeyword {
match self.schema_type.as_deref() {
Some("array") => SizeKeyword::Items,
Some("object") => SizeKeyword::Properties,
_ => SizeKeyword::Length,
}
}
pub(super) fn apply_field_constraints(&mut self, constraints: &[FieldConstraint]) {
let Some(properties) = self.properties.as_mut() else {
return;
};
for constraint in constraints {
if let Some(property) = properties.get_mut(&constraint.field) {
property.apply_constraint(&constraint.constraint);
}
}
}
pub fn object() -> Self {
Self {
schema_type: Some("object".to_string()),
..Self::empty()
}
}
pub fn string() -> Self {
Self {
schema_type: Some("string".to_string()),
..Self::empty()
}
}
pub fn integer() -> Self {
Self {
schema_type: Some("integer".to_string()),
..Self::empty()
}
}
pub fn number() -> Self {
Self {
schema_type: Some("number".to_string()),
..Self::empty()
}
}
pub fn boolean() -> Self {
Self {
schema_type: Some("boolean".to_string()),
..Self::empty()
}
}
pub fn binary() -> Self {
Self {
schema_type: Some("string".to_string()),
format: Some("binary".to_string()),
..Self::empty()
}
}
pub fn multipart() -> Self {
Self::object()
.with_property("file", OpenApiSchema::binary())
.with_property("meta", OpenApiSchema::string())
.with_required(["file"])
}
pub fn array(items: OpenApiSchema) -> Self {
Self {
schema_type: Some("array".to_string()),
items: Some(Box::new(items)),
..Self::empty()
}
}
pub fn reference(name: &str) -> Self {
Self {
schema_ref: Some(format!("#/components/schemas/{name}")),
..Self::empty()
}
}
pub fn from_example(example: &Value) -> Self {
match example {
Value::Null => OpenApiSchema::object().nullable(),
Value::Bool(_) => OpenApiSchema::boolean(),
Value::Number(number) => {
if number.is_i64() || number.is_u64() {
OpenApiSchema::integer()
} else {
OpenApiSchema::number()
}
}
Value::String(_) => OpenApiSchema::string(),
Value::Array(items) => {
let item_schema = items
.first()
.map(OpenApiSchema::from_example)
.unwrap_or_else(OpenApiSchema::object);
OpenApiSchema::array(item_schema)
}
Value::Object(map) => {
let mut schema = OpenApiSchema::object();
let mut required = Vec::new();
for (key, value) in map {
schema = schema.with_property(key.clone(), OpenApiSchema::from_example(value));
required.push(key.clone());
}
schema.with_required(required)
}
}
}
pub fn nullable(mut self) -> Self {
self.nullable = Some(true);
self
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_property(mut self, name: impl Into<String>, schema: OpenApiSchema) -> Self {
self.properties
.get_or_insert_with(BTreeMap::new)
.insert(name.into(), schema);
self
}
pub fn with_format(mut self, fmt: impl Into<String>) -> Self {
self.format = Some(fmt.into());
self
}
pub fn with_required<I, T>(mut self, required: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<String>,
{
self.required = Some(required.into_iter().map(Into::into).collect());
self
}
pub fn is_ref(&self) -> bool {
self.schema_ref.is_some()
}
pub(super) fn collect_component_refs(&self, out: &mut BTreeSet<String>) {
if let Some(name) = self.component_ref_name()
&& out.insert(name.clone())
{
}
if let Some(properties) = &self.properties {
for schema in properties.values() {
schema.collect_component_refs(out);
}
}
if let Some(items) = &self.items {
items.collect_component_refs(out);
}
}
pub(super) fn component_ref_name(&self) -> Option<String> {
const PREFIX: &str = "#/components/schemas/";
self.schema_ref
.as_deref()
.and_then(|r| r.strip_prefix(PREFIX))
.map(str::to_string)
}
}
impl Default for OpenApiSchema {
fn default() -> Self {
Self::object()
}
}
#[derive(Debug)]
pub(super) struct ProbeError(String);
impl std::fmt::Display for ProbeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ProbeError {}
impl DeError for ProbeError {
fn custom<M: std::fmt::Display>(msg: M) -> Self {
ProbeError(msg.to_string())
}
}
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub(super) enum Probed {
Shape(OpenApiSchema, Value),
ReadAsMap(ReadAsMap),
Nothing,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct ReadAsMap {
pub(super) expecting: String,
pub(super) at_root: bool,
}
pub(super) struct Probe {
root: Option<(OpenApiSchema, Value)>,
depth: usize,
read_as_map: Option<ReadAsMap>,
}
impl Probe {
pub(super) fn new() -> Self {
Self {
root: None,
depth: 0,
read_as_map: None,
}
}
pub(super) fn finish(self) -> Probed {
if let Some(read_as_map) = self.read_as_map {
return Probed::ReadAsMap(read_as_map);
}
match self.root {
Some((schema, example)) => Probed::Shape(schema, example),
None => Probed::Nothing,
}
}
fn descend<R>(&mut self, probe: impl FnOnce(&mut Self) -> R) -> R {
self.depth += 1;
let value = probe(self);
self.depth -= 1;
value
}
}
impl<'de> Deserializer<'de> for &mut Probe {
type Error = ProbeError;
fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_unit()
}
fn deserialize_bool<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
self.root = Some((OpenApiSchema::boolean(), Value::Bool(false)));
visitor.visit_bool(false)
}
fn deserialize_i8<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
self.root = Some((OpenApiSchema::integer(), Value::Number(0.into())));
visitor.visit_i8(0)
}
fn deserialize_u8<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
self.root = Some((OpenApiSchema::integer(), Value::Number(0.into())));
visitor.visit_u8(0)
}
fn deserialize_i16<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
self.root = Some((OpenApiSchema::integer(), Value::Number(0.into())));
visitor.visit_i16(0)
}
fn deserialize_u16<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
self.root = Some((OpenApiSchema::integer(), Value::Number(0.into())));
visitor.visit_u16(0)
}
fn deserialize_i32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
self.root = Some((OpenApiSchema::integer(), Value::Number(0.into())));
visitor.visit_i32(0)
}
fn deserialize_u32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
self.root = Some((OpenApiSchema::integer(), Value::Number(0.into())));
visitor.visit_u32(0)
}
fn deserialize_f32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
self.root = Some((
OpenApiSchema::number().with_format(FLOAT_FORMAT),
json!(0.0),
));
visitor.visit_f32(0.0)
}
fn deserialize_i64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
self.root = Some((OpenApiSchema::integer(), Value::Number(0.into())));
visitor.visit_i64(0)
}
fn deserialize_u64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
self.root = Some((OpenApiSchema::integer(), Value::Number(0.into())));
visitor.visit_u64(0)
}
fn deserialize_i128<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
self.root = Some((OpenApiSchema::integer(), Value::Number(0.into())));
visitor.visit_i128(0)
}
fn deserialize_u128<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
self.root = Some((OpenApiSchema::integer(), Value::Number(0.into())));
visitor.visit_u128(0)
}
fn deserialize_f64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
self.root = Some((
OpenApiSchema::number().with_format(DOUBLE_FORMAT),
json!(0.0),
));
visitor.visit_f64(0.0)
}
fn deserialize_str<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
self.root = Some((OpenApiSchema::string(), Value::String(String::new())));
visitor.visit_borrowed_str("")
}
fn deserialize_string<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
self.root = Some((OpenApiSchema::string(), Value::String(String::new())));
visitor.visit_string(String::new())
}
fn deserialize_option<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
let out = self.descend(|probe| visitor.visit_some(probe))?;
if let Some((schema, example)) = self.root.take() {
self.root = Some((schema.nullable(), example));
} else {
self.root = Some((OpenApiSchema::object().nullable(), Value::Null));
}
Ok(out)
}
fn deserialize_seq<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_seq(SeqProbeAccess {
probe: self,
yielded: false,
})
}
fn deserialize_map<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
let expecting = (&visitor as &dyn Expected).to_string();
let expecting = expecting.starts_with(STRUCT_EXPECTING).then_some(expecting);
self.root = Some((OpenApiSchema::object(), Value::Object(Map::new())));
visitor.visit_map(MapProbeAccess {
probe: self,
expecting,
})
}
fn deserialize_struct<V>(
self,
_name: &'static str,
fields: &'static [&'static str],
visitor: V,
) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
let access = StructProbeAccess {
fields,
idx: 0,
parent: self,
obj_schema: OpenApiSchema::object(),
example: Map::new(),
required: Vec::new(),
};
visitor.visit_map(access)
}
fn deserialize_enum<V>(
self,
_name: &'static str,
_variants: &'static [&'static str],
_visitor: V,
) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
Err(ProbeError(
"enums are not supported for automatic schema inference".into(),
))
}
serde::forward_to_deserialize_any! {
char bytes byte_buf unit unit_struct
newtype_struct tuple tuple_struct identifier ignored_any
}
}
struct SeqProbeAccess<'a> {
probe: &'a mut Probe,
yielded: bool,
}
impl<'de, 'a> SeqAccess<'de> for SeqProbeAccess<'a> {
type Error = ProbeError;
fn next_element_seed<T>(&mut self, seed: T) -> Result<Option<T::Value>, Self::Error>
where
T: DeserializeSeed<'de>,
{
if self.yielded {
if let Some((item_schema, item_example)) = self.probe.root.take() {
self.probe.root = Some((
OpenApiSchema::array(item_schema),
Value::Array(vec![item_example]),
));
} else {
self.probe.root = Some((
OpenApiSchema::array(OpenApiSchema::object()),
Value::Array(vec![]),
));
}
return Ok(None);
}
self.yielded = true;
let v = self.probe.descend(|probe| seed.deserialize(probe))?;
Ok(Some(v))
}
}
struct StructProbeAccess<'a> {
fields: &'static [&'static str],
idx: usize,
parent: &'a mut Probe,
obj_schema: OpenApiSchema,
example: Map<String, Value>,
required: Vec<String>,
}
impl<'de, 'a> MapAccess<'de> for StructProbeAccess<'a> {
type Error = ProbeError;
fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, Self::Error>
where
K: DeserializeSeed<'de>,
{
if self.idx >= self.fields.len() {
let schema = self.obj_schema.clone().with_required(self.required.clone());
let ex = Value::Object(std::mem::take(&mut self.example));
self.parent.root = Some((schema, ex));
return Ok(None);
}
let key = self.fields[self.idx];
let key_de = key.into_deserializer();
let k = seed.deserialize(key_de)?;
Ok(Some(k))
}
fn next_value_seed<V>(&mut self, seed: V) -> Result<V::Value, Self::Error>
where
V: DeserializeSeed<'de>,
{
let field = self.fields[self.idx];
self.idx += 1;
let v = self.parent.descend(|probe| seed.deserialize(probe))?;
if let Some((field_schema, field_example)) = self.parent.root.take() {
let is_required = field_schema.nullable != Some(true) && field_example != Value::Null;
self.obj_schema = self
.obj_schema
.clone()
.with_property(field.to_string(), field_schema);
self.example.insert(field.to_string(), field_example);
if is_required {
self.required.push(field.to_string());
}
} else {
self.obj_schema = self
.obj_schema
.clone()
.with_property(field.to_string(), OpenApiSchema::object());
self.example.insert(field.to_string(), Value::Null);
}
Ok(v)
}
}
const STRUCT_EXPECTING: &str = "struct ";
struct MapProbeAccess<'a> {
probe: &'a mut Probe,
expecting: Option<String>,
}
impl<'de, 'a> MapAccess<'de> for MapProbeAccess<'a> {
type Error = ProbeError;
fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, Self::Error>
where
K: DeserializeSeed<'de>,
{
let Some(expecting) = self.expecting.take() else {
return Ok(None);
};
let mut reads_fields = false;
let _ = seed.deserialize(KeyProbe {
reads_fields: &mut reads_fields,
});
if !reads_fields {
return Ok(None);
}
self.probe.read_as_map.get_or_insert(ReadAsMap {
expecting,
at_root: self.probe.depth == 0,
});
Err(ProbeError("a struct read as a map".into()))
}
fn next_value_seed<V>(&mut self, _seed: V) -> Result<V::Value, Self::Error>
where
V: DeserializeSeed<'de>,
{
Err(ProbeError("no values".into()))
}
}
struct KeyProbe<'a> {
reads_fields: &'a mut bool,
}
impl<'de, 'a> Deserializer<'de> for KeyProbe<'a> {
type Error = ProbeError;
fn deserialize_any<V>(self, _visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
Err(ProbeError("no keys".into()))
}
fn deserialize_identifier<V>(self, _visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
*self.reads_fields = true;
Err(ProbeError("no keys".into()))
}
serde::forward_to_deserialize_any! {
bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string
bytes byte_buf option unit unit_struct newtype_struct seq tuple
tuple_struct map struct enum ignored_any
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::{BTreeSet, HashMap};
impl Probed {
fn shape(self) -> Option<(OpenApiSchema, Value)> {
match self {
Probed::Shape(schema, example) => Some((schema, example)),
_ => None,
}
}
}
fn probe<T: serde::de::DeserializeOwned>() -> Probed {
let mut probe = Probe::new();
let _ = T::deserialize(&mut probe);
probe.finish()
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct Inner {
name: String,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct Flat {
#[serde(flatten)]
inner: Inner,
page: u32,
}
#[test]
fn probe_reports_a_struct_with_a_flattened_field() {
let Probed::ReadAsMap(found) = probe::<Flat>() else {
panic!("a struct read as a map should be reported");
};
assert_eq!(found.expecting, "struct Flat");
assert!(found.at_root);
}
#[test]
fn probe_reports_a_struct_read_as_a_map_inside_the_type() {
#[derive(Deserialize)]
#[allow(dead_code)]
struct Outer {
id: u32,
flat: Flat,
}
for probed in [
probe::<Outer>(),
probe::<Vec<Flat>>(),
probe::<Option<Flat>>(),
] {
let Probed::ReadAsMap(found) = probed else {
panic!("a struct read as a map should be reported: {probed:?}");
};
assert_eq!(found.expecting, "struct Flat");
assert!(!found.at_root);
}
}
#[test]
fn probe_reports_a_struct_with_only_optional_fields_around_a_flattened_one() {
#[derive(Deserialize)]
#[allow(dead_code)]
struct Optional {
#[serde(flatten)]
rest: HashMap<String, String>,
page: Option<u32>,
}
assert!(matches!(probe::<Optional>(), Probed::ReadAsMap(_)));
}
#[test]
fn probe_describes_a_map_as_an_object() {
let Probed::Shape(schema, example) = probe::<HashMap<String, u32>>() else {
panic!("a map should be described");
};
assert_eq!(schema.schema_type.as_deref(), Some("object"));
assert!(schema.properties.is_none());
assert_eq!(example, json!({}));
}
#[test]
fn probe_describes_a_map_inside_a_struct() {
#[derive(Deserialize)]
#[allow(dead_code)]
struct Tagged {
tags: HashMap<String, String>,
page: u32,
}
let (schema, _) = probe::<Tagged>()
.shape()
.expect("schema should be produced");
let props = schema.properties.expect("properties");
assert_eq!(props["tags"].schema_type.as_deref(), Some("object"));
assert_eq!(props["page"].schema_type.as_deref(), Some("integer"));
}
#[derive(Deserialize, PartialEq, Eq, Hash)]
#[serde(field_identifier, rename_all = "snake_case")]
#[allow(dead_code)]
enum Label {
Name,
Tag,
}
#[test]
fn probe_describes_a_map_with_identifier_keys_as_an_object() {
let Probed::Shape(schema, _) = probe::<HashMap<Label, String>>() else {
panic!("a map should be described");
};
assert_eq!(schema.schema_type.as_deref(), Some("object"));
}
#[test]
fn probe_describes_a_map_with_identifier_keys_inside_a_struct() {
#[derive(Deserialize)]
#[allow(dead_code)]
struct Labelled {
labels: HashMap<Label, String>,
page: u32,
}
let (schema, _) = probe::<Labelled>()
.shape()
.expect("schema should be produced");
let props = schema.properties.expect("properties");
assert_eq!(props["labels"].schema_type.as_deref(), Some("object"));
assert_eq!(props["page"].schema_type.as_deref(), Some("integer"));
}
#[test]
fn probe_takes_a_struct_expecting_something_else_for_a_map() {
#[derive(Deserialize)]
#[serde(expecting = "a search request")]
#[allow(dead_code)]
struct Search {
#[serde(flatten)]
inner: Inner,
page: u32,
}
let Probed::Shape(schema, _) = probe::<Search>() else {
panic!("the struct should be taken for a map");
};
assert_eq!(schema.schema_type.as_deref(), Some("object"));
assert!(schema.properties.is_none());
}
#[test]
fn probe_reports_nothing_for_an_enum() {
#[derive(Deserialize)]
#[allow(dead_code)]
enum Kind {
One,
}
assert!(matches!(probe::<Kind>(), Probed::Nothing));
}
#[test]
fn schema_from_example_object_keeps_properties() {
let schema = OpenApiSchema::from_example(&serde_json::json!({
"name": "Alice",
"age": 42
}));
let props = schema.properties.expect("schema properties");
assert_eq!(
props.get("name").expect("name").schema_type.as_deref(),
Some("string")
);
assert_eq!(
props.get("age").expect("age").schema_type.as_deref(),
Some("integer")
);
}
#[test]
fn schema_from_example_handles_null_and_arrays() {
let null_schema = OpenApiSchema::from_example(&Value::Null);
assert_eq!(null_schema.schema_type.as_deref(), Some("object"));
assert_eq!(null_schema.nullable, Some(true));
let array_schema = OpenApiSchema::from_example(&serde_json::json!([1, 2, 3]));
assert_eq!(array_schema.schema_type.as_deref(), Some("array"));
assert_eq!(
array_schema
.items
.expect("array items")
.schema_type
.as_deref(),
Some("integer")
);
}
#[test]
fn multipart_schema_contains_expected_fields() {
let schema = OpenApiSchema::multipart();
let props = schema.properties.expect("properties");
assert!(props.contains_key("file"));
assert!(props.contains_key("meta"));
assert_eq!(schema.required.expect("required"), vec!["file"]);
}
#[test]
fn schema_reference_marks_ref_and_path() {
let schema = OpenApiSchema::reference("Payload");
assert!(schema.is_ref());
assert_eq!(
schema.schema_ref.as_deref(),
Some("#/components/schemas/Payload")
);
}
#[test]
fn number_and_boolean_builders_set_expected_types() {
assert_eq!(
OpenApiSchema::number().schema_type.as_deref(),
Some("number")
);
assert_eq!(
OpenApiSchema::boolean().schema_type.as_deref(),
Some("boolean")
);
}
#[test]
fn with_format_sets_schema_format() {
let schema = OpenApiSchema::string().with_format("uuid");
assert_eq!(schema.format.as_deref(), Some("uuid"));
}
#[test]
fn default_schema_is_object() {
let schema = OpenApiSchema::default();
assert_eq!(schema.schema_type.as_deref(), Some("object"));
}
#[test]
fn probe_does_not_mark_option_fields_as_required() {
#[derive(Deserialize)]
#[allow(dead_code)]
struct Input {
required_name: String,
optional_age: Option<()>,
}
let mut probe = Probe::new();
let _ = Input::deserialize(&mut probe);
let (schema, _) = probe.finish().shape().expect("schema should be produced");
let required = schema.required.expect("required list");
assert!(required.contains(&"required_name".to_string()));
assert!(!required.contains(&"optional_age".to_string()));
}
#[test]
fn probe_supports_i32_fields_for_schema_inference() {
#[derive(Deserialize)]
#[allow(dead_code)]
struct NumericInput {
value: i32,
}
let mut probe = Probe::new();
let _ = NumericInput::deserialize(&mut probe);
let (schema, _) = probe.finish().shape().expect("schema should be produced");
let props = schema.properties.expect("properties");
assert_eq!(props["value"].schema_type.as_deref(), Some("integer"));
}
#[test]
fn probe_supports_u32_fields_for_schema_inference() {
#[derive(Deserialize)]
#[allow(dead_code)]
struct NumericInput {
value: u32,
}
let mut probe = Probe::new();
let _ = NumericInput::deserialize(&mut probe);
let (schema, _) = probe.finish().shape().expect("schema should be produced");
let props = schema.properties.expect("properties");
assert_eq!(props["value"].schema_type.as_deref(), Some("integer"));
}
#[test]
fn probe_supports_f32_fields_for_schema_inference() {
#[derive(Deserialize)]
#[allow(dead_code)]
struct NumericInput {
value: f32,
}
let mut probe = Probe::new();
let _ = NumericInput::deserialize(&mut probe);
let (schema, _) = probe.finish().shape().expect("schema should be produced");
let props = schema.properties.expect("properties");
assert_eq!(props["value"].schema_type.as_deref(), Some("number"));
}
#[test]
fn probe_supports_i128_fields_for_schema_inference() {
#[derive(Deserialize)]
#[allow(dead_code)]
struct NumericInput {
value: i128,
}
let mut probe = Probe::new();
let _ = NumericInput::deserialize(&mut probe);
let (schema, _) = probe.finish().shape().expect("schema should be produced");
let props = schema.properties.expect("properties");
assert_eq!(props["value"].schema_type.as_deref(), Some("integer"));
}
#[test]
fn probe_supports_u128_fields_for_schema_inference() {
#[derive(Deserialize)]
#[allow(dead_code)]
struct NumericInput {
value: u128,
}
let mut probe = Probe::new();
let _ = NumericInput::deserialize(&mut probe);
let (schema, _) = probe.finish().shape().expect("schema should be produced");
let props = schema.properties.expect("properties");
assert_eq!(props["value"].schema_type.as_deref(), Some("integer"));
}
#[test]
fn probe_supports_i16_fields_for_schema_inference() {
#[derive(Deserialize)]
#[allow(dead_code)]
struct NumericInput {
value: i16,
}
let mut probe = Probe::new();
let _ = NumericInput::deserialize(&mut probe);
let (schema, _) = probe.finish().shape().expect("schema should be produced");
let props = schema.properties.expect("properties");
assert_eq!(props["value"].schema_type.as_deref(), Some("integer"));
}
#[test]
fn probe_supports_u16_fields_for_schema_inference() {
#[derive(Deserialize)]
#[allow(dead_code)]
struct NumericInput {
value: u16,
}
let mut probe = Probe::new();
let _ = NumericInput::deserialize(&mut probe);
let (schema, _) = probe.finish().shape().expect("schema should be produced");
let props = schema.properties.expect("properties");
assert_eq!(props["value"].schema_type.as_deref(), Some("integer"));
}
#[test]
fn probe_supports_i8_fields_for_schema_inference() {
#[derive(Deserialize)]
#[allow(dead_code)]
struct NumericInput {
value: i8,
}
let mut probe = Probe::new();
let _ = NumericInput::deserialize(&mut probe);
let (schema, _) = probe.finish().shape().expect("schema should be produced");
let props = schema.properties.expect("properties");
assert_eq!(props["value"].schema_type.as_deref(), Some("integer"));
}
#[test]
fn probe_supports_u8_fields_for_schema_inference() {
#[derive(Deserialize)]
#[allow(dead_code)]
struct NumericInput {
value: u8,
}
let mut probe = Probe::new();
let _ = NumericInput::deserialize(&mut probe);
let (schema, _) = probe.finish().shape().expect("schema should be produced");
let props = schema.properties.expect("properties");
assert_eq!(props["value"].schema_type.as_deref(), Some("integer"));
}
#[test]
fn collect_component_refs_walks_nested_refs() {
let schema = OpenApiSchema::object()
.with_property("user", OpenApiSchema::reference("User"))
.with_property(
"items",
OpenApiSchema::array(OpenApiSchema::reference("Item")),
);
let mut refs = BTreeSet::new();
schema.collect_component_refs(&mut refs);
assert!(refs.contains("User"));
assert!(refs.contains("Item"));
assert_eq!(refs.len(), 2);
}
}