use serde_json::Value;
const IGNORED_HEADERS: &[&str] = &[
"authorization",
"content-length",
"host",
"proxy-authorization",
"user-agent",
"x-api-key",
"x-request-id",
"date",
"cookie",
];
const COMPARED_FIELDS: &[&str] = &["method", "uri"];
#[must_use]
pub fn header_is_compared(name: &str) -> bool {
!IGNORED_HEADERS.contains(&name.to_ascii_lowercase().as_str())
}
#[must_use]
pub fn describe_difference(recorded: &Value, incoming: &Value) -> Option<String> {
for field in COMPARED_FIELDS {
let expected = recorded.get(*field);
let actual = incoming.get(*field);
if expected != actual {
return Some(format!(
"{field}: recorded {}, received {}",
render(expected),
render(actual)
));
}
}
if let Some(difference) = header_difference(recorded, incoming) {
return Some(difference);
}
compare(
"body",
recorded.get("body").unwrap_or(&Value::Null),
incoming.get("body").unwrap_or(&Value::Null),
)
}
fn header_difference(recorded: &Value, incoming: &Value) -> Option<String> {
let expected = recorded.get("headers").and_then(Value::as_object)?;
let actual = incoming.get("headers").and_then(Value::as_object);
for (name, value) in expected {
if !header_is_compared(name) {
continue;
}
let received = actual.and_then(|actual| actual.get(name));
if received != Some(value) {
return Some(format!(
"header {name}: recorded {}, received {}",
render(Some(value)),
render(received)
));
}
}
let actual = actual?;
actual
.iter()
.filter(|(name, _)| header_is_compared(name))
.find(|(name, _)| !expected.contains_key(name.as_str()))
.map(|(name, value)| {
format!(
"header {name}: absent from the recording, received {}",
render(Some(value))
)
})
}
fn compare(path: &str, recorded: &Value, incoming: &Value) -> Option<String> {
match (recorded, incoming) {
(Value::Object(expected), Value::Object(actual)) => {
for (key, value) in expected {
let Some(received) = actual.get(key) else {
return Some(format!("{path}.{key} is missing from the request"));
};
if let Some(difference) = compare(&format!("{path}.{key}"), value, received) {
return Some(difference);
}
}
actual
.keys()
.find(|key| !expected.contains_key(key.as_str()))
.map(|key| format!("{path}.{key} is not in the recording"))
}
(Value::Array(expected), Value::Array(actual)) => {
if expected.len() != actual.len() {
return Some(format!(
"{path} has {} element(s), the recording has {}",
actual.len(),
expected.len()
));
}
expected
.iter()
.zip(actual)
.enumerate()
.find_map(|(index, (expected, actual))| {
compare(&format!("{path}[{index}]"), expected, actual)
})
}
_ if recorded == incoming => None,
_ => Some(format!(
"{path}: recorded {}, received {}",
render(Some(recorded)),
render(Some(incoming))
)),
}
}
const RENDERED_LIMIT: usize = 120;
fn render(value: Option<&Value>) -> String {
let Some(value) = value else {
return "nothing".to_string();
};
let text = match value {
Value::String(text) => text.clone(),
other => other.to_string(),
};
if text.chars().count() <= RENDERED_LIMIT {
return format!("{text:?}");
}
let head: String = text.chars().take(RENDERED_LIMIT).collect();
format!("{head:?}… ({} characters)", text.chars().count())
}