diff --git a/.changesets/fix_bryn_upgrade_rhai.md b/.changesets/fix_bryn_upgrade_rhai.md new file mode 100644 index 0000000000..0f5fa7aa2b --- /dev/null +++ b/.changesets/fix_bryn_upgrade_rhai.md @@ -0,0 +1,5 @@ +### Fix Rhai scientific notation handling ([PR #8528](https://github.com/apollographql/router/pull/8528)) + +Upgrades the Rhai scripting engine from version 1.21.0 to 1.23.6, fixing scientific notation parsing in scripts and JSON operations. + +By [@BrynCooke](https://github.com/BrynCooke) in https://github.com/apollographql/router/pull/8528 diff --git a/Cargo.lock b/Cargo.lock index d6f92eb6e9..044fd4f0fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2517,7 +2517,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3552,7 +3552,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.0", "tokio", "tower-service", "tracing", @@ -3823,7 +3823,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5420,7 +5420,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5730,9 +5730,9 @@ dependencies = [ [[package]] name = "rhai" -version = "1.21.0" +version = "1.23.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce4d759a4729a655ddfdbb3ff6e77fb9eadd902dae12319455557796e435d2a6" +checksum = "f4e35aaaa439a5bda2f8d15251bc375e4edfac75f9865734644782c9701b5709" dependencies = [ "ahash", "bitflags 2.9.3", @@ -5809,9 +5809,9 @@ dependencies = [ [[package]] name = "rhai_codegen" -version = "2.2.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5a11a05ee1ce44058fa3d5961d05194fdbe3ad6b40f904af764d81b86450e6b" +checksum = "d4322a2a4e8cf30771dd9f27f7f37ca9ac8fe812dddd811096a98483080dabe6" dependencies = [ "proc-macro2", "quote", @@ -6006,7 +6006,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -6692,7 +6692,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] @@ -7773,7 +7773,7 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0978bf7171b3d90bac376700cb56d606feb40f251a475a5d6634613564460b22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.60.2", ] [[package]] diff --git a/apollo-router/Cargo.toml b/apollo-router/Cargo.toml index a2c11d5fe8..2adfb8ee95 100644 --- a/apollo-router/Cargo.toml +++ b/apollo-router/Cargo.toml @@ -199,8 +199,7 @@ prost = "0.13.0" prost-types = "0.13.0" proteus = "0.5.0" rand = "0.9.0" -# Pinned due to https://github.com/apollographql/router/pull/7679 -rhai = { version = "=1.21.0", features = ["sync", "serde", "internals"] } +rhai = { version = "1.23.6", features = ["sync", "serde", "internals"] } regex = "1.10.5" reqwest = { workspace = true, default-features = false, features = [ "rustls-tls", @@ -341,8 +340,7 @@ reqwest = { version = "0.12.9", default-features = false, features = [ "multipart", "stream", ] } -# Pinned due to https://github.com/apollographql/router/pull/7679 -rhai = { version = "=1.21.0", features = [ +rhai = { version = "1.23.6", features = [ "sync", "serde", "internals", diff --git a/apollo-router/src/plugins/rhai/engine.rs b/apollo-router/src/plugins/rhai/engine/mod.rs similarity index 77% rename from apollo-router/src/plugins/rhai/engine.rs rename to apollo-router/src/plugins/rhai/engine/mod.rs index da0f629754..f97474ea53 100644 --- a/apollo-router/src/plugins/rhai/engine.rs +++ b/apollo-router/src/plugins/rhai/engine/mod.rs @@ -1,3 +1,6 @@ +mod registration; +mod types; + use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; @@ -8,11 +11,7 @@ use base64::prelude::BASE64_STANDARD; use base64::prelude::BASE64_STANDARD_NO_PAD; use base64::prelude::BASE64_URL_SAFE; use base64::prelude::BASE64_URL_SAFE_NO_PAD; -use bytes::Bytes; use http::HeaderMap; -use http::Method; -use http::StatusCode; -use http::Uri; use http::header::InvalidHeaderName; use http::uri::Authority; use http::uri::Parts; @@ -41,10 +40,7 @@ use super::execution; use super::router; use super::subgraph; use super::supergraph; -use crate::Context; use crate::configuration::expansion; -use crate::graphql::Request; -use crate::graphql::Response; use crate::http_ext; use crate::plugins::authentication::APOLLO_AUTHENTICATION_JWT_CLAIMS; use crate::plugins::cache::entity::CONTEXT_CACHE_KEY; @@ -64,38 +60,8 @@ const CANNOT_ACCESS_STATUS_CODE_ON_A_DEFERRED_RESPONSE: &str = const CANNOT_GET_ENVIRONMENT_VARIABLE: &str = "environment variable not found"; -pub(super) trait OptionDance { - fn with_mut(&self, f: impl FnOnce(&mut T) -> R) -> R; - - fn replace(&self, f: impl FnOnce(T) -> T); - - fn take_unwrap(self) -> T; -} - -pub(super) type SharedMut = rhai::Shared>>; - -impl OptionDance for SharedMut { - fn with_mut(&self, f: impl FnOnce(&mut T) -> R) -> R { - let mut guard = self.lock(); - f(guard.as_mut().expect("re-entrant option dance")) - } - - fn replace(&self, f: impl FnOnce(T) -> T) { - let mut guard = self.lock(); - *guard = Some(f(guard.take().expect("re-entrant option dance"))) - } - - fn take_unwrap(self) -> T { - match Arc::try_unwrap(self) { - Ok(mutex) => mutex.into_inner(), - - // TODO: Should we assume the Arc refcount is 1 - // and use `try_unwrap().expect("shared ownership")` instead of this fallback ? - Err(arc) => arc.lock().take(), - } - .expect("re-entrant option dance") - } -} +pub(crate) use types::OptionDance; +pub(crate) use types::SharedMut; #[derive(Clone)] #[allow(unreachable_pub)] @@ -300,6 +266,13 @@ mod router_header_map { } // Register a HeaderMap indexer so we can get/set headers + // + // Note: Both getter and setter are registered globally, even though HeaderMap is + // returned from read-only properties in some contexts (e.g., subgraph.headers). + // This is safe because Rhai's automatic value propagation is blocked by the + // absence of a setter on the property that returns the HeaderMap. The read-only-ness + // is determined by the wrapper type (e.g., SharedMut), not by + // the HeaderMap type itself. See registration/subgraph.rs for detailed explanation. #[rhai_fn(index_get, pure, return_raw)] pub(crate) fn header_map_get( x: &mut HeaderMap, @@ -1322,391 +1295,14 @@ mod router_plugin { } } -#[derive(Default)] -pub(crate) struct RhaiRouterFirstRequest { - pub(crate) context: Context, - pub(crate) request: http::Request<()>, -} - -#[allow(dead_code)] -#[derive(Default)] -pub(crate) struct RhaiRouterChunkedRequest { - pub(crate) context: Context, - pub(crate) request: Bytes, -} - -#[derive(Default)] -pub(crate) struct RhaiRouterResponse { - pub(crate) context: Context, - pub(crate) response: http::Response<()>, -} - -#[allow(dead_code)] -#[derive(Default)] -pub(crate) struct RhaiRouterChunkedResponse { - pub(crate) context: Context, - pub(crate) response: Bytes, -} - -#[derive(Default)] -pub(crate) struct RhaiSupergraphResponse { - pub(crate) context: Context, - pub(crate) response: http_ext::Response, -} - -#[derive(Default)] -pub(crate) struct RhaiSupergraphDeferredResponse { - pub(crate) context: Context, - pub(crate) response: Response, -} - -#[derive(Default)] -pub(crate) struct RhaiExecutionResponse { - pub(crate) context: Context, - pub(crate) response: http_ext::Response, -} - -#[derive(Default)] -pub(crate) struct RhaiExecutionDeferredResponse { - pub(crate) context: Context, - pub(crate) response: Response, -} - -macro_rules! if_subgraph { - ( subgraph => $subgraph: block else $not_subgraph: block ) => { - $subgraph - }; - ( $base: ident => $subgraph: block else $not_subgraph: block ) => { - $not_subgraph - }; -} - -macro_rules! register_rhai_router_interface { - ($engine: ident, $($base: ident), *) => { - $( - // Context stuff - $engine.register_get( - "context", - |obj: &mut SharedMut<$base::FirstRequest>| -> Result> { - Ok(obj.with_mut(|request| request.context.clone())) - } - ) - .register_get( - "context", - |obj: &mut SharedMut<$base::ChunkedRequest>| -> Result> { - Ok(obj.with_mut(|request| request.context.clone())) - } - ).register_get( - "context", - |obj: &mut SharedMut<$base::Response>| -> Result> { - Ok(obj.with_mut(|response| response.context.clone())) - } - ) - .register_get( - "context", - |obj: &mut SharedMut<$base::DeferredResponse>| -> Result> { - Ok(obj.with_mut(|response| response.context.clone())) - } - ); - - $engine.register_set( - "context", - |obj: &mut SharedMut<$base::FirstRequest>, context: Context| { - obj.with_mut(|request| request.context = context); - Ok(()) - } - ) - .register_set( - "context", - |obj: &mut SharedMut<$base::ChunkedRequest>, context: Context| { - obj.with_mut(|request| request.context = context); - Ok(()) - } - ) - .register_set( - "context", - |obj: &mut SharedMut<$base::Response>, context: Context| { - obj.with_mut(|response| response.context = context); - Ok(()) - } - ).register_set( - "context", - |obj: &mut SharedMut<$base::DeferredResponse>, context: Context| { - obj.with_mut(|response| response.context = context); - Ok(()) - } - ); - - // Id - $engine.register_get( - "id", - |obj: &mut SharedMut<$base::FirstRequest>| -> String { - obj.with_mut(|request| request.context.id.clone()) - } - ) - .register_get( - "id", - |obj: &mut SharedMut<$base::ChunkedRequest>| -> String { - obj.with_mut(|request| request.context.id.clone()) - } - ) - .register_get( - "id", - |obj: &mut SharedMut<$base::Response>| -> String { - obj.with_mut(|response| response.context.id.clone()) - } - ) - .register_get( - "id", - |obj: &mut SharedMut<$base::DeferredResponse>| -> String { - obj.with_mut(|response| response.context.id.clone()) - } - ); - - // Originating Request - $engine.register_get( - "headers", - |obj: &mut SharedMut<$base::FirstRequest>| -> Result> { - Ok(obj.with_mut(|request| request.request.headers().clone())) - } - ).register_get( - "headers", - |obj: &mut SharedMut<$base::Response>| -> Result> { - Ok(obj.with_mut(|response| response.response.headers().clone())) - } - ); - - $engine.register_set( - "headers", - |obj: &mut SharedMut<$base::FirstRequest>, headers: HeaderMap| { - if_subgraph! { - $base => { - let _unused = (obj, headers); - Err("cannot mutate originating request on a subgraph".into()) - } else { - obj.with_mut(|request| *request.request.headers_mut() = headers); - Ok(()) - } - } - } - ).register_set( - "headers", - |obj: &mut SharedMut<$base::Response>, headers: HeaderMap| { - if_subgraph! { - $base => { - let _unused = (obj, headers); - Err("cannot mutate originating request on a subgraph".into()) - } else { - obj.with_mut(|response| *response.response.headers_mut() = headers); - Ok(()) - } - } - } - ); - - /*TODO: reenable when https://github.com/apollographql/router/issues/3642 is decided - $engine.register_get( - "body", - |obj: &mut SharedMut<$base::ChunkedRequest>| -> Result, Box> { - Ok( obj.with_mut(|request| { request.request.to_vec()})) - } - ); - - $engine.register_set( - "body", - |obj: &mut SharedMut<$base::ChunkedRequest>, body: Vec| { - if_subgraph! { - $base => { - let _unused = (obj, body); - Err("cannot mutate originating request on a subgraph".into()) - } else { - let bytes = Bytes::from(body); - obj.with_mut(|request| request.request = bytes); - Ok(()) - } - } - } - );*/ - - $engine.register_get( - "uri", - |obj: &mut SharedMut<$base::FirstRequest>| -> Result> { - Ok(obj.with_mut(|request| request.request.uri().clone())) - } - ).register_get( - "uri", - |obj: &mut SharedMut<$base::Request>| -> Result> { - Ok(obj.with_mut(|request| request.router_request.uri().clone())) - } - ); - - $engine.register_set( - "uri", - |obj: &mut SharedMut<$base::FirstRequest>, uri: Uri| { - if_subgraph! { - $base => { - let _unused = (obj, headers); - Err("cannot mutate originating request on a subgraph".into()) - } else { - obj.with_mut(|request| *request.request.uri_mut() = uri); - Ok(()) - } - } - } - ).register_set( - "uri", - |obj: &mut SharedMut<$base::Request>, uri: Uri| { - if_subgraph! { - $base => { - let _unused = (obj, uri); - Err("cannot mutate originating request on a subgraph".into()) - } else { - obj.with_mut(|request| *request.router_request.uri_mut() = uri); - Ok(()) - } - } - } - ); - - $engine.register_get( - "method", - |obj: &mut SharedMut<$base::FirstRequest>| -> Result> { - Ok(obj.with_mut(|request| request.request.method().clone())) - } - ); - )* - }; -} - -macro_rules! register_rhai_interface { - ($engine: ident, $($base: ident), *) => { - $( - // Context stuff - $engine.register_get( - "context", - |obj: &mut SharedMut<$base::Request>| -> Result> { - Ok(obj.with_mut(|request| request.context.clone())) - } - ) - .register_get( - "context", - |obj: &mut SharedMut<$base::Response>| -> Result> { - Ok(obj.with_mut(|response| response.context.clone())) - } - ); - - $engine.register_get( - "status_code", - |obj: &mut SharedMut<$base::Response>| -> Result> { - Ok(obj.with_mut(|response| response.response.status())) - } - ); - - $engine.register_set( - "context", - |obj: &mut SharedMut<$base::Request>, context: Context| { - obj.with_mut(|request| request.context = context); - Ok(()) - } - ) - .register_set( - "context", - |obj: &mut SharedMut<$base::Response>, context: Context| { - obj.with_mut(|response| response.context = context); - Ok(()) - } - ); - - // Id - $engine.register_get( - "id", - |obj: &mut SharedMut<$base::Request>| -> String { - obj.with_mut(|request| request.context.id.clone()) - } - ) - .register_get( - "id", - |obj: &mut SharedMut<$base::Response>| -> String { - obj.with_mut(|response| response.context.id.clone()) - } - ); - - // Originating Request - $engine.register_get( - "headers", - |obj: &mut SharedMut<$base::Request>| -> Result> { - Ok(obj.with_mut(|request| request.supergraph_request.headers().clone())) - } - ); - - $engine.register_set( - "headers", - |obj: &mut SharedMut<$base::Request>, headers: HeaderMap| { - if_subgraph! { - $base => { - let _unused = (obj, headers); - Err("cannot mutate originating request on a subgraph".into()) - } else { - obj.with_mut(|request| *request.supergraph_request.headers_mut() = headers); - Ok(()) - } - } - } - ); - - $engine.register_get( - "method", - |obj: &mut SharedMut<$base::Request>| -> Result> { - Ok(obj.with_mut(|request| request.supergraph_request.method().clone())) - } - ); - - $engine.register_get( - "body", - |obj: &mut SharedMut<$base::Request>| -> Result> { - Ok(obj.with_mut(|request| request.supergraph_request.body().clone())) - } - ); - - $engine.register_set( - "body", - |obj: &mut SharedMut<$base::Request>, body: Request| { - if_subgraph! { - $base => { - let _unused = (obj, body); - Err("cannot mutate originating request on a subgraph".into()) - } else { - obj.with_mut(|request| *request.supergraph_request.body_mut() = body); - Ok(()) - } - } - } - ); - - $engine.register_get( - "uri", - |obj: &mut SharedMut<$base::Request>| -> Result> { - Ok(obj.with_mut(|request| request.supergraph_request.uri().clone())) - } - ); - - $engine.register_set( - "uri", - |obj: &mut SharedMut<$base::Request>, uri: Uri| { - if_subgraph! { - $base => { - let _unused = (obj, uri); - Err("cannot mutate originating request on a subgraph".into()) - } else { - obj.with_mut(|request| *request.supergraph_request.uri_mut() = uri); - Ok(()) - } - } - } - ); - )* - }; -} +pub(crate) use types::RhaiExecutionDeferredResponse; +pub(crate) use types::RhaiExecutionResponse; +pub(crate) use types::RhaiRouterChunkedRequest; +pub(crate) use types::RhaiRouterChunkedResponse; +pub(crate) use types::RhaiRouterFirstRequest; +pub(crate) use types::RhaiRouterResponse; +pub(crate) use types::RhaiSupergraphDeferredResponse; +pub(crate) use types::RhaiSupergraphResponse; #[derive(Clone, Debug)] pub(crate) struct RhaiService { @@ -1759,16 +1355,9 @@ impl Rhai { Ok(()) } - pub(super) fn new_rhai_engine(path: Option, sdl: String, main: PathBuf) -> Engine { - let mut engine = Engine::new(); - // If we pass in a path, use it to configure our engine - // with a FileModuleResolver which allows import to work - // in scripts. - if let Some(scripts) = path { - let resolver = FileModuleResolver::new_with_path(scripts); - engine.set_module_resolver(resolver); - } - + /// Register global modules and common functionality needed by Rhai scripts. + /// This is used by both production and test code to ensure consistent engine configuration. + pub(crate) fn register_global_modules(engine: &mut Engine) { // The macro call creates a Rhai module from the plugin module. let mut module = exported_module!(router_plugin); combine_with_exported_module!(&mut module, "header", router_header_map); @@ -1779,9 +1368,39 @@ impl Rhai { let base64_module = exported_module!(router_base64); let json_module = exported_module!(router_json); let sha256_module = exported_module!(router_sha256); - let expansion_module = exported_module!(router_expansion); + engine + // Register our plugin module + .register_global_module(module.into()) + // Register our base64 module (not global) + .register_static_module("base64", base64_module.into()) + // Register our json module (not global) + .register_static_module("json", json_module.into()) + // Register our SHA256 module (not global) + .register_static_module("sha256", sha256_module.into()) + // Register our expansion module (not global) + // Hide the fact that it is an expansion module by calling it "env" + .register_static_module("env", expansion_module.into()) + // Register HeaderMap as an iterator so we can loop over contents + .register_iterator::(); + } + + pub(super) fn new_rhai_engine(path: Option, sdl: String, main: PathBuf) -> Engine { + let mut engine = Engine::new(); + // If we pass in a path, use it to configure our engine + // with a FileModuleResolver which allows import to work + // in scripts. + if let Some(scripts) = path { + let resolver = FileModuleResolver::new_with_path(scripts); + engine.set_module_resolver(resolver); + } + + // Register global modules and common functionality + Self::register_global_modules(&mut engine); + // Add common getter/setters for different types + registration::register(&mut engine); + // Share main so we can move copies into each closure as required for logging let shared_main = Arc::new(main.display().to_string()); @@ -1799,19 +1418,6 @@ impl Rhai { .on_print(move |message| { tracing::info!(%message, target = %print_main); }) - // Register our plugin module - .register_global_module(module.into()) - // Register our base64 module (not global) - .register_static_module("base64", base64_module.into()) - // Register our json module (not global) - .register_static_module("json", json_module.into()) - // Register our SHA256 module (not global) - .register_static_module("sha256", sha256_module.into()) - // Register our expansion module (not global) - // Hide the fact that it is an expansion module by calling it "env" - .register_static_module("env", expansion_module.into()) - // Register HeaderMap as an iterator so we can loop over contents - .register_iterator::() // Register a series of logging functions .register_fn("log_trace", move |message: Dynamic| { tracing::trace!(%message, target = %trace_main); @@ -1828,10 +1434,6 @@ impl Rhai { .register_fn("log_error", move |message: Dynamic| { tracing::error!(%message, target = %error_main); }); - // Add common getter/setters for different types - register_rhai_router_interface!(engine, router); - // Add common getter/setters for different types - register_rhai_interface!(engine, supergraph, execution, subgraph); // Since constants in Rhai don't give us the behaviour we expect, let's create some global // variables which we use in a variable resolver when we create our engine. diff --git a/apollo-router/src/plugins/rhai/engine/registration/execution.rs b/apollo-router/src/plugins/rhai/engine/registration/execution.rs new file mode 100644 index 0000000000..8e127cb4e7 --- /dev/null +++ b/apollo-router/src/plugins/rhai/engine/registration/execution.rs @@ -0,0 +1,328 @@ +use http::HeaderMap; +use http::Method; +use http::StatusCode; +use http::Uri; +use rhai::Engine; +use rhai::EvalAltResult; + +use super::super::types::OptionDance; +use super::super::types::SharedMut; +use crate::context::Context; +use crate::graphql::Request; +use crate::plugins::rhai::execution; + +/// Register properties for execution request/response types. +/// +/// All originating request properties (headers, body, uri) are mutable in the execution context. +pub(super) fn register(engine: &mut Engine) { + engine + .register_get( + "context", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.context.clone())) + }, + ) + .register_get( + "context", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|response| response.context.clone())) + }, + ); + + engine.register_get( + "status_code", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|response| response.response.status())) + }, + ); + + engine + .register_set( + "context", + |obj: &mut SharedMut, context: Context| { + obj.with_mut(|request| request.context = context); + Ok(()) + }, + ) + .register_set( + "context", + |obj: &mut SharedMut, context: Context| { + obj.with_mut(|response| response.context = context); + Ok(()) + }, + ); + + engine + .register_get("id", |obj: &mut SharedMut| -> String { + obj.with_mut(|request| request.context.id.clone()) + }) + .register_get("id", |obj: &mut SharedMut| -> String { + obj.with_mut(|response| response.context.id.clone()) + }); + + engine.register_get_set( + "headers", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.supergraph_request.headers().clone())) + }, + |obj: &mut SharedMut, headers: HeaderMap| { + obj.with_mut(|request| *request.supergraph_request.headers_mut() = headers); + Ok(()) + }, + ); + + engine.register_get( + "method", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.supergraph_request.method().clone())) + }, + ); + + engine.register_get_set( + "body", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.supergraph_request.body().clone())) + }, + |obj: &mut SharedMut, body: Request| { + obj.with_mut(|request| *request.supergraph_request.body_mut() = body); + Ok(()) + }, + ); + + engine.register_get_set( + "uri", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.supergraph_request.uri().clone())) + }, + |obj: &mut SharedMut, uri: Uri| { + obj.with_mut(|request| *request.supergraph_request.uri_mut() = uri); + Ok(()) + }, + ); +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use http::HeaderMap; + use http::StatusCode; + use parking_lot::Mutex; + + use super::*; + use crate::context::Context; + use crate::plugins::rhai::engine::registration; + use crate::plugins::rhai::execution; + use crate::services::ExecutionRequest; + + fn create_engine_with_helpers() -> rhai::Engine { + let mut engine = rhai::Engine::new(); + + // Register global modules (HeaderMap indexer, Context, Request, etc.) + crate::plugins::rhai::Rhai::register_global_modules(&mut engine); + // Add common getter/setters for different types + registration::register(&mut engine); + + engine + } + + fn create_test_execution_request() -> SharedMut { + Arc::new(Mutex::new(Some(ExecutionRequest::fake_builder().build()))) + } + + fn create_test_execution_response() -> SharedMut { + Arc::new(Mutex::new(Some( + execution::Response::fake_builder().build().unwrap(), + ))) + } + + #[test] + fn test_context_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_execution_request(); + let expected_id = request.lock().as_ref().unwrap().context.id.clone(); + + // Context getter returns the full context object + let script = "fn test(req) { req.context }"; + let ast = engine.compile(script).unwrap(); + let result: Context = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + assert_eq!(result.id, expected_id); + } + + #[test] + fn test_context_setter() { + let engine = create_engine_with_helpers(); + let request = create_test_execution_request(); + let new_context = Context::new(); + let expected_id = new_context.id.clone(); + + let script = "fn test(req, ctx) { req.context = ctx; }"; + let ast = engine.compile(script).unwrap(); + let _: () = engine + .call_fn( + &mut rhai::Scope::new(), + &ast, + "test", + (request.clone(), new_context), + ) + .unwrap(); + + assert_eq!(request.lock().as_ref().unwrap().context.id, expected_id); + } + + #[test] + fn test_id_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_execution_request(); + let expected_id = request.lock().as_ref().unwrap().context.id.clone(); + + let script = "fn test(req) { req.id }"; + let ast = engine.compile(script).unwrap(); + let result: String = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + assert_eq!(result, expected_id); + } + + #[test] + fn test_headers_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_execution_request(); + + let script = "fn test(req) { req.headers }"; + let ast = engine.compile(script).unwrap(); + let _result: HeaderMap = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + // Test succeeds if we can retrieve headers without errors + } + + #[test] + fn test_headers_setter() { + let engine = create_engine_with_helpers(); + let request = create_test_execution_request(); + + let script = r#" + fn test(req) { + let headers = req.headers; + req.headers = headers; + } + "#; + + let ast = engine.compile(script).unwrap(); + // Should succeed - headers are mutable in execution context + let _: () = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request.clone(),)) + .unwrap(); + } + + #[test] + fn test_method_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_execution_request(); + + let script = "fn test(req) { req.method }"; + let ast = engine.compile(script).unwrap(); + let result: http::Method = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + assert_eq!(result, http::Method::GET); + } + + #[test] + fn test_body_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_execution_request(); + + let script = "fn test(req) { req.body }"; + let ast = engine.compile(script).unwrap(); + let _result: crate::graphql::Request = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + // Test succeeds if we can retrieve body without errors + } + + #[test] + fn test_body_setter() { + let engine = create_engine_with_helpers(); + let request = create_test_execution_request(); + + let script = "fn test(req, body) { req.body = body; }"; + let ast = engine.compile(script).unwrap(); + + let new_body = crate::graphql::Request::builder() + .query("{ modified }") + .build(); + + let _: () = engine + .call_fn( + &mut rhai::Scope::new(), + &ast, + "test", + (request.clone(), new_body.clone()), + ) + .unwrap(); + + let binding = request.lock(); + let body = binding.as_ref().unwrap().supergraph_request.body(); + assert_eq!(body.query, new_body.query); + } + + #[test] + fn test_uri_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_execution_request(); + + let script = "fn test(req) { req.uri }"; + let ast = engine.compile(script).unwrap(); + let result: http::Uri = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + assert_eq!(result.path(), "/"); + } + + #[test] + fn test_uri_setter() { + let engine = create_engine_with_helpers(); + let request = create_test_execution_request(); + + let script = "fn test(req, uri) { req.uri = uri; }"; + let ast = engine.compile(script).unwrap(); + + let new_uri = http::Uri::from_static("http://example.com/graphql"); + let _: () = engine + .call_fn( + &mut rhai::Scope::new(), + &ast, + "test", + (request.clone(), new_uri.clone()), + ) + .unwrap(); + + let binding = request.lock(); + let uri = binding.as_ref().unwrap().supergraph_request.uri(); + assert_eq!(uri.path(), "/graphql"); + } + + #[test] + fn test_response_status_code() { + let engine = create_engine_with_helpers(); + let response = create_test_execution_response(); + + let script = "fn test(resp) { resp.status_code }"; + let ast = engine.compile(script).unwrap(); + let result: StatusCode = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (response,)) + .unwrap(); + + assert_eq!(result, StatusCode::OK); + } +} diff --git a/apollo-router/src/plugins/rhai/engine/registration/mod.rs b/apollo-router/src/plugins/rhai/engine/registration/mod.rs new file mode 100644 index 0000000000..9a2fd1aac2 --- /dev/null +++ b/apollo-router/src/plugins/rhai/engine/registration/mod.rs @@ -0,0 +1,20 @@ +mod execution; +mod router; +mod subgraph; +mod supergraph; + +use rhai::Engine; + +/// Register all context-specific properties and methods on the Rhai engine. +/// +/// This registers properties for different pipeline stages: +/// - Router: First stage, can mutate originating HTTP request +/// - Supergraph: After parsing GraphQL, can mutate supergraph request +/// - Execution: During query execution, can mutate supergraph request +/// - Subgraph: Before calling subgraphs, originating request is read-only +pub(super) fn register(engine: &mut Engine) { + router::register(engine); + supergraph::register(engine); + execution::register(engine); + subgraph::register(engine); +} diff --git a/apollo-router/src/plugins/rhai/engine/registration/router.rs b/apollo-router/src/plugins/rhai/engine/registration/router.rs new file mode 100644 index 0000000000..bf46b37a44 --- /dev/null +++ b/apollo-router/src/plugins/rhai/engine/registration/router.rs @@ -0,0 +1,422 @@ +use http::HeaderMap; +use http::Method; +use http::Uri; +use rhai::Engine; +use rhai::EvalAltResult; + +use super::super::types::OptionDance; +use super::super::types::SharedMut; +use crate::context::Context; +use crate::plugins::rhai::router; + +/// Register properties for router request/response types. +/// +/// All originating request properties (headers, body, uri) are mutable in the router context. +pub(super) fn register(engine: &mut Engine) { + engine + .register_get( + "context", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.context.clone())) + }, + ) + .register_get( + "context", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.context.clone())) + }, + ) + .register_get( + "context", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|response| response.context.clone())) + }, + ) + .register_get( + "context", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|response| response.context.clone())) + }, + ); + + engine + .register_set( + "context", + |obj: &mut SharedMut, context: Context| { + obj.with_mut(|request| request.context = context); + Ok(()) + }, + ) + .register_set( + "context", + |obj: &mut SharedMut, context: Context| { + obj.with_mut(|request| request.context = context); + Ok(()) + }, + ) + .register_set( + "context", + |obj: &mut SharedMut, context: Context| { + obj.with_mut(|response| response.context = context); + Ok(()) + }, + ) + .register_set( + "context", + |obj: &mut SharedMut, context: Context| { + obj.with_mut(|response| response.context = context); + Ok(()) + }, + ); + + engine + .register_get( + "id", + |obj: &mut SharedMut| -> String { + obj.with_mut(|request| request.context.id.clone()) + }, + ) + .register_get( + "id", + |obj: &mut SharedMut| -> String { + obj.with_mut(|request| request.context.id.clone()) + }, + ) + .register_get( + "id", + |obj: &mut SharedMut| -> String { + obj.with_mut(|response| response.context.id.clone()) + }, + ) + .register_get( + "id", + |obj: &mut SharedMut| -> String { + obj.with_mut(|response| response.context.id.clone()) + }, + ); + + engine.register_get_set( + "headers", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.request.headers().clone())) + }, + |obj: &mut SharedMut, headers: HeaderMap| { + obj.with_mut(|request| *request.request.headers_mut() = headers); + Ok(()) + }, + ); + + engine.register_get_set( + "headers", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|response| response.response.headers().clone())) + }, + |obj: &mut SharedMut, headers: HeaderMap| { + obj.with_mut(|response| *response.response.headers_mut() = headers); + Ok(()) + }, + ); + + engine.register_get( + "uri", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.request.uri().clone())) + }, + ); + + engine.register_get( + "uri", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.router_request.uri().clone())) + }, + ); + + engine.register_set( + "uri", + |obj: &mut SharedMut, uri: Uri| { + obj.with_mut(|request| *request.request.uri_mut() = uri); + Ok(()) + }, + ); + + engine.register_set("uri", |obj: &mut SharedMut, uri: Uri| { + obj.with_mut(|request| *request.router_request.uri_mut() = uri); + Ok(()) + }); + + engine.register_get( + "method", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.request.method().clone())) + }, + ); +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use http::HeaderMap; + use parking_lot::Mutex; + + use super::*; + use crate::context::Context; + use crate::plugins::rhai::engine::registration; + use crate::plugins::rhai::router; + use crate::services::RouterRequest; + use crate::services::RouterResponse; + + fn create_engine_with_helpers() -> rhai::Engine { + let mut engine = rhai::Engine::new(); + + // Register global modules (HeaderMap indexer, Context, Request, etc.) + crate::plugins::rhai::Rhai::register_global_modules(&mut engine); + // Add common getter/setters for different types + registration::register(&mut engine); + + engine + } + + fn create_test_first_request() -> SharedMut { + let router_request = RouterRequest::fake_builder().build().unwrap(); + let context = router_request.context.clone(); + let http_request = router_request.router_request.map(|_| ()); + + Arc::new(Mutex::new(Some(router::FirstRequest { + context, + request: http_request, + }))) + } + + fn create_test_first_response() -> SharedMut { + let router_response = RouterResponse::fake_builder().build().unwrap(); + let context = router_response.context.clone(); + let http_response = router_response.response.map(|_| ()); + + Arc::new(Mutex::new(Some(router::FirstResponse { + context, + response: http_response, + }))) + } + + #[test] + fn test_first_request_context_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_first_request(); + let expected_id = request.lock().as_ref().unwrap().context.id.clone(); + + // Context getter returns the full context object + let script = "fn test(req) { req.context }"; + let ast = engine.compile(script).unwrap(); + let result: Context = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + assert_eq!(result.id, expected_id); + } + + #[test] + fn test_first_request_context_setter() { + let engine = create_engine_with_helpers(); + let request = create_test_first_request(); + let new_context = Context::new(); + let expected_id = new_context.id.clone(); + + let script = "fn test(req, ctx) { req.context = ctx; }"; + let ast = engine.compile(script).unwrap(); + let _: () = engine + .call_fn( + &mut rhai::Scope::new(), + &ast, + "test", + (request.clone(), new_context), + ) + .unwrap(); + + assert_eq!(request.lock().as_ref().unwrap().context.id, expected_id); + } + + #[test] + fn test_first_request_id_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_first_request(); + let expected_id = request.lock().as_ref().unwrap().context.id.clone(); + + let script = "fn test(req) { req.id }"; + let ast = engine.compile(script).unwrap(); + let result: String = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + assert_eq!(result, expected_id); + } + + #[test] + fn test_first_request_headers_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_first_request(); + + let script = "fn test(req) { req.headers }"; + let ast = engine.compile(script).unwrap(); + let _result: HeaderMap = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + // Test succeeds if we can retrieve headers without errors + } + + #[test] + fn test_first_request_headers_setter() { + let engine = create_engine_with_helpers(); + let request = create_test_first_request(); + + let script = r#" + fn test(req) { + let headers = req.headers; + req.headers = headers; + } + "#; + + let ast = engine.compile(script).unwrap(); + // Should succeed - headers are mutable in router context + let _: () = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request.clone(),)) + .unwrap(); + } + + #[test] + fn test_first_request_method_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_first_request(); + + let script = "fn test(req) { req.method }"; + let ast = engine.compile(script).unwrap(); + let result: http::Method = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + assert_eq!(result, http::Method::GET); + } + + #[test] + fn test_first_request_uri_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_first_request(); + + let script = "fn test(req) { req.uri }"; + let ast = engine.compile(script).unwrap(); + let result: http::Uri = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + assert_eq!(result.path(), "/"); + } + + #[test] + fn test_first_request_uri_setter() { + let engine = create_engine_with_helpers(); + let request = create_test_first_request(); + + let script = "fn test(req, uri) { req.uri = uri; }"; + let ast = engine.compile(script).unwrap(); + + let new_uri = http::Uri::from_static("http://example.com/graphql"); + let _: () = engine + .call_fn( + &mut rhai::Scope::new(), + &ast, + "test", + (request.clone(), new_uri.clone()), + ) + .unwrap(); + + let binding = request.lock(); + let uri = binding.as_ref().unwrap().request.uri(); + assert_eq!(uri.path(), "/graphql"); + } + + #[test] + fn test_first_response_context_getter() { + let engine = create_engine_with_helpers(); + let response = create_test_first_response(); + let expected_id = response.lock().as_ref().unwrap().context.id.clone(); + + // Context getter returns the full context object + let script = "fn test(resp) { resp.context }"; + let ast = engine.compile(script).unwrap(); + let result: Context = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (response,)) + .unwrap(); + + assert_eq!(result.id, expected_id); + } + + #[test] + fn test_first_response_context_setter() { + let engine = create_engine_with_helpers(); + let response = create_test_first_response(); + let new_context = Context::new(); + let expected_id = new_context.id.clone(); + + let script = "fn test(resp, ctx) { resp.context = ctx; }"; + let ast = engine.compile(script).unwrap(); + let _: () = engine + .call_fn( + &mut rhai::Scope::new(), + &ast, + "test", + (response.clone(), new_context), + ) + .unwrap(); + + assert_eq!(response.lock().as_ref().unwrap().context.id, expected_id); + } + + #[test] + fn test_first_response_id_getter() { + let engine = create_engine_with_helpers(); + let response = create_test_first_response(); + let expected_id = response.lock().as_ref().unwrap().context.id.clone(); + + let script = "fn test(resp) { resp.id }"; + let ast = engine.compile(script).unwrap(); + let result: String = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (response,)) + .unwrap(); + + assert_eq!(result, expected_id); + } + + #[test] + fn test_first_response_headers_getter() { + let engine = create_engine_with_helpers(); + let response = create_test_first_response(); + + let script = "fn test(resp) { resp.headers }"; + let ast = engine.compile(script).unwrap(); + let _result: HeaderMap = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (response,)) + .unwrap(); + + // Test succeeds if we can retrieve headers without errors + } + + #[test] + fn test_first_response_headers_setter() { + let engine = create_engine_with_helpers(); + let response = create_test_first_response(); + + let script = r#" + fn test(resp) { + let headers = resp.headers; + resp.headers = headers; + } + "#; + + let ast = engine.compile(script).unwrap(); + // Should succeed - headers are mutable in router context + let _: () = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (response.clone(),)) + .unwrap(); + } +} diff --git a/apollo-router/src/plugins/rhai/engine/registration/subgraph.rs b/apollo-router/src/plugins/rhai/engine/registration/subgraph.rs new file mode 100644 index 0000000000..a792021748 --- /dev/null +++ b/apollo-router/src/plugins/rhai/engine/registration/subgraph.rs @@ -0,0 +1,509 @@ +use http::HeaderMap; +use http::Method; +use http::StatusCode; +use http::Uri; +use rhai::Engine; +use rhai::EvalAltResult; + +use super::super::types::OptionDance; +use super::super::types::SharedMut; +use crate::context::Context; +use crate::graphql::Request; +use crate::plugins::rhai::subgraph; + +/// Register properties for subgraph request/response types. +/// +/// The originating (supergraph) request properties (headers, body, uri) are intentionally +/// READ-ONLY in the subgraph context. Setters are NOT registered for these properties. +/// +/// ## Why no setters? +/// +/// Rhai uses automatic property value propagation through chains. When calling a method +/// on a property chain like `request.headers["cookie"].split(';')`, Rhai will attempt to +/// propagate the result back by calling setters on the property chain, even when the method +/// is non-mutating (like `split()` or `trim()`). +/// +/// If a setter exists but throws an error (as it would for read-only supergraph request +/// in subgraph context), this causes scripts to fail even for simple read operations. +/// By not registering setters at all, Rhai knows the property is truly read-only and +/// doesn't attempt value propagation, allowing read operations to work correctly. +/// +/// ## How this works with types that have setters +/// +/// Even though types like `HeaderMap` have indexer setters registered globally (in +/// `router_header_map` module), property chains remain read-only when the initial +/// property has no setter. +/// +/// For example, with `req.headers["cookie"].split(';')`: +/// 1. `req.headers` returns a `HeaderMap` (no setter exists on `SharedMut`) +/// 2. `["cookie"]` uses the global `HeaderMap` indexer (both getter AND setter exist) +/// 3. `.split(';')` returns an `Array` +/// 4. Rhai attempts value propagation backwards through the chain: +/// - Would call `HeaderMap` indexer setter to set `req.headers["cookie"] = result` +/// - Would then call `req.headers` setter to propagate the modified map back +/// - BUT: No setter exists for `headers` on `SharedMut`! +/// - Propagation stops, **no error occurs**, and modifications are silently ignored +/// +/// This behavior is verified by `test_headers_indexer_setter_blocked`: attempting +/// `req.headers["key"] = "value"` succeeds without error, but the modification is +/// silently ignored because Rhai can't propagate it back through the property chain. +/// +/// The key: **The read-only-ness is determined by the first link in the property chain** +/// (the wrapper type like `SharedMut`), not by intermediate types like +/// `HeaderMap`. This allows the same types (HeaderMap, Context, etc.) to be read-only in +/// some contexts and writable in others, based solely on whether their property getter +/// has a corresponding setter on the wrapper type. +/// +/// Scripts can still modify `request.subgraph.headers`, `request.subgraph.body`, etc. +/// which are the actual outgoing subgraph request properties. +pub(super) fn register(engine: &mut Engine) { + engine + .register_get( + "context", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.context.clone())) + }, + ) + .register_get( + "context", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|response| response.context.clone())) + }, + ); + + engine.register_get( + "status_code", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|response| response.response.status())) + }, + ); + + engine + .register_set( + "context", + |obj: &mut SharedMut, context: Context| { + obj.with_mut(|request| request.context = context); + Ok(()) + }, + ) + .register_set( + "context", + |obj: &mut SharedMut, context: Context| { + obj.with_mut(|response| response.context = context); + Ok(()) + }, + ); + + engine + .register_get("id", |obj: &mut SharedMut| -> String { + obj.with_mut(|request| request.context.id.clone()) + }) + .register_get("id", |obj: &mut SharedMut| -> String { + obj.with_mut(|response| response.context.id.clone()) + }); + + // Note: No setters for headers, body, uri - they are read-only in subgraph context + engine.register_get( + "headers", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.supergraph_request.headers().clone())) + }, + ); + + engine.register_get( + "method", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.supergraph_request.method().clone())) + }, + ); + + engine.register_get( + "body", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.supergraph_request.body().clone())) + }, + ); + + engine.register_get( + "uri", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.supergraph_request.uri().clone())) + }, + ); +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use http::HeaderValue; + use http::StatusCode; + use parking_lot::Mutex; + + use super::*; + use crate::context::Context; + use crate::plugins::rhai::engine::registration; + use crate::plugins::rhai::subgraph; + use crate::services::SubgraphRequest; + + fn create_engine_with_helpers() -> rhai::Engine { + let mut engine = rhai::Engine::new(); + + // Register global modules (HeaderMap indexer, Context, Request, etc.) + crate::plugins::rhai::Rhai::register_global_modules(&mut engine); + // Add common getter/setters for different types + registration::register(&mut engine); + + engine + } + + fn create_test_subgraph_request() -> SharedMut { + Arc::new(Mutex::new(Some(SubgraphRequest::fake_builder().build()))) + } + + fn create_test_subgraph_response() -> SharedMut { + Arc::new(Mutex::new(Some(subgraph::Response::fake_builder().build()))) + } + + #[test] + fn test_context_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_subgraph_request(); + let expected_id = request.lock().as_ref().unwrap().context.id.clone(); + + // Context getter returns the full context object + let script = "fn test(req) { req.context }"; + let ast = engine.compile(script).unwrap(); + let result: Context = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + assert_eq!(result.id, expected_id); + } + + #[test] + fn test_context_setter() { + let engine = create_engine_with_helpers(); + let request = create_test_subgraph_request(); + let new_context = Context::new(); + let expected_id = new_context.id.clone(); + + let script = "fn test(req, ctx) { req.context = ctx; }"; + let ast = engine.compile(script).unwrap(); + let _: () = engine + .call_fn( + &mut rhai::Scope::new(), + &ast, + "test", + (request.clone(), new_context), + ) + .unwrap(); + + assert_eq!(request.lock().as_ref().unwrap().context.id, expected_id); + } + + #[test] + fn test_id_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_subgraph_request(); + let expected_id = request.lock().as_ref().unwrap().context.id.clone(); + + let script = "fn test(req) { req.id }"; + let ast = engine.compile(script).unwrap(); + let result: String = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + assert_eq!(result, expected_id); + } + + #[test] + fn test_headers_getter_read_only() { + let engine = create_engine_with_helpers(); + let request = create_test_subgraph_request(); + + // Add a header to test + { + let mut guard = request.lock(); + let req = guard.as_mut().unwrap(); + let mut new_req = (*req.supergraph_request).clone(); + new_req + .headers_mut() + .insert("test-header", HeaderValue::from_static("test-value")); + req.supergraph_request = Arc::new(new_req); + } + + let script = r#"fn test(req) { req.headers["test-header"] }"#; + let ast = engine.compile(script).unwrap(); + let result: String = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + assert_eq!(result, "test-value"); + } + + #[test] + fn test_headers_no_setter() { + let engine = create_engine_with_helpers(); + let request = create_test_subgraph_request(); + + // Attempting to set headers should fail because no setter is registered + let script = r#" + fn test(req) { + let headers = req.headers; + req.headers = headers; + } + "#; + + let ast = engine.compile(script).unwrap(); + let result: Result<(), _> = + engine.call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)); + + assert!( + result.is_err(), + "Should not be able to set read-only headers property" + ); + } + + #[test] + fn test_headers_indexer_setter_blocked() { + let engine = create_engine_with_helpers(); + let request = create_test_subgraph_request(); + + // Add initial header value + { + let mut guard = request.lock(); + let req = guard.as_mut().unwrap(); + let mut new_req = (*req.supergraph_request).clone(); + new_req + .headers_mut() + .insert("test-header", HeaderValue::from_static("original")); + req.supergraph_request = Arc::new(new_req); + } + + // Attempting to set individual header via indexer + // Even though HeaderMap has an indexer setter registered globally, + // it can't actually modify the request because there's no setter for + // the headers property itself. Rhai silently ignores the modification + // when it can't propagate changes back through the property chain. + let script = r#" + fn test(req) { + req.headers["test-header"] = "modified"; + } + "#; + + let ast = engine.compile(script).unwrap(); + let result: Result<(), _> = + engine.call_fn(&mut rhai::Scope::new(), &ast, "test", (request.clone(),)); + + // The script succeeds (Rhai doesn't error when it can't propagate), + // but the modification is silently ignored + assert!(result.is_ok(), "Script should run without error"); + + // Verify the header wasn't actually modified - this proves the setter was blocked + let guard = request.lock(); + let final_value = guard + .as_ref() + .unwrap() + .supergraph_request + .headers() + .get("test-header") + .map(|v| v.to_str().unwrap()) + .unwrap(); + assert_eq!( + final_value, "original", + "Header should remain unchanged despite setter attempt" + ); + } + + #[test] + fn test_headers_property_chain_with_split() { + let engine = create_engine_with_helpers(); + let request = create_test_subgraph_request(); + + // Add cookies + { + let mut guard = request.lock(); + let req = guard.as_mut().unwrap(); + let mut new_req = (*req.supergraph_request).clone(); + new_req + .headers_mut() + .insert("cookie", HeaderValue::from_static("a=1; b=2")); + req.supergraph_request = Arc::new(new_req); + } + + // This is THE critical test - split() should work without triggering setter error + let script = r#" + fn test(req) { + let cookies = req.headers["cookie"].split(';'); + cookies.len() + } + "#; + + let ast = engine.compile(script).unwrap(); + let result: i64 = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + assert_eq!(result, 2); + } + + #[test] + fn test_cookie_parsing_pattern() { + let engine = create_engine_with_helpers(); + let request = create_test_subgraph_request(); + + { + let mut guard = request.lock(); + let req = guard.as_mut().unwrap(); + let mut new_req = (*req.supergraph_request).clone(); + new_req.headers_mut().insert( + "cookie", + HeaderValue::from_static("session=abc; user=john; theme=dark"), + ); + req.supergraph_request = Arc::new(new_req); + } + + // Test the exact pattern from cookies-to-headers example + let script = r#" + fn test(req) { + req.headers["cookie"].split(';').len() + } + "#; + + let ast = engine.compile(script).unwrap(); + let result: i64 = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + assert_eq!(result, 3); + } + + #[test] + fn test_headers_property_chain_with_string_methods() { + let engine = create_engine_with_helpers(); + let request = create_test_subgraph_request(); + + { + let mut guard = request.lock(); + let req = guard.as_mut().unwrap(); + let mut new_req = (*req.supergraph_request).clone(); + new_req + .headers_mut() + .insert("content-type", HeaderValue::from_static("application/json")); + req.supergraph_request = Arc::new(new_req); + } + + // Test various Rhai string methods work in property chains without triggering setter errors + // This verifies that without setters registered, Rhai doesn't try to propagate values back + let script = r#" + fn test(req) { + // Test to_upper() in chain + let upper = req.headers["content-type"].to_upper(); + // Test contains() in chain + let has_json = req.headers["content-type"].contains("json"); + // Return results + [upper, has_json] + } + "#; + + let ast = engine.compile(script).unwrap(); + let result: rhai::Array = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + assert_eq!(result[0].clone().cast::(), "APPLICATION/JSON"); + assert!(result[1].clone().cast::()); + } + + #[test] + fn test_body_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_subgraph_request(); + + // Body getter returns the Request object + let script = "fn test(req) { req.body }"; + let ast = engine.compile(script).unwrap(); + let result: Request = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + // Verify we got a Request object back + assert!(result.query.is_some() || result.query.is_none()); + } + + #[test] + fn test_body_no_setter() { + let engine = create_engine_with_helpers(); + let request = create_test_subgraph_request(); + + let script = "fn test(req) { let b = req.body; req.body = b; }"; + let ast = engine.compile(script).unwrap(); + let result: Result<(), _> = + engine.call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)); + + assert!( + result.is_err(), + "Should not be able to set read-only body property" + ); + } + + #[test] + fn test_uri_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_subgraph_request(); + + let script = "fn test(req) { req.uri }"; + let ast = engine.compile(script).unwrap(); + let result: http::Uri = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + // Should return a valid URI (fake builder creates "/" by default) + assert_eq!(result.path(), "/"); + } + + #[test] + fn test_uri_no_setter() { + let engine = create_engine_with_helpers(); + let request = create_test_subgraph_request(); + + let script = "fn test(req) { let u = req.uri; req.uri = u; }"; + let ast = engine.compile(script).unwrap(); + let result: Result<(), _> = + engine.call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)); + + assert!( + result.is_err(), + "Should not be able to set read-only uri property" + ); + } + + #[test] + fn test_method_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_subgraph_request(); + + let script = "fn test(req) { req.method }"; + let ast = engine.compile(script).unwrap(); + let result: http::Method = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + assert_eq!(result, http::Method::GET); + } + + #[test] + fn test_response_status_code() { + let engine = create_engine_with_helpers(); + let response = create_test_subgraph_response(); + + let script = "fn test(resp) { resp.status_code }"; + let ast = engine.compile(script).unwrap(); + let result: StatusCode = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (response,)) + .unwrap(); + + assert_eq!(result, StatusCode::OK); + } +} diff --git a/apollo-router/src/plugins/rhai/engine/registration/supergraph.rs b/apollo-router/src/plugins/rhai/engine/registration/supergraph.rs new file mode 100644 index 0000000000..2147397a31 --- /dev/null +++ b/apollo-router/src/plugins/rhai/engine/registration/supergraph.rs @@ -0,0 +1,375 @@ +use http::HeaderMap; +use http::Method; +use http::StatusCode; +use http::Uri; +use rhai::Engine; +use rhai::EvalAltResult; + +use super::super::types::OptionDance; +use super::super::types::SharedMut; +use crate::context::Context; +use crate::graphql::Request; +use crate::plugins::rhai::supergraph; + +/// Register properties for supergraph request/response types. +/// +/// All originating request properties (headers, body, uri) are mutable in the supergraph context. +pub(super) fn register(engine: &mut Engine) { + engine + .register_get( + "context", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.context.clone())) + }, + ) + .register_get( + "context", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|response| response.context.clone())) + }, + ); + + engine.register_get( + "status_code", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|response| response.response.status())) + }, + ); + + engine + .register_set( + "context", + |obj: &mut SharedMut, context: Context| { + obj.with_mut(|request| request.context = context); + Ok(()) + }, + ) + .register_set( + "context", + |obj: &mut SharedMut, context: Context| { + obj.with_mut(|response| response.context = context); + Ok(()) + }, + ); + + engine + .register_get("id", |obj: &mut SharedMut| -> String { + obj.with_mut(|request| request.context.id.clone()) + }) + .register_get( + "id", + |obj: &mut SharedMut| -> String { + obj.with_mut(|response| response.context.id.clone()) + }, + ); + + engine.register_get_set( + "headers", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.supergraph_request.headers().clone())) + }, + |obj: &mut SharedMut, headers: HeaderMap| { + obj.with_mut(|request| *request.supergraph_request.headers_mut() = headers); + Ok(()) + }, + ); + + engine.register_get( + "method", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.supergraph_request.method().clone())) + }, + ); + + engine.register_get_set( + "body", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.supergraph_request.body().clone())) + }, + |obj: &mut SharedMut, body: Request| { + obj.with_mut(|request| *request.supergraph_request.body_mut() = body); + Ok(()) + }, + ); + + engine.register_get_set( + "uri", + |obj: &mut SharedMut| -> Result> { + Ok(obj.with_mut(|request| request.supergraph_request.uri().clone())) + }, + |obj: &mut SharedMut, uri: Uri| { + obj.with_mut(|request| *request.supergraph_request.uri_mut() = uri); + Ok(()) + }, + ); +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use http::StatusCode; + use parking_lot::Mutex; + + use super::*; + use crate::context::Context; + use crate::plugins::rhai::engine::registration; + use crate::plugins::rhai::supergraph; + use crate::services::SupergraphRequest; + + fn create_engine_with_helpers() -> rhai::Engine { + let mut engine = rhai::Engine::new(); + + // Register global modules (HeaderMap indexer, Context, Request, etc.) + crate::plugins::rhai::Rhai::register_global_modules(&mut engine); + // Add common getter/setters for different types + registration::register(&mut engine); + + engine + } + + fn create_test_supergraph_request() -> SharedMut { + Arc::new(Mutex::new(Some( + SupergraphRequest::fake_builder().build().unwrap(), + ))) + } + + fn create_test_supergraph_response() -> SharedMut { + Arc::new(Mutex::new(Some( + supergraph::Response::fake_builder().build().unwrap(), + ))) + } + + #[test] + fn test_context_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_supergraph_request(); + let expected_id = request.lock().as_ref().unwrap().context.id.clone(); + + // Context getter returns the full context object + let script = "fn test(req) { req.context }"; + let ast = engine.compile(script).unwrap(); + let result: Context = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + assert_eq!(result.id, expected_id); + } + + #[test] + fn test_context_setter() { + let engine = create_engine_with_helpers(); + let request = create_test_supergraph_request(); + let new_context = Context::new(); + let expected_id = new_context.id.clone(); + + let script = "fn test(req, ctx) { req.context = ctx; }"; + let ast = engine.compile(script).unwrap(); + let _: () = engine + .call_fn( + &mut rhai::Scope::new(), + &ast, + "test", + (request.clone(), new_context), + ) + .unwrap(); + + assert_eq!(request.lock().as_ref().unwrap().context.id, expected_id); + } + + #[test] + fn test_id_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_supergraph_request(); + let expected_id = request.lock().as_ref().unwrap().context.id.clone(); + + let script = "fn test(req) { req.id }"; + let ast = engine.compile(script).unwrap(); + let result: String = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + assert_eq!(result, expected_id); + } + + #[test] + fn test_headers_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_supergraph_request(); + + let script = r#"fn test(req) { req.headers["content-type"] }"#; + let ast = engine.compile(script).unwrap(); + let result: String = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + assert_eq!(result, "application/json"); + } + + #[test] + fn test_headers_setter() { + let engine = create_engine_with_helpers(); + let request = create_test_supergraph_request(); + + let script = r#" + fn test(req) { + let headers = req.headers; + req.headers = headers; + } + "#; + + let ast = engine.compile(script).unwrap(); + // Should succeed - headers are mutable in supergraph context + let _: () = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request.clone(),)) + .unwrap(); + } + + #[test] + fn test_method_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_supergraph_request(); + + let script = "fn test(req) { req.method }"; + let ast = engine.compile(script).unwrap(); + let result: http::Method = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + assert_eq!(result, http::Method::POST); + } + + #[test] + fn test_body_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_supergraph_request(); + + let script = "fn test(req) { req.body }"; + let ast = engine.compile(script).unwrap(); + let _result: crate::graphql::Request = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + // Test succeeds if we can retrieve body without errors + } + + #[test] + fn test_body_setter() { + let engine = create_engine_with_helpers(); + let request = create_test_supergraph_request(); + + let script = "fn test(req, body) { req.body = body; }"; + let ast = engine.compile(script).unwrap(); + + let new_body = crate::graphql::Request::builder() + .query("{ modified }") + .build(); + + let _: () = engine + .call_fn( + &mut rhai::Scope::new(), + &ast, + "test", + (request.clone(), new_body.clone()), + ) + .unwrap(); + + let binding = request.lock(); + let body = binding.as_ref().unwrap().supergraph_request.body(); + assert_eq!(body.query, new_body.query); + } + + #[test] + fn test_uri_getter() { + let engine = create_engine_with_helpers(); + let request = create_test_supergraph_request(); + + let script = "fn test(req) { req.uri }"; + let ast = engine.compile(script).unwrap(); + let result: http::Uri = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request,)) + .unwrap(); + + assert_eq!(result.path(), "/"); + } + + #[test] + fn test_uri_setter() { + let engine = create_engine_with_helpers(); + let request = create_test_supergraph_request(); + + let script = "fn test(req, uri) { req.uri = uri; }"; + let ast = engine.compile(script).unwrap(); + + let new_uri = http::Uri::from_static("http://example.com/graphql"); + let _: () = engine + .call_fn( + &mut rhai::Scope::new(), + &ast, + "test", + (request.clone(), new_uri.clone()), + ) + .unwrap(); + + let binding = request.lock(); + let uri = binding.as_ref().unwrap().supergraph_request.uri(); + assert_eq!(uri.path(), "/graphql"); + } + + #[test] + fn test_response_status_code() { + let engine = create_engine_with_helpers(); + let response = create_test_supergraph_response(); + + let script = "fn test(resp) { resp.status_code }"; + let ast = engine.compile(script).unwrap(); + let result: StatusCode = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (response,)) + .unwrap(); + + assert_eq!(result, StatusCode::OK); + } + + #[test] + fn test_header_trim_property_chain_mutation() { + let mut engine = create_engine_with_helpers(); + + // Register trim function + engine.register_fn("trim", |s: &str| -> String { s.trim().to_string() }); + + // Build request with header that has whitespace + let request = Arc::new(Mutex::new(Some( + SupergraphRequest::fake_builder() + .header("x-test", " value-with-spaces ") + .build() + .unwrap(), + ))); + + // This pattern should work: reading header, trimming it, and Rhai should + // propagate the trimmed value back via the setter + let script = r#" + fn test(req) { + // Get the header value and trim it + // With property value propagation, this should write back the trimmed value + req.headers["x-test"] = req.headers["x-test"].trim(); + + // Verify it was trimmed + req.headers["x-test"] + } + "#; + + let ast = engine.compile(script).unwrap(); + let result: String = engine + .call_fn(&mut rhai::Scope::new(), &ast, "test", (request.clone(),)) + .unwrap(); + + // Should be trimmed + assert_eq!(result, "value-with-spaces"); + + // Verify the header was actually mutated + let guard = request.lock(); + let req = guard.as_ref().unwrap(); + let header_value = req.supergraph_request.headers().get("x-test").unwrap(); + assert_eq!(header_value.to_str().unwrap(), "value-with-spaces"); + } +} diff --git a/apollo-router/src/plugins/rhai/engine/types.rs b/apollo-router/src/plugins/rhai/engine/types.rs new file mode 100644 index 0000000000..fc66b02902 --- /dev/null +++ b/apollo-router/src/plugins/rhai/engine/types.rs @@ -0,0 +1,100 @@ +use std::sync::Arc; + +use bytes::Bytes; +use parking_lot::Mutex; + +use crate::context::Context; +use crate::graphql::Response; +use crate::http_ext; + +/// Helper trait for safely working with Option-wrapped values in shared mutable state. +pub(crate) trait OptionDance { + fn with_mut(&self, f: impl FnOnce(&mut T) -> R) -> R; + + fn replace(&self, f: impl FnOnce(T) -> T); + + fn take_unwrap(self) -> T; +} + +/// Shared mutable state wrapped in Option for safe access from Rhai scripts. +pub(crate) type SharedMut = rhai::Shared>>; + +impl OptionDance for SharedMut { + fn with_mut(&self, f: impl FnOnce(&mut T) -> R) -> R { + let mut guard = self.lock(); + f(guard.as_mut().expect("re-entrant option dance")) + } + + fn replace(&self, f: impl FnOnce(T) -> T) { + let mut guard = self.lock(); + *guard = Some(f(guard.take().expect("re-entrant option dance"))) + } + + fn take_unwrap(self) -> T { + match Arc::try_unwrap(self) { + Ok(mutex) => mutex.into_inner(), + // TODO: Should we assume the Arc refcount is 1 + // and use `try_unwrap().expect("shared ownership")` instead of this fallback ? + Err(arc) => arc.lock().take(), + } + .expect("re-entrant option dance") + } +} + +/// Router stage first request wrapper for Rhai. +#[derive(Default)] +pub(crate) struct RhaiRouterFirstRequest { + pub(crate) context: Context, + pub(crate) request: http::Request<()>, +} + +/// Router stage chunked request wrapper for Rhai. +#[allow(dead_code)] +#[derive(Default)] +pub(crate) struct RhaiRouterChunkedRequest { + pub(crate) context: Context, + pub(crate) request: Bytes, +} + +/// Router stage response wrapper for Rhai. +#[derive(Default)] +pub(crate) struct RhaiRouterResponse { + pub(crate) context: Context, + pub(crate) response: http::Response<()>, +} + +/// Router stage chunked response wrapper for Rhai. +#[allow(dead_code)] +#[derive(Default)] +pub(crate) struct RhaiRouterChunkedResponse { + pub(crate) context: Context, + pub(crate) response: Bytes, +} + +/// Supergraph stage response wrapper for Rhai. +#[derive(Default)] +pub(crate) struct RhaiSupergraphResponse { + pub(crate) context: Context, + pub(crate) response: http_ext::Response, +} + +/// Supergraph stage deferred response wrapper for Rhai. +#[derive(Default)] +pub(crate) struct RhaiSupergraphDeferredResponse { + pub(crate) context: Context, + pub(crate) response: Response, +} + +/// Execution stage response wrapper for Rhai. +#[derive(Default)] +pub(crate) struct RhaiExecutionResponse { + pub(crate) context: Context, + pub(crate) response: http_ext::Response, +} + +/// Execution stage deferred response wrapper for Rhai. +#[derive(Default)] +pub(crate) struct RhaiExecutionDeferredResponse { + pub(crate) context: Context, + pub(crate) response: Response, +} diff --git a/apollo-router/src/plugins/rhai/tests.rs b/apollo-router/src/plugins/rhai/tests.rs index 7ae29baa93..3b848e7193 100644 --- a/apollo-router/src/plugins/rhai/tests.rs +++ b/apollo-router/src/plugins/rhai/tests.rs @@ -1049,3 +1049,121 @@ async fn test_subgraph_error_logging_with_body() -> Result<(), BoxError> { .with_subscriber(assert_snapshot_subscriber!()) .await } + +// Helper for calling property mutation test functions +async fn call_property_mutation_test( + fn_name: &str, + arg: impl Sync + Send + 'static, +) -> Result<(), Box> { + let dyn_plugin: Box = crate::plugin::plugins() + .find(|factory| factory.name == "apollo.rhai") + .expect("Plugin not found") + .create_instance_without_schema( + &Value::from_str( + r#"{"scripts":"tests/fixtures", "main":"test_property_mutations.rhai"}"#, + ) + .unwrap(), + ) + .await + .unwrap(); + + let it: &dyn std::any::Any = dyn_plugin.as_any(); + let rhai_instance: &Rhai = it.downcast_ref::().expect("downcast"); + + let scope = rhai_instance.scope.clone(); + let mut guard = scope.lock(); + + let wrapped_arg = Arc::new(Mutex::new(Some(arg))); + + rhai_instance + .engine + .call_fn(&mut guard, &rhai_instance.ast, fn_name, (wrapped_arg,)) +} + +#[tokio::test] +async fn test_supergraph_header_mutation() { + let request = SupergraphRequest::fake_builder().build().unwrap(); + call_property_mutation_test("test_supergraph_header_mutation", request) + .await + .expect("test failed"); +} + +#[tokio::test] +async fn test_supergraph_body_mutation() { + let request = SupergraphRequest::fake_builder().build().unwrap(); + call_property_mutation_test("test_supergraph_body_mutation", request) + .await + .expect("test failed"); +} + +#[tokio::test] +async fn test_execution_header_mutation() { + let request = ExecutionRequest::fake_builder().build(); + call_property_mutation_test("test_execution_header_mutation", request) + .await + .expect("test failed"); +} + +#[tokio::test] +async fn test_router_header_mutation() { + let request = RhaiRouterFirstRequest::default(); + call_property_mutation_test("test_router_header_mutation", request) + .await + .expect("test failed"); +} + +#[tokio::test] +async fn test_subgraph_read_only_headers() { + let request = SubgraphRequest::fake_builder().build(); + call_property_mutation_test("test_subgraph_read_only_headers", request) + .await + .expect("test failed"); +} + +#[tokio::test] +async fn test_subgraph_property_chain_with_split() { + let supergraph_req = http::Request::builder() + .header("cookie", "session=abc; user=john; theme=dark") + .body(graphql::Request::builder().query(String::new()).build()) + .unwrap(); + + let request = SubgraphRequest::fake_builder() + .supergraph_request(Arc::new(supergraph_req)) + .build(); + + call_property_mutation_test("test_subgraph_property_chain_with_split", request) + .await + .expect("test failed - property chains should work with read-only properties"); +} + +#[tokio::test] +async fn test_subgraph_property_chain_with_trim() { + let supergraph_req = http::Request::builder() + .header("auth", " token ") + .body(graphql::Request::builder().query(String::new()).build()) + .unwrap(); + + let request = SubgraphRequest::fake_builder() + .supergraph_request(Arc::new(supergraph_req)) + .build(); + + call_property_mutation_test("test_subgraph_property_chain_with_trim", request) + .await + .expect("test failed - property chains should work with read-only properties"); +} + +#[tokio::test] +async fn test_complex_property_chain() { + let supergraph_req = http::Request::builder() + .header("cookie", " session=abc ; user=john") + .body(graphql::Request::builder().query(String::new()).build()) + .unwrap(); + + let request = SubgraphRequest::fake_builder() + .supergraph_request(Arc::new(supergraph_req)) + .build(); + + call_property_mutation_test("test_complex_property_chain", request) + .await + .expect("test failed - complex property chains should work"); +} diff --git a/apollo-router/tests/fixtures/test_property_mutations.rhai b/apollo-router/tests/fixtures/test_property_mutations.rhai new file mode 100644 index 0000000000..0dc0f19a1f --- /dev/null +++ b/apollo-router/tests/fixtures/test_property_mutations.rhai @@ -0,0 +1,83 @@ +// Test that property mutations work correctly in contexts where they should + +fn test_supergraph_header_mutation(request) { + // Should be able to mutate headers in supergraph context + request.headers["x-test-mutation"] = "mutated"; + + if request.headers["x-test-mutation"] != "mutated" { + throw(`Failed to mutate header in supergraph context`); + } +} + +fn test_supergraph_body_mutation(request) { + // Should be able to mutate body in supergraph context + let new_body = request.body; + new_body.operation_name = "mutated_operation"; + request.body = new_body; + + if request.body.operation_name != "mutated_operation" { + throw(`Failed to mutate body in supergraph context`); + } +} + +fn test_execution_header_mutation(request) { + // Should be able to mutate headers in execution context + request.headers["x-execution-test"] = "execution"; + + if request.headers["x-execution-test"] != "execution" { + throw(`Failed to mutate header in execution context`); + } +} + +fn test_router_header_mutation(request) { + // Should be able to mutate headers in router context + request.headers["x-router-test"] = "router"; + + if request.headers["x-router-test"] != "router" { + throw(`Failed to mutate header in router context`); + } +} + +fn test_subgraph_read_only_headers(request) { + // Should be able to READ headers in subgraph context + let method = request.method; + if method == () { + throw(`Failed to read method in subgraph context`); + } + + // Should be able to access URI + let uri = request.uri; + if uri.path == () { + throw(`Failed to read URI in subgraph context`); + } + + // Should be able to access body + let body = request.body; + if body == () { + throw(`Failed to read body in subgraph context`); + } +} + +fn test_subgraph_property_chain_with_split(request) { + // Verify property chains work on read-only headers in subgraph context + let cookies = request.headers["cookie"].split(';'); + if cookies.len() != 3 { + throw(`Expected 3 cookies, got ${cookies.len()}`); + } +} + +fn test_subgraph_property_chain_with_trim(request) { + // Verify trim() works on read-only headers in subgraph context + let auth = request.headers["auth"]; + if auth.is_empty() { + throw(`Header not found or empty`); + } + let trimmed = auth.trim(); +} + +fn test_complex_property_chain(request) { + // Verify chained operations (split, index, trim) work on read-only headers in subgraph context + let cookies = request.headers["cookie"].split(';'); + let first = cookies[0]; + let first_cookie = first.trim(); +} diff --git a/examples/cookies-to-headers/rhai/src/main.rs b/examples/cookies-to-headers/rhai/src/main.rs index de52fe0418..c300be9083 100644 --- a/examples/cookies-to-headers/rhai/src/main.rs +++ b/examples/cookies-to-headers/rhai/src/main.rs @@ -81,7 +81,8 @@ mod tests { "rhai": { "scripts": "src", "main": "cookies_to_headers.rhai", - } + }, + "include_subgraph_errors": {"all": true} }); let test_harness = apollo_router::TestHarness::builder() .configuration_json(config)