use serde_json::{Map, Value, json};
use super::Config;
use crate::pipeline::PipelineError;
pub const CODE_CONFIG: &str = "FW_NODE_WEB_RESPONSE_SEND_CONFIG";
pub const CODE_HEADER: &str = "FW_NODE_WEB_RESPONSE_SEND_HEADER";
pub const CODE_REDIRECT: &str = "FW_NODE_WEB_RESPONSE_SEND_REDIRECT";
#[derive(Debug, Clone, PartialEq)]
pub struct Head {
pub status: Option<u16>,
pub headers: Vec<(String, String)>,
}
impl Head {
pub fn has(&self, name: &str) -> bool {
self.headers.iter().any(|(key, _)| key.eq_ignore_ascii_case(name))
}
pub fn default_header(&mut self, name: &str, value: &str) {
if !self.has(name) {
self.headers.push((name.to_string(), value.to_string()));
}
}
pub fn is_redirect(&self) -> bool {
self.status.is_some_and(|status| (300..400).contains(&status))
}
pub fn envelope(&self) -> Map<String, Value> {
let mut map = Map::new();
map.insert("status".to_string(), json!(self.status));
map.insert(
"headers".to_string(),
Value::Array(self.headers.iter().map(|(k, v)| json!([k, v])).collect()),
);
map
}
}
impl Config {
pub fn check(&self) -> Result<Head, PipelineError> {
let sources = [self.body.is_some(), self.template.is_some(), self.file.is_some(), self.path.is_some()];
if sources.iter().filter(|given| **given).count() > 1 {
return Err(PipelineError::new(
CODE_CONFIG,
"web.response.send answers one of --body, --template, --file or --path",
));
}
if self.root.is_some() && self.file.is_none() {
return Err(PipelineError::new(CODE_CONFIG, "--root confines --file; give --file too"));
}
if self.filename.is_some() && self.file.is_none() && self.path.is_none() {
return Err(PipelineError::new(CODE_CONFIG, "--filename names a file download; give --file or --path too"));
}
self.head()
}
pub fn head(&self) -> Result<Head, PipelineError> {
let mut headers = Vec::new();
for (name, value) in &self.headers {
if name.is_empty() || !name.bytes().all(is_token_byte) {
return Err(PipelineError::new(CODE_HEADER, format!("--header '{name}' is not a header name")));
}
let values: Vec<&Value> = match value {
Value::Array(items) => items.iter().collect(),
other => vec![other],
};
for value in values {
headers.push((name.clone(), header_text(name, value)?));
}
}
let head = Head { status: self.status, headers };
let locations = head.headers.iter().filter(|(key, _)| key.eq_ignore_ascii_case("location")).count();
if locations > 1 {
return Err(PipelineError::new(CODE_REDIRECT, "a response has one Location header"));
}
match (head.status, locations) {
(Some(status), 1) if (300..400).contains(&status) => Ok(head),
(_, 1) => Err(PipelineError::new(
CODE_REDIRECT,
"a Location header needs a 3xx --status: --status 303 --header \"Location=/home\"",
)),
(Some(status), _) if (300..400).contains(&status) && status != 304 => Err(PipelineError::new(
CODE_REDIRECT,
format!("--status {status} redirects; say where with --header \"Location=/home\""),
)),
_ => Ok(head),
}
}
}
fn is_token_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte)
}
fn header_text(name: &str, value: &Value) -> Result<String, PipelineError> {
let text = match value {
Value::String(text) => text.clone(),
Value::Number(number) => number.to_string(),
Value::Bool(flag) => flag.to_string(),
_ => return Err(PipelineError::new(CODE_HEADER, format!("--header {name} must be text"))),
};
if text.contains(['\r', '\n']) {
return Err(PipelineError::new(CODE_HEADER, format!("--header {name} must be one line")));
}
Ok(text)
}
pub fn body_envelope(config: &Config, head: &Head, payload: &Value) -> Value {
let mut envelope = head.envelope();
let body = match &config.body {
Some(body) => Some(body.clone()),
None if head.is_redirect() => None,
None => Some(payload.clone()),
};
match body {
Some(Value::String(text)) => {
envelope.insert("text".to_string(), Value::String(text));
}
Some(value) => {
envelope.insert("json".to_string(), value);
}
None => {}
}
Value::Object(envelope)
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use super::{Config, body_envelope};
fn config(value: Value) -> Config {
serde_json::from_value(value).expect("config")
}
#[test]
fn a_redirect_is_a_3xx_with_a_location_and_nothing_less() {
let head = config(json!({ "status": 303, "headers": { "Location": "/home" } })).check().unwrap();
assert_eq!(head.headers, vec![("Location".to_string(), "/home".to_string())]);
assert!(head.is_redirect());
for refused in [
json!({ "headers": { "Location": "/home" } }),
json!({ "status": 200, "headers": { "location": "/home" } }),
json!({ "status": 302 }),
json!({ "status": 303, "headers": { "Location": ["/a", "/b"] } }),
] {
let err = config(refused.clone()).check().unwrap_err();
assert_eq!(err.code, super::CODE_REDIRECT, "{refused}");
}
assert!(config(json!({ "status": 304 })).check().is_ok(), "304 points nowhere");
}
#[test]
fn a_repeated_header_stays_two_headers_in_order() {
let head = config(json!({
"headers": { "Set-Cookie": ["session=a1; Path=/; Max-Age=86400; SameSite=Lax; HttpOnly", "theme=dark"], "X-Trace": "t1" }
}))
.check()
.unwrap();
assert_eq!(head.envelope()["headers"][2], json!(["X-Trace", "t1"]));
assert_eq!(head.headers.iter().filter(|(k, _)| k == "Set-Cookie").count(), 2);
}
#[test]
fn a_set_cookie_header_is_sent_exactly_as_written() {
for written in [
"theme=dark",
"session=a1; Path=/; Max-Age=86400; SameSite=Lax; HttpOnly; Secure",
"session=; Path=/; Max-Age=0",
"id=7; expires=Wed, 21 Oct 2026 07:28:00 GMT",
] {
let head = config(json!({ "headers": { "Set-Cookie": written } })).check().unwrap();
assert_eq!(head.headers, vec![("Set-Cookie".to_string(), written.to_string())]);
}
}
#[test]
fn one_source_at_most_and_a_root_only_for_a_file() {
for refused in [
json!({ "body": "x", "template": "pages/a.tsx" }),
json!({ "body": "x", "file": "a.txt" }),
json!({ "template": "pages/a.tsx", "file": "a.txt" }),
json!({ "file": "{{ input.file }}", "path": "uploads/a.pdf" }),
json!({ "body": "x", "path": "uploads/a.pdf" }),
json!({ "root": "pwa/icons" }),
json!({ "filename": "a.pdf" }),
json!({ "body": "x", "filename": "a.pdf" }),
] {
assert_eq!(config(refused.clone()).check().unwrap_err().code, super::CODE_CONFIG, "{refused}");
}
assert!(config(json!({ "root": "pwa/icons", "file": "a.png" })).check().is_ok());
assert!(config(json!({ "path": "uploads/a.pdf", "filename": "a.pdf" })).check().is_ok());
assert!(config(json!({ "file": null })).check().is_ok(), "a --file that resolved to null is a 404 at run, not a config error");
}
#[test]
fn a_header_is_one_line_of_text_under_a_header_name() {
for refused in [
json!({ "headers": { "X Bad": "v" } }),
json!({ "headers": { "X-A": "one\r\nX-B: two" } }),
json!({ "headers": { "X-A": { "nested": true } } }),
] {
assert_eq!(config(refused.clone()).check().unwrap_err().code, super::CODE_HEADER, "{refused}");
}
let head = config(json!({ "headers": { "X-Count": 3 } })).check().unwrap();
assert_eq!(head.headers, vec![("X-Count".to_string(), "3".to_string())]);
}
#[test]
fn a_string_body_is_text_and_anything_else_json() {
let payload = json!({ "rows": [1] });
let text = config(json!({ "body": "hello" }));
assert_eq!(body_envelope(&text, &text.check().unwrap(), &payload)["text"], "hello");
let rows = config(json!({ "body": [1, 2] }));
assert_eq!(body_envelope(&rows, &rows.check().unwrap(), &payload)["json"], json!([1, 2]));
let none = config(json!({}));
assert_eq!(body_envelope(&none, &none.check().unwrap(), &payload)["json"], payload);
let redirect = config(json!({ "status": 303, "headers": { "Location": "/home" } }));
let envelope = body_envelope(&redirect, &redirect.check().unwrap(), &payload);
assert!(envelope.get("json").is_none() && envelope.get("text").is_none(), "{envelope}");
}
}