#![expect(
clippy::disallowed_types,
reason = "dev/verification tooling over JSON artifacts (the catalogue, results, wire \
exchanges), whose shapes belong to the artifacts and the SUT"
)]
use std::collections::BTreeMap;
use serde_json::Value;
use crate::model::binding::{BodySelector, WireExpectation};
#[derive(Debug)]
pub struct RequestContext<'a> {
pub accept: Option<&'a str>,
pub prefer: Option<&'a str>,
}
#[must_use]
pub fn evaluate(
expectation: &WireExpectation,
body: Option<&Value>,
response_headers: &BTreeMap<String, String>,
ctx: &RequestContext<'_>,
) -> Vec<String> {
let Some(selector) = expectation.body else {
return Vec::new();
};
judge(selector, body, response_headers, ctx)
.into_iter()
.collect()
}
fn judge(
selector: BodySelector,
body: Option<&Value>,
response_headers: &BTreeMap<String, String>,
ctx: &RequestContext<'_>,
) -> Option<String> {
match selector {
BodySelector::Present => content(body)
.is_none()
.then(|| "body: expected a response body, got none".to_owned()),
BodySelector::Absent => {
content(body).map(|v| format!("body: expected no response body, got {}", preview(v)))
}
BodySelector::ErrorLoose => judge_error_loose(body, ctx),
BodySelector::ResultSetBody => judge_result_set(body),
BodySelector::Negotiated => judge_negotiated(body, response_headers, ctx),
BodySelector::PreferConditional => judge_prefer_conditional(body, ctx),
}
}
fn judge_error_loose(body: Option<&Value>, ctx: &RequestContext<'_>) -> Option<String> {
if return_preference(ctx.prefer) != Some(ReturnPreference::Representation) {
return None;
}
let Some(Value::Object(map)) = content(body) else {
return None;
};
match map.get("message") {
Some(Value::String(m)) if !m.trim().is_empty() => None,
Some(Value::String(_)) => {
Some("body: the error detail's `message` is empty, expected the error text".to_owned())
}
Some(other) => Some(format!(
"body: the error detail's `message` is {}, expected a string",
preview(other)
)),
None => Some(
"body: the error detail object carries no `message` (the error text the section's worked example fixes)"
.to_owned(),
),
}
}
fn judge_result_set(body: Option<&Value>) -> Option<String> {
match content(body) {
Some(Value::Object(map)) => match map.get("rows") {
Some(Value::Array(_)) => None,
Some(other) => Some(format!(
"body: RESULT_SET `rows` is {}, expected an array",
preview(other)
)),
None => Some(
"body: expected a RESULT_SET, got an object without the required `rows` array"
.to_owned(),
),
},
Some(other) => Some(format!(
"body: expected a RESULT_SET object, got {}",
preview(other)
)),
None => Some("body: expected a RESULT_SET object, got no body".to_owned()),
}
}
fn judge_negotiated(
body: Option<&Value>,
response_headers: &BTreeMap<String, String>,
ctx: &RequestContext<'_>,
) -> Option<String> {
let accept = ctx.accept?;
content(body)?;
let offered: Vec<&str> = accept.split(',').map(media_token).collect();
if offered.iter().any(|t| t.ends_with("/*") || *t == "*") {
return None;
}
let Some(v) = header_value(response_headers, "Content-Type") else {
return Some(format!(
"body: a response with content must carry Content-Type (expected the negotiated {accept:?}), got none"
));
};
let observed = media_token(v);
if offered.iter().any(|t| t.eq_ignore_ascii_case(observed)) {
None
} else {
Some(format!(
"body: expected the negotiated media type {accept:?}, got Content-Type {v:?}"
))
}
}
fn judge_prefer_conditional(body: Option<&Value>, ctx: &RequestContext<'_>) -> Option<String> {
match return_preference(ctx.prefer) {
Some(ReturnPreference::Representation) => content(body).is_none().then(|| {
"body: Prefer return=representation was sent, expected the resource representation, got no body"
.to_owned()
}),
Some(ReturnPreference::Identifier) => match content(body) {
Some(Value::Object(map)) if map.contains_key("uid") => None,
Some(Value::Object(_)) => Some(
"body: Prefer return=identifier was sent, expected an object carrying `uid`, got one without it"
.to_owned(),
),
Some(_) => None,
None => Some(
"body: Prefer return=identifier was sent, expected the identifier body (never 204), got no body"
.to_owned(),
),
},
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReturnPreference {
Minimal,
Identifier,
Representation,
}
fn return_preference(prefer: Option<&str>) -> Option<ReturnPreference> {
prefer?.split(',').find_map(|part| {
let token = part.split(';').next().unwrap_or(part).trim();
let name_len = "return=".len();
if !token
.get(..name_len)
.is_some_and(|p| p.eq_ignore_ascii_case("return="))
{
return None;
}
let value = token.get(name_len..)?.trim().trim_matches('"');
if value.eq_ignore_ascii_case("minimal") {
Some(ReturnPreference::Minimal)
} else if value.eq_ignore_ascii_case("identifier") {
Some(ReturnPreference::Identifier)
} else if value.eq_ignore_ascii_case("representation") {
Some(ReturnPreference::Representation)
} else {
None
}
})
}
pub(crate) fn content(body: Option<&Value>) -> Option<&Value> {
match body {
None | Some(Value::Null) => None,
Some(Value::String(s)) if s.trim().is_empty() => None,
Some(v) => Some(v),
}
}
fn header_value<'a>(headers: &'a BTreeMap<String, String>, name: &str) -> Option<&'a str> {
headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
fn media_token(value: &str) -> &str {
value.split(';').next().unwrap_or(value).trim()
}
fn preview(value: &Value) -> String {
value.to_string().chars().take(80).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn expectation(selector: &str) -> WireExpectation {
serde_json::from_value(serde_json::json!({ "status": 200, "body": selector })).unwrap()
}
fn headers(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect()
}
fn ctx<'a>() -> RequestContext<'a> {
RequestContext {
accept: None,
prefer: None,
}
}
fn run(selector: &str, body: Option<&Value>, ctx: &RequestContext<'_>) -> Vec<String> {
evaluate(&expectation(selector), body, &headers(&[]), ctx)
}
#[test]
fn no_declaration_evaluates_nothing() {
let e: WireExpectation =
serde_json::from_value(serde_json::json!({ "status": 204 })).unwrap();
assert!(evaluate(&e, None, &headers(&[]), &ctx()).is_empty());
}
#[test]
fn present_requires_content_of_any_shape() {
let json = serde_json::json!({ "_type": "CONTRIBUTION" });
assert!(run("present", Some(&json), &ctx()).is_empty());
let xml = Value::String("<contribution/>".to_owned());
assert!(run("present", Some(&xml), &ctx()).is_empty());
assert_eq!(run("present", None, &ctx()).len(), 1);
assert_eq!(run("present", Some(&Value::Null), &ctx()).len(), 1);
let blank = Value::String(" ".to_owned());
assert_eq!(run("present", Some(&blank), &ctx()).len(), 1);
}
#[test]
fn absent_forbids_content() {
assert!(run("absent", None, &ctx()).is_empty());
assert!(run("absent", Some(&Value::Null), &ctx()).is_empty());
let body = serde_json::json!({ "uid": "x::sys::1" });
let failures = run("absent", Some(&body), &ctx());
assert_eq!(failures.len(), 1, "{failures:?}");
assert!(failures[0].contains("expected no response body"));
}
#[test]
fn error_loose_only_judges_under_return_representation() {
let detail = serde_json::json!({ "error": "Unprocessable Entity" });
assert!(run("error_loose", Some(&detail), &ctx()).is_empty());
let minimal = RequestContext {
accept: None,
prefer: Some("return=minimal"),
};
assert!(run("error_loose", Some(&detail), &minimal).is_empty());
}
#[test]
fn error_loose_asserts_the_message_string() {
let representation = RequestContext {
accept: None,
prefer: Some("return=representation"),
};
let ok = serde_json::json!({ "error": "Unprocessable Entity", "message": "bad content" });
assert!(run("error_loose", Some(&ok), &representation).is_empty());
assert!(run("error_loose", None, &representation).is_empty());
let xml = Value::String("<error/>".to_owned());
assert!(run("error_loose", Some(&xml), &representation).is_empty());
let no_message = serde_json::json!({ "error": "Unprocessable Entity" });
assert_eq!(
run("error_loose", Some(&no_message), &representation).len(),
1
);
let wrong_type = serde_json::json!({ "message": 42 });
assert_eq!(
run("error_loose", Some(&wrong_type), &representation).len(),
1
);
let empty = serde_json::json!({ "message": " " });
assert_eq!(run("error_loose", Some(&empty), &representation).len(), 1);
}
#[test]
fn result_set_body_requires_the_rows_array() {
let ok = serde_json::json!({ "q": "SELECT e/ehr_id/value FROM EHR e", "rows": [] });
assert!(run("result_set_body", Some(&ok), &ctx()).is_empty());
let no_rows = serde_json::json!({ "q": "SELECT e/ehr_id/value FROM EHR e" });
assert_eq!(run("result_set_body", Some(&no_rows), &ctx()).len(), 1);
let wrong_type = serde_json::json!({ "rows": 3 });
assert_eq!(run("result_set_body", Some(&wrong_type), &ctx()).len(), 1);
let not_an_object = Value::String("<resultSet/>".to_owned());
assert_eq!(
run("result_set_body", Some(¬_an_object), &ctx()).len(),
1
);
assert_eq!(run("result_set_body", None, &ctx()).len(), 1);
}
#[test]
fn negotiated_compares_the_media_token() {
let e = expectation("negotiated");
let body = serde_json::json!({ "_type": "EHR" });
let json = RequestContext {
accept: Some("application/json"),
prefer: None,
};
let ok = headers(&[("content-type", "application/json; charset=utf-8")]);
assert!(evaluate(&e, Some(&body), &ok, &json).is_empty());
let bad = headers(&[("content-type", "application/xml")]);
assert_eq!(evaluate(&e, Some(&body), &bad, &json).len(), 1);
assert_eq!(evaluate(&e, Some(&body), &headers(&[]), &json).len(), 1);
}
#[test]
fn negotiated_is_silent_without_accept_and_without_a_body() {
let e = expectation("negotiated");
let body = serde_json::json!({ "_type": "EHR" });
let none = headers(&[("content-type", "application/xml")]);
assert!(evaluate(&e, Some(&body), &none, &ctx()).is_empty());
let json = RequestContext {
accept: Some("application/json"),
prefer: None,
};
assert!(evaluate(&e, None, &headers(&[]), &json).is_empty());
let any = RequestContext {
accept: Some("*/*"),
prefer: None,
};
assert!(evaluate(&e, Some(&body), &none, &any).is_empty());
let list = RequestContext {
accept: Some("application/json, application/xml;q=0.9"),
prefer: None,
};
assert!(evaluate(&e, Some(&body), &none, &list).is_empty());
}
#[test]
fn prefer_conditional_representation_requires_a_body() {
let representation = RequestContext {
accept: None,
prefer: Some("return=representation"),
};
let body = serde_json::json!({ "_type": "COMPOSITION" });
assert!(run("prefer_conditional", Some(&body), &representation).is_empty());
let failures = run("prefer_conditional", None, &representation);
assert_eq!(failures.len(), 1, "{failures:?}");
assert!(failures[0].contains("return=representation"));
let with_refs = RequestContext {
accept: None,
prefer: Some("return=representation, resolve_refs"),
};
assert_eq!(run("prefer_conditional", None, &with_refs).len(), 1);
}
#[test]
fn prefer_conditional_identifier_requires_uid() {
let identifier = RequestContext {
accept: None,
prefer: Some("return=identifier"),
};
let ok = serde_json::json!({ "uid": "8849182c::openEHRSys.example.com::3" });
assert!(run("prefer_conditional", Some(&ok), &identifier).is_empty());
assert_eq!(run("prefer_conditional", None, &identifier).len(), 1);
let full = serde_json::json!({ "_type": "COMPOSITION" });
assert_eq!(run("prefer_conditional", Some(&full), &identifier).len(), 1);
let xml = Value::String("<uid>x::sys::1</uid>".to_owned());
assert!(run("prefer_conditional", Some(&xml), &identifier).is_empty());
}
#[test]
fn prefer_conditional_minimal_and_absent_never_fail() {
let minimal = RequestContext {
accept: None,
prefer: Some("Return=Minimal"),
};
assert!(run("prefer_conditional", None, &minimal).is_empty());
let body = serde_json::json!({ "_type": "EHR" });
assert!(run("prefer_conditional", Some(&body), &minimal).is_empty());
assert!(run("prefer_conditional", None, &ctx()).is_empty());
assert!(run("prefer_conditional", Some(&body), &ctx()).is_empty());
}
}