diff --git a/Cargo.toml b/Cargo.toml index ee8f353..c7e5809 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,11 +5,16 @@ members = [ "examples/esi_example_minimal", "examples/esi_example_advanced_error_handling", "examples/esi_try_example", + "examples/esi_vars_example", "examples/esi_example_variants", ] [workspace.package] version = "0.5.0" -authors = ["Kailan Blanks "] +authors = [ + "Kailan Blanks ", + "Vadim Getmanshchuk ", + "Tyler McMullen ", +] license = "MIT" edition = "2018" diff --git a/README.md b/README.md index a231000..1a6249b 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,19 @@ The implementation is a subset of the [ESI Language Specification 1.0](https://w - `` (+ `alt`, `onerror="continue"`) - `` | `` | `` +- `` | `` +- `` | `` | `` - `` - `` Other tags will be ignored and served to the client as-is. +This implementation also includes an expression interpreter and library of functions that can be used. Current functions include: + +- `$lower(string)` +- `$html_encode(string)` +- `$replace(haystack, needle, replacement [, count])` + ## Example Usage ```rust,no_run diff --git a/esi/Cargo.toml b/esi/Cargo.toml index 6649f2a..d11dc26 100644 --- a/esi/Cargo.toml +++ b/esi/Cargo.toml @@ -9,10 +9,12 @@ repository = "https://github.com/fastly/esi" readme = "./README.md" [dependencies] -quick-xml = "0.36.0" -thiserror = "^1.0" +quick-xml = "0.37.1" +thiserror = "2.0.6" fastly = "^0.11" log = "^0.4" +regex = "1.11.1" +html-escape = "0.2.13" [dev-dependencies] env_logger = "^0.11" diff --git a/esi/src/error.rs b/esi/src/error.rs index a052aa6..90266fe 100644 --- a/esi/src/error.rs +++ b/esi/src/error.rs @@ -41,6 +41,18 @@ pub enum ExecutionError { /// Writer error #[error("writer error: {0}")] WriterError(#[from] std::io::Error), + + /// Expression error + #[error("expression failed to evaluate: `{0}`")] + ExpressionError(String), + + /// An error occurred while creating a regular expression in an eval context + #[error("failed to create a regular expression")] + RegexError(#[from] regex::Error), + + /// An error occurred while executing a function in an eval context + #[error("failed to execute a function: `{0}`")] + FunctionError(String), } pub type Result = std::result::Result; diff --git a/esi/src/expression.rs b/esi/src/expression.rs new file mode 100644 index 0000000..1ac6ebd --- /dev/null +++ b/esi/src/expression.rs @@ -0,0 +1,1206 @@ +use fastly::http::Method; +use fastly::Request; +use regex::RegexBuilder; +use std::fmt::Write; +use std::iter::Peekable; +use std::slice::Iter; +use std::str::Chars; +use std::{collections::HashMap, fmt::Display}; + +use crate::{functions, ExecutionError, Result}; + +pub fn try_evaluate_interpolated( + cur: &mut Peekable, + ctx: &mut EvalContext, +) -> Option { + evaluate_interpolated(cur, ctx) + .map_err(|e| { + println!("Error while evaluating interpolated expression: {e}"); + }) + .ok() +} + +pub fn evaluate_interpolated(cur: &mut Peekable, ctx: &mut EvalContext) -> Result { + let tokens = lex_interpolated_expr(cur)?; + let expr = parse(&tokens)?; + eval_expr(expr, ctx) +} + +pub fn evaluate_expression(raw_expr: &str, ctx: &mut EvalContext) -> Result { + lex_expr(raw_expr) + .and_then(|tokens| parse(&tokens)) + .and_then(|expr: Expr| eval_expr(expr, ctx)) + .map_err(|e| { + ExecutionError::ExpressionError(format!( + "Error occurred during expression evaluation: {e}" + )) + }) +} + +pub struct EvalContext { + vars: HashMap, + match_name: String, + request: Request, +} +impl EvalContext { + pub fn new() -> Self { + Self { + vars: HashMap::new(), + match_name: "MATCHES".to_string(), + request: Request::new(Method::GET, "http://localhost"), + } + } + pub fn new_with_vars(vars: HashMap) -> Self { + Self { + vars, + match_name: "MATCHES".to_string(), + request: Request::new(Method::GET, "http://localhost"), + } + } + pub fn get_variable(&self, key: &str, subkey: Option) -> Value { + match key { + "REQUEST_METHOD" => Value::String(self.request.get_method_str().to_string()), + "REQUEST_PATH" => Value::String(self.request.get_path().to_string()), + "REMOTE_ADDR" => Value::String( + self.request + .get_client_ip_addr() + .map_or_else(String::new, |ip| ip.to_string()), + ), + "QUERY_STRING" => match self.request.get_query_str() { + Some(query) => match subkey { + None => Value::String(query.to_string()), + Some(field) => { + return self + .request + .get_query_parameter(&field) + .map_or_else(|| Value::Null, |v| Value::String(v.to_string())); + } + }, + None => Value::Null, + }, + _ if key.starts_with("HTTP_") => { + let header = key.strip_prefix("HTTP_").unwrap_or_default(); + self.request.get_header(header).map_or(Value::Null, |h| { + let value = h.to_str().unwrap_or_default(); + subkey.map_or_else( + || Value::String(value.to_string()), + |field| { + value + .split(';') + .find_map(|s| { + s.trim() + .split_once('=') + .filter(|(key, _)| *key == field) + .map(|(_, val)| Value::String(val.to_string())) + }) + .unwrap_or(Value::Null) + }, + ) + }) + } + + _ => self + .vars + .get(&format_key(key, subkey)) + .unwrap_or(&Value::Null) + .to_owned(), + } + } + pub fn set_variable(&mut self, key: &str, subkey: Option, value: Value) { + let key = format_key(key, subkey); + + match value { + Value::Null => {} + _ => { + self.vars.insert(key, value); + } + }; + } + + pub fn set_match_name(&mut self, match_name: &str) { + self.match_name = match_name.to_string(); + } + + pub fn set_request(&mut self, request: Request) { + self.request = request; + } +} +impl From<[(String, Value); N]> for EvalContext { + fn from(data: [(String, Value); N]) -> Self { + Self::new_with_vars(HashMap::from(data)) + } +} + +fn format_key(key: &str, subkey: Option) -> String { + subkey.map_or_else(|| key.to_string(), |subkey| format!("{key}[{subkey}]")) +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + Integer(i32), + String(String), + Boolean(BoolValue), + Null, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BoolValue { + True, + False, +} + +impl Value { + pub fn to_bool(&self) -> bool { + match self { + &Self::Integer(n) => !matches!(n, 0), + Self::String(s) => !matches!(s, s if s == &String::new()), + Self::Boolean(b) => *b == BoolValue::True, + &Self::Null => false, + } + } +} + +impl Display for Value { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Integer(i) => write!(f, "{i}"), + Self::String(s) => write!(f, "{s}"), + Self::Boolean(b) => write!( + f, + "{}", + match b { + BoolValue::True => "true", + BoolValue::False => "false", + } + ), + Self::Null => write!(f, "null"), + } + } +} + +fn eval_expr(expr: Expr, ctx: &mut EvalContext) -> Result { + let result = match expr { + Expr::Integer(i) => Value::Integer(i), + Expr::String(s) => Value::String(s), + Expr::Variable(key, None) => ctx.get_variable(&key, None), + Expr::Variable(key, Some(subkey_expr)) => { + let subkey = eval_expr(*subkey_expr, ctx)?.to_string(); + ctx.get_variable(&key, Some(subkey)) + } + Expr::Comparison(c) => { + let left = eval_expr(c.left, ctx)?; + let right = eval_expr(c.right, ctx)?; + match c.operator { + Operator::Matches | Operator::MatchesInsensitive => { + let test = left.to_string(); + let pattern = right.to_string(); + + let re = if c.operator == Operator::Matches { + RegexBuilder::new(&pattern).build()? + } else { + RegexBuilder::new(&pattern).case_insensitive(true).build()? + }; + + if let Some(captures) = re.captures(&test) { + for (i, cap) in captures.iter().enumerate() { + let capval = + cap.map_or(Value::Null, |s| Value::String(s.as_str().to_string())); + ctx.set_variable(&ctx.match_name.clone(), Some(i.to_string()), capval); + } + + Value::Boolean(BoolValue::True) + } else { + Value::Boolean(BoolValue::False) + } + } + } + } + Expr::Call(identifier, args) => { + let mut values = Vec::new(); + for arg in args { + values.push(eval_expr(arg, ctx)?); + } + call_dispatch(&identifier, &values)? + } + }; + Ok(result) +} + +fn call_dispatch(identifier: &str, args: &[Value]) -> Result { + match identifier { + "ping" => Ok(Value::String("pong".to_string())), + "lower" => functions::lower(args), + "html_encode" => functions::html_encode(args), + "replace" => functions::replace(args), + _ => Err(ExecutionError::FunctionError(format!( + "unknown function: {identifier}" + ))), + } +} + +#[derive(Debug, Clone, PartialEq)] +enum Expr { + Integer(i32), + String(String), + Variable(String, Option>), + Comparison(Box), + Call(String, Vec), +} + +#[derive(Debug, Clone, PartialEq)] +enum Operator { + Matches, + MatchesInsensitive, +} + +#[derive(Debug, Clone, PartialEq)] +struct Comparison { + left: Expr, + operator: Operator, + right: Expr, +} + +// The parser attempts to implement this BNF: +// +// Expr <- integer | string | Variable | Call | BinaryOp +// Variable <- '$' '(' bareword ['{' Expr '}'] ')' +// Call <- '$' bareword '(' Expr? [',' Expr] ')' +// BinaryOp <- Expr Operator Expr +// +fn parse(tokens: &[Token]) -> Result { + let mut cur = tokens.iter().peekable(); + + let expr = parse_expr(&mut cur) + .map_err(|e| ExecutionError::ExpressionError(format!("parse error: {e}")))?; + + // Check if we've reached the end of the tokens + if cur.peek().is_some() { + let cur_left = cur.fold(String::new(), |mut acc, t| { + write!(&mut acc, "{t:?}").unwrap(); + acc + }); + return Err(ExecutionError::ExpressionError(format!( + "expected eof. tokens left: {cur_left}" + ))); + } + Ok(expr) +} + +fn parse_expr(cur: &mut Peekable>) -> Result { + let node = if let Some(token) = cur.next() { + match token { + Token::Integer(i) => Expr::Integer(*i), + Token::String(s) => Expr::String(s.clone()), + Token::Dollar => parse_dollar(cur)?, + unexpected => { + return Err(ExecutionError::ExpressionError(format!( + "unexpected token starting expression: {unexpected:?}", + ))) + } + } + } else { + return Err(ExecutionError::ExpressionError( + "unexpected end of tokens".to_string(), + )); + }; + + // Check if there's a binary operation, or if we've reached the end of the expression + match cur.peek() { + Some(Token::Bareword(s)) => { + let left = node; + let operator = match s.as_str() { + "matches" => { + cur.next(); + Operator::Matches + } + "matches_i" => { + cur.next(); + Operator::MatchesInsensitive + } + unexpected => { + return Err(ExecutionError::ExpressionError(format!( + "unexpected operator: {unexpected}" + ))) + } + }; + let right = parse_expr(cur)?; + let expr = Expr::Comparison(Box::new(Comparison { + left, + operator, + right, + })); + Ok(expr) + } + _ => Ok(node), + } +} + +fn parse_dollar(cur: &mut Peekable>) -> Result { + match cur.next() { + Some(&Token::OpenParen) => parse_variable(cur), + Some(Token::Bareword(s)) => parse_call(s, cur), + unexpected => Err(ExecutionError::ExpressionError(format!( + "unexpected token: {unexpected:?}", + ))), + } +} + +fn parse_variable(cur: &mut Peekable>) -> Result { + let Some(Token::Bareword(basename)) = cur.next() else { + return Err(ExecutionError::ExpressionError(format!( + "unexpected token: {:?}", + cur.next() + ))); + }; + + match cur.next() { + Some(&Token::OpenBracket) => { + // TODO: I think there might be cases of $var{key} where key is a + // bareword. If that's the case, then handle here by checking + // if the next token is a bareword instead of trying to parse + // an expression. + let subfield = parse_expr(cur)?; + let Some(&Token::CloseBracket) = cur.next() else { + return Err(ExecutionError::ExpressionError(format!( + "unexpected token: {:?}", + cur.next() + ))); + }; + + let Some(&Token::CloseParen) = cur.next() else { + return Err(ExecutionError::ExpressionError(format!( + "unexpected token: {:?}", + cur.next() + ))); + }; + + Ok(Expr::Variable( + basename.to_string(), + Some(Box::new(subfield)), + )) + } + Some(&Token::CloseParen) => Ok(Expr::Variable(basename.to_string(), None)), + unexpected => Err(ExecutionError::ExpressionError(format!( + "unexpected token: {unexpected:?}", + ))), + } +} + +fn parse_call(identifier: &str, cur: &mut Peekable>) -> Result { + match cur.next() { + Some(Token::OpenParen) => { + let mut args = Vec::new(); + loop { + if Some(&&Token::CloseParen) == cur.peek() { + cur.next(); + break; + } + args.push(parse_expr(cur)?); + match cur.peek() { + Some(&&Token::CloseParen) => { + cur.next(); + break; + } + Some(&&Token::Comma) => { + cur.next(); + continue; + } + _ => { + return Err(ExecutionError::ExpressionError( + "unexpected token in arg list".to_string(), + )); + } + } + } + Ok(Expr::Call(identifier.to_string(), args)) + } + _ => Err(ExecutionError::ExpressionError( + "unexpected token following identifier".to_string(), + )), + } +} + +#[derive(Debug, Clone, PartialEq)] +enum Token { + Integer(i32), + String(String), + OpenParen, + CloseParen, + OpenBracket, + CloseBracket, + Comma, + Dollar, + Bareword(String), +} + +fn lex_expr(expr: &str) -> Result> { + let mut cur = expr.chars().peekable(); + // Lex the expression, but don't stop at the first closing paren + let single = false; + lex_tokens(&mut cur, single) +} + +fn lex_interpolated_expr(cur: &mut Peekable) -> Result> { + if cur.peek() != Some(&'$') { + return Err(ExecutionError::ExpressionError("no expression".to_string())); + } + // Lex the expression, but stop at the first closing paren + let single = true; + lex_tokens(cur, single) +} + +// Lexes an expression, stopping at the first closing paren if `single` is true +fn lex_tokens(cur: &mut Peekable, single: bool) -> Result> { + let mut result = Vec::new(); + let mut paren_depth = 0; + + while let Some(&c) = cur.peek() { + match c { + '\'' => { + cur.next(); + result.push(get_string(cur)?); + } + '$' => { + cur.next(); + result.push(Token::Dollar); + } + '0'..='9' | '-' => { + result.push(get_integer(cur)?); + } + 'a'..='z' | 'A'..='Z' => { + result.push(get_bareword(cur)); + } + '(' | ')' | '{' | '}' | ',' => { + cur.next(); + match c { + '(' => { + result.push(Token::OpenParen); + paren_depth += 1; + } + ')' => { + result.push(Token::CloseParen); + paren_depth -= 1; + if single && paren_depth <= 0 { + break; + } + } + '{' => result.push(Token::OpenBracket), + '}' => result.push(Token::CloseBracket), + ',' => result.push(Token::Comma), + _ => unreachable!(), + } + } + ' ' => { + cur.next(); // Ignore spaces + } + _ => { + return Err(ExecutionError::ExpressionError( + "error in lexing interpolated".to_string(), + )); + } + } + } + // We should have hit the end of the expression + if paren_depth != 0 { + return Err(ExecutionError::ExpressionError( + "missing closing parenthesis".to_string(), + )); + } + + Ok(result) +} + +fn get_integer(cur: &mut Peekable) -> Result { + let mut buf = Vec::new(); + let c = cur.next().unwrap(); + buf.push(c); + + if c == '0' { + // Zero is a special case, as the only number that can start with a zero. + let Some(c) = cur.peek() else { + cur.next(); + // EOF after a zero. That's a valid number. + return Ok(Token::Integer(0)); + }; + // Make sure the zero isn't followed by another digit. + if let '0'..='9' = *c { + return Err(ExecutionError::ExpressionError( + "invalid number".to_string(), + )); + } + } + + if c == '-' { + let Some(c) = cur.next() else { + return Err(ExecutionError::ExpressionError( + "invalid number".to_string(), + )); + }; + match c { + '1'..='9' => buf.push(c), + _ => { + return Err(ExecutionError::ExpressionError( + "invalid number".to_string(), + )) + } + } + } + + while let Some(c) = cur.peek() { + match c { + '0'..='9' => buf.push(cur.next().unwrap()), + _ => break, + } + } + let Ok(num) = buf.into_iter().collect::().parse() else { + return Err(ExecutionError::ExpressionError( + "invalid number".to_string(), + )); + }; + Ok(Token::Integer(num)) +} + +fn get_bareword(cur: &mut Peekable) -> Token { + let mut buf = Vec::new(); + buf.push(cur.next().unwrap()); + + while let Some(c) = cur.peek() { + match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' => buf.push(cur.next().unwrap()), + _ => break, + } + } + Token::Bareword(buf.into_iter().collect()) +} + +fn get_string(cur: &mut Peekable) -> Result { + let mut buf = Vec::new(); + let mut triple_tick = false; + + if cur.peek() == Some(&'\'') { + // This is either an empty string, or the start of a triple tick string + cur.next(); + if cur.peek() == Some(&'\'') { + // It's a triple tick string + triple_tick = true; + cur.next(); + } else { + // It's an empty string, let's just return it + return Ok(Token::String(String::new())); + } + } + + while let Some(c) = cur.next() { + match c { + '\'' => { + if !triple_tick { + break; + } + if let Some(c2) = cur.next() { + if c2 == '\'' && cur.peek() == Some(&'\'') { + // End of a triple tick string + cur.next(); + break; + } + // Just two ticks + buf.push(c); + buf.push(c2); + } else { + // error + return Err(ExecutionError::ExpressionError( + "unexpected eof while parsing string".to_string(), + )); + }; + } + '\\' => { + if triple_tick { + // no escaping inside a triple tick string + buf.push(c); + } else { + // in a normal string, we'll ignore this and buffer the + // next char + if let Some(escaped_c) = cur.next() { + buf.push(escaped_c); + } else { + // error + return Err(ExecutionError::ExpressionError( + "unexpected eof while parsing string".to_string(), + )); + } + } + } + _ => buf.push(c), + } + } + Ok(Token::String(buf.into_iter().collect())) +} + +#[cfg(test)] +mod tests { + use super::*; + use regex::Regex; + + #[test] + fn test_lex_integer() -> Result<()> { + let tokens = lex_expr("1 23 456789 0 -987654 -32 -1 0")?; + assert_eq!( + tokens, + vec![ + Token::Integer(1), + Token::Integer(23), + Token::Integer(456789), + Token::Integer(0), + Token::Integer(-987654), + Token::Integer(-32), + Token::Integer(-1), + Token::Integer(0) + ] + ); + Ok(()) + } + #[test] + fn test_lex_empty_string() -> Result<()> { + let tokens = lex_expr("''")?; + assert_eq!(tokens, vec![Token::String("".to_string())]); + Ok(()) + } + #[test] + fn test_lex_simple_string() -> Result<()> { + let tokens = lex_expr("'hello'")?; + assert_eq!(tokens, vec![Token::String("hello".to_string())]); + Ok(()) + } + #[test] + fn test_lex_escaped_string() -> Result<()> { + let tokens = lex_expr(r#"'hel\'lo'"#)?; + assert_eq!(tokens, vec![Token::String("hel\'lo".to_string())]); + Ok(()) + } + #[test] + fn test_lex_triple_tick_string() -> Result<()> { + let tokens = lex_expr(r#"'''h'el''l\'o\'''"#)?; + assert_eq!(tokens, vec![Token::String(r#"h'el''l\'o\"#.to_string())]); + Ok(()) + } + #[test] + fn test_lex_triple_tick_and_escaping_torture() -> Result<()> { + let tokens = lex_expr(r#"'\\\'triple\'/' matches '''\'triple'/'''"#)?; + assert_eq!(tokens[0], tokens[2]); + let Token::String(ref test) = tokens[0] else { + panic!() + }; + let Token::String(ref pattern) = tokens[2] else { + panic!() + }; + let re = Regex::new(pattern)?; + assert!(re.is_match(test)); + Ok(()) + } + + #[test] + fn test_lex_variable() -> Result<()> { + let tokens = lex_expr("$(hello)")?; + assert_eq!( + tokens, + vec![ + Token::Dollar, + Token::OpenParen, + Token::Bareword("hello".to_string()), + Token::CloseParen + ] + ); + Ok(()) + } + #[test] + fn test_lex_variable_with_subscript() -> Result<()> { + let tokens = lex_expr("$(hello{'goodbye'})")?; + assert_eq!( + tokens, + vec![ + Token::Dollar, + Token::OpenParen, + Token::Bareword("hello".to_string()), + Token::OpenBracket, + Token::String("goodbye".to_string()), + Token::CloseBracket, + Token::CloseParen, + ] + ); + Ok(()) + } + #[test] + fn test_lex_variable_with_integer_subscript() -> Result<()> { + let tokens = lex_expr("$(hello{6})")?; + assert_eq!( + tokens, + vec![ + Token::Dollar, + Token::OpenParen, + Token::Bareword("hello".to_string()), + Token::OpenBracket, + Token::Integer(6), + Token::CloseBracket, + Token::CloseParen, + ] + ); + Ok(()) + } + #[test] + fn test_lex_matches_operator() -> Result<()> { + let tokens = lex_expr("matches")?; + assert_eq!(tokens, vec![Token::Bareword("matches".to_string())]); + Ok(()) + } + #[test] + fn test_lex_matches_i_operator() -> Result<()> { + let tokens = lex_expr("matches_i")?; + assert_eq!(tokens, vec![Token::Bareword("matches_i".to_string())]); + Ok(()) + } + #[test] + fn test_lex_identifier() -> Result<()> { + let tokens = lex_expr("$foo2BAZ")?; + assert_eq!( + tokens, + vec![Token::Dollar, Token::Bareword("foo2BAZ".to_string())] + ); + Ok(()) + } + #[test] + fn test_lex_simple_call() -> Result<()> { + let tokens = lex_expr("$fn()")?; + assert_eq!( + tokens, + vec![ + Token::Dollar, + Token::Bareword("fn".to_string()), + Token::OpenParen, + Token::CloseParen + ] + ); + Ok(()) + } + #[test] + fn test_lex_call_with_arg() -> Result<()> { + let tokens = lex_expr("$fn('hello')")?; + assert_eq!( + tokens, + vec![ + Token::Dollar, + Token::Bareword("fn".to_string()), + Token::OpenParen, + Token::String("hello".to_string()), + Token::CloseParen + ] + ); + Ok(()) + } + #[test] + fn test_lex_call_with_empty_string_arg() -> Result<()> { + let tokens = lex_expr("$fn('')")?; + assert_eq!( + tokens, + vec![ + Token::Dollar, + Token::Bareword("fn".to_string()), + Token::OpenParen, + Token::String("".to_string()), + Token::CloseParen + ] + ); + Ok(()) + } + #[test] + fn test_lex_call_with_two_args() -> Result<()> { + let tokens = lex_expr("$fn($(hello), 'hello')")?; + assert_eq!( + tokens, + vec![ + Token::Dollar, + Token::Bareword("fn".to_string()), + Token::OpenParen, + Token::Dollar, + Token::OpenParen, + Token::Bareword("hello".to_string()), + Token::CloseParen, + Token::Comma, + Token::String("hello".to_string()), + Token::CloseParen + ] + ); + Ok(()) + } + #[test] + fn test_lex_comparison() -> Result<()> { + let tokens = lex_expr("$(foo) matches 'bar'")?; + assert_eq!( + tokens, + vec![ + Token::Dollar, + Token::OpenParen, + Token::Bareword("foo".to_string()), + Token::CloseParen, + Token::Bareword("matches".to_string()), + Token::String("bar".to_string()) + ] + ); + Ok(()) + } + + #[test] + fn test_parse_integer() -> Result<()> { + let tokens = lex_expr("1")?; + let expr = parse(&tokens)?; + assert_eq!(expr, Expr::Integer(1)); + Ok(()) + } + #[test] + fn test_parse_simple_string() -> Result<()> { + let tokens = lex_expr("'hello'")?; + let expr = parse(&tokens)?; + assert_eq!(expr, Expr::String("hello".to_string())); + Ok(()) + } + #[test] + fn test_parse_variable() -> Result<()> { + let tokens = lex_expr("$(hello)")?; + let expr = parse(&tokens)?; + assert_eq!(expr, Expr::Variable("hello".to_string(), None)); + Ok(()) + } + + #[test] + fn test_parse_comparison() -> Result<()> { + let tokens = lex_expr("$(foo) matches 'bar'")?; + let expr = parse(&tokens)?; + assert_eq!( + expr, + Expr::Comparison(Box::new(Comparison { + left: Expr::Variable("foo".to_string(), None), + operator: Operator::Matches, + right: Expr::String("bar".to_string()) + })) + ); + Ok(()) + } + #[test] + fn test_parse_call() -> Result<()> { + let tokens = lex_expr("$hello()")?; + let expr = parse(&tokens)?; + assert_eq!(expr, Expr::Call("hello".to_string(), Vec::new())); + Ok(()) + } + #[test] + fn test_parse_call_with_arg() -> Result<()> { + let tokens = lex_expr("$fn('hello')")?; + let expr = parse(&tokens)?; + assert_eq!( + expr, + Expr::Call("fn".to_string(), vec![Expr::String("hello".to_string())]) + ); + Ok(()) + } + #[test] + fn test_parse_call_with_two_args() -> Result<()> { + let tokens = lex_expr("$fn($(hello), 'hello')")?; + let expr = parse(&tokens)?; + assert_eq!( + expr, + Expr::Call( + "fn".to_string(), + vec![ + Expr::Variable("hello".to_string(), None), + Expr::String("hello".to_string()) + ] + ) + ); + Ok(()) + } + + #[test] + fn test_eval_string() -> Result<()> { + let expr = Expr::String("hello".to_string()); + let result = eval_expr(expr, &mut EvalContext::new())?; + assert_eq!(result, Value::String("hello".to_string())); + Ok(()) + } + + #[test] + fn test_eval_variable() -> Result<()> { + let expr = Expr::Variable("hello".to_string(), None); + let result = eval_expr( + expr, + &mut EvalContext::from([("hello".to_string(), Value::String("goodbye".to_string()))]), + )?; + assert_eq!(result, Value::String("goodbye".to_string())); + Ok(()) + } + #[test] + fn test_eval_subscripted_variable() -> Result<()> { + let expr = Expr::Variable( + "hello".to_string(), + Some(Box::new(Expr::String("abc".to_string()))), + ); + let result = eval_expr( + expr, + &mut EvalContext::from([( + "hello[abc]".to_string(), + Value::String("goodbye".to_string()), + )]), + )?; + assert_eq!(result, Value::String("goodbye".to_string())); + Ok(()) + } + #[test] + fn test_eval_matches_comparison() -> Result<()> { + let result = evaluate_expression( + "$(hello) matches '^foo'", + &mut EvalContext::from([("hello".to_string(), Value::String("foobar".to_string()))]), + )?; + assert_eq!(result, Value::Boolean(BoolValue::True)); + Ok(()) + } + #[test] + fn test_eval_matches_i_comparison() -> Result<()> { + let result = evaluate_expression( + "$(hello) matches_i '^foo'", + &mut EvalContext::from([("hello".to_string(), Value::String("FOOBAR".to_string()))]), + )?; + assert_eq!(result, Value::Boolean(BoolValue::True)); + Ok(()) + } + #[test] + fn test_eval_matches_with_captures() -> Result<()> { + let ctx = + &mut EvalContext::from([("hello".to_string(), Value::String("foobar".to_string()))]); + + let result = evaluate_expression("$(hello) matches '^(fo)o'", ctx)?; + assert_eq!(result, Value::Boolean(BoolValue::True)); + + let result = evaluate_expression("$(MATCHES{1})", ctx)?; + assert_eq!(result, Value::String("fo".to_string())); + Ok(()) + } + #[test] + fn test_eval_matches_with_captures_and_match_name() -> Result<()> { + let ctx = + &mut EvalContext::from([("hello".to_string(), Value::String("foobar".to_string()))]); + + ctx.set_match_name("my_custom_name"); + let result = evaluate_expression("$(hello) matches '^(fo)o'", ctx)?; + assert_eq!(result, Value::Boolean(BoolValue::True)); + + let result = evaluate_expression("$(my_custom_name{1})", ctx)?; + assert_eq!(result, Value::String("fo".to_string())); + Ok(()) + } + #[test] + fn test_eval_matches_comparison_negative() -> Result<()> { + let result = evaluate_expression( + "$(hello) matches '^foo'", + &mut EvalContext::from([("hello".to_string(), Value::String("nope".to_string()))]), + )?; + assert_eq!(result, Value::Boolean(BoolValue::False)); + Ok(()) + } + #[test] + fn test_eval_function_call() -> Result<()> { + let result = evaluate_expression("$ping()", &mut EvalContext::new())?; + assert_eq!(result, Value::String("pong".to_string())); + Ok(()) + } + #[test] + fn test_eval_lower_call() -> Result<()> { + let result = evaluate_expression("$lower('FOO')", &mut EvalContext::new())?; + assert_eq!(result, Value::String("foo".to_string())); + Ok(()) + } + #[test] + fn test_eval_html_encode_call() -> Result<()> { + let result = evaluate_expression("$html_encode('a > b < c')", &mut EvalContext::new())?; + assert_eq!(result, Value::String("a > b < c".to_string())); + Ok(()) + } + #[test] + fn test_eval_replace_call() -> Result<()> { + let result = evaluate_expression( + "$replace('abc-def-ghi-', '-', '==')", + &mut EvalContext::new(), + )?; + assert_eq!(result, Value::String("abc==def==ghi==".to_string())); + Ok(()) + } + #[test] + fn test_eval_replace_call_with_empty_string() -> Result<()> { + let result = + evaluate_expression("$replace('abc-def-ghi-', '-', '')", &mut EvalContext::new())?; + assert_eq!(result, Value::String("abcdefghi".to_string())); + Ok(()) + } + + #[test] + fn test_eval_replace_call_with_count() -> Result<()> { + let result = evaluate_expression( + "$replace('abc-def-ghi-', '-', '==', 2)", + &mut EvalContext::new(), + )?; + assert_eq!(result, Value::String("abc==def==ghi-".to_string())); + Ok(()) + } + + #[test] + fn test_eval_get_request_method() -> Result<()> { + let mut ctx = EvalContext::new(); + let result = evaluate_expression("$(REQUEST_METHOD)", &mut ctx)?; + assert_eq!(result, Value::String("GET".to_string())); + Ok(()) + } + #[test] + fn test_eval_get_request_path() -> Result<()> { + let mut ctx = EvalContext::new(); + ctx.set_request(Request::new(Method::GET, "http://localhost/hello/there")); + + let result = evaluate_expression("$(REQUEST_PATH)", &mut ctx)?; + assert_eq!(result, Value::String("/hello/there".to_string())); + Ok(()) + } + #[test] + fn test_eval_get_request_query() -> Result<()> { + let mut ctx = EvalContext::new(); + ctx.set_request(Request::new(Method::GET, "http://localhost?hello")); + + let result = evaluate_expression("$(QUERY_STRING)", &mut ctx)?; + assert_eq!(result, Value::String("hello".to_string())); + Ok(()) + } + #[test] + fn test_eval_get_request_query_field() -> Result<()> { + let mut ctx = EvalContext::new(); + ctx.set_request(Request::new(Method::GET, "http://localhost?hello=goodbye")); + + let result = evaluate_expression("$(QUERY_STRING{'hello'})", &mut ctx)?; + assert_eq!(result, Value::String("goodbye".to_string())); + let result = evaluate_expression("$(QUERY_STRING{'nonexistent'})", &mut ctx)?; + assert_eq!(result, Value::Null); + Ok(()) + } + #[test] + fn test_eval_get_remote_addr() -> Result<()> { + // This is kind of a useless test as this will always return an empty string. + let mut ctx = EvalContext::new(); + ctx.set_request(Request::new(Method::GET, "http://localhost?hello")); + + let result = evaluate_expression("$(REMOTE_ADDR)", &mut ctx)?; + assert_eq!(result, Value::String("".to_string())); + Ok(()) + } + #[test] + fn test_eval_get_header() -> Result<()> { + // This is kind of a useless test as this will always return an empty string. + let mut ctx = EvalContext::new(); + let mut req = Request::new(Method::GET, "http://localhost"); + req.set_header("host", "hello.com"); + req.set_header("foobar", "baz"); + ctx.set_request(req); + + let result = evaluate_expression("$(HTTP_HOST)", &mut ctx)?; + assert_eq!(result, Value::String("hello.com".to_string())); + let result = evaluate_expression("$(HTTP_FOOBAR)", &mut ctx)?; + assert_eq!(result, Value::String("baz".to_string())); + Ok(()) + } + #[test] + fn test_eval_get_header_field() -> Result<()> { + // This is kind of a useless test as this will always return an empty string. + let mut ctx = EvalContext::new(); + let mut req = Request::new(Method::GET, "http://localhost"); + req.set_header("Cookie", "foo=bar; bar=baz"); + ctx.set_request(req); + + let result = evaluate_expression("$(HTTP_COOKIE{'foo'})", &mut ctx)?; + assert_eq!(result, Value::String("bar".to_string())); + let result = evaluate_expression("$(HTTP_COOKIE{'bar'})", &mut ctx)?; + assert_eq!(result, Value::String("baz".to_string())); + let result = evaluate_expression("$(HTTP_COOKIE{'baz'})", &mut ctx)?; + assert_eq!(result, Value::Null); + Ok(()) + } + + #[test] + fn test_bool_coercion() -> Result<()> { + assert!(Value::Boolean(BoolValue::True).to_bool()); + assert!(!Value::Boolean(BoolValue::False).to_bool()); + assert!(Value::Integer(1).to_bool()); + assert!(!Value::Integer(0).to_bool()); + assert!(!Value::String("".to_string()).to_bool()); + assert!(Value::String("hello".to_string()).to_bool()); + assert!(!Value::Null.to_bool()); + + Ok(()) + } + + #[test] + fn test_string_coercion() -> Result<()> { + assert_eq!(Value::Boolean(BoolValue::True).to_string(), "true"); + assert_eq!(Value::Boolean(BoolValue::False).to_string(), "false"); + assert_eq!(Value::Integer(1).to_string(), "1"); + assert_eq!(Value::Integer(0).to_string(), "0"); + assert_eq!(Value::String("".to_string()).to_string(), ""); + assert_eq!(Value::String("hello".to_string()).to_string(), "hello"); + assert_eq!(Value::Null.to_string(), "null"); + + Ok(()) + } + #[test] + fn test_lex_interpolated_basic() -> Result<()> { + let mut chars = "$(foo)bar".chars().peekable(); + let tokens = lex_interpolated_expr(&mut chars)?; + assert_eq!( + tokens, + vec![ + Token::Dollar, + Token::OpenParen, + Token::Bareword("foo".to_string()), + Token::CloseParen + ] + ); + // Verify remaining chars are untouched + assert_eq!(chars.collect::(), "bar"); + Ok(()) + } + + #[test] + fn test_lex_interpolated_nested() -> Result<()> { + let mut chars = "$(foo{$(bar)})rest".chars().peekable(); + let tokens = lex_interpolated_expr(&mut chars)?; + assert_eq!( + tokens, + vec![ + Token::Dollar, + Token::OpenParen, + Token::Bareword("foo".to_string()), + Token::OpenBracket, + Token::Dollar, + Token::OpenParen, + Token::Bareword("bar".to_string()), + Token::CloseParen, + Token::CloseBracket, + Token::CloseParen + ] + ); + assert_eq!(chars.collect::(), "rest"); + Ok(()) + } + + #[test] + fn test_lex_interpolated_no_dollar() { + let mut chars = "foo".chars().peekable(); + assert!(lex_interpolated_expr(&mut chars).is_err()); + } + + #[test] + fn test_lex_interpolated_incomplete() { + let mut chars = "$(foo".chars().peekable(); + assert!(lex_interpolated_expr(&mut chars).is_err()); + } +} diff --git a/esi/src/functions.rs b/esi/src/functions.rs new file mode 100644 index 0000000..51a69d9 --- /dev/null +++ b/esi/src/functions.rs @@ -0,0 +1,186 @@ +use crate::{expression::Value, ExecutionError, Result}; +use std::convert::TryFrom; + +pub fn lower(args: &[Value]) -> Result { + if args.len() != 1 { + return Err(ExecutionError::FunctionError( + "wrong number of arguments to 'lower'".to_string(), + )); + } + + Ok(Value::String(args[0].to_string().to_lowercase())) +} + +pub fn html_encode(args: &[Value]) -> Result { + if args.len() != 1 { + return Err(ExecutionError::FunctionError( + "wrong number of arguments to 'html_encode'".to_string(), + )); + } + + Ok(Value::String( + html_escape::encode_text(&args[0].to_string()).to_string(), + )) +} + +pub fn replace(args: &[Value]) -> Result { + if args.len() < 3 || args.len() > 4 { + return Err(ExecutionError::FunctionError( + "wrong number of arguments to 'replace'".to_string(), + )); + } + let Value::String(haystack) = &args[0] else { + return Err(ExecutionError::FunctionError( + "incorrect haystack passed to 'replace'".to_string(), + )); + }; + let Value::String(needle) = &args[1] else { + return Err(ExecutionError::FunctionError( + "incorrect needle passed to 'replace'".to_string(), + )); + }; + let Value::String(replacement) = &args[2] else { + return Err(ExecutionError::FunctionError( + "incorrect replacement passed to 'replace'".to_string(), + )); + }; + + // count is optional, default to usize::MAX + let count = match args.get(3) { + Some(Value::Integer(count)) => { + // cap count to usize::MAX + let count: usize = usize::try_from(*count).unwrap_or(usize::MAX); + count + } + Some(_) => { + return Err(ExecutionError::FunctionError( + "incorrect type passed to 'replace'".to_string(), + )); + } + None => usize::MAX, + }; + Ok(Value::String(haystack.replacen(needle, replacement, count))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lower() { + match lower(&[Value::String("HELLO".to_string())]) { + Ok(value) => assert_eq!(value, Value::String("hello".to_string())), + Err(err) => panic!("Unexpected error: {:?}", err), + } + match lower(&[Value::String("Rust".to_string())]) { + Ok(value) => assert_eq!(value, Value::String("rust".to_string())), + Err(err) => panic!("Unexpected error: {:?}", err), + } + match lower(&[Value::String("".to_string())]) { + Ok(value) => assert_eq!(value, Value::String("".to_string())), + Err(err) => panic!("Unexpected error: {:?}", err), + } + match lower(&[Value::Integer(123), Value::Integer(456)]) { + Ok(_) => panic!("Expected error, but got Ok"), + Err(err) => assert_eq!( + err.to_string(), + ExecutionError::FunctionError("wrong number of arguments to 'lower'".to_string()) + .to_string() + ), + } + } + + #[test] + fn test_html_encode() { + match html_encode(&[Value::String("
".to_string())]) { + Ok(value) => assert_eq!(value, Value::String("<div>".to_string())), + Err(err) => panic!("Unexpected error: {:?}", err), + } + match html_encode(&[Value::String("&".to_string())]) { + Ok(value) => assert_eq!(value, Value::String("&".to_string())), + Err(err) => panic!("Unexpected error: {:?}", err), + } + // assert_eq!( + // html_encode(&[Value::String('"'.to_string())]), + // Value::String(""".to_string()) + // ); + match html_encode(&[Value::Integer(123), Value::Integer(456)]) { + Ok(_) => panic!("Expected error, but got Ok"), + Err(err) => assert_eq!( + err.to_string(), + ExecutionError::FunctionError( + "wrong number of arguments to 'html_encode'".to_string() + ) + .to_string() + ), + } + } + + #[test] + fn test_replace() { + match replace(&[ + Value::String("hello world".to_string()), + Value::String("world".to_string()), + Value::String("Rust".to_string()), + ]) { + Ok(value) => assert_eq!(value, Value::String("hello Rust".to_string())), + Err(err) => panic!("Unexpected error: {:?}", err), + }; + + match replace(&[ + Value::String("hello world world".to_string()), + Value::String("world".to_string()), + Value::String("Rust".to_string()), + Value::Integer(1), + ]) { + Ok(value) => assert_eq!(value, Value::String("hello Rust world".to_string())), + Err(err) => panic!("Unexpected error: {:?}", err), + }; + + match replace(&[ + Value::String("hello world world".to_string()), + Value::String("world".to_string()), + Value::String("Rust".to_string()), + Value::Integer(2), + ]) { + Ok(value) => assert_eq!(value, Value::String("hello Rust Rust".to_string())), + Err(err) => panic!("Unexpected error: {:?}", err), + }; + + match replace(&[ + Value::String("hello world".to_string()), + Value::String("world".to_string()), + Value::String("Rust".to_string()), + Value::Integer(usize::MAX as i32), + ]) { + Ok(value) => assert_eq!(value, Value::String("hello Rust".to_string())), + Err(err) => panic!("Unexpected error: {:?}", err), + }; + + match replace(&[ + Value::String("hello world".to_string()), + Value::String("world".to_string()), + Value::String("Rust".to_string()), + Value::String("not an integer".to_string()), + ]) { + Ok(_) => panic!("Expected error, but got Ok"), + Err(err) => assert_eq!( + err.to_string(), + ExecutionError::FunctionError("incorrect type passed to 'replace'".to_string()) + .to_string() + ), + }; + + match replace(&[ + Value::String("hello world".to_string()), + Value::String("world".to_string()), + ]) { + Ok(_) => panic!("Expected error, but got Ok"), + Err(err) => assert_eq!( + err.to_string(), + ExecutionError::FunctionError("wrong number of arguments to 'replace'".to_string()) + .to_string() + ), + }; + } +} diff --git a/esi/src/lib.rs b/esi/src/lib.rs index 11ac9ba..8ead165 100644 --- a/esi/src/lib.rs +++ b/esi/src/lib.rs @@ -3,9 +3,12 @@ mod config; mod document; mod error; +mod expression; +mod functions; mod parse; use document::{FetchState, Task}; +use expression::{evaluate_expression, try_evaluate_interpolated, EvalContext}; use fastly::http::request::PendingRequest; use fastly::http::{header, Method, StatusCode, Url}; use fastly::{mime, Body, Request, Response}; @@ -132,6 +135,10 @@ impl Processor { // `root_task` is the root task that will be used to fetch tags in recursive manner let root_task = &mut Task::new(); + // context for the interpreter + let mut ctx = EvalContext::new(); + ctx.set_request(original_request_metadata.clone_without_body()); + for event in src_events { event_receiver( event, @@ -139,10 +146,11 @@ impl Processor { self.configuration.is_escaped_content, &original_request_metadata, dispatch_fragment_request, + &mut ctx, )?; } - self.process_root_task( + Self::process_root_task( root_task, output_writer, dispatch_fragment_request, @@ -171,6 +179,10 @@ impl Processor { // `root_task` is the root task that will be used to fetch tags in recursive manner let root_task = &mut Task::new(); + // context for the interpreter + let mut ctx = EvalContext::new(); + ctx.set_request(original_request_metadata.clone_without_body()); + // Call the library to parse fn `parse_tags` which will call the callback function // on each tag / event it finds in the document. // The callback function `handle_events` will handle the event. @@ -184,11 +196,12 @@ impl Processor { self.configuration.is_escaped_content, &original_request_metadata, dispatch_fragment_request, + &mut ctx, ) }, )?; - self.process_root_task( + Self::process_root_task( root_task, output_writer, dispatch_fragment_request, @@ -197,7 +210,6 @@ impl Processor { } fn process_root_task( - self, root_task: &mut Task, output_writer: &mut Writer, dispatch_fragment_request: &FragmentRequestDispatcher, @@ -448,9 +460,8 @@ fn event_receiver( is_escaped: bool, original_request_metadata: &Request, dispatch_fragment_request: &FragmentRequestDispatcher, + ctx: &mut EvalContext, ) -> Result<()> { - debug!("got {:?}", event); - match event { Event::ESI(Tag::Include { src, @@ -485,12 +496,14 @@ fn event_receiver( is_escaped, original_request_metadata, dispatch_fragment_request, + ctx, )?; let except_task = task_handler( except_events, is_escaped, original_request_metadata, dispatch_fragment_request, + ctx, )?; trace!( @@ -504,7 +517,97 @@ fn event_receiver( except_task, }); } - Event::XML(event) => { + Event::ESI(Tag::Assign { name, value }) => { + // TODO: the 'name' here might have a subfield, we need to parse it + let result = evaluate_expression(&value, ctx)?; + ctx.set_variable(&name, None, result); + } + Event::ESI(Tag::Vars { name }) => { + if let Some(name) = name { + let result = evaluate_expression(&name, ctx)?; + queue.push_back(Element::Raw(result.to_string().into_bytes())); + } + } + Event::ESI(Tag::When { .. }) => { + println!("Shouldn't be possible to get a When tag here"); + } + Event::ESI(Tag::Choose { + when_branches, + otherwise_events, + }) => { + let mut chose_branch = false; + for (when, events) in when_branches { + if let Tag::When { test, match_name } = when { + if let Some(match_name) = match_name { + ctx.set_match_name(&match_name); + } + let result = evaluate_expression(&test, ctx)?; + if result.to_bool() { + chose_branch = true; + for event in events { + event_receiver( + event, + queue, + is_escaped, + original_request_metadata, + dispatch_fragment_request, + ctx, + )?; + } + break; + } + } else { + println!("Somehow got something other than a When in a Choose: {when:?}",); + } + } + + if !chose_branch { + for event in otherwise_events { + event_receiver( + event, + queue, + is_escaped, + original_request_metadata, + dispatch_fragment_request, + ctx, + )?; + } + } + } + + Event::InterpolatedContent(event) => { + let mut buf = vec![]; + let event_str = String::from_utf8(event.iter().copied().collect()).unwrap_or_default(); + let mut cur = event_str.chars().peekable(); + while let Some(c) = cur.peek() { + if *c == '$' { + let mut new_cur = cur.clone(); + let result = try_evaluate_interpolated(&mut new_cur, ctx); + match result { + Some(r) => { + // push what we have so far + queue.push_back(Element::Raw( + buf.into_iter().collect::().into_bytes(), + )); + // push the result + queue.push_back(Element::Raw(r.to_string().into_bytes())); + // setup a new buffer + buf = vec![]; + cur = new_cur; + } + None => { + buf.push(cur.next().unwrap()); + } + } + } else { + buf.push(cur.next().unwrap()); + } + } + queue.push_back(Element::Raw( + buf.into_iter().collect::().into_bytes(), + )); + } + Event::Content(event) => { debug!("pushing content to buffer, len: {}", queue.len()); let mut buf = vec![]; let mut writer = Writer::new(&mut buf); @@ -522,6 +625,7 @@ fn task_handler( is_escaped: bool, original_request_metadata: &Request, dispatch_fragment_request: &FragmentRequestDispatcher, + ctx: &mut EvalContext, ) -> Result { let mut task = Task::new(); for event in events { @@ -531,6 +635,7 @@ fn task_handler( is_escaped, original_request_metadata, dispatch_fragment_request, + ctx, )?; } Ok(task) diff --git a/esi/src/parse.rs b/esi/src/parse.rs index 6db6b6b..6f5023e 100644 --- a/esi/src/parse.rs +++ b/esi/src/parse.rs @@ -33,13 +33,29 @@ pub enum Tag<'a> { attempt_events: Vec>, except_events: Vec>, }, + Assign { + name: String, + value: String, + }, + Vars { + name: Option, + }, + When { + test: String, + match_name: Option, + }, + Choose { + when_branches: Vec<(Tag<'a>, Vec>)>, + otherwise_events: Vec>, + }, } /// Representation of either XML data or a parsed ESI tag. #[derive(Debug)] #[allow(clippy::upper_case_acronyms)] pub enum Event<'e> { - XML(XmlEvent<'e>), + Content(XmlEvent<'e>), + InterpolatedContent(XmlEvent<'e>), ESI(Tag<'e>), } @@ -51,6 +67,11 @@ struct TagNames { r#try: Vec, attempt: Vec, except: Vec, + assign: Vec, + vars: Vec, + choose: Vec, + when: Vec, + otherwise: Vec, } impl TagNames { fn init(namespace: &str) -> Self { @@ -61,28 +82,55 @@ impl TagNames { r#try: format!("{namespace}:try",).into_bytes(), attempt: format!("{namespace}:attempt",).into_bytes(), except: format!("{namespace}:except",).into_bytes(), + assign: format!("{namespace}:assign",).into_bytes(), + vars: format!("{namespace}:vars",).into_bytes(), + choose: format!("{namespace}:choose",).into_bytes(), + when: format!("{namespace}:when",).into_bytes(), + otherwise: format!("{namespace}:otherwise",).into_bytes(), } } } +#[derive(Debug, PartialEq)] +enum ContentType { + Normal, + Interpolated, +} + fn do_parse<'a, R>( reader: &mut Reader, callback: &mut dyn FnMut(Event<'a>) -> Result<()>, task: &mut Vec>, - depth: &mut usize, + use_queue: bool, + try_depth: &mut usize, + choose_depth: &mut usize, current_arm: &mut Option, tag: &TagNames, + content_type: &ContentType, ) -> Result<()> where R: BufRead, { let mut is_remove_tag = false; let mut open_include = false; + let mut open_assign = false; + let mut open_vars = false; let attempt_events = &mut Vec::new(); let except_events = &mut Vec::new(); + // choose/when variables + let when_branches = &mut Vec::new(); + let otherwise_events = &mut Vec::new(); + let mut buffer = Vec::new(); + + // When you are in the top level of a try or choose block, the + // only allowable tags are attempt/except or when/otherwise. All + // other data should be eaten. + let mut in_try = false; + let mut in_choose = false; + // Parse tags and build events vec loop { match reader.read_event_into(&mut buffer) { @@ -102,12 +150,12 @@ where // Handle tags, and ignore the contents if they are not self-closing Ok(XmlEvent::Empty(e)) if e.name().into_inner().starts_with(&tag.include) => { - include_tag_handler(&e, callback, task, *depth)?; + include_tag_handler(&e, callback, task, use_queue)?; } Ok(XmlEvent::Start(e)) if e.name().into_inner().starts_with(&tag.include) => { open_include = true; - include_tag_handler(&e, callback, task, *depth)?; + include_tag_handler(&e, callback, task, use_queue)?; } Ok(XmlEvent::End(e)) if e.name().into_inner().starts_with(&tag.include) => { @@ -126,7 +174,8 @@ where // Handle tags Ok(XmlEvent::Start(ref e)) if e.name() == QName(&tag.r#try) => { *current_arm = Some(TryTagArms::Try); - *depth += 1; + *try_depth += 1; + in_try = true; continue; } @@ -139,20 +188,42 @@ where } if e.name() == QName(&tag.attempt) { *current_arm = Some(TryTagArms::Attempt); - do_parse(reader, callback, attempt_events, depth, current_arm, tag)?; + do_parse( + reader, + callback, + attempt_events, + true, + try_depth, + choose_depth, + current_arm, + tag, + &ContentType::Interpolated, + )?; } else if e.name() == QName(&tag.except) { *current_arm = Some(TryTagArms::Except); - do_parse(reader, callback, except_events, depth, current_arm, tag)?; + do_parse( + reader, + callback, + except_events, + true, + try_depth, + choose_depth, + current_arm, + tag, + &ContentType::Interpolated, + )?; } } Ok(XmlEvent::End(ref e)) if e.name() == QName(&tag.r#try) => { *current_arm = None; - if *depth == 0 { + in_try = false; + + if *try_depth == 0 { return unexpected_closing_tag_error(e); } - try_end_handler(*depth, task, attempt_events, except_events, callback)?; - *depth -= 1; + try_end_handler(use_queue, task, attempt_events, except_events, callback)?; + *try_depth -= 1; continue; } @@ -160,21 +231,130 @@ where if e.name() == QName(&tag.attempt) || e.name() == QName(&tag.except) => { *current_arm = Some(TryTagArms::Try); - if *depth == 0 { + if *try_depth == 0 { return unexpected_closing_tag_error(e); } return Ok(()); } + // Handle tags, and ignore the contents if they are not self-closing + // TODO: assign tags have a long form where the contents are interpolated and assigned to the variable + Ok(XmlEvent::Empty(e)) if e.name().into_inner().starts_with(&tag.assign) => { + assign_tag_handler(&e, callback, task, use_queue)?; + } + + Ok(XmlEvent::Start(e)) if e.name().into_inner().starts_with(&tag.assign) => { + open_assign = true; + assign_tag_handler(&e, callback, task, use_queue)?; + } + + Ok(XmlEvent::End(e)) if e.name().into_inner().starts_with(&tag.assign) => { + if !open_assign { + return unexpected_closing_tag_error(&e); + } + + open_assign = false; + } + + // Handle tags + Ok(XmlEvent::Empty(e)) if e.name().into_inner().starts_with(&tag.vars) => { + vars_tag_handler(&e, callback, task, use_queue)?; + } + + Ok(XmlEvent::Start(e)) if e.name().into_inner().starts_with(&tag.vars) => { + open_vars = true; + vars_tag_handler(&e, callback, task, use_queue)?; + } + + Ok(XmlEvent::End(e)) if e.name().into_inner().starts_with(&tag.vars) => { + if !open_vars { + return unexpected_closing_tag_error(&e); + } + + open_vars = false; + } + + // when/choose + Ok(XmlEvent::Start(ref e)) if e.name() == QName(&tag.choose) => { + in_choose = true; + *choose_depth += 1; + } + Ok(XmlEvent::End(ref e)) if e.name() == QName(&tag.choose) => { + in_choose = false; + *choose_depth -= 1; + choose_tag_handler(when_branches, otherwise_events, callback, task, use_queue)?; + } + + Ok(XmlEvent::Start(ref e)) if e.name() == QName(&tag.when) => { + if *choose_depth == 0 { + // invalid when tag outside of choose + return unexpected_opening_tag_error(e); + } + + let when_tag = parse_when(e)?; + let mut when_events = Vec::new(); + do_parse( + reader, + callback, + &mut when_events, + true, + try_depth, + choose_depth, + current_arm, + tag, + &ContentType::Interpolated, + )?; + when_branches.push((when_tag, when_events)); + } + Ok(XmlEvent::End(e)) if e.name() == QName(&tag.when) => { + if *choose_depth == 0 { + return unexpected_closing_tag_error(&e); + } + + return Ok(()); + } + + Ok(XmlEvent::Start(ref e)) if e.name() == QName(&tag.otherwise) => { + if *choose_depth == 0 { + return unexpected_opening_tag_error(e); + } + do_parse( + reader, + callback, + otherwise_events, + true, + try_depth, + choose_depth, + current_arm, + tag, + &ContentType::Interpolated, + )?; + } + Ok(XmlEvent::End(e)) if e.name() == QName(&tag.otherwise) => { + if *choose_depth == 0 { + return unexpected_closing_tag_error(&e); + } + return Ok(()); + } + Ok(XmlEvent::Eof) => { debug!("End of document"); break; } Ok(e) => { - if *depth == 0 { - callback(Event::XML(e.into_owned()))?; + if in_try || in_choose { + continue; + } + + let event = if open_vars || content_type == &ContentType::Interpolated { + Event::InterpolatedContent(e.into_owned()) + } else { + Event::Content(e.into_owned()) + }; + if use_queue { + task.push(event); } else { - task.push(Event::XML(e.into_owned())); + callback(event)?; } } _ => {} @@ -197,7 +377,8 @@ where // Initialize the ESI tags let tags = TagNames::init(namespace); // set the initial depth of nested tags - let mut depth = 0; + let mut try_depth = 0; + let mut choose_depth = 0; let mut root = Vec::new(); let mut current_arm: Option = None; @@ -206,9 +387,12 @@ where reader, callback, &mut root, - &mut depth, + false, + &mut try_depth, + &mut choose_depth, &mut current_arm, &tags, + &ContentType::Normal, )?; debug!("Root: {:?}", root); @@ -249,26 +433,92 @@ fn parse_include<'a>(elem: &BytesStart) -> Result> { }) } +fn parse_assign<'a>(elem: &BytesStart) -> Result> { + let name = match elem + .attributes() + .flatten() + .find(|attr| attr.key.into_inner() == b"name") + { + Some(attr) => String::from_utf8(attr.value.to_vec()).unwrap(), + None => { + return Err(ExecutionError::MissingRequiredParameter( + String::from_utf8(elem.name().into_inner().to_vec()).unwrap(), + "name".to_string(), + )); + } + }; + + let value = match elem + .attributes() + .flatten() + .find(|attr| attr.key.into_inner() == b"value") + { + Some(attr) => String::from_utf8(attr.value.to_vec()).unwrap(), + None => { + return Err(ExecutionError::MissingRequiredParameter( + String::from_utf8(elem.name().into_inner().to_vec()).unwrap(), + "value".to_string(), + )); + } + }; + + Ok(Tag::Assign { name, value }) +} + +fn parse_vars<'a>(elem: &BytesStart) -> Result> { + let name = elem + .attributes() + .flatten() + .find(|attr| attr.key.into_inner() == b"name") + .map(|attr| String::from_utf8(attr.value.to_vec()).unwrap()); + + Ok(Tag::Vars { name }) +} + +fn parse_when<'a>(elem: &BytesStart) -> Result> { + let test = match elem + .attributes() + .flatten() + .find(|attr| attr.key.into_inner() == b"test") + { + Some(attr) => String::from_utf8(attr.value.to_vec()).unwrap(), + None => { + return Err(ExecutionError::MissingRequiredParameter( + String::from_utf8(elem.name().into_inner().to_vec()).unwrap(), + "test".to_string(), + )); + } + }; + + let match_name = elem + .attributes() + .flatten() + .find(|attr| attr.key.into_inner() == b"matchname") + .map(|attr| String::from_utf8(attr.value.to_vec()).unwrap()); + + Ok(Tag::When { test, match_name }) +} + // Helper function to handle the end of a tag // If the depth is 1, the `callback` closure is called with the `Tag::Try` event // Otherwise, a new `Tag::Try` event is pushed to the `task` vector fn try_end_handler<'a>( - depth: usize, + use_queue: bool, task: &mut Vec>, attempt_events: &mut Vec>, except_events: &mut Vec>, callback: &mut dyn FnMut(Event<'a>) -> Result<()>, ) -> Result<()> { - if depth == 1 { - callback(Event::ESI(Tag::Try { + if use_queue { + task.push(Event::ESI(Tag::Try { attempt_events: std::mem::take(attempt_events), except_events: std::mem::take(except_events), - }))?; + })); } else { - task.push(Event::ESI(Tag::Try { + callback(Event::ESI(Tag::Try { attempt_events: std::mem::take(attempt_events), except_events: std::mem::take(except_events), - })); + }))?; } Ok(()) @@ -281,12 +531,68 @@ fn include_tag_handler<'e>( elem: &BytesStart, callback: &mut dyn FnMut(Event<'e>) -> Result<()>, task: &mut Vec>, - depth: usize, + use_queue: bool, ) -> Result<()> { - if depth == 0 { + if use_queue { + task.push(Event::ESI(parse_include(elem)?)); + } else { callback(Event::ESI(parse_include(elem)?))?; + } + + Ok(()) +} + +// Helper function to handle tags +// If the depth is 0, the `callback` closure is called with the `Tag::Assign` event +// Otherwise, a new `Tag::Assign` event is pushed to the `task` vector +fn assign_tag_handler<'e>( + elem: &BytesStart, + callback: &mut dyn FnMut(Event<'e>) -> Result<()>, + task: &mut Vec>, + use_queue: bool, +) -> Result<()> { + if use_queue { + task.push(Event::ESI(parse_assign(elem)?)); } else { - task.push(Event::ESI(parse_include(elem)?)); + callback(Event::ESI(parse_assign(elem)?))?; + } + + Ok(()) +} + +// Helper function to handle tags +// If the depth is 0, the `callback` closure is called with the `Tag::Assign` event +// Otherwise, a new `Tag::Vars` event is pushed to the `task` vector +fn vars_tag_handler<'e>( + elem: &BytesStart, + callback: &mut dyn FnMut(Event<'e>) -> Result<()>, + task: &mut Vec>, + use_queue: bool, +) -> Result<()> { + if use_queue { + task.push(Event::ESI(parse_vars(elem)?)); + } else { + callback(Event::ESI(parse_vars(elem)?))?; + } + + Ok(()) +} + +fn choose_tag_handler<'a>( + when_branches: &mut Vec<(Tag<'a>, Vec>)>, + otherwise_events: &mut Vec>, + callback: &mut dyn FnMut(Event<'a>) -> Result<()>, + task: &mut Vec>, + use_queue: bool, +) -> Result<()> { + let choose_tag = Tag::Choose { + when_branches: std::mem::take(when_branches), + otherwise_events: std::mem::take(otherwise_events), + }; + if use_queue { + task.push(Event::ESI(choose_tag)); + } else { + callback(Event::ESI(choose_tag))?; } Ok(()) diff --git a/esi/tests/parse.rs b/esi/tests/parse.rs index 4d06abc..4319287 100644 --- a/esi/tests/parse.rs +++ b/esi/tests/parse.rs @@ -182,7 +182,6 @@ fn parse_try_accept_except_include() -> Result<(), ExecutionError> { let mut except_include_parsed = false; parse_tags("esi", &mut Reader::from_str(input), &mut |event| { - println!("Event - {event:?}"); if let Event::ESI(Tag::Include { ref src, ref alt, @@ -269,7 +268,7 @@ fn parse_try_nested() -> Result<(), ExecutionError> { parse_tags("esi", &mut Reader::from_str(input), &mut |event| { assert_eq!( format!("{event:?}"), - r#"ESI(Try { attempt_events: [XML(Text(BytesText { content: Owned("0xA ") })), ESI(Include { src: "/abc", alt: None, continue_on_error: false }), XML(Text(BytesText { content: Owned("0xA ") })), XML(Text(BytesText { content: Owned("0xA ") })), XML(Text(BytesText { content: Owned("0xA ") })), XML(Text(BytesText { content: Owned("0xA ") })), ESI(Try { attempt_events: [XML(Text(BytesText { content: Owned("0xA ") })), ESI(Include { src: "/foo", alt: None, continue_on_error: false }), XML(Text(BytesText { content: Owned("0xA ") }))], except_events: [XML(Text(BytesText { content: Owned("0xA ") })), ESI(Include { src: "/bar", alt: None, continue_on_error: false }), XML(Text(BytesText { content: Owned("0xA ") }))] }), XML(Text(BytesText { content: Owned("0xA ") }))], except_events: [XML(Text(BytesText { content: Owned("0xA ") })), ESI(Include { src: "/xyz", alt: None, continue_on_error: false }), XML(Text(BytesText { content: Owned("0xA ") })), XML(Empty(BytesStart { buf: Owned("a href=\"/efg\""), name_len: 1 })), XML(Text(BytesText { content: Owned("0xA just text0xA ") }))] })"# + r#"ESI(Try { attempt_events: [InterpolatedContent(Text(BytesText { content: Owned("0xA ") })), ESI(Include { src: "/abc", alt: None, continue_on_error: false }), InterpolatedContent(Text(BytesText { content: Owned("0xA ") })), ESI(Try { attempt_events: [InterpolatedContent(Text(BytesText { content: Owned("0xA ") })), ESI(Include { src: "/foo", alt: None, continue_on_error: false }), InterpolatedContent(Text(BytesText { content: Owned("0xA ") }))], except_events: [InterpolatedContent(Text(BytesText { content: Owned("0xA ") })), ESI(Include { src: "/bar", alt: None, continue_on_error: false }), InterpolatedContent(Text(BytesText { content: Owned("0xA ") }))] }), InterpolatedContent(Text(BytesText { content: Owned("0xA ") }))], except_events: [InterpolatedContent(Text(BytesText { content: Owned("0xA ") })), ESI(Include { src: "/xyz", alt: None, continue_on_error: false }), InterpolatedContent(Text(BytesText { content: Owned("0xA ") })), InterpolatedContent(Empty(BytesStart { buf: Owned("a href=\"/efg\""), name_len: 1 })), InterpolatedContent(Text(BytesText { content: Owned("0xA just text0xA ") }))] })"# ); if let Event::ESI(Tag::Try { attempt_events, @@ -347,3 +346,67 @@ fn parse_try_nested() -> Result<(), ExecutionError> { Ok(()) } + +#[test] +fn parse_assign() -> Result<(), ExecutionError> { + setup(); + + let input = ""; + let mut parsed = false; + + parse_tags("esi", &mut Reader::from_str(input), &mut |event| { + if let Event::ESI(Tag::Assign { name, value }) = event { + assert_eq!(name, "foo"); + assert_eq!(value, "bar"); + parsed = true; + } + + Ok(()) + })?; + + assert!(parsed); + + Ok(()) +} + +#[test] +fn parse_vars_short() -> Result<(), ExecutionError> { + setup(); + + let input = ""; + let mut parsed = false; + + parse_tags("esi", &mut Reader::from_str(input), &mut |event| { + if let Event::ESI(Tag::Vars { name }) = event { + assert_eq!(name, Some("foo".to_string())); + parsed = true; + } + + Ok(()) + })?; + + assert!(parsed); + + Ok(()) +} + +#[test] +fn parse_vars_long() -> Result<(), ExecutionError> { + setup(); + + let input = "$(foo)"; + let mut parsed = false; + + parse_tags("esi", &mut Reader::from_str(input), &mut |event| { + if let Event::ESI(Tag::Vars { name }) = event { + assert_eq!(name, None); + parsed = true; + } + + Ok(()) + })?; + + assert!(parsed); + + Ok(()) +} diff --git a/examples/esi_example_variants/src/main.rs b/examples/esi_example_variants/src/main.rs index bd4bdd0..3ce0d26 100644 --- a/examples/esi_example_variants/src/main.rs +++ b/examples/esi_example_variants/src/main.rs @@ -10,9 +10,9 @@ fn get_variant_urls(urls: Vec) -> HashMap { for url in urls { // For demonstration, add a query parameter to each request let variant_url = if url.contains('?') { - format!("{}&variant=1", url) + format!("{url}&variant=1") } else { - format!("{}?variant=1", url) + format!("{url}?variant=1") }; variant_urls.insert(url, variant_url); } diff --git a/examples/esi_vars_example/.cargo/config.toml b/examples/esi_vars_example/.cargo/config.toml new file mode 100644 index 0000000..0787801 --- /dev/null +++ b/examples/esi_vars_example/.cargo/config.toml @@ -0,0 +1,6 @@ +[target.wasm32-wasi] +rustflags = ["-C", "debuginfo=2"] +runner = "viceroy run -C fastly.toml -- " + +[build] +target = "wasm32-wasi" diff --git a/examples/esi_vars_example/Cargo.toml b/examples/esi_vars_example/Cargo.toml new file mode 100644 index 0000000..b3d99b1 --- /dev/null +++ b/examples/esi_vars_example/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "esi_vars_example" +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +publish = false + +[dependencies] +fastly = "^0.11" +esi = { path = "../../esi" } +log = "^0.4" +env_logger = "^0.11" diff --git a/examples/esi_vars_example/fastly.toml b/examples/esi_vars_example/fastly.toml new file mode 100644 index 0000000..91e6f44 --- /dev/null +++ b/examples/esi_vars_example/fastly.toml @@ -0,0 +1,28 @@ +# This file describes a Fastly Compute package. To learn more visit: +# https://developer.fastly.com/reference/fastly-toml/ + +authors = ["tyler@fastly.com"] +description = "" +language = "rust" +manifest_version = 2 +name = "esi_example_minimal" +service_id = "" + +[local_server] + + [local_server.backends] + + [local_server.backends.mock-s3] + url = "https://mock-s3.edgecompute.app" + override_host = "mock-s3.edgecompute.app" + +[scripts] + build = "cargo build --bin esi_example_minimal --release --target wasm32-wasi --color always" + +[setup] + + [setup.backends] + + [setup.backends.mock-s3] + address = "mock-s3.edgecompute.app" + port = 443 diff --git a/examples/esi_vars_example/src/index.html b/examples/esi_vars_example/src/index.html new file mode 100644 index 0000000..990db69 --- /dev/null +++ b/examples/esi_vars_example/src/index.html @@ -0,0 +1,250 @@ + + + + My Variable Website + + +
+

My Variable Website

+
+
+

Variable Tests

+ Assigning "YES" to variable $test...
+ + Check (short form): YES=
+ Check (long form): YES=$(test)
+
+ Updating variable $test to "PERHAPS"...
+ + Check (short form): PERHAPS=
+ Check (long form): PERHAPS=$(test)
+
+ Setting variable $test2 to $test...
+ + Check (short form): PERHAPS=
+ Check (long form): PERHAPS=$(test2)
+
+ Setting variable $test3 to a syntax error...
+ + Check (short form): (EMPTY)=(EMPTY)
+ Check (long form): (EMPTY)=$(test3)(EMPTY)
+
+ Setting variable $test4 to the result of function $replace('H-E-L-L-O', '-', '')...
+ + Check (short form): HELLO=
+ Check (long form): HELLO=$(test4)
+ +

Choose Tests

+ Simple one branch choose:
+ HELLO= + + + HELLO + + +
+ + Choose with otherwise, positive:
+ HELLO= + + + HELLO + + + GOODBYE + + +
+ + Choose with otherwise, negative:
+ GOODBYE= + + + HELLO + + + GOODBYE + + +
+ + Two branch choose with otherwise, first positive:
+ HELLO= + + + HELLO + + + HELLO2 + + + GOODBYE + + +
+ + Two branch choose with otherwise, second positive:
+ HELLO2= + + + HELLO + + + HELLO2 + + + GOODBYE + + +
+ + Two branch choose with otherwise, negative:
+ GOODBYE= + + + HELLO + + + HELLO2 + + + GOODBYE + + +
+ +

Matches tests

+ Check if a static string "foobar" matches the regex /bar$/
+ YES = + + YES + + + UH OH + + +
+ + Check if a static string "foobar" matches the regex /^nope/
+ NO = + + YES + + + NO + + +
+ + Check if a static string 'hel\'lo' matches the regex '''l'l''' (triple tick variant)
+ YES = + + YES + + + NO + + +
+ + Assign "barbaz" to a variable and see if matches the regex /^bar/
+ + YES = + + YES + + + UH OH + + +
+ + Assign "barbaz" to a variables and see if it matches the regex /nope/
+ + NO = + + YES + + + NO + + +
+ + Match with captures against "foobar" using /(oo)(ba)/
+ MATCHES{0}+MATCHES{1}+MATCHES{2} = oobaooba = + + + + + UH OH + + +
+ + Match with captures against "foobar" using /(oo)(ba)/ and matchname="mycustomname"
+ mycustomname{0}+mycustomname{1}+mycustomname{2} = oobaooba = + + + + + UH OH + + +
+ + Match with captures against "foobar" using /(oo)(ba)/ and matchname="mycustomname" (vars long form)
+ mycustomname{0}+mycustomname{1}+mycustomname{2} = oobaooba = + + + $(mycustomname{0})$(mycustomname{1})$(mycustomname{2}) + + + + UH OH + + +
+ +

Interpolation Tests

+ Interpolated content in a when branch:
+ HELLO= + + + + $(interptest) + + +
+ + + Interpolated content in an otherwise branch:
+ HELLO= + + + + + $(interptest) + + +
+ + Function call in a vars block: $replace('H-E-L-L-O','-','')
+ HELLO= + + $replace('H-E-L-L-O', '-', '') + +
+ + Ordering remains correct:
+ WELL HELLO THERE= + + WELL + $replace('H-E-L-L-O','-','') + THERE + +
+ +
+ + diff --git a/examples/esi_vars_example/src/main.rs b/examples/esi_vars_example/src/main.rs new file mode 100644 index 0000000..a6f29be --- /dev/null +++ b/examples/esi_vars_example/src/main.rs @@ -0,0 +1,62 @@ +use fastly::{http::StatusCode, mime, Error, Request, Response}; +use log::info; + +fn main() { + env_logger::builder() + .filter(None, log::LevelFilter::Trace) + .init(); + + if let Err(err) = handle_request(Request::from_client()) { + println!("returning error response"); + + Response::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_body(err.to_string()) + .send_to_client(); + } +} + +fn handle_request(req: Request) -> Result<(), Error> { + if req.get_path() != "/" { + Response::from_status(StatusCode::NOT_FOUND).send_to_client(); + return Ok(()); + } + + // Generate synthetic test response from "index.html" file. + // You probably want replace this with a backend call, e.g. `req.clone_without_body().send("origin_0")` + let mut beresp = + Response::from_body(include_str!("index.html")).with_content_type(mime::TEXT_HTML); + + // If the response is HTML, we can parse it for ESI tags. + if beresp + .get_content_type() + .is_some_and(|c| c.subtype() == mime::HTML) + { + let processor = esi::Processor::new(Some(req), esi::Configuration::default()); + + processor.process_response( + &mut beresp, + None, + Some(&|req| { + info!("Sending request {} {}", req.get_method(), req.get_path()); + Ok(req.with_ttl(120).send_async("mock-s3")?.into()) + }), + Some(&|req, mut resp| { + info!( + "Received response for {} {}", + req.get_method(), + req.get_path() + ); + if !resp.get_status().is_success() { + // Override status so we still insert errors. + resp.set_status(StatusCode::OK); + } + Ok(resp) + }), + )?; + } else { + // Otherwise, we can just return the response. + beresp.send_to_client(); + } + + Ok(()) +}