use crate::wire::ir::ResolvedEndpoint;
#[derive(Debug, Clone)]
pub struct CallSite {
pub caller: String,
pub snippet: String,
pub method: String,
pub reads: Vec<String>,
pub writes: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ViolationKind {
ResponseFieldMissing(String),
RequestFieldUnknown(String),
RequestFieldMissing(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Violation {
pub kind: ViolationKind,
pub snippet: String,
pub caller: String,
pub endpoint: String,
pub route: String,
}
impl Violation {
#[must_use]
pub fn message(&self) -> String {
let Self {
snippet,
caller,
endpoint,
route,
..
} = self;
let detail = match &self.kind {
ViolationKind::ResponseFieldMissing(f) => format!(
"reads response field `{f}`, which endpoint `{endpoint}` ({route}) no longer produces"
),
ViolationKind::RequestFieldUnknown(f) => format!(
"sets request field `{f}`, which endpoint `{endpoint}` ({route}) does not accept"
),
ViolationKind::RequestFieldMissing(f) => {
format!(
"does not set request field `{f}`, which endpoint `{endpoint}` ({route}) \
requires and the request type does not always put on the wire"
)
}
};
format!("wire contract broken in `{caller}` at `{snippet}`: {detail}")
}
}
#[must_use]
pub fn check(site: &CallSite, endpoint: &ResolvedEndpoint) -> Vec<Violation> {
let mut out = Vec::new();
let mut report = |kind| {
out.push(Violation {
kind,
snippet: site.snippet.clone(),
caller: site.caller.clone(),
endpoint: endpoint.endpoint.id(),
route: format!("{} {}", endpoint.endpoint.method, endpoint.endpoint.path),
});
};
for read in &site.reads {
if endpoint.response.produced(read).is_none() {
report(ViolationKind::ResponseFieldMissing(read.clone()));
}
}
let Some(writes) = &site.writes else {
return out;
};
for write in writes {
if endpoint.request.accepted(write).is_none() {
report(ViolationKind::RequestFieldUnknown(write.clone()));
}
}
for required in endpoint.request.deserialized.iter().filter(|f| f.required) {
let sent = endpoint.request.produced(&required.rust_name);
let never_sent = sent.is_none();
let sometimes_sent = sent.is_some_and(|f| !f.required);
if never_sent || (sometimes_sent && !writes.contains(&required.rust_name)) {
report(ViolationKind::RequestFieldMissing(
required.rust_name.clone(),
));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wire::ir::{EndpointDescriptor, WireFieldDescriptor, WireTypeDescriptor};
fn field(rust_name: &str, required: bool) -> WireFieldDescriptor {
WireFieldDescriptor {
rust_name: rust_name.to_owned(),
wire_name: rust_name.to_owned(),
ty: "String".to_owned(),
required,
aliases: Vec::new(),
}
}
fn endpoint() -> ResolvedEndpoint {
ResolvedEndpoint {
endpoint: EndpointDescriptor {
service: "catalog".to_owned(),
name: "get_item".to_owned(),
endpoint_ident: "get_item_endpoint".to_owned(),
krate: "catalog".to_owned(),
method: "POST".to_owned(),
path: "/items".to_owned(),
request_type: "NewItem".to_owned(),
response_type: "Item".to_owned(),
},
request: WireTypeDescriptor {
name: "NewItem".to_owned(),
serialized: vec![field("name", true), field("price_cents", true)],
deserialized: vec![field("name", true), field("price_cents", true)],
closed: false,
},
response: WireTypeDescriptor {
name: "Item".to_owned(),
serialized: vec![
field("id", true),
field("name", true),
field("price_cents", true),
],
deserialized: vec![
field("id", true),
field("name", true),
field("price_cents", true),
],
closed: false,
},
}
}
fn site(reads: &[&str], writes: Option<&[&str]>) -> CallSite {
CallSite {
caller: "show_item".to_owned(),
snippet: "catalog.get_item(req)".to_owned(),
method: "get_item".to_owned(),
reads: reads.iter().map(|s| (*s).to_owned()).collect(),
writes: writes.map(|w| w.iter().map(|s| (*s).to_owned()).collect()),
}
}
#[test]
fn agreeing_call_site_has_no_violations() {
let v = check(&site(&["id", "name"], Some(&["name"])), &endpoint());
assert!(
v.is_empty(),
"compatible call site must not be rejected: {v:?}"
);
}
#[test]
fn reading_a_field_the_service_does_not_produce_is_a_violation() {
let v = check(&site(&["id", "sku"], Some(&["name"])), &endpoint());
assert_eq!(
v.iter().map(|v| v.kind.clone()).collect::<Vec<_>>(),
vec![ViolationKind::ResponseFieldMissing("sku".to_owned())]
);
}
#[test]
fn setting_a_field_the_service_does_not_accept_is_a_violation() {
let v = check(&site(&["id"], Some(&["name", "colour"])), &endpoint());
assert_eq!(
v.iter().map(|v| v.kind.clone()).collect::<Vec<_>>(),
vec![ViolationKind::RequestFieldUnknown("colour".to_owned())]
);
}
#[test]
fn leaving_an_always_sent_required_field_out_of_the_literal_is_not_a_violation() {
let v = check(&site(&["id"], Some(&["price_cents"])), &endpoint());
assert!(
v.is_empty(),
"`name` is always serialized, so the body carries it: {v:?}"
);
}
#[test]
fn not_setting_a_required_field_the_request_may_omit_is_a_violation() {
let mut e = endpoint();
e.request.serialized.push(field("sku", false));
e.request.deserialized.push(field("sku", true));
let v = check(&site(&["id"], Some(&["name"])), &e);
assert_eq!(
v.iter().map(|v| v.kind.clone()).collect::<Vec<_>>(),
vec![ViolationKind::RequestFieldMissing("sku".to_owned())]
);
assert_eq!(
check(&site(&["id"], Some(&["name", "sku"])), &e),
[] as [Violation; 0]
);
}
#[test]
fn a_required_field_the_request_never_sends_is_a_violation_however_it_is_called() {
let mut e = endpoint();
e.request.deserialized.push(field("sku", true));
for writes in [Some(["name"].as_slice()), Some(["name", "sku"].as_slice())] {
let v = check(&site(&["id"], writes), &e);
assert_eq!(
v.iter().map(|v| v.kind.clone()).collect::<Vec<_>>(),
vec![ViolationKind::RequestFieldMissing("sku".to_owned())]
);
}
}
#[test]
fn unknown_write_set_checks_reads_only() {
let v = check(&site(&["sku"], None), &endpoint());
assert_eq!(
v.iter().map(|v| v.kind.clone()).collect::<Vec<_>>(),
vec![ViolationKind::ResponseFieldMissing("sku".to_owned())]
);
}
#[test]
fn message_names_the_call_site_the_endpoint_and_the_field() {
let v = check(&site(&["sku"], None), &endpoint());
let msg = v[0].message();
assert!(msg.contains("catalog.get_item(req)"), "{msg}");
assert!(msg.contains("show_item"), "{msg}");
assert!(msg.contains("catalog.get_item"), "{msg}");
assert!(msg.contains("`sku`"), "{msg}");
}
#[test]
#[allow(clippy::too_many_lines)]
fn every_wire_breaking_mutation_is_caught_and_no_compatible_one_is() {
fn drop_field(mut e: ResolvedEndpoint, name: &str) -> ResolvedEndpoint {
e.response.serialized.retain(|f| f.rust_name != name);
e.request.deserialized.retain(|f| f.rust_name != name);
e
}
fn rename(mut e: ResolvedEndpoint, from: &str, to: &str) -> ResolvedEndpoint {
for f in e
.response
.serialized
.iter_mut()
.chain(e.request.deserialized.iter_mut())
{
if f.rust_name == from {
f.rust_name = to.to_owned();
f.wire_name = to.to_owned();
}
}
e
}
let site = site(&["id", "name"], Some(&["name"]));
let breaking: Vec<(&str, ResolvedEndpoint)> = vec![
("response field removed", {
let mut e = endpoint();
e.response.serialized.retain(|f| f.rust_name != "id");
e
}),
("response field renamed", {
let mut e = endpoint();
e.response.serialized[0].rust_name = "item_id".to_owned();
e
}),
(
"response field serde-skipped in both directions",
drop_field(endpoint(), "name"),
),
("response field skip_serializing", {
let mut e = endpoint();
e.response.serialized.retain(|f| f.rust_name != "name");
e
}),
("request field removed", {
let mut e = endpoint();
e.request.deserialized.retain(|f| f.rust_name != "name");
e
}),
("request field renamed", rename(endpoint(), "name", "title")),
("request field skip_deserializing", {
let mut e = endpoint();
e.request.deserialized.retain(|f| f.rust_name != "name");
e
}),
("required request field became conditionally sent", {
let mut e = endpoint();
e.request.serialized[1].required = false;
e
}),
("new required request field the type never sends", {
let mut e = endpoint();
e.request.deserialized.push(field("sku", true));
e
}),
("whole response type replaced", {
let mut e = endpoint();
e.response.serialized = vec![field("sku", true)];
e
}),
];
let compatible: Vec<(&str, ResolvedEndpoint)> = vec![
("new optional response field", {
let mut e = endpoint();
e.response.serialized.push(field("badge", false));
e
}),
("new required response field", {
let mut e = endpoint();
e.response.serialized.push(field("badge", true));
e
}),
("new optional request field", {
let mut e = endpoint();
e.request.serialized.push(field("coupon", true));
e.request.deserialized.push(field("coupon", false));
e
}),
("new required request field the type always sends", {
let mut e = endpoint();
e.request.serialized.push(field("sku", true));
e.request.deserialized.push(field("sku", true));
e
}),
("required request field became optional", {
let mut e = endpoint();
e.request.deserialized[0].required = false;
e
}),
(
"unset request field became optional and conditionally sent",
{
let mut e = endpoint();
e.request.serialized[1].required = false;
e.request.deserialized[1].required = false;
e
},
),
("response field renamed on the wire only", {
let mut e = endpoint();
e.response.serialized[0].wire_name = "itemId".to_owned();
e
}),
("response fields reordered", {
let mut e = endpoint();
e.response.serialized.reverse();
e
}),
("response field type widened", {
let mut e = endpoint();
e.response.serialized[0].ty = "std::string::String".to_owned();
e
}),
("unread response field removed", {
let mut e = endpoint();
e.response
.serialized
.retain(|f| f.rust_name != "price_cents");
e
}),
("unset optional request field removed", {
let mut e = endpoint();
e.request
.deserialized
.retain(|f| f.rust_name != "price_cents");
e
}),
("route path changed", {
let mut e = endpoint();
e.endpoint.path = "/v2/items".to_owned();
e
}),
];
let missed: Vec<&str> = breaking
.iter()
.filter(|(_, e)| check(&site, e).is_empty())
.map(|(label, _)| *label)
.collect();
let false_positives: Vec<&str> = compatible
.iter()
.filter(|(_, e)| !check(&site, e).is_empty())
.map(|(label, _)| *label)
.collect();
assert!(
missed.is_empty(),
"wire-breaking mutations not caught: {missed:?}"
);
assert!(
false_positives.is_empty(),
"compatible mutations wrongly rejected: {false_positives:?}"
);
}
#[test]
fn every_violation_is_reported_not_just_the_first() {
let v = check(&site(&["sku", "colour"], Some(&["shade"])), &endpoint());
assert_eq!(v.len(), 3, "two bad reads and one bad write: {v:?}");
}
}