From 2017abdaab59d0d255a4e20a3c18dc29d7e7f352 Mon Sep 17 00:00:00 2001 From: Vadim Getmanshchuk Date: Fri, 22 May 2026 23:36:56 -0500 Subject: [PATCH 1/7] feat: add default_dca config option for DCA inheritance Add Configuration.default_dca field that controls how and tags without an explicit dca attribute are processed. - Change IncludeAttributes.dca to Option to distinguish 'not specified' (None) from explicit 'dca=none' (Some(None)) - Add with_default_dca() builder method on Configuration - Re-export DcaMode publicly for use in config API - Resolve effective DCA at evaluation time: explicit tag attr > Configuration.default_dca Addresses fastly/esi#46 --- esi/src/config.rs | 19 +++++++++++++++++++ esi/src/lib.rs | 5 +++-- esi/src/parser.rs | 10 +++++----- esi/src/parser_types.rs | 5 +++-- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/esi/src/config.rs b/esi/src/config.rs index d653671..c841faa 100644 --- a/esi/src/config.rs +++ b/esi/src/config.rs @@ -1,4 +1,5 @@ use crate::cache::CacheConfig; +use crate::parser_types::DcaMode; /// This struct is used to configure optional behaviour within the ESI processor. /// @@ -25,6 +26,10 @@ pub struct Configuration { pub function_recursion_depth: usize, /// Size of the read buffer (in bytes) used when streaming ESI input (default: 16384) pub chunk_size: usize, + /// Default DCA mode for includes/evals without an explicit `dca` attribute. + /// When set to `DcaMode::Esi`, fragments are processed as ESI by default + /// (matching Akamai-style behaviour). Default: `DcaMode::None`. + pub default_dca: DcaMode, } impl Default for Configuration { @@ -34,6 +39,7 @@ impl Default for Configuration { cache: CacheConfig::default(), function_recursion_depth: 5, chunk_size: 16384, + default_dca: DcaMode::None, } } } @@ -68,4 +74,17 @@ impl Configuration { self.chunk_size = chunk_size; self } + + /// Configure the default DCA mode for `` and `` tags + /// that do not specify an explicit `dca` attribute. + /// + /// Set to `DcaMode::Esi` to enable Akamai-style behaviour where all + /// fragments are ESI-processed by default. An explicit `dca="none"` on a + /// tag still opts out. + /// + /// Default: `DcaMode::None`. + pub const fn with_default_dca(mut self, dca: DcaMode) -> Self { + self.default_dca = dca; + self + } } diff --git a/esi/src/lib.rs b/esi/src/lib.rs index cde5c70..897e90b 100644 --- a/esi/src/lib.rs +++ b/esi/src/lib.rs @@ -12,7 +12,8 @@ pub(crate) mod parser_types; use crate::element_handler::{ElementHandler, Flow}; use crate::expression::EvalContext; -use crate::parser_types::{DcaMode, IncludeAttributes}; +pub use crate::parser_types::DcaMode; +use crate::parser_types::IncludeAttributes; #[cfg(not(feature = "expose-internals"))] use crate::parser_types::{Element, Expr}; use bytes::{Bytes, BytesMut}; @@ -915,7 +916,7 @@ impl Processor { ttl_override, continue_on_error: attrs.continue_on_error, maxwait: attrs.maxwait, - dca: attrs.dca, + dca: attrs.dca.unwrap_or(self.configuration.default_dca), }) } diff --git a/esi/src/parser.rs b/esi/src/parser.rs index eb7f874..6af8aa7 100644 --- a/esi/src/parser.rs +++ b/esi/src/parser.rs @@ -1048,11 +1048,11 @@ fn extract_include_attrs(mut attrs: Attrs<'_>, params: Vec<(String, Expr)>) -> I let alt = attrs_remove(&mut attrs, "alt").map(parse_attr_as_expr); let continue_on_error = attrs_get(&attrs, "onerror").is_some_and(|v| v == "continue"); - // Parse dca attribute - default to None - let dca = if attrs_get(&attrs, "dca").is_some_and(|v| v.eq_ignore_ascii_case("esi")) { - DcaMode::Esi - } else { - DcaMode::None + // Parse dca attribute - None means not specified (inherits config default) + let dca = match attrs_get(&attrs, "dca") { + Some(v) if v.eq_ignore_ascii_case("esi") => Some(DcaMode::Esi), + Some(_) => Some(DcaMode::None), + None => None, }; let ttl = attrs_remove(&mut attrs, "ttl").map(ToOwned::to_owned); diff --git a/esi/src/parser_types.rs b/esi/src/parser_types.rs index 74389f5..3539d6c 100644 --- a/esi/src/parser_types.rs +++ b/esi/src/parser_types.rs @@ -20,8 +20,9 @@ pub struct IncludeAttributes { pub alt: Option, /// Whether to continue on error (from onerror="continue") pub continue_on_error: bool, - /// Dynamic Content Assembly mode - controls pre-processing - pub dca: DcaMode, + /// Dynamic Content Assembly mode - controls pre-processing. + /// `None` means no explicit attribute was set (inherits config default). + pub dca: Option, /// Time-To-Live for caching (e.g., "120m", "1h", "2d", "0s") pub ttl: Option, /// Timeout in milliseconds for the request From 8c77610fece9694ec261d5ac9bea6224d8f76546 Mon Sep 17 00:00:00 2001 From: Vadim Getmanshchuk Date: Sat, 23 May 2026 00:09:12 -0500 Subject: [PATCH 2/7] feat: add max_include_depth config with depth limit enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a safety limit on recursive ESI include/eval nesting depth (default: 15, per EdgeSuite ESI spec). When the limit is reached, fragments are silently omitted from output and a warning is logged — matching Akamai behaviour. - Add max_include_depth field to Configuration (default: 15) - Add with_max_include_depth() builder method - Track include_depth in Processor, increment for nested processing - Enforce limit in process_fragment_body (include dca=esi) and on_eval (both dca=esi two-phase and dca=none single-phase paths) - Log warn! when depth limit is exceeded Addresses fastly/esi#46 --- esi/Cargo.toml | 2 +- esi/src/config.rs | 19 +++++++++++++++++++ esi/src/lib.rs | 35 ++++++++++++++++++++++++++++++++++- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/esi/Cargo.toml b/esi/Cargo.toml index 076e2c1..0841923 100644 --- a/esi/Cargo.toml +++ b/esi/Cargo.toml @@ -27,7 +27,7 @@ chrono = { version = "0.4", default-features = false, features = [ "clock", "std", ] } -rand = "0.10.0" +rand = "0.10.1" [dev-dependencies] esi = { path = ".", features = ["expose-internals"] } diff --git a/esi/src/config.rs b/esi/src/config.rs index c841faa..072de65 100644 --- a/esi/src/config.rs +++ b/esi/src/config.rs @@ -30,6 +30,11 @@ pub struct Configuration { /// When set to `DcaMode::Esi`, fragments are processed as ESI by default /// (matching Akamai-style behaviour). Default: `DcaMode::None`. pub default_dca: DcaMode, + /// Maximum nesting depth for ESI includes/evals (default: 15). + /// Per the EdgeSuite ESI spec, up to fifteen levels of nested include + /// statements are supported. When the limit is reached, fragment content + /// is passed through as raw bytes without ESI processing. + pub max_include_depth: usize, } impl Default for Configuration { @@ -40,6 +45,7 @@ impl Default for Configuration { function_recursion_depth: 5, chunk_size: 16384, default_dca: DcaMode::None, + max_include_depth: 15, } } } @@ -87,4 +93,17 @@ impl Configuration { self.default_dca = dca; self } + + /// Configure the maximum nesting depth for ESI includes and evals. + /// + /// Per the EdgeSuite ESI spec, up to fifteen levels of nested include + /// statements are supported by default. When the limit is reached, + /// fragment content is passed through as raw bytes without further ESI + /// processing. + /// + /// Default: `15`. + pub const fn with_max_include_depth(mut self, depth: usize) -> Self { + self.max_include_depth = depth; + self + } } diff --git a/esi/src/lib.rs b/esi/src/lib.rs index 897e90b..f87c430 100644 --- a/esi/src/lib.rs +++ b/esi/src/lib.rs @@ -20,7 +20,7 @@ use bytes::{Bytes, BytesMut}; use fastly::http::request::{select, PendingRequest}; use fastly::http::{header, Method, StatusCode, Url}; use fastly::{mime, Backend, Request, Response}; -use log::debug; +use log::{debug, warn}; use std::borrow::Cow; use std::collections::{HashMap, VecDeque}; use std::io::{BufRead, Write}; @@ -215,6 +215,8 @@ pub struct Processor { configuration: Configuration, // Queue for pending fragments and blocked content queue: VecDeque, + // Current include nesting depth (0 = top-level document) + include_depth: usize, } /// [`ElementHandler`] implementation for top-level ESI document processing. @@ -317,6 +319,14 @@ impl ElementHandler for DocumentHandler<'_, W> { } if fragment.metadata.dca == DcaMode::Esi { + // Depth limit reached — silently omit fragment (per Akamai behaviour) + if self.processor.include_depth + >= self.processor.configuration.max_include_depth + { + warn!("ESI include depth limit ({}) exceeded for eval fragment {eval_url}, omitting", self.processor.configuration.max_include_depth); + return Ok(Flow::Continue); + } + // dca="esi": TWO-PHASE processing // Phase 1: Process fragment in ISOLATED context // Reborrow before the exclusive borrow of self.processor below @@ -326,6 +336,7 @@ impl ElementHandler for DocumentHandler<'_, W> { Some(self.processor.ctx.get_request().clone_without_body()), self.processor.configuration.clone(), ); + isolated_processor.include_depth = self.processor.include_depth + 1; let mut isolated_output = Vec::new(); { @@ -371,13 +382,24 @@ impl ElementHandler for DocumentHandler<'_, W> { } } } else { + // Depth limit reached — silently omit fragment (per Akamai behaviour) + if self.processor.include_depth + >= self.processor.configuration.max_include_depth + { + warn!("ESI include depth limit ({}) exceeded for eval fragment {eval_url}, omitting", self.processor.configuration.max_include_depth); + return Ok(Flow::Continue); + } + // dca="none": SINGLE-PHASE processing in PARENT's context // Fragment included first, then executed in parent (variables affect parent) + self.processor.include_depth += 1; for element in elements { if matches!(self.process(&element)?, Flow::Break) { + self.processor.include_depth -= 1; return Ok(Flow::Break); // Propagate break from eval'd content } } + self.processor.include_depth -= 1; } Ok(Flow::Continue) @@ -434,6 +456,7 @@ impl Processor { ctx, configuration, queue: VecDeque::new(), + include_depth: 0, } } @@ -1800,6 +1823,15 @@ impl Processor { process_fragment_response: Option<&FragmentResponseProcessor>, ) -> Result<()> { if dca_mode == DcaMode::Esi { + // Depth limit reached — silently omit fragment (per Akamai behaviour) + if self.include_depth >= self.configuration.max_include_depth { + warn!( + "ESI include depth limit ({}) exceeded for fragment {url}, omitting", + self.configuration.max_include_depth + ); + return Ok(()); + } + // Parse and process the content as ESI let body_as_bytes = Bytes::from(body_bytes); let (rest, elements) = parser::parse_complete(&body_as_bytes).map_err(|e| { @@ -1821,6 +1853,7 @@ impl Processor { Some(self.ctx.get_request().clone_without_body()), self.configuration.clone(), ); + isolated_processor.include_depth = self.include_depth + 1; { let mut handler = DocumentHandler { From ddf36798c8d8536bcdc9bfd84c81a0af09c1560c Mon Sep 17 00:00:00 2001 From: Vadim Getmanshchuk Date: Sat, 23 May 2026 13:11:08 -0500 Subject: [PATCH 3/7] feat: add `Edge-Control` header support for per-fragment DCA control When `enable_edge_control` is true, inspect the `Edge-Control` response header on fragment responses for `dca=esi` or `dca=noop` directives. This provides per-fragment control of ESI processing via origin response headers, matching Akamai's 'Enable Through Response Headers' property setting. NOTE: No other Edge-Control header directives are supported at this time - Add `enable_edge_control` config option (default: `false`) - Change `FragmentMetadata.dca` to `Option` to defer resolution - Add `Processor::resolve_dca()` applying precedence: `ESI tag attribute > Edge-Control header > Configuration.default_dca` - Add parse_edge_control_dca() per EdgeSuite ESI spec: `dca=esi` (process as ESI) and `dca=noop` (do not process) - Wire into `process_include()` and `on_eval()` response paths Addresses fastly/esi#46 --- esi/src/config.rs | 21 +++++++++++++++++ esi/src/lib.rs | 58 ++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/esi/src/config.rs b/esi/src/config.rs index 072de65..63ca00b 100644 --- a/esi/src/config.rs +++ b/esi/src/config.rs @@ -35,6 +35,11 @@ pub struct Configuration { /// statements are supported. When the limit is reached, fragment content /// is passed through as raw bytes without ESI processing. pub max_include_depth: usize, + /// Enable parsing of `Edge-Control` response headers for per-fragment DCA + /// directives (e.g. `Edge-Control: dca=esi`). When enabled, the header + /// value overrides `default_dca` but is itself overridden by an explicit + /// `dca` attribute on the tag. Default: `false`. + pub enable_edge_control: bool, } impl Default for Configuration { @@ -46,6 +51,7 @@ impl Default for Configuration { chunk_size: 16384, default_dca: DcaMode::None, max_include_depth: 15, + enable_edge_control: false, } } } @@ -106,4 +112,19 @@ impl Configuration { self.max_include_depth = depth; self } + + /// Enable or disable `Edge-Control` response header parsing. + /// + /// When enabled, fragment responses may include an `Edge-Control` header + /// with a `dca=esi` or `dca=none` directive to control per-fragment ESI + /// processing. This matches Akamai's "Enable Through Response Headers" + /// property setting. + /// + /// Precedence (highest wins): tag attribute → Edge-Control header → `default_dca`. + /// + /// Default: `false` (disabled). + pub const fn with_edge_control(mut self, enabled: bool) -> Self { + self.enable_edge_control = enabled; + self + } } diff --git a/esi/src/lib.rs b/esi/src/lib.rs index f87c430..e8d2e67 100644 --- a/esi/src/lib.rs +++ b/esi/src/lib.rs @@ -81,8 +81,10 @@ struct FragmentMetadata { continue_on_error: bool, /// Optional timeout in milliseconds for this specific request maxwait: Option, - /// Dynamic Content Assembly mode for this request I(controls pre-processing) - dca: DcaMode, + /// Dynamic Content Assembly mode from the tag attribute. + /// `None` means no explicit attribute — resolved at response time using + /// Edge-Control header (if enabled) or config default. + dca: Option, } /// Representation of an ESI fragment request with its metadata and pending response @@ -302,6 +304,9 @@ impl ElementHandler for DocumentHandler<'_, W> { }); } + // Resolve DCA mode from tag attr / Edge-Control header / config default + let dca_mode = self.processor.resolve_dca(fragment.metadata.dca, &response); + // Get the response body let body_bytes = response.into_body_bytes(); let body_as_bytes = Bytes::from(body_bytes); @@ -318,7 +323,7 @@ impl ElementHandler for DocumentHandler<'_, W> { ))); } - if fragment.metadata.dca == DcaMode::Esi { + if dca_mode == DcaMode::Esi { // Depth limit reached — silently omit fragment (per Akamai behaviour) if self.processor.include_depth >= self.processor.configuration.max_include_depth @@ -939,7 +944,7 @@ impl Processor { ttl_override, continue_on_error: attrs.continue_on_error, maxwait: attrs.maxwait, - dca: attrs.dca.unwrap_or(self.configuration.default_dca), + dca: attrs.dca, }) } @@ -1748,10 +1753,11 @@ impl Processor { // Check if successful if final_response.get_status().is_success() { + let dca_mode = self.resolve_dca(fragment.metadata.dca, &final_response); let body_bytes = final_response.into_body_bytes(); self.process_fragment_body( body_bytes, - fragment.metadata.dca, + dca_mode, &fragment_url, output_writer, dispatch_fragment_request, @@ -1779,10 +1785,11 @@ impl Processor { alt_response }; + let dca_mode = self.resolve_dca(fragment.metadata.dca, &final_alt); let body_bytes = final_alt.into_body_bytes(); self.process_fragment_body( body_bytes, - fragment.metadata.dca, + dca_mode, &String::from_utf8_lossy(&alt_src), output_writer, dispatch_fragment_request, @@ -1810,6 +1817,25 @@ impl Processor { } } + /// Resolve the effective DCA mode for a fragment, applying precedence: + /// tag attribute > Edge-Control response header > Configuration.default_dca + fn resolve_dca(&self, tag_dca: Option, response: &Response) -> DcaMode { + // 1. Explicit tag attribute wins + if let Some(mode) = tag_dca { + return mode; + } + + // 2. Edge-Control header (if enabled) + if self.configuration.enable_edge_control { + if let Some(dca) = parse_edge_control_dca(response) { + return dca; + } + } + + // 3. Configuration default + self.configuration.default_dca + } + /// Process fragment body based on dca mode /// - dca="esi": Parse and process content as ESI /// - dca="none": Write raw content @@ -1883,6 +1909,26 @@ impl Processor { /// Only emitted for HTML content (when `is_escaped_content` is true). const FRAGMENT_REQUEST_FAILED: &[u8] = b""; +/// Parse the `Edge-Control` response header for a `dca=` directive. +/// +/// Per the EdgeSuite ESI spec, the header form is `Edge-control:dca=esi`. +/// The `dca=` directive may appear alongside other directives (e.g. +/// `Edge-Control: no-store, dca=esi`). +/// Recognised values: `esi` (process as ESI) and `noop` (do not process). +/// Returns `None` if the header is absent or the value is unrecognised. +fn parse_edge_control_dca(response: &Response) -> Option { + let header_value = response.get_header_str("edge-control")?; + let lower = header_value.to_ascii_lowercase(); + let dca_pos = lower.find("dca=")?; + let after_eq = &lower[dca_pos + 4..]; + let value = after_eq.split([',', ' ']).next()?; + match value { + "esi" => Some(DcaMode::Esi), + "noop" => Some(DcaMode::None), + _ => None, + } +} + /// Evaluate an [`Expr`] to a [`Bytes`] value. /// /// Free function (not a `Processor` method) so callers can independently borrow other From e09d248e56c8b738a32b480e67a592a4d9a51224 Mon Sep 17 00:00:00 2001 From: Vadim Getmanshchuk Date: Sat, 23 May 2026 14:09:23 -0500 Subject: [PATCH 4/7] feat: add inherit_parent_dca config for subtree DCA inheritance When enabled, an unspecified inside an explicit dca=esi subtree resolves to Esi processing instead of falling back to the global default_dca. Allows users to set default_dca=None globally while still having dca=esi propagate to its children's subtree. Precedence: tag attr > Edge-Control > inherited (if enabled & depth>0) > default_dca. --- esi/src/config.rs | 22 ++++++++++++++++++++++ esi/src/lib.rs | 9 +++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/esi/src/config.rs b/esi/src/config.rs index 63ca00b..0ae9dd6 100644 --- a/esi/src/config.rs +++ b/esi/src/config.rs @@ -40,6 +40,11 @@ pub struct Configuration { /// value overrides `default_dca` but is itself overridden by an explicit /// `dca` attribute on the tag. Default: `false`. pub enable_edge_control: bool, + /// When true, an unspecified child fragment inside an explicit `dca=esi` + /// subtree inherits ESI processing instead of falling back to `default_dca`. + /// This lets `dca=esi` "stick" to its children without forcing all + /// top-level includes to default to ESI. Default: `false`. + pub inherit_parent_dca: bool, } impl Default for Configuration { @@ -52,6 +57,7 @@ impl Default for Configuration { default_dca: DcaMode::None, max_include_depth: 15, enable_edge_control: false, + inherit_parent_dca: false, } } } @@ -127,4 +133,20 @@ impl Configuration { self.enable_edge_control = enabled; self } + + /// Enable or disable subtree DCA inheritance. + /// + /// When enabled, an unspecified child fragment inside an explicit + /// `dca=esi` subtree is processed as ESI, rather than falling back to + /// the global `default_dca`. Top-level includes (not inside any ESI + /// subtree) still use `default_dca`. + /// + /// Precedence (highest wins): tag attribute → Edge-Control header → + /// inherited parent DCA (if enabled & inside subtree) → `default_dca`. + /// + /// Default: `false` (disabled). + pub const fn with_inherit_parent_dca(mut self, enabled: bool) -> Self { + self.inherit_parent_dca = enabled; + self + } } diff --git a/esi/src/lib.rs b/esi/src/lib.rs index e8d2e67..e5400e8 100644 --- a/esi/src/lib.rs +++ b/esi/src/lib.rs @@ -1818,7 +1818,7 @@ impl Processor { } /// Resolve the effective DCA mode for a fragment, applying precedence: - /// tag attribute > Edge-Control response header > Configuration.default_dca + /// tag attribute > Edge-Control header > inherited parent (if enabled & depth>0) > default_dca fn resolve_dca(&self, tag_dca: Option, response: &Response) -> DcaMode { // 1. Explicit tag attribute wins if let Some(mode) = tag_dca { @@ -1832,7 +1832,12 @@ impl Processor { } } - // 3. Configuration default + // 3. Inherit parent DCA (if enabled and inside an ESI subtree) + if self.configuration.inherit_parent_dca && self.include_depth > 0 { + return DcaMode::Esi; + } + + // 4. Configuration default self.configuration.default_dca } From ce459f21eec7734e0d7177cb15ad047645287a88 Mon Sep 17 00:00:00 2001 From: Vadim Getmanshchuk Date: Sat, 23 May 2026 14:09:38 -0500 Subject: [PATCH 5/7] test: add comprehensive DCA configuration tests Cover all four DCA features: default_dca, max_include_depth, Edge-Control header parsing, and inherit_parent_dca subtree inheritance. 24 tests verifying precedence rules, edge cases, and feature interactions. --- esi/tests/dca_tests.rs | 488 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 488 insertions(+) create mode 100644 esi/tests/dca_tests.rs diff --git a/esi/tests/dca_tests.rs b/esi/tests/dca_tests.rs new file mode 100644 index 0000000..9e3c735 --- /dev/null +++ b/esi/tests/dca_tests.rs @@ -0,0 +1,488 @@ +//! Tests for DCA (Dynamic Content Assembly) configuration features: +//! - `default_dca`: global default DCA mode +//! - `max_include_depth`: nesting depth limit +//! - `Edge-Control` header parsing (via `enable_edge_control`) +//! - `inherit_parent_dca`: subtree DCA inheritance + +use esi::{Configuration, DcaMode, Processor}; +use fastly::{Request, Response}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +// --------------------------------------------------------------------------- +// Helper: run ESI processing with a config + dispatcher, return output string +// --------------------------------------------------------------------------- + +fn run(input: &str, config: Configuration, dispatcher: &F) -> esi::Result +where + F: Fn(Request, Option) -> esi::Result + 'static, +{ + let reader = std::io::BufReader::new(std::io::Cursor::new(input.as_bytes())); + let mut output = Vec::new(); + let mut processor = Processor::new(None, config); + processor.process_stream( + reader, + &mut output, + Some( + dispatcher as &dyn Fn(Request, Option) -> esi::Result, + ), + None, + )?; + Ok(String::from_utf8(output).unwrap()) +} + +fn static_body( + body: &'static str, +) -> impl Fn(Request, Option) -> esi::Result { + move |_req, _maxwait| { + Ok(esi::PendingFragmentContent::CompletedRequest(Box::new( + Response::from_body(body), + ))) + } +} + +fn with_edge_control( + body: &'static str, + header: &'static str, +) -> impl Fn(Request, Option) -> esi::Result { + move |_req, _maxwait| { + let mut resp = Response::from_body(body); + resp.set_header("Edge-Control", header); + Ok(esi::PendingFragmentContent::CompletedRequest(Box::new( + resp, + ))) + } +} + +// =========================================================================== +// 1. default_dca tests +// =========================================================================== + +/// With default_dca=None (the default), include without dca attr inserts raw content. +#[test] +fn test_default_dca_none_includes_raw() -> esi::Result<()> { + let d = static_body(r#"$(x)"#); + let result = run( + r#""#, + Configuration::default(), + &d, + )?; + assert_eq!( + result, + r#"$(x)"# + ); + Ok(()) +} + +/// With default_dca=Esi, include without dca attr processes content as ESI. +#[test] +fn test_default_dca_esi_processes_content() -> esi::Result<()> { + let d = static_body(r#"X is $(x)"#); + let result = run( + r#""#, + Configuration::default().with_default_dca(DcaMode::Esi), + &d, + )?; + assert_eq!(result, "X is 42"); + Ok(()) +} + +/// Explicit dca="none" on tag overrides default_dca=Esi. +#[test] +fn test_explicit_dca_none_overrides_default_esi() -> esi::Result<()> { + let d = static_body(r#"$(x)"#); + let result = run( + r#""#, + Configuration::default().with_default_dca(DcaMode::Esi), + &d, + )?; + assert_eq!( + result, + r#"$(x)"# + ); + Ok(()) +} + +/// Explicit dca="esi" on tag overrides default_dca=None. +#[test] +fn test_explicit_dca_esi_overrides_default_none() -> esi::Result<()> { + let d = static_body(r#"Y is $(y)"#); + let result = run( + r#""#, + Configuration::default(), + &d, + )?; + assert_eq!(result, "Y is 99"); + Ok(()) +} + +// =========================================================================== +// 2. max_include_depth tests +// =========================================================================== + +/// Include at depth exactly at the limit is silently omitted. +#[test] +fn test_max_include_depth_omits_at_limit() -> esi::Result<()> { + let d = static_body(r#"L0[]"#); + let result = run( + r#"BeforeAfter"#, + Configuration::default().with_max_include_depth(1), + &d, + )?; + // level0 processed (depth 0 < 1), level1 omitted (depth 1 >= 1) + assert_eq!(result, "BeforeL0[]After"); + Ok(()) +} + +/// Include within depth limit is processed normally. +#[test] +fn test_max_include_depth_allows_within_limit() -> esi::Result<()> { + let d = static_body("inner"); + let result = run( + r#""#, + Configuration::default().with_max_include_depth(5), + &d, + )?; + assert_eq!(result, "inner"); + Ok(()) +} + +/// Eval with dca="esi" at depth limit is omitted. +#[test] +fn test_max_include_depth_eval_dca_esi_omitted() -> esi::Result<()> { + let d = static_body(r#"E0[]"#); + let result = run( + r#"BeforeAfter"#, + Configuration::default().with_max_include_depth(1), + &d, + )?; + assert_eq!(result, "BeforeE0[]After"); + Ok(()) +} + +/// Eval with dca="none" at depth limit is also omitted. +#[test] +fn test_max_include_depth_eval_dca_none_omitted() -> esi::Result<()> { + let d = static_body(r#"E0[]"#); + let result = run( + r#"BeforeAfter"#, + Configuration::default().with_max_include_depth(1), + &d, + )?; + assert_eq!(result, "BeforeE0[]After"); + Ok(()) +} + +/// Default depth limit of 15. +#[test] +fn test_max_include_depth_default_is_15() { + assert_eq!(Configuration::default().max_include_depth, 15); +} + +// =========================================================================== +// 3. Edge-Control header tests +// =========================================================================== + +/// Edge-Control: dca=esi causes fragment to be ESI-processed (when enabled). +#[test] +fn test_edge_control_dca_esi() -> esi::Result<()> { + let d = with_edge_control( + r#"$(v)"#, + "dca=esi", + ); + let result = run( + r#""#, + Configuration::default().with_edge_control(true), + &d, + )?; + assert_eq!(result, "42"); + Ok(()) +} + +/// Edge-Control: dca=noop causes fragment to be inserted raw. +#[test] +fn test_edge_control_dca_noop() -> esi::Result<()> { + let d = with_edge_control(r#"raw"#, "dca=noop"); + let result = run( + r#""#, + Configuration::default() + .with_edge_control(true) + .with_default_dca(DcaMode::Esi), + &d, + )?; + assert_eq!(result, "raw"); + Ok(()) +} + +/// Edge-Control header is ignored when enable_edge_control is false. +#[test] +fn test_edge_control_ignored_when_disabled() -> esi::Result<()> { + let d = with_edge_control( + r#"$(v)"#, + "dca=esi", + ); + let result = run( + r#""#, + Configuration::default(), // enable_edge_control=false, default_dca=None + &d, + )?; + assert_eq!( + result, + r#"$(v)"# + ); + Ok(()) +} + +/// Explicit tag dca="none" beats Edge-Control: dca=esi. +#[test] +fn test_tag_dca_beats_edge_control() -> esi::Result<()> { + let d = with_edge_control(r#"should be raw"#, "dca=esi"); + let result = run( + r#""#, + Configuration::default().with_edge_control(true), + &d, + )?; + assert_eq!(result, "should be raw"); + Ok(()) +} + +/// Edge-Control with dca= alongside other directives. +#[test] +fn test_edge_control_mixed_directives() -> esi::Result<()> { + let d = with_edge_control( + r#"$(z)"#, + "no-store, dca=esi", + ); + let result = run( + r#""#, + Configuration::default().with_edge_control(true), + &d, + )?; + assert_eq!(result, "7"); + Ok(()) +} + +/// Edge-Control with unrecognised dca value falls through to default. +#[test] +fn test_edge_control_unknown_dca_value() -> esi::Result<()> { + let d = with_edge_control(r#"raw"#, "dca=xslt"); + let result = run( + r#""#, + Configuration::default() + .with_edge_control(true) + .with_default_dca(DcaMode::None), + &d, + )?; + assert_eq!(result, "raw"); + Ok(()) +} + +/// Edge-Control without dca= directive falls through to default. +#[test] +fn test_edge_control_no_dca_directive() -> esi::Result<()> { + let d = with_edge_control(r#"raw"#, "no-store"); + let result = run( + r#""#, + Configuration::default() + .with_edge_control(true) + .with_default_dca(DcaMode::None), + &d, + )?; + assert_eq!(result, "raw"); + Ok(()) +} + +/// Edge-Control beats default_dca (header > config default). +#[test] +fn test_edge_control_beats_default_dca() -> esi::Result<()> { + let d = with_edge_control(r#"raw"#, "dca=noop"); + let result = run( + r#""#, + Configuration::default() + .with_edge_control(true) + .with_default_dca(DcaMode::Esi), + &d, + )?; + assert_eq!(result, "raw"); + Ok(()) +} + +// =========================================================================== +// 4. inherit_parent_dca tests +// =========================================================================== + +/// Inheritance off + default=None: child inside dca=esi parent is NOT ESI-processed. +#[test] +fn test_inherit_off_child_uses_default() -> esi::Result<()> { + let call_count = Arc::new(AtomicUsize::new(0)); + let cc = call_count.clone(); + let d = + move |_req: Request, _maxwait: Option| -> esi::Result { + let n = cc.fetch_add(1, Ordering::SeqCst); + let body = if n == 0 { + r#"OUTER[]"# + } else { + r#"INNER"# + }; + Ok(esi::PendingFragmentContent::CompletedRequest(Box::new( + Response::from_body(body), + ))) + }; + let result = run( + r#""#, + Configuration::default(), // inherit=false, default=None + &d, + )?; + assert_eq!(result, "OUTER[INNER]"); + Ok(()) +} + +/// Inheritance on + default=None: child inside dca=esi parent IS ESI-processed. +#[test] +fn test_inherit_on_child_inherits_esi() -> esi::Result<()> { + let call_count = Arc::new(AtomicUsize::new(0)); + let cc = call_count.clone(); + let d = + move |_req: Request, _maxwait: Option| -> esi::Result { + let n = cc.fetch_add(1, Ordering::SeqCst); + let body = if n == 0 { + r#"OUTER[]"# + } else { + r#"$(v)"# + }; + Ok(esi::PendingFragmentContent::CompletedRequest(Box::new( + Response::from_body(body), + ))) + }; + let result = run( + r#""#, + Configuration::default().with_inherit_parent_dca(true), + &d, + )?; + assert_eq!(result, "OUTER[42]"); + Ok(()) +} + +/// Inheritance on: top-level include (depth=0) falls to default, not Esi. +#[test] +fn test_inherit_on_top_level_uses_default() -> esi::Result<()> { + let d = static_body(r#"raw"#); + let result = run( + r#""#, + Configuration::default().with_inherit_parent_dca(true), + &d, + )?; + assert_eq!(result, "raw"); + Ok(()) +} + +/// Inheritance on + explicit dca="none" on child → still None (tag wins). +#[test] +fn test_inherit_on_explicit_dca_none_wins() -> esi::Result<()> { + let call_count = Arc::new(AtomicUsize::new(0)); + let cc = call_count.clone(); + let d = + move |_req: Request, _maxwait: Option| -> esi::Result { + let n = cc.fetch_add(1, Ordering::SeqCst); + let body = if n == 0 { + r#"OUTER[]"# + } else { + r#"INNER"# + }; + Ok(esi::PendingFragmentContent::CompletedRequest(Box::new( + Response::from_body(body), + ))) + }; + let result = run( + r#""#, + Configuration::default().with_inherit_parent_dca(true), + &d, + )?; + assert_eq!(result, "OUTER[INNER]"); + Ok(()) +} + +/// Inheritance on + Edge-Control dca=noop on child → still None (header beats inheritance). +#[test] +fn test_inherit_on_edge_control_beats_inheritance() -> esi::Result<()> { + let call_count = Arc::new(AtomicUsize::new(0)); + let cc = call_count.clone(); + let d = + move |_req: Request, _maxwait: Option| -> esi::Result { + let n = cc.fetch_add(1, Ordering::SeqCst); + if n == 0 { + Ok(esi::PendingFragmentContent::CompletedRequest(Box::new( + Response::from_body(r#"OUTER[]"#), + ))) + } else { + let mut resp = Response::from_body(r#"INNER"#); + resp.set_header("Edge-Control", "dca=noop"); + Ok(esi::PendingFragmentContent::CompletedRequest(Box::new( + resp, + ))) + } + }; + let result = run( + r#""#, + Configuration::default() + .with_edge_control(true) + .with_inherit_parent_dca(true), + &d, + )?; + assert_eq!(result, "OUTER[INNER]"); + Ok(()) +} + +/// Inheritance on + mixed subtrees: only the dca=esi subtree inherits. +#[test] +fn test_inherit_on_mixed_subtrees() -> esi::Result<()> { + let call_count = Arc::new(AtomicUsize::new(0)); + let cc = call_count.clone(); + let d = + move |_req: Request, _maxwait: Option| -> esi::Result { + let n = cc.fetch_add(1, Ordering::SeqCst); + let body = match n { + 0 => r#"A[]"#, + 1 => r#"$(v)"#, + 2 => r#"RAW"#, + _ => "unexpected", + }; + Ok(esi::PendingFragmentContent::CompletedRequest(Box::new( + Response::from_body(body), + ))) + }; + let result = run( + r#""#, + Configuration::default().with_inherit_parent_dca(true), + &d, + )?; + // A's child inherits ESI, C is raw (top-level, default=None) + assert_eq!(result, "A[1]RAW"); + Ok(()) +} + +/// Inheritance on with eval: child of dca=esi eval inherits ESI. +#[test] +fn test_inherit_on_eval_subtree() -> esi::Result<()> { + let call_count = Arc::new(AtomicUsize::new(0)); + let cc = call_count.clone(); + let d = + move |_req: Request, _maxwait: Option| -> esi::Result { + let n = cc.fetch_add(1, Ordering::SeqCst); + let body = if n == 0 { + r#"E0[]"# + } else { + r#"$(w)"# + }; + Ok(esi::PendingFragmentContent::CompletedRequest(Box::new( + Response::from_body(body), + ))) + }; + let result = run( + r#""#, + Configuration::default().with_inherit_parent_dca(true), + &d, + )?; + assert_eq!(result, "E0[88]"); + Ok(()) +} From 298f3d8b8ded2345c1f01f3c0656a32607227e22 Mon Sep 17 00:00:00 2001 From: Vadim Getmanshchuk Date: Sun, 24 May 2026 20:52:05 -0500 Subject: [PATCH 6/7] Add DCA configuration example Demonstrates all four DCA config options: default_dca, edge_control, inherit_parent_dca, and max_include_depth. --- Cargo.lock | 10 ++++ Cargo.toml | 1 + examples/esi_dca_example/Cargo.toml | 13 +++++ examples/esi_dca_example/fastly.toml | 28 +++++++++ examples/esi_dca_example/src/index.html | 32 +++++++++++ examples/esi_dca_example/src/main.rs | 75 +++++++++++++++++++++++++ 6 files changed, 159 insertions(+) create mode 100644 examples/esi_dca_example/Cargo.toml create mode 100644 examples/esi_dca_example/fastly.toml create mode 100644 examples/esi_dca_example/src/index.html create mode 100644 examples/esi_dca_example/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 5d7536d..dec17a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -405,6 +405,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "esi_dca_example" +version = "0.7.0" +dependencies = [ + "env_logger", + "esi", + "fastly", + "log", +] + [[package]] name = "esi_example_advanced_error_handling" version = "0.7.0" diff --git a/Cargo.toml b/Cargo.toml index a57ca1a..72a2687 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "examples/esi_try_example", "examples/esi_vars_example", "examples/esi_example_variants", + "examples/esi_dca_example", ] resolver = "2" diff --git a/examples/esi_dca_example/Cargo.toml b/examples/esi_dca_example/Cargo.toml new file mode 100644 index 0000000..cc37839 --- /dev/null +++ b/examples/esi_dca_example/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "esi_dca_example" +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +publish = false + +[dependencies] +fastly = "^0.12" +esi = { path = "../../esi" } +log = "^0.4" +env_logger = "^0.11" diff --git a/examples/esi_dca_example/fastly.toml b/examples/esi_dca_example/fastly.toml new file mode 100644 index 0000000..9c0f2fb --- /dev/null +++ b/examples/esi_dca_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 = ["kblanks@fastly.com"] +description = "" +language = "rust" +manifest_version = 2 +name = "esi_dca_example" +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_dca_example --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_dca_example/src/index.html b/examples/esi_dca_example/src/index.html new file mode 100644 index 0000000..19f8fb8 --- /dev/null +++ b/examples/esi_dca_example/src/index.html @@ -0,0 +1,32 @@ + + + + DCA Configuration Example + + +
+

