use serde_json::{Value, json};
use std::fmt::Write as _;
use std::sync::Arc;
pub const MAX_ISSUES: usize = 64;
#[derive(Debug, Default, Clone)]
pub struct Issues {
items: Vec<String>,
overflow: usize,
}
impl Issues {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, issue: impl Into<String>) {
if self.items.len() < MAX_ISSUES {
self.items.push(issue.into());
} else {
self.overflow += 1;
}
}
pub fn is_empty(&self) -> bool {
self.items.is_empty() && self.overflow == 0
}
pub fn render(&self) -> Vec<String> {
let mut out = self.items.clone();
if self.overflow > 0 {
out.push(format!(
"… et {overflow} problème(s) supplémentaire(s) omis",
overflow = self.overflow
));
}
out
}
}
pub struct Path {
buf: String,
}
impl Path {
#[must_use]
pub fn root(root: &str) -> Self {
Self {
buf: root.to_string(),
}
}
#[must_use]
pub fn mark(&self) -> usize {
self.buf.len()
}
pub fn push_field(&mut self, name: &str) {
self.buf.push('.');
self.buf.push_str(name);
}
pub fn push_index(&mut self, index: usize) {
let _ = write!(self.buf, "[{index}]");
}
pub fn truncate(&mut self, mark: usize) {
self.buf.truncate(mark);
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.buf
}
}
pub trait Schema: Send + Sync {
fn json(&self) -> Value;
fn validate(&self, path: &mut Path, value: &Value, issues: &mut Issues);
}
impl<S: Schema + ?Sized> Schema for Box<S> {
fn json(&self) -> Value {
(**self).json()
}
fn validate(&self, path: &mut Path, value: &Value, issues: &mut Issues) {
(**self).validate(path, value, issues);
}
}
impl<S: Schema + ?Sized> Schema for Arc<S> {
fn json(&self) -> Value {
(**self).json()
}
fn validate(&self, path: &mut Path, value: &Value, issues: &mut Issues) {
(**self).validate(path, value, issues);
}
}
impl<S: Schema + ?Sized> Schema for &S {
fn json(&self) -> Value {
(**self).json()
}
fn validate(&self, path: &mut Path, value: &Value, issues: &mut Issues) {
(**self).validate(path, value, issues);
}
}
fn assert_ordered<T: PartialOrd + std::fmt::Debug>(min: &T, max: &T, quoi: &str) {
assert!(
min <= max,
"litemcp/zod : bornes incohérentes ({min:?} > {max:?}) sur {quoi} — \
le schéma serait insatisfaisable"
);
}
#[derive(Clone)]
struct Field {
name: String,
schema: std::sync::Arc<dyn Schema>,
required: bool,
}
#[derive(Clone, Default)]
pub struct Obj {
fields: Vec<Field>,
strict: bool,
}
pub fn object() -> Obj {
Obj::default()
}
impl Obj {
#[must_use]
pub fn field(mut self, name: &str, schema: impl Schema + 'static) -> Self {
self.push_field(name, schema, true);
self
}
#[must_use]
pub fn opt(mut self, name: &str, schema: impl Schema + 'static) -> Self {
self.push_field(name, schema, false);
self
}
fn push_field(&mut self, name: &str, schema: impl Schema + 'static, required: bool) {
assert!(
!self.fields.iter().any(|f| f.name == name),
"litemcp/zod : le champ `{name}` est déjà défini — un doublon produirait \
un inputSchema invalide (required avec doublons) et un outil \
insatisfaisable"
);
self.fields.push(Field {
name: name.to_string(),
schema: std::sync::Arc::new(schema),
required,
});
}
#[must_use]
pub fn strict(mut self) -> Self {
self.strict = true;
self
}
}
impl Schema for Obj {
fn json(&self) -> Value {
let mut properties = serde_json::Map::new();
let mut required = Vec::new();
for field in &self.fields {
properties.insert(field.name.clone(), field.schema.json());
if field.required {
required.push(json!(field.name));
}
}
let mut schema = json!({ "type": "object", "properties": properties });
if !required.is_empty() {
schema["required"] = json!(required);
}
if self.strict {
schema["additionalProperties"] = json!(false);
}
schema
}
fn validate(&self, path: &mut Path, value: &Value, issues: &mut Issues) {
let Some(map) = value.as_object() else {
issues.push(format!("`{}` : objet attendu", path.as_str()));
return;
};
if self.strict {
for name in map.keys() {
if !self.fields.iter().any(|f| &f.name == name) {
let mark = path.mark();
path.push_field(name);
issues.push(format!(
"`{}` : champ inconnu (objet strict)",
path.as_str()
));
path.truncate(mark);
}
}
}
for field in &self.fields {
match map.get(&field.name) {
Some(child) => {
let mark = path.mark();
path.push_field(&field.name);
field.schema.validate(path, child, issues);
path.truncate(mark);
}
None if field.required => {
let mark = path.mark();
path.push_field(&field.name);
issues.push(format!("`{}` requis", path.as_str()));
path.truncate(mark);
}
None => {}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
Email,
Uuid,
Url,
Datetime,
}
impl Format {
#[must_use]
pub fn keyword(self) -> &'static str {
match self {
Self::Email => "email",
Self::Uuid => "uuid",
Self::Url => "uri",
Self::Datetime => "date-time",
}
}
fn label(self) -> &'static str {
match self {
Self::Email => "adresse e-mail",
Self::Uuid => "UUID",
Self::Url => "URL absolue",
Self::Datetime => "date-time ISO 8601",
}
}
fn matches(self, text: &str) -> bool {
match self {
Self::Email => is_email(text),
Self::Uuid => is_uuid(text),
Self::Url => is_url(text),
Self::Datetime => is_datetime(text),
}
}
}
fn is_email(text: &str) -> bool {
let Some((local, domain)) = text.split_once('@') else {
return false;
};
if domain.contains('@') || local.is_empty() || domain.is_empty() {
return false;
}
if text.chars().any(char::is_whitespace) || !domain.contains('.') {
return false;
}
domain.split('.').all(|label| {
!label.is_empty() && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}) && local
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._%+-".contains(c))
}
fn is_uuid(text: &str) -> bool {
let bytes = text.as_bytes();
bytes.len() == 36
&& [8usize, 13, 18, 23].iter().all(|&i| bytes[i] == b'-')
&& bytes
.iter()
.enumerate()
.all(|(i, &b)| matches!(i, 8 | 13 | 18 | 23) || b.is_ascii_hexdigit())
}
fn is_url(text: &str) -> bool {
if text.chars().any(char::is_whitespace) {
return false;
}
let Some((scheme, rest)) = text.split_once("://") else {
return false;
};
scheme
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c))
&& rest
.split(['/', '?', '#'])
.next()
.is_some_and(|authority| !authority.is_empty())
}
fn is_datetime(text: &str) -> bool {
let bytes = text.as_bytes();
if bytes.len() < 20 {
return false;
}
let digits = |range: std::ops::Range<usize>| {
range
.clone()
.all(|i| bytes.get(i).is_some_and(|b| b.is_ascii_digit()))
};
let two = |i: usize| -> u32 { (bytes[i] - b'0') as u32 * 10 + (bytes[i + 1] - b'0') as u32 };
if !(digits(0..4) && bytes[4] == b'-' && digits(5..7) && bytes[7] == b'-' && digits(8..10)) {
return false;
}
let year = two(0) * 100 + two(2);
let month = two(5);
let day = two(8);
if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) {
return false;
}
let separator = bytes[10];
if !matches!(separator, b'T' | b't' | b' ') {
return false;
}
if !(digits(11..13)
&& bytes[13] == b':'
&& digits(14..16)
&& bytes[16] == b':'
&& digits(17..19))
{
return false;
}
if two(11) > 23 || two(14) > 59 || two(17) > 60 {
return false; }
let mut i = 19;
if bytes.get(i) == Some(&b'.') {
i += 1;
let fraction_start = i;
while bytes.get(i).is_some_and(|b| b.is_ascii_digit()) {
i += 1;
}
if i == fraction_start {
return false;
}
}
match bytes.get(i) {
Some(&b'Z') | Some(&b'z') => i + 1 == bytes.len(),
Some(&b'+') | Some(&b'-') => {
digits(i + 1..i + 3)
&& bytes.get(i + 3) == Some(&b':')
&& digits(i + 4..i + 6)
&& i + 6 == bytes.len()
}
_ => false,
}
}
fn days_in_month(year: u32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 {
29
} else {
28
}
}
_ => 0,
}
}
#[derive(Clone, Default)]
pub struct Str {
min: Option<usize>,
max: Option<usize>,
format: Option<Format>,
}
pub fn string() -> Str {
Str::default()
}
impl Str {
#[must_use]
pub fn min(mut self, n: usize) -> Self {
self.min = Some(n);
self.check();
self
}
#[must_use]
pub fn max(mut self, n: usize) -> Self {
self.max = Some(n);
self.check();
self
}
fn check(&self) {
if let (Some(min), Some(max)) = (self.min, self.max) {
assert_ordered(&min, &max, "string()");
}
}
#[must_use]
pub fn email(mut self) -> Self {
self.format = Some(Format::Email);
self
}
#[must_use]
pub fn uuid(mut self) -> Self {
self.format = Some(Format::Uuid);
self
}
#[must_use]
pub fn url(mut self) -> Self {
self.format = Some(Format::Url);
self
}
#[must_use]
pub fn datetime(mut self) -> Self {
self.format = Some(Format::Datetime);
self
}
}
impl Schema for Str {
fn json(&self) -> Value {
let mut schema = json!({ "type": "string" });
if let Some(min) = self.min {
schema["minLength"] = json!(min);
}
if let Some(max) = self.max {
schema["maxLength"] = json!(max);
}
if let Some(format) = self.format {
schema["format"] = json!(format.keyword());
}
schema
}
fn validate(&self, path: &mut Path, value: &Value, issues: &mut Issues) {
let Some(text) = value.as_str() else {
issues.push(format!("`{}` : chaîne attendue", path.as_str()));
return;
};
let chars = text.chars().count();
if let Some(min) = self.min {
if chars < min {
issues.push(format!("`{}` : au moins {min} caractère(s)", path.as_str()));
}
}
if let Some(max) = self.max {
if chars > max {
issues.push(format!("`{}` : au plus {max} caractère(s)", path.as_str()));
}
}
if let Some(format) = self.format {
if !format.matches(text) {
issues.push(format!("`{}` : {} attendu", path.as_str(), format.label()));
}
}
}
}
#[derive(Clone, Default)]
pub struct Int {
min: Option<i64>,
max: Option<i64>,
}
pub fn integer() -> Int {
Int::default()
}
impl Int {
#[must_use]
pub fn min(mut self, n: i64) -> Self {
self.min = Some(n);
self.check();
self
}
#[must_use]
pub fn max(mut self, n: i64) -> Self {
self.max = Some(n);
self.check();
self
}
fn check(&self) {
if let (Some(min), Some(max)) = (self.min, self.max) {
assert_ordered(&min, &max, "integer()");
}
}
}
impl Schema for Int {
fn json(&self) -> Value {
let mut schema = json!({ "type": "integer" });
schema["minimum"] = json!(self.min.unwrap_or(i64::MIN));
schema["maximum"] = json!(self.max.unwrap_or(i64::MAX));
schema
}
fn validate(&self, path: &mut Path, value: &Value, issues: &mut Issues) {
let Some(n) = value.as_i64() else {
issues.push(format!("`{}` : entier attendu", path.as_str()));
return;
};
if let Some(min) = self.min {
if n < min {
issues.push(format!("`{}` : doit être ≥ {min}", path.as_str()));
}
}
if let Some(max) = self.max {
if n > max {
issues.push(format!("`{}` : doit être ≤ {max}", path.as_str()));
}
}
}
}
#[derive(Clone, Default)]
pub struct Num {
min: Option<f64>,
max: Option<f64>,
}
pub fn number() -> Num {
Num::default()
}
impl Num {
#[must_use]
pub fn min(mut self, n: f64) -> Self {
assert!(
n.is_finite(),
"litemcp/zod : borne non finie ({n:?}) sur number() — serde_json la \
publierait en null et la validation ne l'appliquerait jamais"
);
self.min = Some(n);
self.check();
self
}
#[must_use]
pub fn max(mut self, n: f64) -> Self {
assert!(
n.is_finite(),
"litemcp/zod : borne non finie ({n:?}) sur number() — serde_json la \
publierait en null et la validation ne l'appliquerait jamais"
);
self.max = Some(n);
self.check();
self
}
fn check(&self) {
if let (Some(min), Some(max)) = (self.min, self.max) {
assert_ordered(&min, &max, "number()");
}
}
}
impl Schema for Num {
fn json(&self) -> Value {
let mut schema = json!({ "type": "number" });
if let Some(min) = self.min {
schema["minimum"] = json!(min);
}
if let Some(max) = self.max {
schema["maximum"] = json!(max);
}
schema
}
fn validate(&self, path: &mut Path, value: &Value, issues: &mut Issues) {
match value.as_f64() {
Some(n) => {
if let Some(min) = self.min {
if n < min {
issues.push(format!("`{}` : doit être ≥ {min}", path.as_str()));
}
}
if let Some(max) = self.max {
if n > max {
issues.push(format!("`{}` : doit être ≤ {max}", path.as_str()));
}
}
}
None => issues.push(format!("`{}` : nombre attendu", path.as_str())),
}
}
}
#[derive(Clone, Default)]
pub struct Bool;
pub fn boolean() -> Bool {
Bool
}
impl Schema for Bool {
fn json(&self) -> Value {
json!({ "type": "boolean" })
}
fn validate(&self, path: &mut Path, value: &Value, issues: &mut Issues) {
if value.as_bool().is_none() {
issues.push(format!("`{}` : booléen attendu", path.as_str()));
}
}
}
#[derive(Clone)]
pub struct Enum {
values: Vec<String>,
}
pub fn enumeration(values: &[&str]) -> Enum {
assert!(
!values.is_empty(),
"litemcp/zod : enumeration vide — schéma insatisfaisable"
);
for (index, value) in values.iter().enumerate() {
assert!(
!values[index + 1..].contains(value),
"litemcp/zod : doublon {value:?} dans enumeration — le meta-schema \
JSON Schema exige des éléments uniques"
);
}
Enum {
values: values.iter().map(|v| (*v).to_string()).collect(),
}
}
impl Schema for Enum {
fn json(&self) -> Value {
json!({ "type": "string", "enum": self.values })
}
fn validate(&self, path: &mut Path, value: &Value, issues: &mut Issues) {
match value.as_str() {
Some(s) if self.values.iter().any(|v| v == s) => {}
Some(_) => issues.push(format!(
"`{}` : doit être l'un de {}",
path.as_str(),
self.values.join(", ")
)),
None => issues.push(format!("`{}` : chaîne attendue", path.as_str())),
}
}
}
#[derive(Clone)]
pub struct Arr {
inner: std::sync::Arc<dyn Schema>,
min: Option<usize>,
max: Option<usize>,
}
pub fn array(inner: impl Schema + 'static) -> Arr {
Arr {
inner: std::sync::Arc::new(inner),
min: None,
max: None,
}
}
impl Arr {
#[must_use]
pub fn min(mut self, n: usize) -> Self {
self.min = Some(n);
self.check();
self
}
#[must_use]
pub fn max(mut self, n: usize) -> Self {
self.max = Some(n);
self.check();
self
}
fn check(&self) {
if let (Some(min), Some(max)) = (self.min, self.max) {
assert_ordered(&min, &max, "array()");
}
}
}
impl Schema for Arr {
fn json(&self) -> Value {
let mut schema = json!({ "type": "array", "items": self.inner.json() });
if let Some(min) = self.min {
schema["minItems"] = json!(min);
}
if let Some(max) = self.max {
schema["maxItems"] = json!(max);
}
schema
}
fn validate(&self, path: &mut Path, value: &Value, issues: &mut Issues) {
let Some(items) = value.as_array() else {
issues.push(format!("`{}` : tableau attendu", path.as_str()));
return;
};
if let Some(min) = self.min {
if items.len() < min {
issues.push(format!("`{}` : au moins {min} élément(s)", path.as_str()));
}
}
if let Some(max) = self.max {
if items.len() > max {
issues.push(format!("`{}` : au plus {max} élément(s)", path.as_str()));
}
}
for (index, item) in items.iter().enumerate() {
let mark = path.mark();
path.push_index(index);
self.inner.validate(path, item, issues);
path.truncate(mark);
}
}
}
pub struct Nullable {
inner: Arc<dyn Schema>,
}
pub fn nullable(inner: impl Schema + 'static) -> Nullable {
Nullable {
inner: Arc::new(inner),
}
}
impl Schema for Nullable {
fn json(&self) -> Value {
json!({ "anyOf": [self.inner.json(), { "type": "null" }] })
}
fn validate(&self, path: &mut Path, value: &Value, issues: &mut Issues) {
if value.is_null() {
return;
}
self.inner.validate(path, value, issues);
}
}
pub struct Described {
description: String,
inner: Arc<dyn Schema>,
}
pub fn describe(description: &str, inner: impl Schema + 'static) -> Described {
Described {
description: description.to_string(),
inner: Arc::new(inner),
}
}
impl Schema for Described {
fn json(&self) -> Value {
let mut schema = self.inner.json();
if let Some(object) = schema.as_object_mut() {
object.insert("description".to_string(), json!(self.description));
}
schema
}
fn validate(&self, path: &mut Path, value: &Value, issues: &mut Issues) {
self.inner.validate(path, value, issues);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn issues_of(schema: &dyn Schema, value: Value) -> Vec<String> {
let mut issues = Issues::new();
let mut path = Path::root("arguments");
schema.validate(&mut path, &value, &mut issues);
issues.render()
}
#[test]
fn object_schema_is_derived() {
let schema = object()
.field("a", integer())
.opt("b", string().max(8))
.strict();
assert_eq!(
schema.json(),
json!({
"type": "object",
"properties": {
"a": { "type": "integer", "minimum": i64::MIN, "maximum": i64::MAX },
"b": { "type": "string", "maxLength": 8 }
},
"required": ["a"],
"additionalProperties": false
})
);
}
#[test]
fn valid_object_has_no_issue() {
let schema = object().field("a", integer()).opt("b", string());
assert!(issues_of(&schema, json!({ "a": 1 })).is_empty());
assert!(issues_of(&schema, json!({ "a": 1, "b": "x" })).is_empty());
}
#[test]
fn missing_required_and_wrong_type_are_reported() {
let schema = object().field("a", integer()).field("b", string());
let issues = issues_of(&schema, json!({ "a": "oops" }));
assert_eq!(issues.len(), 2, "{issues:?}");
assert!(issues[0].contains("arguments.a"));
assert!(issues[1].contains("requis"));
}
#[test]
fn strict_mode_rejects_unknown_fields() {
let schema = object().field("a", integer()).strict();
let issues = issues_of(&schema, json!({ "a": 1, "zzz": true }));
assert_eq!(issues.len(), 1);
assert!(issues[0].contains("champ inconnu"));
}
#[test]
fn non_object_arguments_are_rejected() {
let schema = object().field("a", integer());
let issues = issues_of(&schema, json!("not an object"));
assert_eq!(issues.len(), 1);
assert!(issues[0].contains("objet attendu"));
}
#[test]
fn nested_paths_are_reported() {
let schema = object().field("list", array(integer()).min(1));
let issues = issues_of(&schema, json!({ "list": [1, "x", 3] }));
assert_eq!(issues.len(), 1);
assert!(issues[0].contains("arguments.list[1]"));
}
#[test]
fn integer_bounds_apply() {
let schema = object().field("n", integer().min(1).max(10));
assert!(!issues_of(&schema, json!({ "n": 0 })).is_empty());
assert!(issues_of(&schema, json!({ "n": 10 })).is_empty());
assert!(!issues_of(&schema, json!({ "n": 1.5 })).is_empty());
}
#[test]
fn integer_rejects_non_integral_floats() {
let schema = object().field("n", integer());
assert!(!issues_of(&schema, json!({ "n": 3.0 })).is_empty());
assert!(!issues_of(&schema, json!({ "n": 1e2 })).is_empty());
assert!(!issues_of(&schema, json!({ "n": 0.0 })).is_empty());
assert!(!issues_of(&schema, json!({ "n": -0.0 })).is_empty());
assert!(issues_of(&schema, json!({ "n": 3 })).is_empty());
assert!(issues_of(&schema, json!({ "n": -3 })).is_empty());
assert!(issues_of(&schema, json!({ "n": 0 })).is_empty());
}
#[test]
fn integer_published_bounds_match_the_validation() {
let schema = object().field("n", integer().min(1).max(10));
assert_eq!(schema.json()["properties"]["n"]["minimum"], 1);
assert_eq!(schema.json()["properties"]["n"]["maximum"], 10);
let schema = object().field("n", integer());
assert_eq!(schema.json()["properties"]["n"]["minimum"], i64::MIN);
assert_eq!(schema.json()["properties"]["n"]["maximum"], i64::MAX);
}
#[test]
fn duplicate_field_name_panics() {
let result =
std::panic::catch_unwind(|| object().field("a", integer()).field("a", string()));
assert!(result.is_err(), "un doublon de nom doit paniquer");
}
#[test]
fn integer_rejects_values_outside_i64_range() {
let schema = object().field("n", integer());
let issues = issues_of(&schema, json!({ "n": 9_223_372_036_854_775_808u64 }));
assert_eq!(issues.len(), 1, "{issues:?}");
let two_pow_63 = 2f64.powi(63);
assert_eq!(issues_of(&schema, json!({ "n": two_pow_63 })).len(), 1);
assert_eq!(issues_of(&schema, json!({ "n": -two_pow_63 })).len(), 1);
assert!(issues_of(&schema, json!({ "n": i64::MAX })).is_empty());
assert!(issues_of(&schema, json!({ "n": i64::MIN })).is_empty());
}
#[test]
fn enumeration_limits_values() {
let schema = object().field("mode", enumeration(&["fast", "safe"]));
assert!(issues_of(&schema, json!({ "mode": "fast" })).is_empty());
let issues = issues_of(&schema, json!({ "mode": "wild" }));
assert!(issues[0].contains("fast, safe"));
}
#[test]
fn issues_are_capped_to_bound_memory() {
let schema = array(integer());
let value = json!(vec!["x"; 200]);
let mut issues = Issues::new();
let mut path = Path::root("arguments");
schema.validate(&mut path, &value, &mut issues);
assert_eq!(issues.render().len(), MAX_ISSUES + 1);
assert!(issues.render().last().unwrap().contains("omis"));
assert!(!issues.is_empty());
}
#[test]
fn inconsistent_bounds_panic_at_construction() {
for broken in [
|| {
let _ = integer().min(10).max(5);
},
|| {
let _ = string().min(5).max(3);
},
|| {
let _ = number().min(10.0).max(5.0);
},
|| {
let _ = array(integer()).min(4).max(2);
},
] {
let result = std::panic::catch_unwind(broken);
assert!(result.is_err(), "bornes incohérentes = panique dev");
}
}
#[test]
fn non_finite_number_bounds_panic_at_construction() {
for broken in [
|| {
let _ = number().min(f64::NAN);
},
|| {
let _ = number().max(f64::NAN);
},
|| {
let _ = number().min(f64::INFINITY);
},
|| {
let _ = number().max(f64::NEG_INFINITY);
},
] {
let result = std::panic::catch_unwind(broken);
assert!(result.is_err(), "borne non finie = panique dev");
}
}
#[test]
fn broken_enumerations_panic_at_construction() {
let result = std::panic::catch_unwind(|| {
let _ = enumeration(&[]);
});
assert!(result.is_err(), "enumeration vide = panique dev");
let result = std::panic::catch_unwind(|| {
let _ = enumeration(&["a", "a", "b"]);
});
assert!(result.is_err(), "doublon dans enumeration = panique dev");
}
#[test]
fn nullable_accepts_null_and_validates_the_inner_schema() {
let required = object().field("s", nullable(string().max(3)));
let optional = object().opt("s", nullable(string().max(3)));
assert!(issues_of(&required, json!({ "s": null })).is_empty());
assert!(issues_of(&optional, json!({ "s": null })).is_empty());
assert!(!issues_of(&required, json!({})).is_empty());
assert!(issues_of(&optional, json!({})).is_empty());
assert!(issues_of(&required, json!({ "s": "abc" })).is_empty());
assert!(!issues_of(&required, json!({ "s": "abcd" })).is_empty());
assert_eq!(
required.json()["properties"]["s"]["anyOf"][1],
json!({ "type": "null" })
);
}
fn formatted(format: Format) -> Str {
match format {
Format::Email => string().email(),
Format::Uuid => string().uuid(),
Format::Url => string().url(),
Format::Datetime => string().datetime(),
}
}
#[test]
fn formats_are_published_and_enforced() {
let cases: [(Format, &str, &str, &str); 4] = [
(Format::Email, "a@b.cd", "pas-un-email", "email"),
(
Format::Uuid,
"123e4567-e89b-12d3-a456-426614174000",
"123e4567-e89b-12d3-a456-42661417400",
"uuid",
),
(Format::Url, "https://a.example/x?y=1", "pas-une-url", "uri"),
(
Format::Datetime,
"2026-10-24T18:30:00Z",
"2026-13-01T00:00:00Z",
"date-time",
),
];
for (format, valid, invalid, keyword) in cases {
let schema = object().field("v", formatted(format));
assert_eq!(schema.json()["properties"]["v"]["format"], keyword);
assert!(
issues_of(&schema, json!({ "v": valid })).is_empty(),
"{format:?} : {valid} doit passer"
);
assert!(
!issues_of(&schema, json!({ "v": invalid })).is_empty(),
"{format:?} : {invalid} doit être refusé"
);
}
}
#[test]
fn datetime_calendar_is_enforced() {
let schema = object().field("d", string().datetime());
assert!(!issues_of(&schema, json!({ "d": "2026-02-30T00:00:00Z" })).is_empty());
assert!(!issues_of(&schema, json!({ "d": "2027-02-29T00:00:00Z" })).is_empty());
assert!(issues_of(&schema, json!({ "d": "2028-02-29T00:00:00Z" })).is_empty());
assert!(issues_of(&schema, json!({ "d": "2026-10-24T18:30:00.123+02:00" })).is_empty());
assert!(!issues_of(&schema, json!({ "d": "2026-10-24T18:30:00" })).is_empty());
assert!(!issues_of(&schema, json!({ "d": "éééé-éé-ééTéé:éé:ééZ" })).is_empty());
}
#[test]
fn describe_publishes_the_description_and_delegates() {
let schema = object().field("q", describe("termes de la recherche", string().min(2)));
let published = &schema.json()["properties"]["q"];
assert_eq!(published["description"], "termes de la recherche");
assert_eq!(published["type"], "string");
assert!(issues_of(&schema, json!({ "q": "ok" })).is_empty());
assert!(!issues_of(&schema, json!({ "q": "x" })).is_empty());
}
}