//! 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}"); } } } }