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