Params go from consumer to provider, results from provider to consumer. Sender objects are closed, receiver objects open unless they reject additional properties. Covers the Draft 7 subset with refs, cycles, nullable values, string enums, arrays, and integer ranges, rejects other constructs, and gives counterexamples. Property test over random subset schemas.
375 lines
14 KiB
Rust
375 lines
14 KiB
Rust
//! Draft 7 schemas and randomized subset values exercise the compatibility contract.
|
|
#![allow(dead_code)]
|
|
use proptest::prelude::*;
|
|
use sausage_interface::compat::check;
|
|
use schemars::{JsonSchema, generate::SchemaSettings};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Value, json};
|
|
|
|
/// Generates a shared-definition schema under the selected serde contract.
|
|
fn schema<T: JsonSchema>(serialize: bool) -> Value {
|
|
let settings = SchemaSettings::draft07().with(|settings| {
|
|
settings.definitions_path = "/components/schemas/".into();
|
|
});
|
|
let mut generator = if serialize {
|
|
settings.for_serialize()
|
|
} else {
|
|
settings.for_deserialize()
|
|
}
|
|
.into_generator();
|
|
let root = serde_json::to_value(generator.subschema_for::<T>()).unwrap();
|
|
let definitions = generator.take_definitions(true);
|
|
let mut root = root["$ref"]
|
|
.as_str()
|
|
.and_then(|reference| reference.strip_prefix("#/components/schemas/"))
|
|
.and_then(|name| definitions.get(name))
|
|
.cloned()
|
|
.unwrap_or(root);
|
|
root["definitions"] = json!(definitions);
|
|
root
|
|
}
|
|
|
|
/// Builds a method document with deserialize params and a serialize result.
|
|
fn doc<Params: JsonSchema, Result: JsonSchema>() -> Value {
|
|
let params_schema = schema::<Params>(false);
|
|
let mut result_schema = schema::<Result>(true);
|
|
let mut definitions = params_schema["definitions"]
|
|
.as_object()
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
definitions.extend(
|
|
result_schema
|
|
.as_object_mut()
|
|
.unwrap()
|
|
.remove("definitions")
|
|
.unwrap()
|
|
.as_object()
|
|
.unwrap()
|
|
.clone(),
|
|
);
|
|
let params: Vec<_> = params_schema["properties"].as_object().into_iter().flat_map(|properties| properties.iter()).map(|(name, schema)| {
|
|
json!({"name":name,"required":params_schema["required"].as_array().is_some_and(|required| required.contains(&json!(name))),"schema":schema})
|
|
}).collect();
|
|
json!({"methods":[{"name":"add","params":params,"result":{"name":"result","schema":result_schema}}],"components":{"schemas":definitions}})
|
|
}
|
|
#[derive(JsonSchema, Serialize, Deserialize)]
|
|
struct Text {
|
|
text: String,
|
|
}
|
|
#[derive(JsonSchema, Serialize, Deserialize)]
|
|
struct Body {
|
|
body: String,
|
|
}
|
|
#[derive(JsonSchema, Serialize, Deserialize)]
|
|
struct Optional {
|
|
text: String,
|
|
extra: Option<String>,
|
|
}
|
|
#[derive(JsonSchema, Serialize, Deserialize)]
|
|
struct Nullable {
|
|
text: Option<String>,
|
|
}
|
|
#[derive(JsonSchema, Serialize, Deserialize)]
|
|
struct Defaulted {
|
|
text: String,
|
|
#[serde(default)]
|
|
extra: String,
|
|
}
|
|
#[derive(JsonSchema, Serialize, Deserialize)]
|
|
struct Nested {
|
|
value: Text,
|
|
}
|
|
#[derive(JsonSchema, Serialize, Deserialize)]
|
|
struct Recursive {
|
|
next: Option<Box<Recursive>>,
|
|
}
|
|
#[derive(JsonSchema, Serialize, Deserialize)]
|
|
enum Small {
|
|
A,
|
|
}
|
|
#[derive(JsonSchema, Serialize, Deserialize)]
|
|
enum Large {
|
|
A,
|
|
/// The other variant.
|
|
B,
|
|
}
|
|
|
|
/// A renamed required param produces a concrete diagnostic for the provider's params.
|
|
#[test]
|
|
fn compat_renamed_param() {
|
|
let failures = check(&doc::<Text, Text>(), &doc::<Body, Text>());
|
|
assert_eq!(failures.len(), 1);
|
|
assert_eq!(failures[0].counterexample, Some(json!({"text":"milk"})));
|
|
assert_eq!(
|
|
failures[0].to_string(),
|
|
"- add: the provider's params are incompatible: the provider requires the param \"body\" (string), but the consumer does not send it.\n Counterexample: {\"text\":\"milk\"} is not valid for the provider's params."
|
|
);
|
|
}
|
|
/// The provider can add an optional param.
|
|
#[test]
|
|
fn compat_optional_param() {
|
|
assert!(check(&doc::<Text, Text>(), &doc::<Optional, Text>()).is_empty());
|
|
}
|
|
/// The provider can add a result field because the consumer accepts unknown fields.
|
|
#[test]
|
|
fn compat_added_result_field() {
|
|
assert!(check(&doc::<Text, Text>(), &doc::<Text, Optional>()).is_empty());
|
|
}
|
|
/// A removed method breaks its consumer.
|
|
#[test]
|
|
fn compat_removed_method() {
|
|
let expected = doc::<Text, Text>();
|
|
let mut actual = expected.clone();
|
|
actual["methods"] = json!([]);
|
|
assert_eq!(check(&expected, &actual)[0].method, "add");
|
|
}
|
|
/// A changed result type identifies the consumer as the receiver.
|
|
#[test]
|
|
fn compat_changed_type() {
|
|
let failures = check(&doc::<Text, String>(), &doc::<Text, u32>());
|
|
assert!(
|
|
failures[0]
|
|
.message
|
|
.contains("the provider sends integer, but the consumer expects string")
|
|
);
|
|
assert!(
|
|
failures[0]
|
|
.to_string()
|
|
.ends_with("is not valid for the consumer's result.")
|
|
);
|
|
}
|
|
/// A new required param rejects an existing consumer call.
|
|
#[test]
|
|
fn compat_required_param() {
|
|
let expected = doc::<Text, Text>();
|
|
let mut actual = expected.clone();
|
|
actual["methods"][0]["params"]
|
|
.as_array_mut()
|
|
.unwrap()
|
|
.push(json!({"name":"extra","required":true,"schema":{"type":"string"}}));
|
|
assert!(!check(&expected, &actual).is_empty());
|
|
}
|
|
/// Option fields permit omission and null, which a required string rejects.
|
|
#[test]
|
|
fn compat_option_fields() {
|
|
assert!(!check(&doc::<Nullable, Text>(), &doc::<Text, Text>()).is_empty());
|
|
}
|
|
/// A serde default makes a new param optional for deserialization.
|
|
#[test]
|
|
fn compat_default_fields() {
|
|
assert!(check(&doc::<Text, Text>(), &doc::<Defaulted, Defaulted>()).is_empty());
|
|
}
|
|
/// Nested struct definitions resolve within each document.
|
|
#[test]
|
|
fn compat_nested_refs() {
|
|
assert!(check(&doc::<Nested, Nested>(), &doc::<Nested, Nested>()).is_empty());
|
|
}
|
|
/// Recursive references terminate through active schema pairs.
|
|
#[test]
|
|
fn compat_recursive_types() {
|
|
assert!(
|
|
check(
|
|
&doc::<Recursive, Recursive>(),
|
|
&doc::<Recursive, Recursive>()
|
|
)
|
|
.is_empty()
|
|
);
|
|
}
|
|
/// Documented enum variants support safe narrowing but reject new result variants.
|
|
#[test]
|
|
fn compat_enums() {
|
|
assert!(check(&doc::<(), Large>(), &doc::<(), Small>()).is_empty());
|
|
assert!(!check(&doc::<(), Small>(), &doc::<(), Large>()).is_empty());
|
|
}
|
|
/// Integer ranges preserve u8 versus u32 and exact bounds above the f64 integer range.
|
|
#[test]
|
|
fn compat_integer_ranges() {
|
|
assert!(check(&doc::<(), u32>(), &doc::<(), u8>()).is_empty());
|
|
assert!(!check(&doc::<(), u8>(), &doc::<(), u32>()).is_empty());
|
|
assert!(!fits(
|
|
json!({"type":"integer","maximum":9007199254740993u64}),
|
|
json!({"type":"integer","maximum":9007199254740992u64})
|
|
));
|
|
}
|
|
/// Endpoint methods use the same rules and include the endpoint name in problems.
|
|
#[test]
|
|
fn compat_endpoint_methods() {
|
|
let expected = json!({"methods":[],"x-sausage-endpoints":{"control":doc::<Text, Text>()}});
|
|
let mut actual = expected.clone();
|
|
actual["x-sausage-endpoints"]["control"]["methods"] = json!([]);
|
|
assert_eq!(check(&expected, &actual)[0].method, "control.add");
|
|
}
|
|
/// Unsupported constructs fail explicitly, including constructs behind references.
|
|
#[test]
|
|
fn compat_unsupported_construct() {
|
|
let mut actual = result_doc(json!({"$ref":"#/definitions/Text"}));
|
|
actual["definitions"] = json!({"Text":{"type":"string","pattern":"x"}});
|
|
assert!(
|
|
check(&result_doc(json!({"type":"string"})), &actual)[0]
|
|
.message
|
|
.contains("unsupported schema construct pattern")
|
|
);
|
|
}
|
|
/// Nullable union spellings accept the same values, but plain strings reject null.
|
|
#[test]
|
|
fn compat_nullable_unions() {
|
|
assert!(fits(
|
|
json!({"anyOf":[{"type":"string"},{"type":"null"}]}),
|
|
json!({"type":["string","null"]})
|
|
));
|
|
assert!(!fits(
|
|
json!({"type":["string","null"]}),
|
|
json!({"type":"string"})
|
|
));
|
|
}
|
|
/// Nested nullable unions still reject values outside every branch.
|
|
#[test]
|
|
fn compat_nested_nullable_unions() {
|
|
assert!(!fits(
|
|
json!({"type":"integer","minimum":0,"maximum":0}),
|
|
json!({"anyOf":[{"anyOf":[{"type":"string"},{"type":"null"}]},{"type":"null"}]})
|
|
));
|
|
}
|
|
/// A closed receiver rejects extra fields that an open receiver accepts.
|
|
#[test]
|
|
fn compat_closed_objects() {
|
|
let sender = json!({"type":"object","properties":{"extra":{"type":"string"}}});
|
|
assert!(fits(sender.clone(), json!({"type":"object"})));
|
|
assert!(!fits(
|
|
sender,
|
|
json!({"type":"object","additionalProperties":false})
|
|
));
|
|
}
|
|
/// Integers fit numbers, but numbers do not fit integers.
|
|
#[test]
|
|
fn compat_numeric_types() {
|
|
assert!(fits(json!({"type":"integer"}), json!({"type":"number"})));
|
|
assert!(!fits(json!({"type":"number"}), json!({"type":"integer"})));
|
|
}
|
|
/// Legacy definitions references resolve in the sender document.
|
|
#[test]
|
|
fn compat_definitions_ref() {
|
|
let mut actual = result_doc(json!({"$ref":"#/definitions/Text"}));
|
|
actual["definitions"] = json!({"Text":{"type":"string"}});
|
|
assert!(check(&result_doc(json!({"type":"string"})), &actual).is_empty());
|
|
}
|
|
/// Wraps one result schema in a minimal OpenRPC document.
|
|
fn result_doc(schema: Value) -> Value {
|
|
json!({"methods":[{"name":"value","params":[],"result":{"schema":schema}}]})
|
|
}
|
|
/// Checks inclusion in the result direction.
|
|
fn fits(sender: Value, receiver: Value) -> bool {
|
|
check(&result_doc(receiver), &result_doc(sender)).is_empty()
|
|
}
|
|
|
|
/// Generates bounded recursive schemas across the supported data shapes.
|
|
fn random_schema() -> BoxedStrategy<Value> {
|
|
let leaf = prop_oneof![
|
|
Just(json!({"type":"string"})),
|
|
Just(json!({"type":"boolean"})),
|
|
Just(json!({"type":"null"})),
|
|
(-20i64..20, 0i64..40).prop_map(
|
|
|(minimum, width)| json!({"type":"integer","minimum":minimum,"maximum":minimum+width})
|
|
),
|
|
proptest::collection::vec("[a-c]{1,3}", 1..5)
|
|
.prop_map(|values| json!({"type":"string","enum":values})),
|
|
];
|
|
leaf.prop_recursive(3, 32, 4, |inner| prop_oneof![
|
|
inner.clone().prop_map(|items|json!({"type":"array","items":items})),
|
|
inner.clone().prop_map(|schema|json!({"anyOf":[schema,{"type":"null"}]})),
|
|
(inner.clone(),inner,any::<bool>(),any::<bool>(),any::<bool>()).prop_map(|(first,second,require_first,require_second,closed)| {
|
|
let required: Vec<_> = [("first",require_first),("second",require_second)].into_iter().filter_map(|(name,required)|required.then_some(name)).collect();
|
|
json!({"type":"object","properties":{"first":first,"second":second},"required":required,"additionalProperties":!closed})
|
|
}),
|
|
]).boxed()
|
|
}
|
|
/// Independently validates generated schemas with open receiver object semantics.
|
|
fn valid(schema: &Value, value: &Value) -> bool {
|
|
if let Some(branches) = schema["anyOf"].as_array() {
|
|
return branches.iter().any(|branch| valid(branch, value));
|
|
}
|
|
if let Some(values) = schema["enum"].as_array() {
|
|
return values.contains(value);
|
|
}
|
|
match schema["type"].as_str().unwrap() {
|
|
"null" => value.is_null(),
|
|
"string" => value.is_string(),
|
|
"boolean" => value.is_boolean(),
|
|
"integer" => value.as_i64().is_some_and(|number| {
|
|
number >= schema["minimum"].as_i64().unwrap()
|
|
&& number <= schema["maximum"].as_i64().unwrap()
|
|
}),
|
|
"array" => value
|
|
.as_array()
|
|
.is_some_and(|items| items.iter().all(|item| valid(&schema["items"], item))),
|
|
"object" => value.as_object().is_some_and(|object| {
|
|
schema["required"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.all(|name| object.contains_key(name.as_str().unwrap()))
|
|
&& object.iter().all(|(name, value)| {
|
|
schema["properties"]
|
|
.get(name)
|
|
.map_or(schema["additionalProperties"] != false, |child| {
|
|
valid(child, value)
|
|
})
|
|
})
|
|
}),
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
/// Generates a valid sender value from random choices without discarding optional fields globally.
|
|
fn generate_value(schema: &Value, choices: &mut impl Iterator<Item = u8>) -> Value {
|
|
let choice = choices.next().unwrap_or(0) as usize;
|
|
if let Some(branches) = schema["anyOf"].as_array() {
|
|
return generate_value(&branches[choice % branches.len()], choices);
|
|
}
|
|
if let Some(values) = schema["enum"].as_array() {
|
|
return values[choice % values.len()].clone();
|
|
}
|
|
match schema["type"].as_str().unwrap() {
|
|
"null" => Value::Null,
|
|
"string" => json!(format!("text{choice}")),
|
|
"boolean" => json!(choice.is_multiple_of(2)),
|
|
"integer" => {
|
|
let minimum = schema["minimum"].as_i64().unwrap();
|
|
let maximum = schema["maximum"].as_i64().unwrap();
|
|
json!(minimum + choice as i64 % (maximum - minimum + 1))
|
|
}
|
|
"array" => json!(
|
|
(0..choice % 4)
|
|
.map(|_| generate_value(&schema["items"], choices))
|
|
.collect::<Vec<_>>()
|
|
),
|
|
"object" => {
|
|
let mut object = serde_json::Map::new();
|
|
for (name, child) in schema["properties"].as_object().unwrap() {
|
|
if schema["required"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!(name))
|
|
|| choices.next().unwrap_or(0).is_multiple_of(2)
|
|
{
|
|
object.insert(name.clone(), generate_value(child, choices));
|
|
}
|
|
}
|
|
Value::Object(object)
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
proptest! {
|
|
#![proptest_config(ProptestConfig { failure_persistence: None, ..ProptestConfig::default() })]
|
|
/// Random supported schemas preserve every sampled sender value after successful inclusion checks.
|
|
#[test]
|
|
fn compat_subset_property(sender in random_schema(), independent_receiver in random_schema(), reuse_sender in any::<bool>(), choices in proptest::collection::vec(proptest::collection::vec(any::<u8>(),64..128),1..12)) {
|
|
let receiver = if reuse_sender { sender.clone() } else { independent_receiver };
|
|
for choices in choices {
|
|
let value = generate_value(&sender,&mut choices.into_iter());
|
|
prop_assert!(valid(&sender,&value));
|
|
if fits(sender.clone(),receiver.clone()) { prop_assert!(valid(&receiver,&value), "sender={sender}, receiver={receiver}, value={value}"); }
|
|
}
|
|
}
|
|
}
|