use anyhow::{Result, anyhow};
use reqwest::StatusCode;
use reqwest::header::{HeaderMap, HeaderName};
use serde_json::Value;
use serde_json_path::{ExactlyOneError, JsonPath};
#[derive(Debug, Clone)]
pub enum CaptureSource {
JsonPath(JsonPath),
Header(String),
Status,
Body,
}
#[derive(Debug, Clone)]
pub struct Capture {
pub name: String,
pub expr: String,
pub source: CaptureSource,
}
impl Capture {
pub fn parse(name: &str, expr: &str) -> Result<Self> {
if name.is_empty() || name.contains(|c: char| c.is_whitespace() || c == '{' || c == '}') {
return Err(anyhow!(
"'{}' is not a valid variable name (it can't be empty or contain whitespace or braces)",
name
));
}
let source = if expr.starts_with('$') {
let path = JsonPath::parse(expr)
.map_err(|e| anyhow!("'{}' is not a valid JSONPath query: {}", expr, e))?;
CaptureSource::JsonPath(path)
} else if let Some(header) = expr.strip_prefix("header:") {
let header = header.trim();
HeaderName::from_bytes(header.as_bytes())
.map_err(|_| anyhow!("'{}' is not a valid header name", header))?;
CaptureSource::Header(header.to_string())
} else if expr == "status" {
CaptureSource::Status
} else if expr == "body" {
CaptureSource::Body
} else {
return Err(anyhow!(
"'{}' must be a JSONPath query starting with '$', 'header:<Name>', 'status', or 'body'",
expr
));
};
Ok(Capture {
name: name.to_string(),
expr: expr.to_string(),
source,
})
}
pub fn extract(
&self,
status: StatusCode,
headers: &HeaderMap,
body: &str,
json: Option<&Value>,
) -> Result<String> {
self.extract_inner(status, headers, body, json)
.map_err(|e| anyhow!("capture '{}': {}", self.name, e))
}
fn extract_inner(
&self,
status: StatusCode,
headers: &HeaderMap,
body: &str,
json: Option<&Value>,
) -> Result<String> {
match &self.source {
CaptureSource::JsonPath(path) => {
let json = json.ok_or_else(|| anyhow!("response body is not JSON"))?;
let value = path.query(json).exactly_one().map_err(|e| match e {
ExactlyOneError::Empty => anyhow!("no value matched '{}'", self.expr),
ExactlyOneError::MoreThanOne(n) => {
anyhow!("'{}' matched {} values, expected 1", self.expr, n)
}
})?;
value_to_string(value)
}
CaptureSource::Header(name) => {
let value = headers
.get(name.as_str())
.ok_or_else(|| anyhow!("response has no '{}' header", name))?;
value
.to_str()
.map(str::to_string)
.map_err(|_| anyhow!("response header '{}' is not valid text", name))
}
CaptureSource::Status => Ok(status.as_u16().to_string()),
CaptureSource::Body => Ok(body.to_string()),
}
}
}
fn value_to_string(value: &Value) -> Result<String> {
match value {
Value::Null => Err(anyhow!("matched value is null")),
Value::String(s) => Ok(s.clone()),
other => Ok(other.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::header::HeaderValue;
fn run(expr: &str, body: &str) -> Result<String> {
let capture = Capture::parse("v", expr)?;
let json = serde_json::from_str::<Value>(body).ok();
capture.extract(StatusCode::OK, &HeaderMap::new(), body, json.as_ref())
}
const USERS: &str = r#"{
"id": 42,
"name": "Toad",
"active": true,
"deleted_at": null,
"address": {"city": "Austin", "zip": "78701"},
"roles": ["reader", "editor"],
"users": [
{"id": 1, "role": "admin"},
{"id": 2, "role": "user"},
{"id": 3, "role": "user"}
]
}"#;
#[test]
fn number_is_captured_as_text() {
assert_eq!(run("$.id", USERS).unwrap(), "42");
}
#[test]
fn string_is_captured_without_quotes() {
assert_eq!(run("$.name", USERS).unwrap(), "Toad");
}
#[test]
fn boolean_is_captured_as_text() {
assert_eq!(run("$.active", USERS).unwrap(), "true");
}
#[test]
fn object_and_array_are_captured_as_compact_json() {
assert_eq!(
run("$.address", USERS).unwrap(),
r#"{"city":"Austin","zip":"78701"}"#
);
assert_eq!(run("$.roles", USERS).unwrap(), r#"["reader","editor"]"#);
}
#[test]
fn filter_selects_one_item() {
assert_eq!(run("$.users[?@.role == 'admin'].id", USERS).unwrap(), "1");
}
#[test]
fn negative_index_selects_last_item() {
assert_eq!(run("$.users[-1].id", USERS).unwrap(), "3");
}
#[test]
fn null_is_an_error() {
let err = run("$.deleted_at", USERS).unwrap_err();
assert_eq!(err.to_string(), "capture 'v': matched value is null");
}
#[test]
fn no_match_is_an_error() {
let err = run("$.missing", USERS).unwrap_err();
assert_eq!(err.to_string(), "capture 'v': no value matched '$.missing'");
}
#[test]
fn multiple_matches_is_an_error() {
let err = run("$.users[?@.role == 'user'].id", USERS).unwrap_err();
assert!(err.to_string().contains("matched 2 values, expected 1"));
}
#[test]
fn json_path_on_non_json_body_is_an_error() {
let err = run("$.id", "not json").unwrap_err();
assert!(err.to_string().contains("response body is not JSON"));
}
#[test]
fn body_captures_raw_text() {
assert_eq!(run("body", "plain text").unwrap(), "plain text");
}
#[test]
fn status_is_captured() {
let capture = Capture::parse("code", "status").unwrap();
let value = capture
.extract(StatusCode::CREATED, &HeaderMap::new(), "", None)
.unwrap();
assert_eq!(value, "201");
}
#[test]
fn header_lookup_is_case_insensitive() {
let mut headers = HeaderMap::new();
headers.insert("location", HeaderValue::from_static("/users/42"));
let capture = Capture::parse("loc", "header:Location").unwrap();
let value = capture
.extract(StatusCode::CREATED, &headers, "", None)
.unwrap();
assert_eq!(value, "/users/42");
}
#[test]
fn missing_header_is_an_error() {
let capture = Capture::parse("loc", "header:Location").unwrap();
let err = capture
.extract(StatusCode::OK, &HeaderMap::new(), "", None)
.unwrap_err();
assert_eq!(
err.to_string(),
"capture 'loc': response has no 'Location' header"
);
}
#[test]
fn invalid_json_path_is_rejected() {
assert!(Capture::parse("v", "$.users[?").is_err());
}
#[test]
fn unknown_source_is_rejected() {
let err = Capture::parse("v", "id").unwrap_err();
assert!(err.to_string().contains("must be a JSONPath query"));
}
#[test]
fn invalid_variable_name_is_rejected() {
assert!(Capture::parse("user id", "$.id").is_err());
assert!(Capture::parse("", "$.id").is_err());
}
}