use praxis_core::config::{ResponseCondition, ResponseConditionMatch};
use crate::context::Response;
pub fn should_execute_response(conditions: &[ResponseCondition], resp: &Response) -> bool {
should_execute_response_ref(conditions, resp.status, &resp.headers)
}
pub fn should_execute_response_ref(
conditions: &[ResponseCondition],
status: http::StatusCode,
headers: &http::HeaderMap,
) -> bool {
for condition in conditions {
match condition {
ResponseCondition::When(m) => {
if !matches_status_headers(m, status, headers) {
return false;
}
},
ResponseCondition::Unless(m) => {
if matches_status_headers(m, status, headers) {
return false;
}
},
}
}
true
}
fn matches_status_headers(m: &ResponseConditionMatch, status: http::StatusCode, headers: &http::HeaderMap) -> bool {
if let Some(statuses) = &m.status
&& !statuses.contains(&status.as_u16())
{
return false;
}
if let Some(required) = &m.headers {
for (name, value) in required {
match headers.get(name) {
Some(v) if header_value_matches(name, v, value) => {},
_ => return false,
}
}
}
true
}
fn header_value_matches(name: &str, actual: &http::HeaderValue, expected: &str) -> bool {
let Ok(actual) = actual.to_str() else {
return false;
};
if name.eq_ignore_ascii_case("content-type") {
if has_parameters(expected) {
return media_type(actual).eq_ignore_ascii_case(media_type(expected)) && params_match(actual, expected);
}
return media_type(actual).eq_ignore_ascii_case(media_type(expected));
}
actual == expected
}
fn media_type(value: &str) -> &str {
value.split(';').next().unwrap_or_default().trim()
}
fn has_parameters(value: &str) -> bool {
value
.split_once(';')
.is_some_and(|(_, params)| !params.trim().is_empty())
}
fn params_match(actual: &str, expected: &str) -> bool {
params(actual).count() == params(expected).count()
&& params(expected).all(|param| count_matching(actual, param) == count_matching(expected, param))
}
fn count_matching(value: &str, (name, val): (&str, &str)) -> usize {
params(value)
.filter(|(other_name, other_val)| {
other_name.eq_ignore_ascii_case(name) && param_value_matches(name, other_val, val)
})
.count()
}
fn param_value_matches(name: &str, actual: &str, expected: &str) -> bool {
if name.eq_ignore_ascii_case("charset") {
value_chars(actual)
.map(|ch| ch.to_ascii_lowercase())
.eq(value_chars(expected).map(|ch| ch.to_ascii_lowercase()))
} else {
value_chars(actual).eq(value_chars(expected))
}
}
fn params(value: &str) -> impl Iterator<Item = (&str, &str)> {
split_outside_quotes(value.split_once(';').map_or("", |(_, params)| params))
.filter_map(|param| param.split_once('='))
.map(|(name, val)| (name.trim(), val.trim()))
}
fn split_outside_quotes(params: &str) -> impl Iterator<Item = &str> {
let mut in_quotes = false;
let mut escaped = false;
params.split(move |ch: char| {
let is_separator = ch == ';' && !in_quotes;
match ch {
_ if escaped => escaped = false,
'\\' if in_quotes => escaped = true,
'"' => in_quotes = !in_quotes,
_ => {},
}
is_separator
})
}
fn value_chars(value: &str) -> impl Iterator<Item = char> + '_ {
let quoted = value.strip_prefix('"').and_then(|inner| inner.strip_suffix('"'));
let mut chars = quoted.unwrap_or(value).chars();
let decode = quoted.is_some();
std::iter::from_fn(move || {
let ch = chars.next()?;
Some(if decode && ch == '\\' {
chars.next().unwrap_or(ch)
} else {
ch
})
})
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic,
reason = "tests"
)]
mod tests {
use std::collections::HashMap;
use http::{HeaderMap, HeaderValue};
use super::*;
#[test]
fn empty_response_conditions_always_execute() {
let resp = make_response(200, HeaderMap::new());
assert!(should_execute_response(&[], &resp));
}
#[test]
fn when_status_matches() {
let resp = make_response(200, HeaderMap::new());
assert!(should_execute_response(&[resp_when(status_match(&[200, 201]))], &resp));
}
#[test]
fn when_status_does_not_match() {
let resp = make_response(404, HeaderMap::new());
assert!(!should_execute_response(&[resp_when(status_match(&[200, 201]))], &resp));
}
#[test]
fn unless_status_skips() {
let resp = make_response(500, HeaderMap::new());
assert!(!should_execute_response(
&[resp_unless(status_match(&[500, 502, 503]))],
&resp
));
}
#[test]
fn unless_status_runs_when_not_matched() {
let resp = make_response(200, HeaderMap::new());
assert!(should_execute_response(
&[resp_unless(status_match(&[500, 502, 503]))],
&resp
));
}
#[test]
fn when_response_header_matches() {
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_static("application/json"));
let resp = make_response(200, headers);
assert!(should_execute_response(
&[resp_when(resp_header_match(&[("content-type", "application/json")]))],
&resp
));
}
#[test]
fn when_response_header_missing() {
let resp = make_response(200, HeaderMap::new());
assert!(!should_execute_response(
&[resp_when(resp_header_match(&[("content-type", "application/json")]))],
&resp
));
}
#[test]
fn mixed_response_conditions() {
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_static("application/json"));
let resp = make_response(200, headers);
let conditions = vec![
resp_when(status_match(&[200])),
resp_unless(resp_header_match(&[("x-skip", "true")])),
];
assert!(should_execute_response(&conditions, &resp));
}
#[test]
fn empty_response_condition_match_is_vacuously_true() {
let resp = make_response(500, HeaderMap::new());
let m = ResponseConditionMatch {
status: None,
headers: None,
};
assert!(should_execute_response(&[resp_when(m)], &resp));
}
#[test]
fn multiple_response_conditions_all_must_pass() {
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_static("application/json"));
let resp = make_response(200, headers);
let conditions = vec![
resp_when(status_match(&[200, 201])),
resp_when(resp_header_match(&[("content-type", "application/json")])),
];
assert!(should_execute_response(&conditions, &resp));
}
#[test]
fn multiple_response_conditions_one_fails() {
let resp = make_response(200, HeaderMap::new());
let conditions = vec![
resp_when(status_match(&[200])),
resp_when(resp_header_match(&[("content-type", "application/json")])),
];
assert!(
!should_execute_response(&conditions, &resp),
"missing header should fail condition"
);
}
#[test]
fn content_type_strips_parameters() {
let mut headers = HeaderMap::new();
headers.insert(
"content-type",
HeaderValue::from_static("text/event-stream; charset=utf-8"),
);
let resp = make_response(200, headers);
assert!(should_execute_response(
&[resp_when(resp_header_match(&[("content-type", "text/event-stream")]))],
&resp
));
}
#[test]
fn content_type_case_insensitive() {
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_static("Application/JSON"));
let resp = make_response(200, headers);
assert!(should_execute_response(
&[resp_when(resp_header_match(&[("content-type", "application/json")]))],
&resp
));
}
#[test]
fn content_type_wrong_media_type() {
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_static("text/plain; charset=utf-8"));
let resp = make_response(200, headers);
assert!(!should_execute_response(
&[resp_when(resp_header_match(&[("content-type", "text/event-stream")]))],
&resp
));
}
#[test]
fn content_type_case_insensitive_with_parameters() {
let mut headers = HeaderMap::new();
headers.insert(
"content-type",
HeaderValue::from_static("Text/Event-Stream; charset=utf-8"),
);
let resp = make_response(200, headers);
assert!(should_execute_response(
&[resp_when(resp_header_match(&[("content-type", "text/event-stream")]))],
&resp
));
}
#[test]
fn content_type_expected_parameters_match_exactly() {
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_static("application/json; profile=a"));
let resp = make_response(200, headers);
assert!(should_execute_response(
&[resp_when(resp_header_match(&[(
"content-type",
"application/json; profile=a"
)]))],
&resp
));
}
#[test]
fn content_type_expected_parameters_case_insensitive_media_type() {
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_static("Application/JSON; profile=a"));
let resp = make_response(200, headers);
assert!(should_execute_response(
&[resp_when(resp_header_match(&[(
"content-type",
"application/json; profile=a"
)]))],
&resp
));
}
#[test]
fn content_type_expected_parameters_mismatch() {
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_static("application/json; profile=b"));
let resp = make_response(200, headers);
assert!(!should_execute_response(
&[resp_when(resp_header_match(&[(
"content-type",
"application/json; profile=a"
)]))],
&resp
));
}
#[test]
fn content_type_parameters_ignore_whitespace_and_value_case() {
let mut headers = HeaderMap::new();
headers.insert(
"content-type",
HeaderValue::from_static("application/json;charset=UTF-8"),
);
let resp = make_response(200, headers);
assert!(should_execute_response(
&[resp_when(resp_header_match(&[(
"content-type",
"application/json; charset=utf-8"
)]))],
&resp
));
}
#[test]
fn content_type_parameters_ignore_order_and_quotes() {
let mut headers = HeaderMap::new();
headers.insert(
"content-type",
HeaderValue::from_static("multipart/form-data; boundary=\"abc\"; charset=utf-8"),
);
let resp = make_response(200, headers);
assert!(should_execute_response(
&[resp_when(resp_header_match(&[(
"content-type",
"multipart/form-data; charset=utf-8; boundary=abc"
)]))],
&resp
));
}
#[test]
fn content_type_boundary_value_is_case_sensitive() {
let mut headers = HeaderMap::new();
headers.insert(
"content-type",
HeaderValue::from_static("multipart/form-data; boundary=ABC"),
);
let resp = make_response(200, headers);
assert!(
!should_execute_response(
&[resp_when(resp_header_match(&[(
"content-type",
"multipart/form-data; boundary=abc"
)]))],
&resp
),
"only charset values are case-insensitive (RFC 9110 §8.3.1)"
);
}
#[test]
fn content_type_quoted_parameter_value_keeps_semicolons() {
let mut headers = HeaderMap::new();
headers.insert(
"content-type",
HeaderValue::from_static("multipart/form-data; boundary=\"foo;one\""),
);
let resp = make_response(200, headers);
assert!(
!should_execute_response(
&[resp_when(resp_header_match(&[(
"content-type",
"multipart/form-data; boundary=\"foo;two\""
)]))],
&resp
),
"a semicolon inside a quoted-string must not split the parameter"
);
assert!(
should_execute_response(
&[resp_when(resp_header_match(&[(
"content-type",
"multipart/form-data; boundary=\"foo;one\""
)]))],
&resp
),
"the same quoted value must still match"
);
}
#[test]
fn content_type_quoted_pair_decodes_before_compare() {
let mut headers = HeaderMap::new();
headers.insert(
"content-type",
HeaderValue::from_static("multipart/form-data; boundary=\"foo\\;bar\""),
);
let resp = make_response(200, headers);
assert!(
should_execute_response(
&[resp_when(resp_header_match(&[(
"content-type",
"multipart/form-data; boundary=\"foo;bar\""
)]))],
&resp
),
"`\\;` inside a quoted-string is the same octet as `;` (RFC 9110 §5.6.4)"
);
assert!(
!should_execute_response(
&[resp_when(resp_header_match(&[(
"content-type",
"multipart/form-data; boundary=\"foo;baz\""
)]))],
&resp
),
"decoding must not make different values match"
);
}
#[test]
fn content_type_duplicate_parameter_is_not_a_subset_match() {
let mut headers = HeaderMap::new();
headers.insert(
"content-type",
HeaderValue::from_static("text/plain; charset=utf-8; foo=bar"),
);
let resp = make_response(200, headers);
assert!(
!should_execute_response(
&[resp_when(resp_header_match(&[(
"content-type",
"text/plain; charset=utf-8; charset=utf-8"
)]))],
&resp
),
"a repeated parameter must not stand in for a missing one"
);
}
#[test]
fn content_type_parameters_compare_as_multiset() {
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_static("text/plain; a=1; a=1; b=2"));
let resp = make_response(200, headers);
assert!(
!should_execute_response(
&[resp_when(resp_header_match(&[(
"content-type",
"text/plain; a=1; b=2; b=2"
)]))],
&resp
),
"equal counts and mutual containment are not enough; multiplicities must agree"
);
assert!(
should_execute_response(
&[resp_when(resp_header_match(&[(
"content-type",
"text/plain; b=2; a=1; a=1"
)]))],
&resp
),
"the same multiset in another order must still match"
);
}
#[test]
fn non_content_type_header_stays_exact() {
let mut headers = HeaderMap::new();
headers.insert("x-custom", HeaderValue::from_static("value; extra"));
let resp = make_response(200, headers);
assert!(!should_execute_response(
&[resp_when(resp_header_match(&[("x-custom", "value")]))],
&resp
));
}
fn make_response(status: u16, headers: HeaderMap) -> Response {
Response {
status: http::StatusCode::from_u16(status).unwrap(),
headers,
}
}
fn resp_when(m: ResponseConditionMatch) -> ResponseCondition {
ResponseCondition::When(m)
}
fn resp_unless(m: ResponseConditionMatch) -> ResponseCondition {
ResponseCondition::Unless(m)
}
fn status_match(codes: &[u16]) -> ResponseConditionMatch {
ResponseConditionMatch {
status: Some(codes.to_vec()),
headers: None,
}
}
fn resp_header_match(pairs: &[(&str, &str)]) -> ResponseConditionMatch {
let mut headers = HashMap::new();
for (k, v) in pairs {
headers.insert((*k).to_owned(), (*v).to_owned());
}
ResponseConditionMatch {
status: None,
headers: Some(headers),
}
}
}