use std::collections::BTreeMap;
use std::path::PathBuf;
use jsonpath_rust::JsonPath;
use serde::{Deserialize, Serialize};
use crate::environment::describe_environment;
use crate::{Environment, Response};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(untagged)]
pub enum CaptureSource {
JsonPath(String),
Header { header: String },
Status { status: bool },
}
impl<'de> Deserialize<'de> for CaptureSource {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged, deny_unknown_fields)]
enum Raw {
JsonPath(String),
Header { header: String },
Status { status: bool },
}
match Raw::deserialize(deserializer)? {
Raw::JsonPath(path) => Ok(CaptureSource::JsonPath(path)),
Raw::Header { header } => Ok(CaptureSource::Header { header }),
Raw::Status { status: true } => Ok(CaptureSource::Status { status: true }),
Raw::Status { status: false } => Err(serde::de::Error::custom(
"`status: false` does not capture anything; use `status: true` or remove this \
entry",
)),
}
}
}
#[cfg(feature = "schema")]
impl schemars::JsonSchema for CaptureSource {
fn schema_name() -> std::borrow::Cow<'static, str> {
"CaptureSource".into()
}
fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"description": "Where one `capture` entry reads its value from: a bare string is a \
JSON path into the response body; `{ header: <name> }` reads a response header; \
`{ status: true }` captures the numeric status code. `status: false` is invalid.",
"oneOf": [
{
"type": "string",
"description": "A JSON path into the response body."
},
{
"type": "object",
"properties": { "header": { "type": "string" } },
"required": ["header"],
"additionalProperties": false
},
{
"type": "object",
"properties": { "status": { "type": "boolean", "const": true } },
"required": ["status"],
"additionalProperties": false
}
]
})
}
}
impl CaptureSource {
fn label(&self) -> String {
match self {
CaptureSource::JsonPath(path) => path.clone(),
CaptureSource::Header { header } => format!("header `{header}`"),
CaptureSource::Status { .. } => "status".to_string(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(transparent)]
pub struct Captures {
entries: BTreeMap<String, CaptureSource>,
}
impl Captures {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn variables(&self) -> Vec<String> {
self.entries.keys().cloned().collect()
}
pub fn entries(&self) -> &BTreeMap<String, CaptureSource> {
&self.entries
}
pub fn evaluate(&self, response: &Response, environment: &Environment) -> CaptureReport {
if self.entries.is_empty() {
return CaptureReport::default();
}
let body = serde_json::from_str::<serde_json::Value>(&response.body);
CaptureReport {
results: self
.entries
.iter()
.map(|(variable, source)| {
capture_one(variable, source, body.as_ref(), response, environment)
})
.collect(),
}
}
}
impl FromIterator<(String, CaptureSource)> for Captures {
fn from_iter<T: IntoIterator<Item = (String, CaptureSource)>>(iter: T) -> Self {
Self {
entries: iter.into_iter().collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CaptureFailure {
Shadowed {
environment: Option<PathBuf>,
},
InvalidPath { reason: String },
BodyNotJson {
reason: String,
content_type: Option<String>,
},
NoMatch,
Ambiguous {
count: usize,
sample: Vec<String>,
},
NotAScalar {
kind: &'static str,
},
HeaderNotFound {
header: String,
present: Vec<String>,
},
}
impl std::fmt::Display for CaptureFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CaptureFailure::Shadowed { environment } => write!(
f,
"{} already defines this variable; rename the capture or the environment entry",
describe_environment(environment)
),
CaptureFailure::InvalidPath { reason } => write!(f, "not a valid JSON path: {reason}"),
CaptureFailure::BodyNotJson {
reason,
content_type,
} => write!(
f,
"the response body is not JSON: {reason}{}",
match content_type {
Some(content_type) => format!(" (content-type: {content_type})"),
None => " (no content-type header)".to_string(),
}
),
CaptureFailure::NoMatch => f.write_str("matched nothing in the response body"),
CaptureFailure::Ambiguous { count, sample } => write!(
f,
"matched {count} values ({}); a capture needs a source that selects exactly one",
sample.join(", ")
),
CaptureFailure::NotAScalar { kind } => write!(
f,
"matched {kind}, which has no text form to substitute; capture a string, \
number or boolean"
),
CaptureFailure::HeaderNotFound { header, present } => write!(
f,
"no `{header}` header in the response{}",
if present.is_empty() {
"; the response carries no headers at all".to_string()
} else {
format!(" (the response has: {})", present.join(", "))
}
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CaptureResult {
pub variable: String,
pub path: String,
value: Option<String>,
failure: Option<CaptureFailure>,
}
impl CaptureResult {
pub fn passed(&self) -> bool {
self.failure.is_none()
}
pub fn value(&self) -> Option<&str> {
self.value.as_deref()
}
pub fn failure(&self) -> Option<&CaptureFailure> {
self.failure.as_ref()
}
fn captured(variable: &str, path: &str, value: String) -> Self {
Self {
variable: variable.to_string(),
path: path.to_string(),
value: Some(value),
failure: None,
}
}
fn fail(variable: &str, path: &str, failure: CaptureFailure) -> Self {
Self {
variable: variable.to_string(),
path: path.to_string(),
value: None,
failure: Some(failure),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CaptureReport {
results: Vec<CaptureResult>,
}
impl CaptureReport {
pub fn results(&self) -> &[CaptureResult] {
&self.results
}
pub fn is_empty(&self) -> bool {
self.results.is_empty()
}
pub fn len(&self) -> usize {
self.results.len()
}
pub fn passed(&self) -> bool {
self.results.iter().all(CaptureResult::passed)
}
pub fn captured_count(&self) -> usize {
self.results.iter().filter(|result| result.passed()).count()
}
pub fn failed_count(&self) -> usize {
self.results.len() - self.captured_count()
}
pub fn failures(&self) -> impl Iterator<Item = &CaptureResult> {
self.results.iter().filter(|result| !result.passed())
}
pub fn values(&self) -> BTreeMap<String, String> {
self.results
.iter()
.filter_map(|result| {
result
.value()
.map(|value| (result.variable.clone(), value.to_string()))
})
.collect()
}
}
fn capture_one(
variable: &str,
source: &CaptureSource,
body: Result<&serde_json::Value, &serde_json::Error>,
response: &Response,
environment: &Environment,
) -> CaptureResult {
let label = source.label();
let fail = |failure| CaptureResult::fail(variable, &label, failure);
if environment.variables.contains_key(variable) {
return fail(CaptureFailure::Shadowed {
environment: environment.source.clone(),
});
}
match source {
CaptureSource::JsonPath(path) => capture_json_path(variable, path, body, response),
CaptureSource::Header { header } => capture_header(variable, header, response),
CaptureSource::Status { .. } => {
CaptureResult::captured(variable, &label, response.status.to_string())
}
}
}
fn capture_json_path(
variable: &str,
path: &str,
body: Result<&serde_json::Value, &serde_json::Error>,
response: &Response,
) -> CaptureResult {
let fail = |failure| CaptureResult::fail(variable, path, failure);
if let Err(err) = jsonpath_rust::parser::parse_json_path(path) {
return fail(CaptureFailure::InvalidPath {
reason: err.to_string(),
});
}
let body = match body {
Ok(body) => body,
Err(err) => {
return fail(CaptureFailure::BodyNotJson {
reason: err.to_string(),
content_type: content_type(response).map(str::to_owned),
})
}
};
let selected = match body.query(path) {
Ok(selected) => selected,
Err(err) => {
return fail(CaptureFailure::InvalidPath {
reason: err.to_string(),
})
}
};
match selected.as_slice() {
[only] => match scalar_text(only) {
Ok(text) => CaptureResult::captured(variable, path, text),
Err(kind) => fail(CaptureFailure::NotAScalar { kind }),
},
[] => fail(CaptureFailure::NoMatch),
many => fail(CaptureFailure::Ambiguous {
count: many.len(),
sample: many
.iter()
.take(3)
.map(|value| serde_json::to_string(value).unwrap_or_else(|_| value.to_string()))
.collect(),
}),
}
}
fn capture_header(variable: &str, header: &str, response: &Response) -> CaptureResult {
let label = format!("header `{header}`");
let fail = |failure| CaptureResult::fail(variable, &label, failure);
let matches: Vec<&str> = response
.headers
.iter()
.filter(|(name, _)| name.eq_ignore_ascii_case(header))
.map(|(_, value)| value.as_str())
.collect();
match matches.as_slice() {
[only] => CaptureResult::captured(variable, &label, only.to_string()),
[] => fail(CaptureFailure::HeaderNotFound {
header: header.to_string(),
present: response
.headers
.iter()
.map(|(name, _)| name.clone())
.collect(),
}),
many => fail(CaptureFailure::Ambiguous {
count: many.len(),
sample: many.iter().take(3).map(|value| value.to_string()).collect(),
}),
}
}
fn scalar_text(value: &serde_json::Value) -> Result<String, &'static str> {
use serde_json::Value;
match value {
Value::String(text) => Ok(text.clone()),
Value::Number(number) => Ok(number.to_string()),
Value::Bool(flag) => Ok(flag.to_string()),
Value::Null => Err("null"),
Value::Array(_) => Err("an array"),
Value::Object(_) => Err("an object"),
}
}
fn content_type(response: &Response) -> Option<&str> {
response
.headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("content-type"))
.map(|(_, value)| value.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn response(headers: &[(&str, &str)], body: &str) -> Response {
Response {
status: 200,
status_text: "OK".to_string(),
headers: headers
.iter()
.map(|(name, value)| (name.to_string(), value.to_string()))
.collect(),
body: body.to_string(),
elapsed: Duration::from_millis(1),
redirects: Vec::new(),
}
}
fn json_response() -> Response {
response(
&[("content-type", "application/json")],
r#"{"token": "abc123", "user": {"id": 42, "admin": true}, "tags": ["a", "b"],
"price": 1.50, "nothing": null}"#,
)
}
fn captures(yaml: &str) -> Captures {
serde_yaml::from_str(yaml).expect("test capture block should parse")
}
fn report(yaml: &str) -> CaptureReport {
captures(yaml).evaluate(&json_response(), &Environment::default())
}
fn only(report: &CaptureReport) -> &CaptureResult {
assert_eq!(report.len(), 1, "expected one result: {report:?}");
&report.results()[0]
}
#[test]
fn captures_a_string_without_its_json_quotes() {
let report = report("auth_token: $.token\n");
assert_eq!(only(&report).value(), Some("abc123"));
assert!(report.passed());
assert_eq!(
report.values(),
BTreeMap::from([("auth_token".to_string(), "abc123".to_string())])
);
}
#[test]
fn captures_numbers_and_booleans_as_their_value_not_their_spelling() {
let report = report("id: $.user.id\nadmin: $.user.admin\nprice: $.price\n");
assert_eq!(
report.values(),
BTreeMap::from([
("admin".to_string(), "true".to_string()),
("id".to_string(), "42".to_string()),
("price".to_string(), "1.5".to_string()),
])
);
}
#[test]
fn a_path_that_matches_nothing_is_a_reported_failure() {
let report = report("missing: $.nope\n");
assert!(!report.passed());
assert_eq!(only(&report).failure(), Some(&CaptureFailure::NoMatch));
assert_eq!(only(&report).value(), None);
assert!(report.values().is_empty(), "nothing is defined by a miss");
assert!(
only(&report)
.failure()
.unwrap()
.to_string()
.contains("matched nothing"),
"the message is the one a user reads"
);
}
#[test]
fn a_path_matching_several_values_is_ambiguous_rather_than_first_wins() {
let report = report("tag: $.tags[*]\n");
match only(&report).failure() {
Some(CaptureFailure::Ambiguous { count, sample }) => {
assert_eq!(*count, 2);
assert_eq!(sample, &[r#""a""#.to_string(), r#""b""#.to_string()]);
}
other => panic!("expected Ambiguous, got {other:?}"),
}
}
#[test]
fn null_arrays_and_objects_have_no_text_form_to_substitute() {
for (path, kind) in [
("$.nothing", "null"),
("$.tags", "an array"),
("$.user", "an object"),
] {
let report = report(&format!("v: {path}\n"));
assert_eq!(
only(&report).failure(),
Some(&CaptureFailure::NotAScalar { kind }),
"{path} should not capture"
);
}
}
#[test]
fn a_body_that_is_not_json_reports_the_parser_message_and_the_content_type() {
let captures = captures("v: $.token\n");
let report = captures.evaluate(
&response(&[("content-type", "text/html")], "<html></html>"),
&Environment::default(),
);
match only(&report).failure() {
Some(CaptureFailure::BodyNotJson {
reason,
content_type,
}) => {
assert!(!reason.is_empty());
assert_eq!(content_type.as_deref(), Some("text/html"));
}
other => panic!("expected BodyNotJson, got {other:?}"),
}
}
#[test]
fn a_body_with_no_content_type_says_so_rather_than_naming_one() {
let report =
captures("v: $.token\n").evaluate(&response(&[], "not json"), &Environment::default());
let message = only(&report).failure().unwrap().to_string();
assert!(message.contains("no content-type header"), "got {message}");
}
#[test]
fn a_path_that_is_not_a_json_path_is_told_apart_from_one_that_missed() {
let report = report("v: not a path\n");
assert!(
matches!(
only(&report).failure(),
Some(CaptureFailure::InvalidPath { .. })
),
"got {:?}",
only(&report).failure()
);
}
#[test]
fn a_name_the_environment_already_defines_is_refused_rather_than_shadowing_it() {
let environment = Environment::from_yaml_str("auth_token: from-the-file\n").unwrap();
let report = captures("auth_token: $.token\n").evaluate(&json_response(), &environment);
assert!(!report.passed());
assert!(
matches!(
only(&report).failure(),
Some(CaptureFailure::Shadowed { .. })
),
"got {:?}",
only(&report).failure()
);
assert!(
report.values().is_empty(),
"a refused capture defines nothing, so the environment's value stands"
);
}
#[test]
fn a_collision_is_checked_before_the_path_is_even_read() {
let environment = Environment::from_yaml_str("v: x\n").unwrap();
let report = captures("v: not a path\n").evaluate(&json_response(), &environment);
assert!(
matches!(
only(&report).failure(),
Some(CaptureFailure::Shadowed { .. })
),
"got {:?}",
only(&report).failure()
);
}
#[test]
fn one_entry_failing_does_not_stop_the_others() {
let report = report("good: $.token\nbad: $.nope\nalso_good: $.user.id\n");
assert_eq!(report.len(), 3, "one result per entry, always");
assert_eq!(report.captured_count(), 2);
assert_eq!(report.failed_count(), 1);
assert_eq!(report.failures().count(), 1);
assert_eq!(
report.values(),
BTreeMap::from([
("also_good".to_string(), "42".to_string()),
("good".to_string(), "abc123".to_string()),
])
);
}
#[test]
fn an_empty_block_captures_nothing_and_reports_nothing() {
let report = captures("{}\n").evaluate(&json_response(), &Environment::default());
assert!(report.is_empty());
assert!(report.passed(), "vacuously");
assert!(report.values().is_empty());
}
#[test]
fn a_bare_string_still_means_a_json_path_unchanged() {
let parsed = captures("auth_token: $.token\n");
assert_eq!(
parsed.entries()["auth_token"],
CaptureSource::JsonPath("$.token".to_string())
);
let report = report("auth_token: $.token\nuser_id: $.user.id\n");
assert_eq!(
report.values(),
BTreeMap::from([
("auth_token".to_string(), "abc123".to_string()),
("user_id".to_string(), "42".to_string()),
])
);
assert_eq!(
only(&captures("v: $.token\n").evaluate(&json_response(), &Environment::default()))
.path,
"$.token"
);
}
#[test]
fn captures_a_response_header_case_insensitively() {
let response = response(&[("X-Request-Id", "abc-123")], "{}");
let report =
captures("id: { header: x-request-id }\n").evaluate(&response, &Environment::default());
assert_eq!(only(&report).value(), Some("abc-123"));
assert!(report.passed());
}
#[test]
fn a_missing_header_is_a_reported_failure_naming_what_is_there() {
let response = response(&[("Content-Type", "application/json")], "{}");
let report =
captures("id: { header: x-request-id }\n").evaluate(&response, &Environment::default());
match only(&report).failure() {
Some(CaptureFailure::HeaderNotFound { header, present }) => {
assert_eq!(header, "x-request-id");
assert_eq!(present, &["Content-Type".to_string()]);
}
other => panic!("expected HeaderNotFound, got {other:?}"),
}
let message = only(&report).failure().unwrap().to_string();
assert!(message.contains("Content-Type"), "got {message}");
}
#[test]
fn a_repeated_header_is_ambiguous_rather_than_first_or_last_wins() {
let response = response(&[("Set-Cookie", "a=1"), ("Set-Cookie", "b=2")], "{}");
let report = captures("session: { header: Set-Cookie }\n")
.evaluate(&response, &Environment::default());
match only(&report).failure() {
Some(CaptureFailure::Ambiguous { count, sample }) => {
assert_eq!(*count, 2);
assert_eq!(sample, &["a=1".to_string(), "b=2".to_string()]);
}
other => panic!("expected Ambiguous, got {other:?}"),
}
assert!(report.values().is_empty());
}
#[test]
fn captures_the_status_code_as_a_string() {
let response = response(&[], "{}");
let report =
captures("code: { status: true }\n").evaluate(&response, &Environment::default());
assert_eq!(only(&report).value(), Some("200"));
assert!(report.passed());
}
#[test]
fn status_false_is_rejected_when_the_file_is_loaded() {
let err = serde_yaml::from_str::<Captures>("code: { status: false }\n").unwrap_err();
assert!(err.to_string().contains("status: false"), "got {err}");
}
#[test]
fn an_object_capture_with_neither_header_nor_status_fails_to_parse() {
let err = serde_yaml::from_str::<Captures>("v: { nonsense: true }\n").unwrap_err();
assert!(!err.to_string().is_empty());
}
#[test]
fn header_and_status_capture_are_shadowed_the_same_as_json_path() {
let environment = Environment::from_yaml_str("session: from-the-file\n").unwrap();
let response = response(&[("Set-Cookie", "a=1")], "{}");
let report =
captures("session: { header: Set-Cookie }\n").evaluate(&response, &environment);
assert!(
matches!(
only(&report).failure(),
Some(CaptureFailure::Shadowed { .. })
),
"got {:?}",
only(&report).failure()
);
}
#[test]
fn a_capture_block_mixing_all_three_sources_evaluates_each_independently() {
let response = response(&[("X-Trace-Id", "trace-1")], r#"{"token": "abc123"}"#);
let report = captures(
"auth_token: $.token\ntrace: { header: x-trace-id }\ncode: { status: true }\n",
)
.evaluate(&response, &Environment::default());
assert_eq!(report.len(), 3);
assert!(report.passed());
assert_eq!(
report.values(),
BTreeMap::from([
("auth_token".to_string(), "abc123".to_string()),
("trace".to_string(), "trace-1".to_string()),
("code".to_string(), "200".to_string()),
])
);
}
}