DCA Configuration Example

+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + diff --git a/examples/esi_dca_example/src/main.rs b/examples/esi_dca_example/src/main.rs new file mode 100644 index 0000000..57f821e --- /dev/null +++ b/examples/esi_dca_example/src/main.rs @@ -0,0 +1,75 @@ +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) + { + // Configure DCA (Dynamic Content Assembly) options: + // - default_dca = Esi: fragments without an explicit `dca` attribute + // are ESI-processed (Akamai-style behaviour). + // - enable_edge_control: honour `Edge-Control: dca=esi` response + // headers from fragment backends. + // - inherit_parent_dca: children inside a `dca="esi"` subtree + // inherit ESI processing without needing their own attribute. + // - max_include_depth = 5: limit nesting to 5 levels. + let config = esi::Configuration::default() + .with_default_dca(esi::DcaMode::Esi) + .with_edge_control(true) + .with_inherit_parent_dca(true) + .with_max_include_depth(5); + + let processor = esi::Processor::new(Some(req), config); + + processor.process_response( + &mut beresp, + None, + Some(&|req, _maxwait: Option| { + 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() { + resp.set_status(StatusCode::OK); + } + Ok(resp) + }), + )?; + } else { + // Otherwise, we can just return the response. + beresp.send_to_client(); + } + + Ok(()) +} From 2c4dc254c83bf2e73f793d7a27d9726308a4380d Mon Sep 17 00:00:00 2001 From: Vadim Getmanshchuk Date: Thu, 28 May 2026 11:11:17 -0500 Subject: [PATCH 7/7] Update examples/esi_dca_example/fastly.toml Co-authored-by: Kailan Blanks --- examples/esi_dca_example/fastly.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/esi_dca_example/fastly.toml b/examples/esi_dca_example/fastly.toml index 9c0f2fb..f07be28 100644 --- a/examples/esi_dca_example/fastly.toml +++ b/examples/esi_dca_example/fastly.toml @@ -1,7 +1,7 @@ # This file describes a Fastly Compute package. To learn more visit: # https://developer.fastly.com/reference/fastly-toml/ -authors = ["kblanks@fastly.com"] +authors = ["vadim@fastly.com"] description = "" language = "rust" manifest_version = 2