diff --git a/interface/src/compat.rs b/interface/src/compat.rs index 8bbff24..f7d1040 100644 --- a/interface/src/compat.rs +++ b/interface/src/compat.rs @@ -1,2 +1,548 @@ -//! The compatibility check between a consumer's import and a provider's -//! interface (PROPOSAL.md, section 11.2). +//! Checks imports against provider interfaces under PROTOCOL.md section 10.2. +//! Every sender value must fit the receiver schema. Sender objects are closed, +//! while receiver objects are open unless they reject additional properties. +use serde_json::{Map, Value, json}; +use std::{collections::HashSet, fmt}; + +/// One incompatible method, with a sender value when a finite example exists. +#[derive(Clone, Debug, PartialEq)] +pub struct Problem { + pub method: String, + pub message: String, + pub counterexample: Option, +} + +impl fmt::Display for Problem { + /// Formats the failure and identifies the receiver of its counterexample. + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "- {}: {}.", self.method, self.message)?; + if let Some(value) = &self.counterexample { + write!( + formatter, + "\n Counterexample: {value} is not valid for {}.", + if self.message.starts_with("the provider's params") { + "the provider's params" + } else { + "the consumer's result" + } + )?; + } + Ok(()) + } +} + +/// Checks every imported method, including endpoint methods. Extra provider methods are harmless. +pub fn check(expected: &Value, actual: &Value) -> Vec { + let mut problems = Vec::new(); + methods(expected, actual, expected, actual, "", &mut problems); + if let Some(endpoints) = expected["x-sausage-endpoints"].as_object() { + for (name, interface) in endpoints { + methods( + interface, + &actual["x-sausage-endpoints"][name], + expected, + actual, + &format!("{name}."), + &mut problems, + ); + } + } + problems +} + +/// Builds an object schema from the OpenRPC by-name parameter list. +fn params(method: &Value) -> Value { + let mut properties = Map::new(); + let mut required = Vec::new(); + for param in method["params"].as_array().into_iter().flatten() { + let name = param["name"].as_str().unwrap_or_default(); + properties.insert(name.into(), param["schema"].clone()); + if param["required"] == true { + required.push(name); + } + } + json!({"type":"object", "properties":properties, "required":required}) +} + +/// Checks imported methods in both directions and records contextual diagnostics. +fn methods( + expected: &Value, + actual: &Value, + expected_doc: &Value, + actual_doc: &Value, + prefix: &str, + problems: &mut Vec, +) { + for method in expected["methods"].as_array().into_iter().flatten() { + let name = method["name"].as_str().unwrap_or_default(); + let label = format!("{prefix}{name}"); + let Some(provider) = actual["methods"] + .as_array() + .into_iter() + .flatten() + .find(|m| m["name"] == name) + else { + problems.push(Problem { + method: label, + message: "the provider has no such method".into(), + counterexample: None, + }); + continue; + }; + for (part, sender, receiver, sender_doc, receiver_doc) in [ + ( + "params", + params(method), + params(provider), + expected_doc, + actual_doc, + ), + ( + "result", + provider["result"]["schema"].clone(), + method["result"]["schema"].clone(), + actual_doc, + expected_doc, + ), + ] { + let failure = supported(&sender, sender_doc, &mut HashSet::new()) + .and_then(|()| supported(&receiver, receiver_doc, &mut HashSet::new())) + .err() + .map(|message| (message, None)) + .or_else(|| { + subset( + &sender, + &receiver, + sender_doc, + receiver_doc, + &mut HashSet::new(), + ) + .err() + }); + if let Some((message, counterexample)) = failure { + problems.push(Problem { + method: label.clone(), + message: { + let (sender_name, receiver_name) = if part == "params" { + ("consumer", "provider") + } else { + ("provider", "consumer") + }; + let detail = message + .replace("the receiver", &format!("the {receiver_name}")) + .replace("the sender", &format!("the {sender_name}")); + let detail = if part == "params" { + detail.replacen("the field", "the param", 1) + } else { + detail + }; + let verb = if part == "params" { "are" } else { "is" }; + format!("the {receiver_name}'s {part} {verb} incompatible: {detail}") + }, + counterexample, + }); + } + } + } +} + +/// Resolves references in one document and stops at reference cycles. +fn resolve<'document>(sender: &'document Value, document: &'document Value) -> &'document Value { + let mut sender = sender; + let mut seen = HashSet::new(); + while let Some(reference) = sender["$ref"].as_str() { + if !seen.insert(reference) { + break; + } + let Some(next) = reference + .strip_prefix('#') + .and_then(|p| document.pointer(p)) + else { + break; + }; + sender = next; + } + sender +} + +/// Rejects constructs outside the supported subset before comparison. +fn supported(sender: &Value, document: &Value, seen: &mut HashSet) -> Result<(), String> { + if !seen.insert(sender as *const Value as usize) { + return Ok(()); + } + if sender.is_boolean() { + return Ok(()); + } + let Some(object) = sender.as_object() else { + return Err("unsupported schema construct non-schema".into()); + }; + for (key, value) in object { + match key.as_str() { + "$ref" => { + let target = resolve(sender, document); + if std::ptr::eq(target, sender) { + return Err("unsupported schema construct unresolved $ref".into()); + } + supported(target, document, seen)?; + } + "properties" | "definitions" | "$defs" => { + for child in value.as_object().into_iter().flat_map(|m| m.values()) { + supported(child, document, seen)?; + } + } + "items" => supported(value, document, seen)?, + "anyOf" | "oneOf" => { + let branches = value + .as_array() + .ok_or_else(|| format!("unsupported schema construct {key}"))?; + if !branches.iter().all(|b| strings(b).is_some()) + && !(branches.len() == 2 && branches.iter().any(|b| b["type"] == "null")) + { + return Err(format!("unsupported schema construct {key}")); + } + for child in branches { + supported(child, document, seen)?; + } + } + "additionalProperties" if value.is_boolean() => (), + "enum" + if value + .as_array() + .is_some_and(|v| v.iter().all(Value::is_string)) => {} + "const" if value.is_string() => (), + "type" => { + let types: Vec<_> = value + .as_array() + .map(|actual| actual.iter().collect()) + .unwrap_or_else(|| vec![value]); + if !types.iter().all(|t| { + matches!( + t.as_str(), + Some( + "object" + | "array" + | "string" + | "integer" + | "number" + | "null" + | "boolean" + ) + ) + }) { + return Err("unsupported schema construct type".into()); + } + } + "required" | "minimum" | "maximum" | "title" | "description" | "default" + | "examples" | "format" | "$schema" | "$id" | "deprecated" | "readOnly" + | "writeOnly" => (), + _ => return Err(format!("unsupported schema construct {key}")), + } + } + Ok(()) +} + +/// Expands nullable schemas and string unions into individual alternatives. +fn alternatives(sender: &Value) -> Vec { + if let Some(types) = sender["type"].as_array() { + return types + .iter() + .map(|t| { + let mut alternative = sender.clone(); + alternative["type"] = t.clone(); + alternative + }) + .collect(); + } + for key in ["anyOf", "oneOf"] { + if let Some(branches) = sender[key].as_array() { + return branches.iter().flat_map(alternatives).collect(); + } + } + vec![sender.clone()] +} +/// Returns the primitive type, including the implicit type of string enums. +fn kind(sender: &Value) -> &str { + sender["type"].as_str().unwrap_or( + if sender["const"].is_string() || sender["enum"].is_array() { + "string" + } else { + "any" + }, + ) +} +/// Returns the finite string values of an enum or constant schema. +fn strings(sender: &Value) -> Option> { + sender["enum"] + .as_array() + .cloned() + .or_else(|| sender.get("const").map(|v| vec![v.clone()])) +} +/// Constructs a finite sender value, or returns None for an unavoidable cycle. +fn sample(sender: &Value, document: &Value, seen: &mut HashSet) -> Option { + let sender = resolve(sender, document); + if !seen.insert(sender as *const Value as usize) { + return None; + } + let result = alternatives(sender).into_iter().find_map(|sender| { + if sender == false { + return None; + } + if let Some(values) = strings(&sender) { + return values.first().cloned(); + } + Some(match kind(&sender) { + "object" => { + let mut object = Map::new(); + for key in sender["required"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + { + object.insert( + key.into(), + sample(&sender["properties"][key], document, seen)?, + ); + } + Value::Object(object) + } + "array" => json!([]), + "string" => json!("milk"), + "boolean" => json!(false), + "integer" | "number" => sender + .get("minimum") + .or(sender.get("maximum")) + .cloned() + .unwrap_or(json!(0)), + _ => Value::Null, + }) + }); + seen.remove(&(sender as *const Value as usize)); + result +} + +type Failure = (String, Option); +/// Checks sender inclusion in the receiver and assumes active reference pairs fit. +fn subset( + sender: &Value, + receiver: &Value, + sender_doc: &Value, + receiver_doc: &Value, + active: &mut HashSet<(usize, usize)>, +) -> Result<(), Failure> { + let (sender, receiver) = (resolve(sender, sender_doc), resolve(receiver, receiver_doc)); + let pair = ( + sender as *const Value as usize, + receiver as *const Value as usize, + ); + if !active.insert(pair) { + return Ok(()); + } + let result = (|| { + if sender == false || receiver == true { + return Ok(()); + } + let sender_alternatives: Vec<_> = alternatives(sender) + .into_iter() + .flat_map(|v| { + if let Some(values) = v["enum"].as_array() { + values.iter().map(|value| json!({"const": value})).collect() + } else { + vec![v] + } + }) + .collect(); + let mut receiver_alternatives = alternatives(receiver); + if receiver_alternatives.iter().all(|v| strings(v).is_some()) { + receiver_alternatives = vec![ + json!({"enum": receiver_alternatives.iter().flat_map(|v| strings(v).unwrap()).collect::>()}), + ]; + } + for sender_variant in &sender_alternatives { + let mut failure = None; + let mut accepted = false; + for receiver_variant in &receiver_alternatives { + match compare_variants( + sender_variant, + receiver_variant, + sender_doc, + receiver_doc, + active, + ) { + Ok(()) => { + accepted = true; + break; + } + Err(problem) => failure = Some(problem), + } + } + if !accepted { + return Err(failure.unwrap_or(( + "no receiver alternative accepts the sender value".into(), + sample(sender_variant, sender_doc, &mut HashSet::new()), + ))); + } + } + Ok(()) + })(); + active.remove(&pair); + result +} +/// Compares individual alternatives and wraps nested counterexamples in their parent values. +fn compare_variants( + sender: &Value, + receiver: &Value, + sender_doc: &Value, + receiver_doc: &Value, + active: &mut HashSet<(usize, usize)>, +) -> Result<(), Failure> { + if sender.get("$ref").is_some() || receiver.get("$ref").is_some() { + return subset(sender, receiver, sender_doc, receiver_doc, active); + } + let fail = |message: String| (message, sample(sender, sender_doc, &mut HashSet::new())); + let (sender_type, receiver_type) = (kind(sender), kind(receiver)); + if sender_type == "number" && receiver_type == "integer" { + let low = sender["minimum"] + .as_f64() + .unwrap_or_else(|| sender["maximum"].as_f64().map_or(0.0, |v| v.floor() - 1.0)); + let high = sender["maximum"].as_f64().unwrap_or(low + 2.0); + let value = if low.fract() != 0.0 { low } else { low + 0.5 }; + return Err(( + "the sender sends number, but the receiver expects integer".into(), + (value <= high).then(|| json!(value)), + )); + } + if receiver == false + || (receiver_type != "any" + && sender_type != receiver_type + && !(sender_type == "integer" && receiver_type == "number")) + { + return Err(fail(format!( + "the sender sends {sender_type}, but the receiver expects {receiver_type}" + ))); + } + if receiver_type == "any" { + return Ok(()); + } + match sender_type { + "object" => { + for key in receiver["required"].as_array().into_iter().flatten() { + if !sender["required"] + .as_array() + .is_some_and(|v| v.contains(key)) + { + return Err(fail(format!( + "the receiver requires the field {key} ({}), but the sender does not send it", + kind(resolve( + &receiver["properties"][key.as_str().unwrap_or_default()], + receiver_doc + )) + ))); + } + } + for (key, child) in sender["properties"] + .as_object() + .into_iter() + .flat_map(|m| m.iter()) + { + let error = if let Some(other) = receiver["properties"].get(key) { + subset(child, other, sender_doc, receiver_doc, active).err() + } else if receiver["additionalProperties"] == false { + Some(( + format!( + "the receiver rejects the field {key:?} ({}), which the sender can send", + kind(resolve(child, sender_doc)) + ), + sample(child, sender_doc, &mut HashSet::new()), + )) + } else { + None + }; + if let Some((message, example)) = error { + let mut object = sample(sender, sender_doc, &mut HashSet::new()); + if let (Some(Value::Object(object)), Some(value)) = (&mut object, example) { + object.insert(key.clone(), value); + } else { + object = None; + } + return Err(( + format!("the field {key:?} is incompatible: {message}"), + object, + )); + } + } + } + "array" => { + if let Err((message, example)) = subset( + sender.get("items").unwrap_or(&Value::Bool(true)), + receiver.get("items").unwrap_or(&Value::Bool(true)), + sender_doc, + receiver_doc, + active, + ) { + return Err((message, example.map(|v| json!([v])))); + } + } + "integer" | "number" => { + for (key, low) in [("minimum", true), ("maximum", false)] { + if let Some(bound) = receiver[key].as_f64() + && sender[key].as_f64().is_none_or(|v| { + let integer = |v: &Value| { + v.as_i64() + .map(i128::from) + .or_else(|| v.as_u64().map(i128::from)) + }; + if let (Some(sender), Some(receiver)) = + (integer(&sender[key]), integer(&receiver[key])) + { + if low { + sender < receiver + } else { + sender > receiver + } + } else if low { + v < bound + } else { + v > bound + } + }) + { + let witness = sender + .get(key) + .cloned() + .unwrap_or_else(|| json!(if low { bound - 1.0 } else { bound + 1.0 })); + return Err(( + format!( + "the sender sends {sender_type} outside the receiver's {key} of {}", + receiver[key] + ), + Some(witness), + )); + } + } + } + "string" => { + if let Some(allowed) = strings(receiver) { + let bad = match strings(sender) { + Some(values) => values.into_iter().find(|v| !allowed.contains(v)), + None => { + let mut text = "milk".to_string(); + while allowed.contains(&json!(text)) { + text.push('!'); + } + Some(json!(text)) + } + }; + if let Some(value) = bad { + return Err(( + "the sender sends a string variant outside the receiver's string enum" + .into(), + Some(value), + )); + } + } + } + _ => (), + } + Ok(()) +} diff --git a/interface/tests/compat.rs b/interface/tests/compat.rs new file mode 100644 index 0000000..6117d8b --- /dev/null +++ b/interface/tests/compat.rs @@ -0,0 +1,374 @@ +//! 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(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::()).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() -> Value { + let params_schema = schema::(false); + let mut result_schema = schema::(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, +} +#[derive(JsonSchema, Serialize, Deserialize)] +struct Nullable { + text: Option, +} +#[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>, +} +#[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::(), &doc::()); + 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::(), &doc::()).is_empty()); +} +/// The provider can add a result field because the consumer accepts unknown fields. +#[test] +fn compat_added_result_field() { + assert!(check(&doc::(), &doc::()).is_empty()); +} +/// A removed method breaks its consumer. +#[test] +fn compat_removed_method() { + let expected = doc::(); + 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::(), &doc::()); + 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::(); + 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::(), &doc::()).is_empty()); +} +/// A serde default makes a new param optional for deserialization. +#[test] +fn compat_default_fields() { + assert!(check(&doc::(), &doc::()).is_empty()); +} +/// Nested struct definitions resolve within each document. +#[test] +fn compat_nested_refs() { + assert!(check(&doc::(), &doc::()).is_empty()); +} +/// Recursive references terminate through active schema pairs. +#[test] +fn compat_recursive_types() { + assert!( + check( + &doc::(), + &doc::() + ) + .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::()}}); + 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 { + 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::(),any::(),any::()).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) -> 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::>() + ), + "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::(), choices in proptest::collection::vec(proptest::collection::vec(any::(),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}"); } + } + } +}