use std::collections::{BTreeMap, BTreeSet};
use rhai::{Dynamic, Map};
use crate::{Request, Response, SendraError};
pub(super) fn request_map(request: &Request) -> Dynamic {
let mut headers = Map::new();
for (name, value) in &request.headers {
headers.insert(name.as_str().into(), value.clone().into());
}
let mut map = Map::new();
map.insert("method".into(), request.method.as_str().into());
map.insert("url".into(), request.url.clone().into());
map.insert("headers".into(), Dynamic::from_map(headers));
map.insert(
"body".into(),
match &request.body {
Some(body) => body.clone().into(),
None => Dynamic::UNIT,
},
);
Dynamic::from_map(map)
}
pub(super) fn response_map(response: &Response) -> Dynamic {
let headers: rhai::Array = response
.headers
.iter()
.map(|(name, value)| {
let mut header = Map::new();
header.insert("name".into(), name.clone().into());
header.insert("value".into(), value.clone().into());
Dynamic::from_map(header)
})
.collect();
let mut map = Map::new();
map.insert("status".into(), (response.status as i64).into());
map.insert("status_text".into(), response.status_text.clone().into());
map.insert("headers".into(), Dynamic::from_array(headers));
map.insert("body".into(), response.body.clone().into());
map.insert(
"elapsed_ms".into(),
(response.elapsed.as_millis() as i64).into(),
);
Dynamic::from_map(map)
}
pub(super) fn request_from_dynamic(
original: &Request,
value: Dynamic,
) -> Result<Request, SendraError> {
let invalid = |reason: String| SendraError::ScriptRequest { reason };
let map = value.try_cast::<Map>().ok_or_else(|| {
invalid(
"`request` was replaced with something that is not an object map; \
modify its fields rather than assigning over it"
.to_string(),
)
})?;
for key in map.keys() {
if !matches!(key.as_str(), "method" | "url" | "headers" | "body") {
return Err(invalid(format!(
"`request.{key}` is not a field a request has (method, url, headers, body)"
)));
}
}
match map.get("method") {
Some(method)
if method.clone().try_cast::<String>().as_deref() == Some(original.method.as_str()) => {
}
Some(method) => {
return Err(invalid(format!(
"`request.method` is read-only: it was `{}` and the script set it to `{}`",
original.method,
method.to_string().trim()
)))
}
None => {
return Err(invalid(
"`request.method` is read-only and was removed by the script".to_string(),
))
}
}
let url = string_field(&map, "url")?.ok_or_else(|| {
invalid("`request.url` was removed by the script; a request needs one".to_string())
})?;
let headers_value = map.get("headers").cloned().ok_or_else(|| {
invalid(
"`request.headers` was removed by the script; assign an empty map (`#{}`) \
to send no headers"
.to_string(),
)
})?;
let headers_map = headers_value.try_cast::<Map>().ok_or_else(|| {
invalid("`request.headers` must be an object map of header name to string".to_string())
})?;
let mut after = BTreeMap::new();
for (name, value) in headers_map {
let value = value.try_cast::<String>().ok_or_else(|| {
invalid(format!(
"`request.headers[\"{name}\"]` must be a string; call `.to_string()` on it"
))
})?;
after.insert(name.to_string(), value);
}
let headers = merge_script_headers(&original.headers, &after);
Ok(Request {
name: original.name.clone(),
method: original.method,
url,
headers,
query: original.query.clone(),
body: string_field(&map, "body")?,
json: original.json.clone(),
body_file: original.body_file.clone(),
form: original.form.clone(),
multipart: original.multipart.clone(),
auth: original.auth.clone(),
assertions: original.assertions.clone(),
pre_request: original.pre_request.clone(),
post_request: original.post_request.clone(),
capture: original.capture.clone(),
retry: original.retry,
})
}
fn merge_script_headers(
original: &[(String, String)],
after: &BTreeMap<String, String>,
) -> Vec<(String, String)> {
let mut before: BTreeMap<&str, &str> = BTreeMap::new();
for (name, value) in original {
before.insert(name.as_str(), value.as_str());
}
let mut merged = Vec::with_capacity(original.len());
let mut written: BTreeSet<&str> = BTreeSet::new();
for (name, value) in original {
let Some(script_value) = after.get(name.as_str()) else {
continue;
};
if before.get(name.as_str()).copied() == Some(script_value.as_str()) {
merged.push((name.clone(), value.clone()));
} else if written.insert(name.as_str()) {
merged.push((name.clone(), script_value.clone()));
}
}
for (name, value) in after {
if !before.contains_key(name.as_str()) {
merged.push((name.clone(), value.clone()));
}
}
merged
}
fn string_field(map: &Map, key: &str) -> Result<Option<String>, SendraError> {
match map.get(key) {
None => Ok(None),
Some(value) if value.is_unit() => Ok(None),
Some(value) => {
value
.clone()
.try_cast::<String>()
.map(Some)
.ok_or_else(|| SendraError::ScriptRequest {
reason: format!(
"`request.{key}` must be a string, or `()` for none; it is {}",
describe(value)
),
})
}
}
}
fn describe(value: &Dynamic) -> String {
let type_name = value.type_name();
let article = if type_name.starts_with(['a', 'e', 'i', 'o', 'u']) {
"an"
} else {
"a"
};
format!("{article} {type_name}")
}
#[cfg(test)]
mod tests {
use super::super::test_support::{request, run_pre, with_pre_request};
use super::*;
#[test]
fn a_repeated_header_the_script_never_touched_survives_intact() {
let request = request(
"method: GET\n\
url: https://example.com\n\
headers:\n \
Accept: application/json\n \
X-Forwarded-For:\n - 1.2.3.4\n - 5.6.7.8\n \
X-Trailing: last\n\
pre_request: |\n request.headers[\"X-Signature\"] = \"abc\";\n",
);
let sent = run_pre(&request).expect("the script should run");
assert_eq!(
sent.headers,
vec![
("Accept".to_string(), "application/json".to_string()),
("X-Forwarded-For".to_string(), "1.2.3.4".to_string()),
("X-Forwarded-For".to_string(), "5.6.7.8".to_string()),
("X-Trailing".to_string(), "last".to_string()),
("X-Signature".to_string(), "abc".to_string()),
],
"an untouched repeated header keeps both values, in file order, \
and the script's own header is appended"
);
}
#[test]
fn a_repeated_header_collapses_only_when_the_script_writes_to_that_name() {
let request = request(
"method: GET\n\
url: https://example.com\n\
headers:\n \
X-Forwarded-For:\n - 1.2.3.4\n - 5.6.7.8\n \
X-Other:\n - a\n - b\n\
pre_request: |\n request.headers[\"X-Forwarded-For\"] = \"9.9.9.9\";\n",
);
let sent = run_pre(&request).expect("the script should run");
assert_eq!(
sent.headers,
vec![
("X-Forwarded-For".to_string(), "9.9.9.9".to_string()),
("X-Other".to_string(), "a".to_string()),
("X-Other".to_string(), "b".to_string()),
],
"the written name collapses to the script's value, at the position \
it held; the name beside it is untouched"
);
}
#[test]
fn removing_a_repeated_header_from_a_script_removes_every_occurrence() {
let request = request(
"method: GET\n\
url: https://example.com\n\
headers:\n \
X-Forwarded-For:\n - 1.2.3.4\n - 5.6.7.8\n \
Accept: application/json\n\
pre_request: |\n request.headers.remove(\"X-Forwarded-For\");\n",
);
let sent = run_pre(&request).expect("the script should run");
assert_eq!(
sent.headers,
vec![("Accept".to_string(), "application/json".to_string())],
"a removed name goes entirely, not just its last occurrence"
);
}
#[test]
fn a_script_that_writes_back_the_value_it_was_handed_changes_nothing() {
let request = request(
"method: GET\n\
url: https://example.com\n\
headers:\n X-Tag:\n - one\n - two\n\
pre_request: |\n request.headers[\"X-Tag\"] = request.headers[\"X-Tag\"];\n",
);
let sent = run_pre(&request).expect("the script should run");
assert_eq!(
sent.headers,
vec![
("X-Tag".to_string(), "one".to_string()),
("X-Tag".to_string(), "two".to_string()),
]
);
}
#[test]
fn a_pre_request_script_writing_a_name_twice_writes_one_header() {
let request = with_pre_request(
"request.headers[\"Set-Cookie\"] = \"a\";\n\
request.headers[\"Set-Cookie\"] = \"b\";",
);
let sent = run_pre(&request).expect("the script should run");
let cookies: Vec<&str> = sent
.headers
.iter()
.filter(|(name, _)| name == "Set-Cookie")
.map(|(_, value)| value.as_str())
.collect();
assert_eq!(cookies, vec!["b"], "a script cannot repeat a header name");
}
#[test]
fn a_pre_request_script_cannot_change_the_method() {
let request = with_pre_request(r#"request.method = "GET";"#);
let err = run_pre(&request).expect_err("assigning to the method is an error");
assert!(
matches!(&err, SendraError::ScriptRequest { reason } if reason.contains("read-only")),
"{err:?}"
);
let message = err.to_string();
assert!(
message.contains("POST") && message.contains("GET"),
"{message}"
);
}
#[test]
fn a_pre_request_script_cannot_invent_a_field() {
let request = with_pre_request("request.timeout = 5;");
let err = run_pre(&request).expect_err("an unknown field is an error");
assert!(
matches!(&err, SendraError::ScriptRequest { reason } if reason.contains("request.timeout")),
"{err:?}"
);
}
#[test]
fn a_pre_request_script_cannot_set_a_header_to_a_non_string() {
let request = with_pre_request(r#"request.headers["X-Count"] = 5;"#);
let err = run_pre(&request).expect_err("a non-string header value is an error");
assert!(err.to_string().contains("to_string()"), "{err}");
}
#[test]
fn a_pre_request_script_cannot_replace_the_request_wholesale() {
let request = with_pre_request("request = 42;");
let err = run_pre(&request).expect_err("replacing `request` is an error");
assert!(matches!(err, SendraError::ScriptRequest { .. }), "{err:?}");
}
#[test]
fn a_script_cannot_reach_the_assertions_or_the_scripts() {
for attempt in ["request.assertions = #{};", r#"request.pre_request = "";"#] {
let request = with_pre_request(attempt);
assert!(
matches!(run_pre(&request), Err(SendraError::ScriptRequest { .. })),
"`{attempt}` should have been refused"
);
}
let request = request(
"method: GET\nurl: https://example.com\nassertions:\n status: 200\npre_request: |\n request.url = \"https://example.com/x\";\n",
);
let sent = run_pre(&request).expect("the script should run");
assert_eq!(sent.assertions, request.assertions);
assert_eq!(sent.pre_request, request.pre_request);
assert_eq!(sent.name, request.name);
}
#[test]
fn a_script_that_does_nothing_changes_nothing() {
let request = request(
"name: Create\n\
method: POST\n\
url: https://example.com/orders\n\
headers:\n Accept: application/json\n X-Api-Key: secret\n\
body: '{\"id\":1}'\n\
assertions:\n status: 201\n\
pre_request: |\n // nothing at all\n",
);
let sent = run_pre(&request).expect("an empty script should run");
assert_eq!(sent, request);
}
}