use super::route::{ContinueOptions, FulfillOptions};
use base64::Engine as _;
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashMap};
pub(crate) fn header_array(headers: impl IntoIterator<Item = (String, String)>) -> Vec<Value> {
headers
.into_iter()
.map(|(name, value)| {
Value::Object(serde_json::Map::from_iter([
("name".to_string(), Value::String(name)),
("value".to_string(), Value::String(value)),
]))
})
.collect()
}
pub(crate) fn name_value_pairs(raw: Option<&[Value]>) -> impl Iterator<Item = (&str, &str)> {
raw.into_iter()
.flatten()
.filter_map(|entry| Some((entry.get("name")?.as_str()?, entry.get("value")?.as_str()?)))
}
fn base64(bytes: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(bytes)
}
pub(crate) fn base64_decode(encoded: &str) -> Option<Vec<u8>> {
base64::engine::general_purpose::STANDARD
.decode(encoded)
.ok()
}
pub(crate) fn post_data_param(post_data: Option<String>, bytes: Option<Vec<u8>>) -> Option<String> {
post_data
.map(String::into_bytes)
.or(bytes)
.map(|bytes| base64(&bytes))
}
pub(crate) fn merge_headers<K: AsRef<str>, V: AsRef<str>>(
pairs: impl IntoIterator<Item = (K, V)>,
join_others: Option<&str>,
) -> HashMap<String, String> {
let mut headers: HashMap<String, String> = HashMap::new();
for (name, value) in pairs {
let name = name.as_ref().to_ascii_lowercase();
let value = value.as_ref();
let separator = if name == "set-cookie" {
Some("\n")
} else {
join_others
};
match (headers.get_mut(&name), separator) {
(Some(existing), Some(separator)) => {
existing.push_str(separator);
existing.push_str(value);
}
_ => {
headers.insert(name, value.to_string());
}
}
}
headers
}
pub(crate) fn fulfill_params(opts: FulfillOptions) -> Value {
let mut headers: BTreeMap<String, String> = opts
.headers
.into_iter()
.flatten()
.map(|(name, value)| (name.to_ascii_lowercase(), value))
.collect();
if let Some(content_type) = opts.content_type {
headers.insert("content-type".to_string(), content_type);
}
if let Some(body) = &opts.body
&& !body.is_empty()
{
headers
.entry("content-length".to_string())
.or_insert_with(|| body.len().to_string());
}
let mut params = json!({ "status": opts.status.unwrap_or(200) });
params["headers"] = Value::Array(header_array(headers));
if let Some(body) = opts.body {
let (encoded, is_base64) = match String::from_utf8(body) {
Ok(text) => (text, false),
Err(not_utf8) => (base64(not_utf8.as_bytes()), true),
};
params["body"] = Value::String(encoded);
params["isBase64"] = Value::Bool(is_base64);
}
params
}
pub(crate) fn continue_params(overrides: Option<ContinueOptions>, is_fallback: bool) -> Value {
let mut params = json!({ "isFallback": is_fallback });
let Some(opts) = overrides else {
return params;
};
if let Some(headers) = opts.headers {
params["headers"] = Value::Array(header_array(headers));
}
if let Some(method) = opts.method {
params["method"] = json!(method);
}
if let Some(encoded) = post_data_param(opts.post_data, opts.post_data_bytes) {
params["postData"] = Value::String(encoded);
}
if let Some(url) = opts.url {
params["url"] = json!(url);
}
params
}
pub(crate) fn har_response_headers(raw: Option<&[Value]>) -> HashMap<String, String> {
merge_headers(name_value_pairs(raw), None)
}
pub(crate) fn har_fulfill_options(
status: Option<u16>,
body_base64: Option<&str>,
headers: Option<&[Value]>,
) -> FulfillOptions {
let mut builder = FulfillOptions::builder()
.status(status.unwrap_or(200))
.headers(har_response_headers(headers));
if let Some(body) = body_base64 {
builder = builder.body(base64_decode(body).unwrap_or_default());
}
builder.build()
}
#[cfg(test)]
pub(crate) fn headers_of(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn header_values(params: &Value) -> Vec<(String, String)> {
params["headers"]
.as_array()
.unwrap()
.iter()
.map(|h| {
(
h["name"].as_str().unwrap().to_string(),
h["value"].as_str().unwrap().to_string(),
)
})
.collect()
}
#[test]
fn fulfill_params_are_top_level_command_parameters_not_nested() {
let params = fulfill_params(FulfillOptions::default());
assert!(params.get("response").is_none());
assert_eq!(params["status"], 200);
assert_eq!(params["headers"], json!([]));
assert!(params.get("body").is_none());
assert!(params.get("isBase64").is_none());
}
#[test]
fn fulfill_status_is_passed_through() {
let opts = FulfillOptions::builder().status(404).build();
assert_eq!(fulfill_params(opts)["status"], 404);
}
#[test]
fn fulfill_utf8_body_is_sent_as_text_with_its_length() {
let opts = FulfillOptions::builder().body_string("héllo").build();
let params = fulfill_params(opts);
assert_eq!(params["body"], "héllo");
assert_eq!(params["isBase64"], false);
assert_eq!(
header_values(¶ms),
vec![("content-length".to_string(), "6".to_string())]
);
}
#[test]
fn fulfill_binary_body_is_sent_as_base64() {
let opts = FulfillOptions::builder()
.body(vec![0xff, 0x00, 0xfe])
.build();
let params = fulfill_params(opts);
assert_eq!(params["body"], "/wD+");
assert_eq!(params["isBase64"], true);
assert_eq!(
header_values(¶ms),
vec![("content-length".to_string(), "3".to_string())]
);
}
#[test]
fn fulfill_empty_body_gets_no_content_length() {
let opts = FulfillOptions::builder().body(vec![]).build();
let params = fulfill_params(opts);
assert_eq!(params["body"], "");
assert_eq!(params["headers"], json!([]));
}
#[test]
fn fulfill_content_type_and_explicit_headers_are_lowercased_and_merged() {
let opts = FulfillOptions::builder()
.headers(headers_of(&[("X-Custom", "yes")]))
.content_type("text/html")
.build();
assert_eq!(
header_values(&fulfill_params(opts)),
vec![
("content-type".to_string(), "text/html".to_string()),
("x-custom".to_string(), "yes".to_string()),
]
);
}
#[test]
fn fulfill_explicit_content_length_is_not_overwritten() {
let opts = FulfillOptions::builder()
.headers(headers_of(&[("Content-Length", "99")]))
.body_string("abc")
.build();
assert_eq!(
header_values(&fulfill_params(opts)),
vec![("content-length".to_string(), "99".to_string())]
);
}
#[test]
fn continue_without_overrides_only_carries_the_fallback_flag() {
assert_eq!(continue_params(None, false), json!({ "isFallback": false }));
assert_eq!(continue_params(None, true), json!({ "isFallback": true }));
}
#[test]
fn continue_overrides_pass_through_with_post_data_encoded() {
let opts = ContinueOptions::builder()
.headers(headers_of(&[("X-Custom", "v")]))
.method("POST".to_string())
.post_data("x".to_string())
.url("https://example.test/x".to_string())
.build();
let params = continue_params(Some(opts), true);
assert_eq!(params["isFallback"], true);
assert_eq!(params["method"], "POST");
assert_eq!(params["url"], "https://example.test/x");
assert_eq!(
header_values(¶ms),
vec![("X-Custom".to_string(), "v".to_string())]
);
assert_eq!(params["postData"], "eA==");
}
#[test]
fn post_data_prefers_the_string_form_and_encodes_both() {
assert_eq!(post_data_param(None, None), None);
assert_eq!(
post_data_param(Some("key=value".to_string()), None).as_deref(),
Some("a2V5PXZhbHVl")
);
assert_eq!(
post_data_param(None, Some(vec![0x01, 0x02, 0xff])).as_deref(),
Some("AQL/")
);
assert_eq!(
post_data_param(Some("text".to_string()), Some(vec![0x01])).as_deref(),
Some("dGV4dA==")
);
}
#[test]
fn merge_headers_joins_others_with_the_given_separator_or_keeps_the_last() {
let pairs = [
("vary", "accept"),
("Vary", "origin"),
("Set-Cookie", "a=1"),
("set-cookie", "b=2"),
];
let joined = merge_headers(pairs, Some(", "));
assert_eq!(joined["vary"], "accept, origin");
assert_eq!(joined["set-cookie"], "a=1\nb=2");
assert_eq!(joined.len(), 2);
let last_wins = merge_headers(pairs, None);
assert_eq!(last_wins["vary"], "origin");
assert_eq!(last_wins["set-cookie"], "a=1\nb=2");
}
#[test]
fn har_fulfill_decodes_the_body_and_defaults_the_status() {
let raw = vec![json!({ "name": "content-type", "value": "text/plain" })];
let opts = har_fulfill_options(None, Some("aGVsbG8="), Some(&raw));
assert_eq!(opts.status, Some(200));
assert_eq!(opts.body.as_deref(), Some(&b"hello"[..]));
assert_eq!(opts.headers.unwrap()["content-type"], "text/plain");
let bare = har_fulfill_options(Some(304), None, None);
assert_eq!(bare.status, Some(304));
assert_eq!(bare.body, None);
assert!(bare.headers.unwrap().is_empty());
}
#[test]
fn har_headers_join_repeated_set_cookie_and_keep_the_last_of_others() {
let raw = vec![
json!({ "name": "set-cookie", "value": "a=1" }),
json!({ "name": "set-cookie", "value": "b=2" }),
json!({ "name": "x-dup", "value": "first" }),
json!({ "name": "x-dup", "value": "second" }),
json!({ "name": "content-type", "value": "text/html" }),
json!({ "name": "broken" }),
];
let map = har_response_headers(Some(&raw));
assert_eq!(map["set-cookie"], "a=1\nb=2");
assert_eq!(map["x-dup"], "second");
assert_eq!(map["content-type"], "text/html");
assert_eq!(map.len(), 3);
}
}