Files
sausage/interface/src/compat.rs
T
surma 4e41061ed4 sausage-interface: compatibility check for imports (PROTOCOL.md 10.2)
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.
2026-10-23 00:03:03 +01:00

549 lines
20 KiB
Rust

//! 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<Value>,
}
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<Problem> {
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<Problem>,
) {
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<usize>) -> 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<Value> {
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<Vec<Value>> {
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<usize>) -> Option<Value> {
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<Value>);
/// 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::<Vec<_>>()}),
];
}
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(())
}