use crate::ast::*;
use regex::Regex;
use std::collections::HashMap;
fn compile_schema(schema: &serde_json::Value) -> Result<jsonschema::Validator, String> {
jsonschema::validator_for(schema).map_err(|e| format!("invalid JSON Schema: {e}"))
}
fn validate_schema(
validator: &jsonschema::Validator,
instance: &serde_json::Value,
) -> Result<(), Vec<String>> {
let errors: Vec<String> = validator
.iter_errors(instance)
.map(|e| format!(" - {}: {}", e.instance_path(), e))
.collect();
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
pub fn evaluate_assertions(
assertions: &[Assertion],
status_code: u16,
headers: &HashMap<String, String>,
body: &Option<serde_json::Value>,
response_time_ms: u64,
) -> Vec<AssertionResult> {
assertions
.iter()
.map(|a| evaluate_assertion(a, status_code, headers, body, response_time_ms))
.collect()
}
pub fn evaluate_assertion(
assertion: &Assertion,
status_code: u16,
headers: &HashMap<String, String>,
body: &Option<serde_json::Value>,
response_time_ms: u64,
) -> AssertionResult {
match assertion {
Assertion::AllOf(children) => {
let results: Vec<_> = children
.iter()
.map(|c| evaluate_assertion(c, status_code, headers, body, response_time_ms))
.collect();
let passed = results.iter().all(|r| r.passed);
let failures: Vec<_> = results
.iter()
.filter(|r| !r.passed)
.map(|r| r.description.clone())
.collect();
AssertionResult {
description: "all of".into(),
passed,
message: if passed {
None
} else {
Some(format!("failed: {}", failures.join(", ")))
},
children: results,
}
}
Assertion::AnyOf(children) => {
let results: Vec<_> = children
.iter()
.map(|c| evaluate_assertion(c, status_code, headers, body, response_time_ms))
.collect();
let passed = results.iter().any(|r| r.passed);
AssertionResult {
description: "any of".into(),
passed,
message: if passed {
None
} else {
Some("no sub-assertion passed".into())
},
children: results,
}
}
Assertion::Not(child) => {
let result = evaluate_assertion(child, status_code, headers, body, response_time_ms);
AssertionResult {
description: format!("not ({})", result.description),
passed: !result.passed,
message: if result.passed {
Some("assertion passed when it should have failed".into())
} else {
None
},
children: vec![result],
}
}
Assertion::Status(expected) => {
if status_code == *expected {
AssertionResult::pass(format!("status is {expected}"))
} else {
AssertionResult::fail(
format!("status is {expected}"),
format!("got {status_code}"),
)
}
}
Assertion::StatusIn(codes) => {
if codes.contains(&status_code) {
AssertionResult::pass(format!("status in {codes:?}"))
} else {
AssertionResult::fail(format!("status in {codes:?}"), format!("got {status_code}"))
}
}
Assertion::Header { name, predicate } => {
let actual = headers.get(name.as_str()).map(|s| s.as_str());
evaluate_value_predicate(name, predicate, actual)
}
Assertion::BodyLength(pred) => {
let body_str = body.as_ref().map(|b| b.to_string()).unwrap_or_default();
let len = body_str.len();
match pred {
BodyLengthPredicate::Eq(expected) => {
if len == *expected {
AssertionResult::pass(format!("body length == {expected}"))
} else {
AssertionResult::fail(
format!("body length == {expected}"),
format!("got {len} bytes"),
)
}
}
BodyLengthPredicate::Min(min) => {
if len >= *min {
AssertionResult::pass(format!("body length >= {min}"))
} else {
AssertionResult::fail(
format!("body length >= {min}"),
format!("got {len} bytes"),
)
}
}
BodyLengthPredicate::Max(max) => {
if len <= *max {
AssertionResult::pass(format!("body length <= {max}"))
} else {
AssertionResult::fail(
format!("body length <= {max}"),
format!("got {len} bytes"),
)
}
}
BodyLengthPredicate::Range { min, max } => {
if len >= *min && len <= *max {
AssertionResult::pass(format!("body length in [{min}, {max}]"))
} else {
AssertionResult::fail(
format!("body length in [{min}, {max}]"),
format!("got {len} bytes"),
)
}
}
}
}
Assertion::JsonPath { path, predicate } => {
let body = match body {
Some(b) => b,
None => {
return AssertionResult::fail(
format!("json path '{path}'"),
"no response body",
);
}
};
let results: Vec<serde_json::Value> = resolve_json_path(body, path);
evaluate_json_predicate(path, predicate, &results)
}
Assertion::Schema { schema } => match compile_schema(schema) {
Ok(validator) => {
let body = match body {
Some(b) => b,
None => {
return AssertionResult::fail("json schema validation", "no response body");
}
};
match validate_schema(&validator, body) {
Ok(()) => AssertionResult::pass("json schema validation"),
Err(errors) => AssertionResult::fail(
"json schema validation",
format!("schema violations:\n{}", errors.join("\n")),
),
}
}
Err(e) => AssertionResult::fail("json schema validation", e),
},
Assertion::ValidJson => {
AssertionResult::pass("response is valid JSON")
}
Assertion::ContentType(expected) => {
let actual = headers
.get("content-type")
.map(|s| s.as_str())
.unwrap_or("");
if actual.to_lowercase().contains(&expected.to_lowercase()) {
AssertionResult::pass(format!("content-type contains '{expected}'"))
} else {
AssertionResult::fail(
format!("content-type contains '{expected}'"),
format!("got '{actual}'"),
)
}
}
Assertion::ResponseTime(max_millis) => {
if response_time_ms <= *max_millis {
AssertionResult::pass(format!(
"response time <= {max_millis}ms (was {response_time_ms}ms)"
))
} else {
AssertionResult::fail(
format!("response time <= {max_millis}ms"),
format!("took {response_time_ms}ms"),
)
}
}
}
}
#[inline]
fn evaluate_value_predicate(
name: &str,
predicate: &ValuePredicate,
actual: Option<&str>,
) -> AssertionResult {
let desc = format!("header '{name}'");
match predicate {
ValuePredicate::Eq(expected) => match actual {
Some(v) if v == expected => AssertionResult::pass(format!("{desc} == '{expected}'")),
Some(v) => {
AssertionResult::fail(format!("{desc} == '{expected}'"), format!("got '{v}'"))
}
None => AssertionResult::fail(format!("{desc} == '{expected}'"), "header not present"),
},
ValuePredicate::Contains(sub) => match actual {
Some(v) if v.contains(sub.as_str()) => {
AssertionResult::pass(format!("{desc} contains '{sub}'"))
}
Some(v) => {
AssertionResult::fail(format!("{desc} contains '{sub}'"), format!("got '{v}'"))
}
None => AssertionResult::fail(format!("{desc} contains '{sub}'"), "header not present"),
},
ValuePredicate::Regex(pattern) => match actual {
Some(v) => match Regex::new(pattern) {
Ok(re) => {
if re.is_match(v) {
AssertionResult::pass(format!("{desc} matches /{pattern}/"))
} else {
AssertionResult::fail(
format!("{desc} matches /{pattern}/"),
format!("got '{v}'"),
)
}
}
Err(e) => AssertionResult::fail(
format!("{desc} matches /{pattern}/"),
format!("invalid regex: {e}"),
),
},
None => {
AssertionResult::fail(format!("{desc} matches /{pattern}/"), "header not present")
}
},
ValuePredicate::Present => {
if actual.is_some() {
AssertionResult::pass(format!("{desc} is present"))
} else {
AssertionResult::fail(format!("{desc} is present"), "header not present")
}
}
ValuePredicate::Absent => {
if actual.is_none() {
AssertionResult::pass(format!("{desc} is absent"))
} else {
AssertionResult::fail(format!("{desc} is absent"), format!("got '{actual:?}'"))
}
}
}
}
#[inline]
fn evaluate_json_predicate(
path: &str,
predicate: &JsonPredicate,
results: &[serde_json::Value],
) -> AssertionResult {
let desc = format!("json path '{path}'");
match predicate {
JsonPredicate::Exists => {
if results.is_empty() {
AssertionResult::fail(desc, "path not found in response body")
} else {
AssertionResult::pass(desc)
}
}
JsonPredicate::NotExists => {
if results.is_empty() {
AssertionResult::pass(desc)
} else {
AssertionResult::fail(desc, format!("path found with {} result(s)", results.len()))
}
}
JsonPredicate::Eq(expected) => {
if results.is_empty() {
return AssertionResult::fail(format!("{desc} == {expected}"), "path not found");
}
let actual = &results[0];
if actual == expected {
AssertionResult::pass(format!("{desc} == {expected}"))
} else {
AssertionResult::fail(format!("{desc} == {expected}"), format!("got {actual}"))
}
}
JsonPredicate::NotEq(expected) => {
if results.is_empty() {
return AssertionResult::pass(format!("{desc} != {expected} (path not found)"));
}
let actual = &results[0];
if actual != expected {
AssertionResult::pass(format!("{desc} != {expected}"))
} else {
AssertionResult::fail(format!("{desc} != {expected}"), format!("got {actual}"))
}
}
JsonPredicate::Cmp { op, value } => {
if results.is_empty() {
return AssertionResult::fail(
format!("{desc} cmp {op:?} {value}"),
"path not found",
);
}
let actual = &results[0];
match (actual.as_f64(), value.as_f64()) {
(Some(a), Some(b)) => {
let passed = match op {
CmpOp::Gt => a > b,
CmpOp::Lt => a < b,
CmpOp::Ge => a >= b,
CmpOp::Le => a <= b,
};
if passed {
AssertionResult::pass(format!("{desc} {op:?} {value}"))
} else {
AssertionResult::fail(
format!("{desc} {op:?} {value}"),
format!("got {actual}"),
)
}
}
_ => AssertionResult::fail(
format!("{desc} {op:?} {value}"),
format!("non-numeric value: {actual}"),
),
}
}
JsonPredicate::Length(pred) => {
let len = results.len();
match pred {
LengthPredicate::Eq(expected) => {
if len == *expected {
AssertionResult::pass(format!("{desc} length == {expected}"))
} else {
AssertionResult::fail(
format!("{desc} length == {expected}"),
format!("got {len}"),
)
}
}
LengthPredicate::Min(min) => {
if len >= *min {
AssertionResult::pass(format!("{desc} length >= {min}"))
} else {
AssertionResult::fail(
format!("{desc} length >= {min}"),
format!("got {len}"),
)
}
}
LengthPredicate::Max(max) => {
if len <= *max {
AssertionResult::pass(format!("{desc} length <= {max}"))
} else {
AssertionResult::fail(
format!("{desc} length <= {max}"),
format!("got {len}"),
)
}
}
LengthPredicate::Range { min, max } => {
if len >= *min && len <= *max {
AssertionResult::pass(format!("{desc} length in [{min}, {max}]"))
} else {
AssertionResult::fail(
format!("{desc} length in [{min}, {max}]"),
format!("got {len}"),
)
}
}
}
}
JsonPredicate::Every(sub) => {
if results.is_empty() {
return AssertionResult::pass(format!("{desc} every (no results)"));
}
let sub_results: Vec<_> = results
.iter()
.map(|r| {
evaluate_json_predicate(&format!("{desc}[*]"), sub, std::slice::from_ref(r))
})
.collect();
let passed = sub_results.iter().all(|r| r.passed);
AssertionResult {
description: format!("{desc} every"),
passed,
message: if passed {
None
} else {
let count = sub_results.iter().filter(|r| !r.passed).count();
Some(format!("{count} element(s) failed"))
},
children: sub_results,
}
}
JsonPredicate::Some(sub) => {
if results.is_empty() {
return AssertionResult::fail(format!("{desc} some"), "no results");
}
let sub_results: Vec<_> = results
.iter()
.map(|r| {
evaluate_json_predicate(&format!("{desc}[*]"), sub, std::slice::from_ref(r))
})
.collect();
let passed = sub_results.iter().any(|r| r.passed);
AssertionResult {
description: format!("{desc} some"),
passed,
message: if passed {
None
} else {
Some("no element matched".into())
},
children: sub_results,
}
}
JsonPredicate::Count(pred) => {
let len = results.len();
match pred {
CountPredicate::Eq(expected) => {
if len == *expected {
AssertionResult::pass(format!("{desc} count == {expected}"))
} else {
AssertionResult::fail(
format!("{desc} count == {expected}"),
format!("got {len}"),
)
}
}
CountPredicate::Min(min) => {
if len >= *min {
AssertionResult::pass(format!("{desc} count >= {min}"))
} else {
AssertionResult::fail(
format!("{desc} count >= {min}"),
format!("got {len}"),
)
}
}
CountPredicate::Max(max) => {
if len <= *max {
AssertionResult::pass(format!("{desc} count <= {max}"))
} else {
AssertionResult::fail(
format!("{desc} count <= {max}"),
format!("got {len}"),
)
}
}
CountPredicate::Range { min, max } => {
if len >= *min && len <= *max {
AssertionResult::pass(format!("{desc} count in [{min}, {max}]"))
} else {
AssertionResult::fail(
format!("{desc} count in [{min}, {max}]"),
format!("got {len}"),
)
}
}
}
}
JsonPredicate::Schema(schema) => match compile_schema(schema) {
Ok(validator) => {
if results.is_empty() {
return AssertionResult::fail(
format!("{desc} schema validation"),
"path not found",
);
}
match validate_schema(&validator, &results[0]) {
Ok(()) => AssertionResult::pass(format!("{desc} schema validation")),
Err(errors) => AssertionResult::fail(
format!("{desc} schema validation"),
format!("schema violations:\n{}", errors.join("\n")),
),
}
}
Err(e) => AssertionResult::fail(format!("{desc} schema validation"), e),
},
}
}
pub fn resolve_json_path(value: &serde_json::Value, path: &str) -> Vec<serde_json::Value> {
use jsonpath_rust::JsonPath;
match value.query(path) {
Ok(results) => results.into_iter().cloned().collect(),
Err(_) => vec![],
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_status_pass() {
let result = evaluate_assertion(&Assertion::Status(200), 200, &HashMap::new(), &None, 0);
assert!(result.passed);
}
#[test]
fn test_status_fail() {
let result = evaluate_assertion(&Assertion::Status(200), 404, &HashMap::new(), &None, 0);
assert!(!result.passed);
assert!(result.message.unwrap().contains("404"));
}
#[test]
fn test_status_in_pass() {
let result = evaluate_assertion(
&Assertion::StatusIn(vec![200, 304]),
304,
&HashMap::new(),
&None,
0,
);
assert!(result.passed);
}
#[test]
fn test_header_present() {
let mut headers = HashMap::new();
headers.insert("content-type".into(), "application/json".into());
let result = evaluate_assertion(
&Assertion::header("content-type", ValuePredicate::Present),
200,
&headers,
&None,
0,
);
assert!(result.passed);
}
#[test]
fn test_header_eq() {
let mut headers = HashMap::new();
headers.insert("x-request-id".into(), "abc-123".into());
let result = evaluate_assertion(
&Assertion::header("x-request-id", ValuePredicate::Eq("abc-123".into())),
200,
&headers,
&None,
0,
);
assert!(result.passed);
}
#[test]
fn test_json_path_exists() {
let body = json!({"resourceType": "Patient", "id": "p1"});
let result = evaluate_assertion(
&Assertion::json_path_exists("$.resourceType"),
200,
&HashMap::new(),
&Some(body),
0,
);
assert!(result.passed);
}
#[test]
fn test_json_path_not_exists() {
let body = json!({"resourceType": "Patient"});
let result = evaluate_assertion(
&Assertion::JsonPath {
path: "$.nonexistent".into(),
predicate: JsonPredicate::NotExists,
},
200,
&HashMap::new(),
&Some(body),
0,
);
assert!(result.passed);
}
#[test]
fn test_json_path_eq() {
let body = json!({"total": 42});
let result = evaluate_assertion(
&Assertion::json_path_eq("$.total", json!(42)),
200,
&HashMap::new(),
&Some(body),
0,
);
assert!(result.passed);
}
#[test]
fn test_json_path_array_wildcard() {
let body = json!({
"entry": [
{"resource": {"id": "a"}},
{"resource": {"id": "b"}},
]
});
let result = evaluate_assertion(
&Assertion::JsonPath {
path: "$.entry[*].resource.id".into(),
predicate: JsonPredicate::Count(CountPredicate::Eq(2)),
},
200,
&HashMap::new(),
&Some(body),
0,
);
assert!(result.passed);
}
#[test]
fn test_all_of_pass() {
let body = json!({"resourceType": "Bundle", "total": 10});
let result = evaluate_assertion(
&Assertion::AllOf(vec![
Assertion::Status(200),
Assertion::json_path_exists("$.resourceType"),
Assertion::json_path_eq("$.total", json!(10)),
]),
200,
&HashMap::new(),
&Some(body),
0,
);
assert!(result.passed);
}
#[test]
fn test_all_of_fail() {
let body = json!({"resourceType": "Bundle"});
let result = evaluate_assertion(
&Assertion::AllOf(vec![
Assertion::Status(200),
Assertion::json_path_eq("$.total", json!(10)),
]),
200,
&HashMap::new(),
&Some(body),
0,
);
assert!(!result.passed);
}
#[test]
fn test_any_of_pass() {
let result = evaluate_assertion(
&Assertion::AnyOf(vec![Assertion::Status(200), Assertion::Status(304)]),
304,
&HashMap::new(),
&None,
0,
);
assert!(result.passed);
}
#[test]
fn test_not() {
let result = evaluate_assertion(
&Assertion::Not(Box::new(Assertion::Status(404))),
200,
&HashMap::new(),
&None,
0,
);
assert!(result.passed);
}
#[test]
fn test_content_type() {
let mut headers = HashMap::new();
headers.insert("content-type".into(), "application/fhir+json".into());
let result = evaluate_assertion(&Assertion::content_type("json"), 200, &headers, &None, 0);
assert!(result.passed);
}
#[test]
fn test_body_length() {
let body = json!({"key": "value"});
let result = evaluate_assertion(
&Assertion::BodyLength(BodyLengthPredicate::Min(10)),
200,
&HashMap::new(),
&Some(body),
0,
);
assert!(result.passed);
}
#[test]
fn test_json_path_cmp() {
let body = json!({"value": 42});
let result = evaluate_assertion(
&Assertion::JsonPath {
path: "$.value".into(),
predicate: JsonPredicate::Cmp {
op: CmpOp::Gt,
value: json!(10),
},
},
200,
&HashMap::new(),
&Some(body),
0,
);
assert!(result.passed);
}
#[test]
fn test_json_path_every() {
let body = json!({"items": [1, 2, 3, 4, 5]});
let result = evaluate_assertion(
&Assertion::JsonPath {
path: "$.items[*]".into(),
predicate: JsonPredicate::Every(Box::new(JsonPredicate::Cmp {
op: CmpOp::Gt,
value: json!(0),
})),
},
200,
&HashMap::new(),
&Some(body),
0,
);
assert!(result.passed);
}
#[test]
fn test_json_path_some() {
let body = json!({"items": [1, 2, 3, 4, 5]});
let result = evaluate_assertion(
&Assertion::JsonPath {
path: "$.items[*]".into(),
predicate: JsonPredicate::Some(Box::new(JsonPredicate::Eq(json!(5)))),
},
200,
&HashMap::new(),
&Some(body),
0,
);
assert!(result.passed);
}
#[test]
fn test_response_time_pass() {
let result = evaluate_assertion(
&Assertion::response_time(500),
200,
&HashMap::new(),
&None,
150,
);
assert!(result.passed);
assert!(result.description.contains("500ms"));
}
#[test]
fn test_response_time_fail() {
let result = evaluate_assertion(
&Assertion::response_time(100),
200,
&HashMap::new(),
&None,
500,
);
assert!(!result.passed);
assert!(result.message.unwrap().contains("500ms"));
}
#[test]
fn test_response_time_exact_boundary() {
let result = evaluate_assertion(
&Assertion::response_time(200),
200,
&HashMap::new(),
&None,
200,
);
assert!(result.passed);
}
#[test]
fn test_resolve_json_path_simple() {
let v = json!({"a": {"b": 1}});
let results = resolve_json_path(&v, "$.a.b");
assert_eq!(results, vec![json!(1)]);
}
#[test]
fn test_resolve_json_path_array_index() {
let v = json!({"items": [10, 20, 30]});
let results = resolve_json_path(&v, "$.items[1]");
assert_eq!(results, vec![json!(20)]);
}
#[test]
fn test_resolve_json_path_wildcard() {
let v = json!({"items": [{"x": 1}, {"x": 2}]});
let results = resolve_json_path(&v, "$.items[*].x");
assert_eq!(results, vec![json!(1), json!(2)]);
}
#[test]
fn test_resolve_json_path_root() {
let v = json!({"a": 1});
let results = resolve_json_path(&v, "$");
assert_eq!(results, vec![v]);
}
#[test]
fn test_resolve_json_path_not_found() {
let v = json!({"a": 1});
let results = resolve_json_path(&v, "$.b");
assert!(results.is_empty());
}
use proptest::prelude::*;
proptest! {
#[test]
fn prop_not_not_equivalent_to_identity(
status_code in 100u16..599u16,
response_time in 0u64..10000u64,
) {
let inner = Assertion::Status(status_code);
let double_not = Assertion::Not(Box::new(Assertion::Not(Box::new(inner.clone()))));
let direct = evaluate_assertion(&inner, status_code, &HashMap::new(), &None, response_time);
let indirect = evaluate_assertion(&double_not, status_code, &HashMap::new(), &None, response_time);
assert_eq!(direct.passed, indirect.passed,
"Not(Not(Status({status_code}))) should be equivalent to Status({status_code})");
}
}
}