schemars/schemars/tests/integration/test_helper.rs
Dmitry Dygalo f8c1fe21b7 chore: Update jsonschema to 0.20.0
Signed-off-by: Dmitry Dygalo <dmitry.dygalo@workato.com>
2025-01-13 10:25:47 +00:00

430 lines
15 KiB
Rust

use jsonschema::Validator;
use schemars::{
generate::{Contract, SchemaSettings},
JsonSchema, Schema,
};
use serde::{de::DeserializeOwned, Serialize};
use serde_json::{json, Value};
use snapbox::IntoJson;
use std::{
any::type_name, borrow::Borrow, cell::OnceCell, f64, marker::PhantomData, path::Path,
sync::OnceLock,
};
pub struct TestHelper<T: JsonSchema> {
settings: SchemaSettings,
name: String,
phantom: PhantomData<T>,
de_schema: Schema,
ser_schema: Schema,
de_schema_validator: OnceCell<Validator>,
ser_schema_validator: OnceCell<Validator>,
validator: fn(&T) -> bool,
}
impl<T: JsonSchema> TestHelper<T> {
/// Should be used via the `test!(SomeType)` macro
pub fn new(name: String, settings: SchemaSettings) -> Self {
let de_schema = schema_for::<T>(&settings, Contract::Deserialize);
let ser_schema = schema_for::<T>(&settings, Contract::Serialize);
Self {
settings,
name,
phantom: PhantomData,
de_schema,
ser_schema,
de_schema_validator: OnceCell::new(),
ser_schema_validator: OnceCell::new(),
validator: |_| true,
}
}
/// Should be used via the `test!(value: SomeType)` macro
pub fn new_for_value(name: String, settings: SchemaSettings, value: T) -> Self
where
T: Serialize,
{
let de_schema = schema_for_value(&settings, Contract::Deserialize, &value);
let ser_schema = schema_for_value(&settings, Contract::Serialize, &value);
Self {
settings,
name,
phantom: PhantomData,
de_schema,
ser_schema,
de_schema_validator: OnceCell::new(),
ser_schema_validator: OnceCell::new(),
validator: |_| true,
}
}
pub fn with_validator(&mut self, validator: fn(&T) -> bool) -> &mut Self {
self.validator = validator;
self
}
/// Checks the generated schema against the saved schema in the snapshots directory, for manual verification of changes.
///
/// Run tests with the SNAPSHOTS env var set to "overwrite" to overwrite any changed snapshots.
pub fn assert_snapshot(&self) -> &Self {
let common_path = format!("tests/integration/snapshots/{}.json", self.name);
let de_path = format!("tests/integration/snapshots/{}.de.json", self.name);
let ser_path = format!("tests/integration/snapshots/{}.ser.json", self.name);
if self.de_schema == self.ser_schema {
snapbox::assert_data_eq!(
(&self.de_schema).into_json(),
snapbox::Data::read_from(Path::new(&common_path), None).raw()
);
_ = std::fs::remove_file(de_path);
_ = std::fs::remove_file(ser_path);
} else {
snapbox::assert_data_eq!(
(&self.de_schema).into_json(),
snapbox::Data::read_from(Path::new(&de_path), None).raw()
);
snapbox::assert_data_eq!(
(&self.ser_schema).into_json(),
snapbox::Data::read_from(Path::new(&ser_path), None).raw()
);
_ = std::fs::remove_file(common_path);
}
self
}
/// Checks that the schema generated for this type is identical to that of another type.
pub fn assert_identical<T2: JsonSchema>(&self) -> &Self {
snapbox::assert_data_eq!(
(&self.de_schema).into_json(),
schema_for::<T2>(&self.settings, Contract::Deserialize)
.into_json()
.raw()
);
snapbox::assert_data_eq!(
(&self.ser_schema).into_json(),
schema_for::<T2>(&self.settings, Contract::Serialize)
.into_json()
.raw()
);
let t = type_name::<T>();
let t2 = type_name::<T2>();
assert_eq!(
T::schema_name(),
T2::schema_name(),
"`{t}` and `{t2}` have identical schemas, so should have the same schema_name"
);
assert_eq!(
T::schema_id(),
T2::schema_id(),
"`{t}` and `{t2}` have identical schemas, so should have the same schema_id"
);
assert_eq!(
T::always_inline_schema(),
T2::always_inline_schema(),
"`{t}` and `{t2}` have identical schemas, so should have the same always_inline_schema"
);
self
}
pub fn custom(&self, assertion: impl Fn(&Schema, Contract)) {
assertion(&self.de_schema, Contract::Deserialize);
assertion(&self.ser_schema, Contract::Serialize);
}
fn de_schema_validate(&self, instance: &Value) -> bool {
self.de_schema_validator
.get_or_init(|| build_validator(&self.de_schema))
.is_valid(instance)
}
fn ser_schema_validate(&self, instance: &Value) -> bool {
self.ser_schema_validator
.get_or_init(|| build_validator(&self.ser_schema))
.is_valid(instance)
}
}
fn build_validator(schema: &Schema) -> Validator {
jsonschema::options()
.should_validate_formats(true)
.build(schema.as_value())
.expect("valid schema")
}
impl<T: JsonSchema + Serialize> TestHelper<T> {
/// Checks that the "serialize" schema allows the given sample values when serialized to JSON.
/// If `T implements `DeserializeOwned`, prefer using `assert_allows_ser_roundtrip()`
pub fn assert_allows_ser_only(&self, samples: impl IntoIterator<Item = T>) -> &Self {
for sample in samples {
let json = serde_json::to_value(&sample).unwrap();
assert!(
(self.validator)(&sample),
"invalid test case - attempt to serialize value failing validation: {json}"
);
assert!(
self.ser_schema_validate(&json),
"serialize schema should allow serialized value: {json}"
);
}
self
}
}
impl<T: JsonSchema + Serialize + DeserializeOwned> TestHelper<T> {
/// Checks that the "serialize" schema allows the given sample values when serialized to JSON
/// and, if the value can then be deserialized, that the "deserialize" schema also allows it.
pub fn assert_allows_ser_roundtrip(&self, samples: impl IntoIterator<Item = T>) -> &Self {
for sample in samples {
let json = serde_json::to_value(&sample).unwrap();
assert!(
(self.validator)(&sample),
"invalid test case - attempt to serialize value failing validation: {json}"
);
assert!(
self.ser_schema_validate(&json),
"serialize schema should allow serialized value: {json}"
);
if T::deserialize(&json).is_ok() {
assert!(
(self.validator)(&sample),
"invalid test case - roundtripped value fails validation: {json}"
);
assert!(
self.de_schema_validate(&json),
"deserialize schema should allow value accepted by deserialization: {json}"
);
} else {
assert!(
!self.de_schema_validate(&json),
"deserialize schema should reject undeserializable value: {json}"
);
}
}
self
}
/// Checks that the "deserialize" schema allow the given sample values, and the "serialize"
/// schema allows the value obtained from deserializing then re-serializing the sample values
/// (only for values that can successfully be serialized).
///
/// This is intended for types that have different serialize/deserialize schemas, or when you
/// want to test specific values that are valid for deserialization but not for serialization.
pub fn assert_allows_de_roundtrip(
&self,
samples: impl IntoIterator<Item = impl Borrow<Value>>,
) -> &Self {
for sample in samples {
let sample = sample.borrow();
let Ok(deserialized) = T::deserialize(sample) else {
panic!(
"expected deserialize to succeed for {}: {sample}",
type_name::<T>()
)
};
assert!(
(self.validator)(&deserialized),
"invalid test case - deserialized value fails validation: {sample}"
);
assert!(
self.de_schema_validate(sample),
"deserialize schema should allow value accepted by deserialization: {sample}"
);
if let Ok(serialized) = serde_json::to_value(&deserialized) {
assert!(
self.ser_schema_validate(&serialized),
"serialize schema should allow serialized value: {serialized}"
);
}
}
self
}
/// Checks that the "deserialize" schema allows only the given sample values that successfully
/// deserialize and pass validation.
///
/// This is intended to be given a range of values (see `arbitrary_values`), allowing limited
/// fuzzing.
pub fn assert_matches_de_roundtrip(
&self,
samples: impl IntoIterator<Item = impl Borrow<Value>>,
) -> &Self {
for value in samples {
let value = value.borrow();
match T::deserialize(value) {
Ok(deserialized) if (self.validator)(&deserialized) => {
assert!(
self.de_schema_validate(value),
"deserialize schema should allow value accepted by deserialization: {value}"
);
if let Ok(serialized) = serde_json::to_value(&deserialized) {
assert!(
self.ser_schema_validate(&serialized),
"serialize schema should allow serialized value: {serialized}"
);
}
}
_ => {
assert!(
!self.de_schema_validate(value),
"deserialize schema should reject invalid value: {value}"
);
// This assertion isn't necessarily valid in the general case but it would be
// odd (though not necessarily wrong) for it to fail. If this does ever fail
// a case that should be legitimate, then this assert can be removed/weakened.
assert!(
!self.ser_schema_validate(value),
"serialize schema should reject invalid value: {value}"
);
}
}
}
self
}
/// Checks that the "deserialize" schema does not allow any of the given sample values.
///
/// While `assert_matches_de_roundtrip()` would also work in this case, `assert_rejects_de()`
/// has the advantage that it also verifies that the test case itself is actually covering the
/// failure case as intended.
pub fn assert_rejects_de(&self, values: impl IntoIterator<Item = impl Borrow<Value>>) -> &Self {
for value in values {
let value = value.borrow();
assert!(
T::deserialize(value).is_err(),
"invalid test case - expected deserialize to fail for {}: {value}",
type_name::<T>()
);
assert!(
!self.de_schema_validate(value),
"deserialize schema should reject invalid value: {value}"
);
}
self
}
/// Checks that neither "serialize" nor "deserialize" schemas allow any of the given sample
/// values when serialized to JSON due to the values failing validation.
pub fn assert_rejects_invalid(&self, samples: impl IntoIterator<Item = T>) -> &Self {
for sample in samples {
let json = serde_json::to_value(&sample).unwrap();
assert!(
!(self.validator)(&sample),
"invalid test case - serialized value passes validation: {json}"
);
assert!(
!self.de_schema_validate(&json),
"deserialize schema should reject invalid value: {json}"
);
assert!(
!self.ser_schema_validate(&json),
"serialize schema should reject invalid value: {json}"
);
}
self
}
/// Checks that both the "serialize" and "deserialize" schema allow the type's default value
/// when serialized to JSON.
pub fn assert_allows_ser_roundtrip_default(&self) -> &Self
where
T: Default,
{
self.assert_allows_ser_roundtrip([T::default()])
}
}
fn schema_for<T: JsonSchema>(base_settings: &SchemaSettings, contract: Contract) -> Schema {
base_settings
.clone()
.with(|s| s.contract = contract)
.into_generator()
.into_root_schema_for::<T>()
}
fn schema_for_value(
base_settings: &SchemaSettings,
contract: Contract,
value: impl Serialize,
) -> Schema {
base_settings
.clone()
.with(|s| s.contract = contract)
.into_generator()
.into_root_schema_for_value(&value)
.unwrap()
}
/// Returns an iterator over an selection of arbitrary JSON values.
///
/// This is intended to be used as `test!(...).assert_matches_de_roundtrip(arbitrary_values())`
pub fn arbitrary_values() -> impl Iterator<Item = &'static Value> {
fn primitives() -> impl Iterator<Item = Value> {
[
Value::Null,
false.into(),
true.into(),
0.into(),
255.into(),
(-1).into(),
u64::MAX.into(),
f64::consts::PI.into(),
"".into(),
"0".into(),
"3E8".into(),
"\tPâté costs “£1”\0".into(),
Value::Array(Default::default()),
Value::Object(Default::default()),
]
.into_iter()
}
// TODO once MSRV has reached 1.80, replace this with LazyLock
static VALUES: OnceLock<Vec<Value>> = OnceLock::new();
VALUES
.get_or_init(|| {
Vec::from_iter(
primitives()
.chain(primitives().map(|p| json!([p])))
.chain(primitives().map(|p| json!({"key": p}))),
)
})
.iter()
}
/// Returns an iterator over an selection of arbitrary JSON values, except for value that match
/// the given filter predicate.
///
/// This is to handle known cases of schemas not matching the actual deserialize behaviour.
pub fn arbitrary_values_except(
filter: impl Fn(&Value) -> bool,
_reason: &str,
) -> impl Iterator<Item = &'static Value> {
arbitrary_values().filter(move |v| !filter(v))
}