From 43c755511da21af74626ddfbd5d61b8c51d9a4d0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 9 Mar 2026 18:08:30 +0530 Subject: [PATCH 1/8] Config store implementation --- .gitignore | 3 + Cargo.lock | 1 + .../edgezero-adapter-axum/src/config_store.rs | 107 +++++++++ .../edgezero-adapter-axum/src/dev_server.rs | 49 +++- crates/edgezero-adapter-axum/src/lib.rs | 4 + crates/edgezero-adapter-axum/src/service.rs | 92 ++++++- crates/edgezero-adapter-cloudflare/Cargo.toml | 3 +- .../src/config_store.rs | 68 ++++++ crates/edgezero-adapter-cloudflare/src/lib.rs | 28 ++- .../src/request.rs | 26 ++ .../src/config_store.rs | 31 +++ crates/edgezero-adapter-fastly/src/lib.rs | 35 ++- crates/edgezero-adapter-fastly/src/request.rs | 31 +++ crates/edgezero-core/src/config_store.rs | 224 ++++++++++++++++++ crates/edgezero-core/src/context.rs | 43 ++++ crates/edgezero-core/src/lib.rs | 1 + crates/edgezero-core/src/manifest.rs | 175 ++++++++++++++ examples/app-demo/Cargo.lock | 1 + .../app-demo-adapter-cloudflare/src/lib.rs | 8 +- .../app-demo-adapter-cloudflare/wrangler.toml | 5 + .../app-demo-adapter-fastly/fastly.toml | 10 + .../crates/app-demo-core/src/handlers.rs | 83 +++++++ examples/app-demo/edgezero.toml | 15 ++ scripts/smoke_test_config.sh | 142 +++++++++++ 24 files changed, 1167 insertions(+), 18 deletions(-) create mode 100644 crates/edgezero-adapter-axum/src/config_store.rs create mode 100644 crates/edgezero-adapter-cloudflare/src/config_store.rs create mode 100644 crates/edgezero-adapter-fastly/src/config_store.rs create mode 100644 crates/edgezero-core/src/config_store.rs create mode 100755 scripts/smoke_test_config.sh diff --git a/.gitignore b/.gitignore index 6aef111..8e13ddd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ pkg/ target/ .wrangler/ +# Node +node_modules/ + # env .env diff --git a/Cargo.lock b/Cargo.lock index d96761a..bb7a34c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -702,6 +702,7 @@ dependencies = [ "futures", "futures-util", "log", + "serde_json", "walkdir", "wasm-bindgen-test", "web-sys", diff --git a/crates/edgezero-adapter-axum/src/config_store.rs b/crates/edgezero-adapter-axum/src/config_store.rs new file mode 100644 index 0000000..fd94af5 --- /dev/null +++ b/crates/edgezero-adapter-axum/src/config_store.rs @@ -0,0 +1,107 @@ +//! Axum adapter config store: env vars with in-memory defaults fallback. + +use std::collections::HashMap; + +use edgezero_core::config_store::ConfigStore; + +/// Config store for local dev / Axum. Reads from env vars with manifest +/// defaults as fallback. Env vars take precedence over defaults. +/// +/// # Note on `from_env` +/// +/// [`AxumConfigStore::from_env`] snapshots the **entire** process environment +/// at construction time. Any env var name is therefore accessible via +/// `ctx.config_store()?.get("VAR_NAME")`. In practice, manifest config keys +/// use lowercase dotted names (e.g. `feature.new_checkout`) which do not +/// collide with typical uppercase process vars (`PATH`, `HOME`, etc.), so +/// accidental leakage is unlikely. For production deployments use Fastly or +/// Cloudflare adapters, which read only from their respective platform stores. +pub struct AxumConfigStore { + env: HashMap, + defaults: HashMap, +} + +impl AxumConfigStore { + /// Create from env vars and optional manifest defaults. + pub fn new( + env: impl IntoIterator, + defaults: impl IntoIterator, + ) -> Self { + Self { + env: env.into_iter().collect(), + defaults: defaults.into_iter().collect(), + } + } + + /// Create from the current process environment and manifest defaults. + pub fn from_env(defaults: impl IntoIterator) -> Self { + Self::new(std::env::vars(), defaults) + } +} + +impl ConfigStore for AxumConfigStore { + fn get(&self, key: &str) -> Option { + self.env + .get(key) + .or_else(|| self.defaults.get(key)) + .cloned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store(env: &[(&str, &str)], defaults: &[(&str, &str)]) -> AxumConfigStore { + AxumConfigStore::new( + env.iter().map(|(k, v)| (k.to_string(), v.to_string())), + defaults.iter().map(|(k, v)| (k.to_string(), v.to_string())), + ) + } + + #[test] + fn axum_config_store_returns_values() { + let s = store(&[("MY_KEY", "my_val")], &[]); + assert_eq!(s.get("MY_KEY"), Some("my_val".to_string())); + } + + #[test] + fn axum_config_store_returns_none_for_missing() { + let s = store(&[], &[]); + assert_eq!(s.get("NOPE"), None); + } + + #[test] + fn axum_config_store_env_overrides_defaults() { + let s = store(&[("KEY", "from_env")], &[("KEY", "from_default")]); + assert_eq!(s.get("KEY"), Some("from_env".to_string())); + } + + #[test] + fn axum_config_store_falls_back_to_defaults() { + let s = store(&[], &[("KEY", "default_val")]); + assert_eq!(s.get("KEY"), Some("default_val".to_string())); + } + + // Run the shared contract tests against AxumConfigStore (env path). + edgezero_core::config_store_contract_tests!(axum_config_store_env_contract, { + AxumConfigStore::new( + [ + ("contract.key.a".to_string(), "value_a".to_string()), + ("contract.key.b".to_string(), "value_b".to_string()), + ], + [], + ) + }); + + // Run the shared contract tests against AxumConfigStore (defaults path). + edgezero_core::config_store_contract_tests!(axum_config_store_defaults_contract, { + AxumConfigStore::new( + [], + [ + ("contract.key.a".to_string(), "value_a".to_string()), + ("contract.key.b".to_string(), "value_b".to_string()), + ], + ) + }); +} diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 1d611f8..6ef7f56 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -7,11 +7,13 @@ use tokio::signal; use tower::{service_fn, Service}; use edgezero_core::app::Hooks; +use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::manifest::ManifestLoader; use edgezero_core::router::RouterService; use log::LevelFilter; use simple_logger::SimpleLogger; +use crate::config_store::AxumConfigStore; use crate::service::EdgeZeroAxumService; /// Configuration used when running the dev server embedding EdgeZero into Axum. @@ -34,6 +36,7 @@ impl Default for AxumDevServerConfig { pub struct AxumDevServer { router: RouterService, config: AxumDevServerConfig, + config_store_handle: Option, } impl AxumDevServer { @@ -41,11 +44,22 @@ impl AxumDevServer { Self { router, config: AxumDevServerConfig::default(), + config_store_handle: None, } } pub fn with_config(router: RouterService, config: AxumDevServerConfig) -> Self { - Self { router, config } + Self { + router, + config, + config_store_handle: None, + } + } + + #[must_use] + pub fn with_config_store(mut self, handle: ConfigStoreHandle) -> Self { + self.config_store_handle = Some(handle); + self } pub fn run(self) -> anyhow::Result<()> { @@ -58,7 +72,11 @@ impl AxumDevServer { } async fn run_async(self) -> anyhow::Result<()> { - let AxumDevServer { router, config } = self; + let AxumDevServer { + router, + config, + config_store_handle, + } = self; // Allow binding to already-open listener if caller created one to surface errors early. let listener = StdTcpListener::bind(config.addr) @@ -70,13 +88,17 @@ impl AxumDevServer { let listener = tokio::net::TcpListener::from_std(listener) .context("failed to adopt std listener into tokio")?; - serve_with_listener(router, listener, config.enable_ctrl_c).await + serve_with_listener(router, listener, config.enable_ctrl_c, config_store_handle).await } #[cfg(test)] async fn run_with_listener(self, listener: tokio::net::TcpListener) -> anyhow::Result<()> { - let AxumDevServer { router, config } = self; - serve_with_listener(router, listener, config.enable_ctrl_c).await + let AxumDevServer { + router, + config, + config_store_handle, + } = self; + serve_with_listener(router, listener, config.enable_ctrl_c, config_store_handle).await } } @@ -84,8 +106,12 @@ async fn serve_with_listener( router: RouterService, listener: tokio::net::TcpListener, enable_ctrl_c: bool, + config_store_handle: Option, ) -> anyhow::Result<()> { - let service = EdgeZeroAxumService::new(router); + let mut service = EdgeZeroAxumService::new(router); + if let Some(handle) = config_store_handle { + service = service.with_config_store_handle(handle); + } let router = Router::new().fallback_service(service_fn(move |req| { let mut svc = service.clone(); async move { svc.call(req).await } @@ -113,7 +139,8 @@ async fn serve_with_listener( pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { let manifest = ManifestLoader::load_from_str(manifest_src); - let logging = manifest.manifest().logging_or_default("axum"); + let m = manifest.manifest(); + let logging = m.logging_or_default("axum"); let level: LevelFilter = logging.level.into(); let level = if logging.echo_stdout.unwrap_or(true) { @@ -127,7 +154,13 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { let app = A::build_app(); let router = app.router().clone(); - AxumDevServer::new(router).run() + let mut server = AxumDevServer::new(router); + if let Some(cfg) = m.stores.config.as_ref() { + let defaults = cfg.config_store_defaults().clone(); + let store = AxumConfigStore::from_env(defaults); + server = server.with_config_store(ConfigStoreHandle::new(std::sync::Arc::new(store))); + } + server.run() } #[cfg(test)] diff --git a/crates/edgezero-adapter-axum/src/lib.rs b/crates/edgezero-adapter-axum/src/lib.rs index 0be160d..b63c953 100644 --- a/crates/edgezero-adapter-axum/src/lib.rs +++ b/crates/edgezero-adapter-axum/src/lib.rs @@ -1,5 +1,7 @@ //! Axum adapter for EdgeZero routers and applications. +#[cfg(feature = "axum")] +pub mod config_store; #[cfg(feature = "axum")] mod context; #[cfg(feature = "axum")] @@ -16,6 +18,8 @@ mod service; #[cfg(feature = "cli")] pub mod cli; +#[cfg(feature = "axum")] +pub use config_store::AxumConfigStore; #[cfg(feature = "axum")] pub use context::AxumRequestContext; #[cfg(feature = "axum")] diff --git a/crates/edgezero-adapter-axum/src/service.rs b/crates/edgezero-adapter-axum/src/service.rs index 9c04bfe..a084814 100644 --- a/crates/edgezero-adapter-axum/src/service.rs +++ b/crates/edgezero-adapter-axum/src/service.rs @@ -10,6 +10,7 @@ use http::StatusCode; use tokio::{runtime::Handle, task}; use tower::Service; +use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::router::RouterService; use crate::request::into_core_request; @@ -19,11 +20,25 @@ use crate::response::into_axum_response; #[derive(Clone)] pub struct EdgeZeroAxumService { router: RouterService, + config_store_handle: Option, } impl EdgeZeroAxumService { pub fn new(router: RouterService) -> Self { - Self { router } + Self { + router, + config_store_handle: None, + } + } + + /// Attach a shared config store to this service. + /// + /// The handle is cloned into every request's extensions, making + /// `ctx.config_store()` available in handlers. + #[must_use] + pub fn with_config_store_handle(mut self, handle: ConfigStoreHandle) -> Self { + self.config_store_handle = Some(handle); + self } } @@ -38,8 +53,9 @@ impl Service> for EdgeZeroAxumService { fn call(&mut self, request: Request) -> Self::Future { let router = self.router.clone(); + let config_store_handle = self.config_store_handle.clone(); Box::pin(async move { - let core_request = match into_core_request(request).await { + let mut core_request = match into_core_request(request).await { Ok(req) => req, Err(e) => { let mut err_response = Response::new(Body::from(e.to_string())); @@ -48,6 +64,11 @@ impl Service> for EdgeZeroAxumService { return Ok(err_response); } }; + + if let Some(handle) = config_store_handle { + core_request.extensions_mut().insert(handle); + } + let core_response = task::block_in_place(move || { Handle::current().block_on(router.oneshot(core_request)) }); @@ -61,11 +82,21 @@ impl Service> for EdgeZeroAxumService { mod tests { use super::*; use edgezero_core::body::Body; + use edgezero_core::config_store::{ConfigStore, ConfigStoreHandle}; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{response_builder, StatusCode}; + use std::sync::Arc; use tower::ServiceExt; + struct FixedConfigStore(String); + + impl ConfigStore for FixedConfigStore { + fn get(&self, _key: &str) -> Option { + Some(self.0.clone()) + } + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn forwards_request_to_router() { let router = RouterService::builder() @@ -83,4 +114,61 @@ mod tests { let response = service.ready().await.unwrap().call(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn with_config_store_handle_injects_into_request() { + let handle = ConfigStoreHandle::new(Arc::new(FixedConfigStore("injected".to_string()))); + + let router = RouterService::builder() + .get("/check", |ctx: RequestContext| async move { + let store = ctx.config_store().expect("config store should be present"); + let val = store.get("any_key").unwrap_or_default(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from(val)) + .expect("response"); + Ok::<_, EdgeError>(response) + }) + .build(); + let mut service = EdgeZeroAxumService::new(router).with_config_store_handle(handle); + + let request = Request::builder() + .uri("/check") + .body(AxumBody::empty()) + .unwrap(); + let response = service.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(&body[..], b"injected"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn service_without_config_store_handle_still_works() { + let router = RouterService::builder() + .get("/no-config", |ctx: RequestContext| async move { + let has_config = ctx.config_store().is_some(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from(format!("has_config={has_config}"))) + .expect("response"); + Ok::<_, EdgeError>(response) + }) + .build(); + let mut service = EdgeZeroAxumService::new(router); + + let request = Request::builder() + .uri("/no-config") + .body(AxumBody::empty()) + .unwrap(); + let response = service.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(&body[..], b"has_config=false"); + } } diff --git a/crates/edgezero-adapter-cloudflare/Cargo.toml b/crates/edgezero-adapter-cloudflare/Cargo.toml index 2be81bd..f352bfa 100644 --- a/crates/edgezero-adapter-cloudflare/Cargo.toml +++ b/crates/edgezero-adapter-cloudflare/Cargo.toml @@ -7,7 +7,7 @@ license = { workspace = true } [features] default = [] -cloudflare = ["dep:worker"] +cloudflare = ["dep:worker", "dep:serde_json"] cli = ["dep:edgezero-adapter", "edgezero-adapter/cli", "dep:ctor", "dep:walkdir"] [dependencies] @@ -21,6 +21,7 @@ futures = { workspace = true } futures-util = { workspace = true } log = { workspace = true } ctor = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } worker = { version = "0.7", default-features = false, features = ["http"], optional = true } walkdir = { workspace = true, optional = true } wasm-bindgen-test = "0.3" diff --git a/crates/edgezero-adapter-cloudflare/src/config_store.rs b/crates/edgezero-adapter-cloudflare/src/config_store.rs new file mode 100644 index 0000000..ce1ffa6 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/config_store.rs @@ -0,0 +1,68 @@ +//! Cloudflare Workers adapter config store: reads a single JSON env var. +//! +//! Config is stored as one Cloudflare string binding (set in `wrangler.toml [vars]`) +//! whose value is a JSON object, e.g.: +//! +//! ```toml +//! [vars] +//! app_config = '{"greeting":"hello","feature.new_checkout":"false"}' +//! ``` +//! +//! This allows arbitrary string keys (including dots) on a platform whose binding +//! names are restricted to JavaScript identifier syntax. + +use std::collections::HashMap; + +use edgezero_core::config_store::ConfigStore; +use worker::Env; + +/// Config store backed by a single Cloudflare JSON string binding. +/// +/// At construction time the binding value is parsed into a `HashMap`. +/// Reads are then O(1) map lookups with no further JS interop. +pub struct CloudflareConfigStore { + data: HashMap, +} + +impl CloudflareConfigStore { + /// Build a store by reading and parsing the JSON binding named `binding_name`. + /// + /// Returns an empty store (graceful fallback) if the binding is absent or + /// the value is not valid JSON. + pub fn new(env: &Env, binding_name: &str) -> Self { + let raw = env.var(binding_name).ok(); + if raw.is_none() { + log::info!( + "config store binding '{}' is not set in wrangler.toml [vars]; proceeding without config", + binding_name + ); + } + let data = raw + .and_then(|v| { + let s = v.to_string(); + serde_json::from_str(&s) + .map_err(|e| { + log::warn!( + "config store binding '{}' is not valid JSON: {}; proceeding without config", + binding_name, + e + ); + e + }) + .ok() + }) + .unwrap_or_default(); + Self { data } + } +} + +impl ConfigStore for CloudflareConfigStore { + fn get(&self, key: &str) -> Option { + self.data.get(key).cloned() + } +} + +// Contract tests cannot run natively: `worker::Env` is only available inside +// the Cloudflare Workers runtime and has no testable mock. Platform-level +// contract coverage is provided by the smoke test +// (`scripts/smoke_test_config.sh cloudflare`) against a live wrangler dev instance. diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index 0c4dcba..fa4bb4f 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -3,6 +3,8 @@ #[cfg(feature = "cli")] pub mod cli; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +pub mod config_store; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] mod context; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] @@ -12,12 +14,14 @@ mod request; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] mod response; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +pub use config_store::CloudflareConfigStore; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub use context::CloudflareRequestContext; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub use proxy::CloudflareProxyClient; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -pub use request::{dispatch, into_core_request}; +pub use request::{dispatch, dispatch_with_config, into_core_request}; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub use response::from_core_response; @@ -67,3 +71,25 @@ pub async fn run_app( let app = A::build_app(); dispatch(&app, req, env, ctx).await } + +/// Run the app resolving the config store binding name from `manifest_src`. +/// +/// If `[stores.config]` is present in the manifest, injects a +/// [`CloudflareConfigStore`] built from the Cloudflare env before dispatching. +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +pub async fn run_app_with_manifest( + manifest_src: &str, + req: worker::Request, + env: worker::Env, + ctx: worker::Context, +) -> Result { + init_logger().expect("init cloudflare logger"); + let manifest = edgezero_core::manifest::ManifestLoader::load_from_str(manifest_src); + let app = A::build_app(); + if let Some(cfg) = manifest.manifest().stores.config.as_ref() { + let binding_name = cfg.config_store_name("cloudflare").to_string(); + dispatch_with_config(&app, req, env, ctx, &binding_name).await + } else { + dispatch(&app, req, env, ctx).await + } +} diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index bd30427..6620638 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -1,8 +1,12 @@ +use std::sync::Arc; + +use crate::config_store::CloudflareConfigStore; use crate::proxy::CloudflareProxyClient; use crate::response::from_core_response; use crate::CloudflareRequestContext; use edgezero_core::app::App; use edgezero_core::body::Body; +use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::error::EdgeError; use edgezero_core::http::{request_builder, Method as CoreMethod, Request, Uri}; use edgezero_core::proxy::ProxyHandle; @@ -59,6 +63,28 @@ pub async fn dispatch( from_core_response(response).map_err(edge_error_to_worker) } +/// Dispatch a request with a Cloudflare JSON config store injected. +/// +/// Reads `binding_name` from `env` (a `[vars]` string whose value is a JSON object), +/// parses it into a `CloudflareConfigStore`, then injects the handle before dispatch. +pub async fn dispatch_with_config( + app: &App, + req: CfRequest, + env: Env, + ctx: Context, + binding_name: &str, +) -> Result { + let config_handle = + ConfigStoreHandle::new(Arc::new(CloudflareConfigStore::new(&env, binding_name))); + let mut core_request = into_core_request(req, env, ctx) + .await + .map_err(edge_error_to_worker)?; + core_request.extensions_mut().insert(config_handle); + let svc = app.router().clone(); + let response = svc.oneshot(core_request).await; + from_core_response(response).map_err(edge_error_to_worker) +} + fn edge_error_to_worker(err: EdgeError) -> WorkerError { WorkerError::RustError(err.to_string()) } diff --git a/crates/edgezero-adapter-fastly/src/config_store.rs b/crates/edgezero-adapter-fastly/src/config_store.rs new file mode 100644 index 0000000..f3ad2a7 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/config_store.rs @@ -0,0 +1,31 @@ +//! Fastly adapter config store: wraps `fastly::ConfigStore`. + +use edgezero_core::config_store::ConfigStore; + +/// Config store backed by a Fastly Config Store resource link. +pub struct FastlyConfigStore { + inner: fastly::ConfigStore, +} + +impl FastlyConfigStore { + /// Open a Fastly Config Store by resource link name. + /// + /// Returns `None` if the store is not available (e.g. not configured in + /// `fastly.toml`), allowing graceful fallback without panicking. + pub fn try_open(name: &str) -> Option { + fastly::ConfigStore::try_open(name) + .ok() + .map(|inner| Self { inner }) + } +} + +impl ConfigStore for FastlyConfigStore { + fn get(&self, key: &str) -> Option { + self.inner.try_get(key).ok().flatten() + } +} + +// Contract tests cannot run natively: `fastly::ConfigStore::try_open` requires +// the Viceroy runtime. Platform-level contract coverage is provided by the +// smoke test (`scripts/smoke_test_config.sh fastly`) which exercises the same +// keys against a live Viceroy instance. diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index 5603831..a329c91 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -3,6 +3,8 @@ #[cfg(feature = "cli")] pub mod cli; +#[cfg(feature = "fastly")] +pub mod config_store; mod context; #[cfg(feature = "fastly")] mod logger; @@ -13,11 +15,13 @@ mod request; #[cfg(feature = "fastly")] mod response; +#[cfg(feature = "fastly")] +pub use config_store::FastlyConfigStore; pub use context::FastlyRequestContext; #[cfg(feature = "fastly")] pub use proxy::FastlyProxyClient; #[cfg(feature = "fastly")] -pub use request::{dispatch, into_core_request}; +pub use request::{dispatch, dispatch_with_config, into_core_request}; #[cfg(feature = "fastly")] pub use response::from_core_response; @@ -78,14 +82,22 @@ pub fn run_app( req: fastly::Request, ) -> Result { let manifest_loader = edgezero_core::manifest::ManifestLoader::load_from_str(manifest_src); - let logging = manifest_loader.manifest().logging_or_default("fastly"); - run_app_with_logging::(logging.into(), req) + let m = manifest_loader.manifest(); + let logging = m.logging_or_default("fastly"); + let config_name = m + .stores + .config + .as_ref() + .map(|cfg| cfg.config_store_name("fastly").to_string()); + run_app_with_config::(logging.into(), req, config_name.as_deref()) } +/// Dispatch with a config store. Prefer this over `run_app_with_logging` for new code. #[cfg(feature = "fastly")] -pub fn run_app_with_logging( +pub fn run_app_with_config( logging: FastlyLogging, req: fastly::Request, + config_store_name: Option<&str>, ) -> Result { if logging.use_fastly_logger { let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); @@ -93,7 +105,20 @@ pub fn run_app_with_logging( } let app = A::build_app(); - dispatch(&app, req) + if let Some(name) = config_store_name { + dispatch_with_config(&app, req, name) + } else { + dispatch(&app, req) + } +} + +/// Compatibility wrapper for callers that do not use a config store. +#[cfg(feature = "fastly")] +pub fn run_app_with_logging( + logging: FastlyLogging, + req: fastly::Request, +) -> Result { + run_app_with_config::(logging, req, None) } #[cfg(all(test, feature = "fastly"))] diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index 8a2cdb5..36023d4 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -1,13 +1,16 @@ use std::io::Read; +use std::sync::Arc; use edgezero_core::app::App; use edgezero_core::body::Body; +use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::error::EdgeError; use edgezero_core::http::{request_builder, Request}; use edgezero_core::proxy::ProxyHandle; use fastly::{Error as FastlyError, Request as FastlyRequest, Response as FastlyResponse}; use futures::executor; +use crate::config_store::FastlyConfigStore; use crate::proxy::FastlyProxyClient; use crate::response::{from_core_response, parse_uri}; use crate::FastlyRequestContext; @@ -46,6 +49,34 @@ pub fn dispatch(app: &App, req: FastlyRequest) -> Result Result { + let mut core_request = into_core_request(req).map_err(map_edge_error)?; + + match FastlyConfigStore::try_open(store_name) { + Some(store) => { + core_request + .extensions_mut() + .insert(ConfigStoreHandle::new(Arc::new(store))); + } + None => { + log::info!( + "config store '{}' is not available; proceeding without it", + store_name + ); + } + } + + let response = executor::block_on(app.router().oneshot(core_request)); + from_core_response(response).map_err(map_edge_error) +} + fn map_edge_error(err: EdgeError) -> FastlyError { FastlyError::msg(err.to_string()) } diff --git a/crates/edgezero-core/src/config_store.rs b/crates/edgezero-core/src/config_store.rs new file mode 100644 index 0000000..07471d1 --- /dev/null +++ b/crates/edgezero-core/src/config_store.rs @@ -0,0 +1,224 @@ +//! Provider-neutral read-only configuration store abstraction. +//! +//! All platforms expose config reads as synchronous operations, so no +//! `async_trait` is needed here. + +use std::fmt; +use std::sync::Arc; + +// --------------------------------------------------------------------------- +// Trait +// --------------------------------------------------------------------------- + +/// Object-safe interface for read-only configuration store backends. +/// +/// Implementations exist per adapter: +/// - `AxumConfigStore` (axum adapter) — env vars + in-memory defaults for dev +/// - `FastlyConfigStore` (fastly adapter) — Fastly Config Store +/// - `CloudflareConfigStore` (cloudflare adapter) — Cloudflare env bindings +pub trait ConfigStore: Send + Sync { + /// Retrieve a config value by key. Returns `None` if the key does not exist. + fn get(&self, key: &str) -> Option; +} + +// --------------------------------------------------------------------------- +// Handle +// --------------------------------------------------------------------------- + +/// A cloneable handle to a config store. +#[derive(Clone)] +pub struct ConfigStoreHandle { + store: Arc, +} + +impl fmt::Debug for ConfigStoreHandle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ConfigStoreHandle").finish_non_exhaustive() + } +} + +impl ConfigStoreHandle { + /// Create a new handle wrapping a config store implementation. + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Get a config value by key. + pub fn get(&self, key: &str) -> Option { + self.store.get(key) + } +} + +// --------------------------------------------------------------------------- +// Contract test macro +// --------------------------------------------------------------------------- + +/// Generate a suite of contract tests for any [`ConfigStore`] implementation. +/// +/// The macro takes the module name and a factory expression that produces a +/// store **pre-seeded** with the following well-known contract keys: +/// +/// | Key | Value | +/// |-----------------------|-------------| +/// | `"contract.key.a"` | `"value_a"` | +/// | `"contract.key.b"` | `"value_b"` | +/// +/// # Example +/// +/// ```rust,ignore +/// edgezero_core::config_store_contract_tests!(axum_config_store_contract, { +/// AxumConfigStore::new( +/// [ +/// ("contract.key.a".to_string(), "value_a".to_string()), +/// ("contract.key.b".to_string(), "value_b".to_string()), +/// ], +/// [], +/// ) +/// }); +/// ``` +#[macro_export] +macro_rules! config_store_contract_tests { + ($mod_name:ident, $factory:expr) => { + mod $mod_name { + use super::*; + use $crate::config_store::ConfigStore; + + #[test] + fn contract_get_returns_value_for_existing_key() { + let store = $factory; + assert_eq!(store.get("contract.key.a"), Some("value_a".to_string())); + } + + #[test] + fn contract_get_returns_none_for_missing_key() { + let store = $factory; + assert_eq!(store.get("contract.key.missing"), None); + } + + #[test] + fn contract_multiple_keys_are_independent() { + let store = $factory; + assert_eq!(store.get("contract.key.a"), Some("value_a".to_string())); + assert_eq!(store.get("contract.key.b"), Some("value_b".to_string())); + } + + #[test] + fn contract_key_lookup_is_case_sensitive() { + let store = $factory; + // lowercase "contract.key.a" exists; uppercase must not match + assert_eq!(store.get("CONTRACT.KEY.A"), None); + } + + #[test] + fn contract_empty_key_returns_none() { + let store = $factory; + assert_eq!(store.get(""), None); + } + + #[test] + fn contract_handle_wraps_store() { + use std::sync::Arc; + use $crate::config_store::ConfigStoreHandle; + + let handle = ConfigStoreHandle::new(Arc::new($factory)); + assert_eq!(handle.get("contract.key.a"), Some("value_a".to_string())); + assert_eq!(handle.get("contract.key.missing"), None); + } + + #[test] + fn contract_cloned_handle_delegates_consistently() { + use std::sync::Arc; + use $crate::config_store::ConfigStoreHandle; + + let h1 = ConfigStoreHandle::new(Arc::new($factory)); + let h2 = h1.clone(); + assert_eq!(h1.get("contract.key.a"), h2.get("contract.key.a")); + assert_eq!( + h1.get("contract.key.missing"), + h2.get("contract.key.missing") + ); + } + } + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + struct TestConfigStore { + data: HashMap, + } + + impl TestConfigStore { + fn new(entries: &[(&str, &str)]) -> Self { + Self { + data: entries + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + } + } + } + + impl ConfigStore for TestConfigStore { + fn get(&self, key: &str) -> Option { + self.data.get(key).cloned() + } + } + + fn handle(entries: &[(&str, &str)]) -> ConfigStoreHandle { + ConfigStoreHandle::new(Arc::new(TestConfigStore::new(entries))) + } + + #[test] + fn config_store_get_returns_value_for_existing_key() { + let h = handle(&[("feature.checkout", "true")]); + assert_eq!(h.get("feature.checkout"), Some("true".to_string())); + } + + #[test] + fn config_store_get_returns_none_for_missing_key() { + let h = handle(&[]); + assert_eq!(h.get("nonexistent"), None); + } + + #[test] + fn config_store_handle_wraps_and_delegates() { + let h = handle(&[("timeout_ms", "1500")]); + assert_eq!(h.get("timeout_ms"), Some("1500".to_string())); + assert_eq!(h.get("missing"), None); + } + + #[test] + fn config_store_handle_is_cloneable() { + let h1 = handle(&[("key", "val")]); + let h2 = h1.clone(); + assert_eq!(h1.get("key"), h2.get("key")); + } + + #[test] + fn config_store_handle_new_accepts_arc() { + let store = Arc::new(TestConfigStore::new(&[("a", "1")])); + let h = ConfigStoreHandle::new(store); + assert_eq!(h.get("a"), Some("1".to_string())); + } + + #[test] + fn config_store_handle_debug_output() { + let h = handle(&[]); + let debug = format!("{:?}", h); + assert!(debug.contains("ConfigStoreHandle")); + } + + // Run the shared contract tests against TestConfigStore. + crate::config_store_contract_tests!( + test_config_store_contract, + TestConfigStore::new(&[("contract.key.a", "value_a"), ("contract.key.b", "value_b"),]) + ); +} diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index 4038c33..799858e 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -1,4 +1,5 @@ use crate::body::Body; +use crate::config_store::ConfigStoreHandle; use crate::error::EdgeError; use crate::http::Request; use crate::params::PathParams; @@ -83,6 +84,13 @@ impl RequestContext { pub fn proxy_handle(&self) -> Option { self.request.extensions().get::().cloned() } + + pub fn config_store(&self) -> Option { + self.request + .extensions() + .get::() + .cloned() + } } #[cfg(test)] @@ -321,4 +329,39 @@ mod tests { let response = futures::executor::block_on(handle.forward(request)).expect("response"); assert_eq!(response.status(), StatusCode::OK); } + + #[test] + fn config_store_is_retrieved_when_present() { + use crate::config_store::{ConfigStore, ConfigStoreHandle}; + use std::sync::Arc; + + struct FixedStore; + impl ConfigStore for FixedStore { + fn get(&self, _key: &str) -> Option { + Some("value".to_string()) + } + } + + let mut request = request_builder() + .method(Method::GET) + .uri("/config") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(ConfigStoreHandle::new(Arc::new(FixedStore))); + + let ctx = RequestContext::new(request, PathParams::default()); + assert!(ctx.config_store().is_some()); + assert_eq!( + ctx.config_store().unwrap().get("any"), + Some("value".to_string()) + ); + } + + #[test] + fn config_store_returns_none_when_absent() { + let ctx = ctx("/test", Body::empty(), PathParams::default()); + assert!(ctx.config_store().is_none()); + } } diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index 2af1b9c..baadd19 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -3,6 +3,7 @@ pub mod app; pub mod body; pub mod compression; +pub mod config_store; pub mod context; pub mod error; pub mod extractor; diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 6f4d464..5205f08 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -53,6 +53,8 @@ fn resolve_root_path(path: &Path, cwd: &Path) -> PathBuf { } } +pub const DEFAULT_CONFIG_STORE_NAME: &str = "EDGEZERO_CONFIG"; + #[derive(Debug, Deserialize, Validate)] pub struct Manifest { #[serde(default)] @@ -70,6 +72,9 @@ pub struct Manifest { #[serde(default)] #[validate(nested)] pub logging: ManifestLogging, + #[serde(default)] + #[validate(nested)] + pub stores: ManifestStores, #[serde(skip)] pub(crate) root: Option, #[serde(skip)] @@ -306,6 +311,65 @@ pub struct ManifestAdapterCommands { pub deploy: Option, } +// --------------------------------------------------------------------------- +// Stores +// --------------------------------------------------------------------------- + +/// Top-level `[stores]` section. Compatible with the KV branch's `kv` sibling. +#[derive(Debug, Default, Deserialize, Validate)] +pub struct ManifestStores { + #[validate(nested)] + pub config: Option, +} + +/// `[stores.config]` section — provider-neutral config store. +#[derive(Debug, Deserialize, Validate)] +pub struct ManifestConfigStoreConfig { + /// Global store/binding name used when no adapter-specific override is set. + #[serde(default)] + #[validate(length(min = 1))] + pub name: Option, + /// Per-adapter name overrides, keyed by lowercase adapter name. + #[serde(default)] + #[validate(nested)] + pub adapters: BTreeMap, + /// Optional default values used for local dev (Axum adapter). + #[serde(default)] + pub defaults: BTreeMap, +} + +/// `[stores.config.adapters.]` override. +#[derive(Debug, Deserialize, Validate)] +pub struct ManifestConfigAdapterConfig { + #[validate(length(min = 1))] + pub name: String, +} + +impl ManifestConfigStoreConfig { + /// Resolve the config store name for a given adapter. + /// + /// Priority: adapter override → global name → `DEFAULT_CONFIG_STORE_NAME`. + pub fn config_store_name(&self, adapter: &str) -> &str { + let adapter_lower = adapter.to_ascii_lowercase(); + if let Some(override_cfg) = self.adapters.get(&adapter_lower) { + return &override_cfg.name; + } + if let Some(name) = &self.name { + return name.as_str(); + } + DEFAULT_CONFIG_STORE_NAME + } + + /// Access the default key-value pairs for local dev. + pub fn config_store_defaults(&self) -> &BTreeMap { + &self.defaults + } +} + +// --------------------------------------------------------------------------- +// Logging (unchanged) +// --------------------------------------------------------------------------- + #[derive(Debug, Default, Deserialize, Validate)] pub struct ManifestLogging { #[serde(flatten)] @@ -1075,6 +1139,117 @@ manifest = "fastly.toml" assert_eq!(HttpMethod::Head.as_str(), "HEAD"); } + // Config store tests + #[test] + fn config_store_name_falls_back_to_default_constant() { + // [stores.config] present but no name and no adapter overrides: + // config_store_name() must return DEFAULT_CONFIG_STORE_NAME. + let toml = "[stores.config]\n"; + let m = ManifestLoader::load_from_str(toml); + let config = m.manifest().stores.config.as_ref().unwrap(); + assert_eq!( + config.config_store_name("fastly"), + DEFAULT_CONFIG_STORE_NAME + ); + assert_eq!( + config.config_store_name("cloudflare"), + DEFAULT_CONFIG_STORE_NAME + ); + assert_eq!(config.config_store_name("axum"), DEFAULT_CONFIG_STORE_NAME); + } + + #[test] + fn config_store_name_defaults_when_omitted() { + // No [stores.config] section at all: callers skip the config store entirely. + let manifest = ManifestLoader::load_from_str(""); + assert!(manifest.manifest().stores.config.is_none()); + } + + #[test] + fn config_store_name_uses_global_name() { + let toml = r#" +[stores.config] +name = "app_config" +"#; + let m = ManifestLoader::load_from_str(toml); + let config = m.manifest().stores.config.as_ref().unwrap(); + assert_eq!(config.config_store_name("fastly"), "app_config"); + assert_eq!(config.config_store_name("cloudflare"), "app_config"); + assert_eq!(config.config_store_name("axum"), "app_config"); + } + + #[test] + fn config_store_name_adapter_override() { + let toml = r#" +[stores.config] +name = "global_config" + +[stores.config.adapters.fastly] +name = "my-config-link" + +[stores.config.adapters.cloudflare] +name = "APP_CONFIG_BINDING" +"#; + let m = ManifestLoader::load_from_str(toml); + let config = m.manifest().stores.config.as_ref().unwrap(); + assert_eq!(config.config_store_name("fastly"), "my-config-link"); + assert_eq!(config.config_store_name("cloudflare"), "APP_CONFIG_BINDING"); + assert_eq!(config.config_store_name("axum"), "global_config"); + } + + #[test] + fn config_store_name_case_insensitive() { + let toml = r#" +[stores.config.adapters.fastly] +name = "fastly-store" +"#; + let m = ManifestLoader::load_from_str(toml); + let config = m.manifest().stores.config.as_ref().unwrap(); + assert_eq!(config.config_store_name("FASTLY"), "fastly-store"); + assert_eq!(config.config_store_name("Fastly"), "fastly-store"); + assert_eq!(config.config_store_name("fastly"), "fastly-store"); + } + + #[test] + fn config_store_defaults_accessible() { + let toml = r#" +[stores.config.defaults] +"feature.checkout" = "true" +"service.timeout_ms" = "1500" +"#; + let m = ManifestLoader::load_from_str(toml); + let config = m.manifest().stores.config.as_ref().unwrap(); + let defaults = config.config_store_defaults(); + assert_eq!( + defaults.get("feature.checkout").map(|s| s.as_str()), + Some("true") + ); + assert_eq!( + defaults.get("service.timeout_ms").map(|s| s.as_str()), + Some("1500") + ); + } + + #[test] + fn empty_manifest_has_no_config_store() { + let m = ManifestLoader::load_from_str(""); + assert!(m.manifest().stores.config.is_none()); + } + + #[test] + fn config_store_empty_global_name_fails_validation() { + let src = r#" +[stores.config] +name = "" +"#; + let manifest: Manifest = toml::from_str(src).expect("should parse"); + let result = manifest.validate(); + assert!( + result.is_err(), + "empty global config store name should fail validation" + ); + } + // Multiple triggers test #[test] fn triggers_with_all_fields() { diff --git a/examples/app-demo/Cargo.lock b/examples/app-demo/Cargo.lock index e4cbc2f..6e64dc0 100644 --- a/examples/app-demo/Cargo.lock +++ b/examples/app-demo/Cargo.lock @@ -536,6 +536,7 @@ dependencies = [ "futures", "futures-util", "log", + "serde_json", "wasm-bindgen-test", "worker", ] diff --git a/examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs b/examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs index 12ae0f3..9869dea 100644 --- a/examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs +++ b/examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs @@ -8,5 +8,11 @@ use worker::*; #[cfg(target_arch = "wasm32")] #[event(fetch)] pub async fn main(req: Request, env: Env, ctx: Context) -> Result { - edgezero_adapter_cloudflare::run_app::(req, env, ctx).await + edgezero_adapter_cloudflare::run_app_with_manifest::( + include_str!("../../../edgezero.toml"), + req, + env, + ctx, + ) + .await } diff --git a/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml b/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml index e971cb4..18929ef 100644 --- a/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml +++ b/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml @@ -4,3 +4,8 @@ compatibility_date = "2023-05-01" [build] command = "worker-build --release" + +# Config store as a single JSON string var, keyed by the binding name from edgezero.toml. +# CloudflareConfigStore parses this at startup into a HashMap, enabling arbitrary key names. +[vars] +app_config = '{"greeting":"hello from config store","feature.new_checkout":"false","service.timeout_ms":"1500"}' diff --git a/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml b/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml index 3ac4b3e..bd03523 100644 --- a/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml +++ b/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml @@ -7,5 +7,15 @@ service_id = "" [local_server] +# Config store entries for local Viceroy testing. +# Mirrors [stores.config.defaults] in edgezero.toml so smoke tests pass on all adapters. +[local_server.config_stores.app_config] +format = "inline-toml" + +[local_server.config_stores.app_config.contents] +greeting = "hello from config store" +"feature.new_checkout" = "false" +"service.timeout_ms" = "1500" + [scripts] build = "cargo build --profile release --target wasm32-wasip1" diff --git a/examples/app-demo/crates/app-demo-core/src/handlers.rs b/examples/app-demo/crates/app-demo-core/src/handlers.rs index dbf4ca9..f0ccb17 100644 --- a/examples/app-demo/crates/app-demo-core/src/handlers.rs +++ b/examples/app-demo/crates/app-demo-core/src/handlers.rs @@ -16,6 +16,11 @@ pub(crate) struct EchoParams { pub(crate) name: String, } +#[derive(serde::Deserialize)] +struct ConfigParams { + name: String, +} + #[derive(serde::Deserialize)] pub(crate) struct EchoBody { pub(crate) name: String, @@ -110,11 +115,35 @@ fn proxy_not_available_response() -> Result { .map_err(EdgeError::internal) } +#[action] +pub(crate) async fn config_get(RequestContext(ctx): RequestContext) -> Result { + let params: ConfigParams = ctx.path()?; + match ctx.config_store().and_then(|s| s.get(¶ms.name)) { + Some(value) => { + let body = Body::text(value); + http::response_builder() + .status(StatusCode::OK) + .header("content-type", "text/plain; charset=utf-8") + .body(body) + .map_err(EdgeError::internal) + } + None => { + let body = Body::text(format!("config key '{}' not found", params.name)); + http::response_builder() + .status(StatusCode::NOT_FOUND) + .header("content-type", "text/plain; charset=utf-8") + .body(body) + .map_err(EdgeError::internal) + } + } +} + #[cfg(test)] mod tests { use super::*; use async_trait::async_trait; use edgezero_core::body::Body; + use edgezero_core::config_store::{ConfigStore, ConfigStoreHandle}; use edgezero_core::context::RequestContext; use edgezero_core::http::header::{HeaderName, HeaderValue}; use edgezero_core::http::{request_builder, Method, StatusCode, Uri}; @@ -124,6 +153,7 @@ mod tests { use futures::{executor::block_on, StreamExt}; use std::collections::HashMap; use std::env; + use std::sync::Arc; #[test] fn root_returns_static_body() { @@ -280,4 +310,57 @@ mod tests { .expect("request"); RequestContext::new(request, PathParams::default()) } + + struct MapConfigStore(HashMap); + + impl ConfigStore for MapConfigStore { + fn get(&self, key: &str) -> Option { + self.0.get(key).cloned() + } + } + + fn context_with_config_key(key: &str, entries: &[(&str, &str)]) -> RequestContext { + let mut request = request_builder() + .method(Method::GET) + .uri(format!("/config/{key}")) + .body(Body::empty()) + .expect("request"); + let store = MapConfigStore( + entries + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + ); + request + .extensions_mut() + .insert(ConfigStoreHandle::new(Arc::new(store))); + let mut params = HashMap::new(); + params.insert("name".to_string(), key.to_string()); + RequestContext::new(request, PathParams::new(params)) + } + + #[test] + fn config_get_returns_value_when_key_exists() { + let ctx = context_with_config_key("greeting", &[("greeting", "hello from config store")]); + let response = block_on(config_get(ctx)).expect("handler ok"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.into_body().into_bytes().as_ref(), + b"hello from config store" + ); + } + + #[test] + fn config_get_returns_404_when_key_missing() { + let ctx = context_with_config_key("missing.key", &[("other.key", "value")]); + let response = block_on(config_get(ctx)).expect("handler ok"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn config_get_returns_404_when_no_store_injected() { + let ctx = context_with_params("/config/greeting", &[("name", "greeting")]); + let response = block_on(config_get(ctx)).expect("handler ok"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } } diff --git a/examples/app-demo/edgezero.toml b/examples/app-demo/edgezero.toml index dd320ac..2d2badb 100644 --- a/examples/app-demo/edgezero.toml +++ b/examples/app-demo/edgezero.toml @@ -52,6 +52,13 @@ methods = ["GET", "POST"] handler = "app_demo_core::handlers::proxy_demo" adapters = ["axum", "cloudflare", "fastly"] +[[triggers.http]] +id = "config_get" +path = "/config/{name}" +methods = ["GET"] +handler = "app_demo_core::handlers::config_get" +adapters = ["axum", "cloudflare", "fastly"] + # [environment] # # [[environment.variables]] @@ -66,6 +73,14 @@ adapters = ["axum", "cloudflare", "fastly"] # adapters = ["axum", "cloudflare", "fastly"] # env = "API_TOKEN" +[stores.config] +name = "app_config" + +[stores.config.defaults] +"feature.new_checkout" = "false" +"service.timeout_ms" = "1500" +"greeting" = "hello from config store" + [adapters.axum.adapter] crate = "crates/app-demo-adapter-axum" manifest = "crates/app-demo-adapter-axum/axum.toml" diff --git a/scripts/smoke_test_config.sh b/scripts/smoke_test_config.sh new file mode 100755 index 0000000..5de4c1a --- /dev/null +++ b/scripts/smoke_test_config.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Smoke-test the config store demo handlers by starting an adapter, running checks, +# and tearing it down automatically. +# +# Usage: +# ./scripts/smoke_test_config.sh # defaults to axum +# ./scripts/smoke_test_config.sh axum +# ./scripts/smoke_test_config.sh fastly +# ./scripts/smoke_test_config.sh cloudflare + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEMO_DIR="$ROOT_DIR/examples/app-demo" +ADAPTER="${1:-axum}" +SERVER_PID="" + +cleanup() { + if [ -n "$SERVER_PID" ]; then + echo "" + echo "==> Stopping server (PID $SERVER_PID)..." + pkill -P "$SERVER_PID" 2>/dev/null || true + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +# -- Adapter-specific config ------------------------------------------------ + +case "$ADAPTER" in + axum) + PORT=8787 + echo "==> Building app-demo (axum)..." + (cd "$DEMO_DIR" && cargo build -p app-demo-adapter-axum 2>&1) + echo "==> Starting Axum adapter on port $PORT..." + (cd "$DEMO_DIR" && cargo run -p app-demo-adapter-axum 2>&1) & + SERVER_PID=$! + ;; + fastly) + PORT=7676 + command -v fastly >/dev/null 2>&1 || { + echo "Fastly CLI is required. Install from https://developer.fastly.com/reference/cli/" >&2 + exit 1 + } + echo "==> Starting Fastly Viceroy on port $PORT..." + (cd "$DEMO_DIR" && fastly compute serve -C crates/app-demo-adapter-fastly 2>&1) & + SERVER_PID=$! + ;; + cloudflare|cf) + PORT=8787 + command -v wrangler >/dev/null 2>&1 || { + echo "wrangler is required. Install with 'npm i -g wrangler'" >&2 + exit 1 + } + echo "==> Starting Cloudflare wrangler dev on port $PORT..." + (cd "$DEMO_DIR" && wrangler dev --cwd crates/app-demo-adapter-cloudflare --port "$PORT" 2>&1) & + SERVER_PID=$! + ;; + *) + echo "Unknown adapter: $ADAPTER" >&2 + echo "Usage: $0 [axum|fastly|cloudflare]" >&2 + exit 1 + ;; +esac + +BASE="http://127.0.0.1:${PORT}" + +# -- Wait for server readiness ---------------------------------------------- + +echo "==> Waiting for server at $BASE ..." +MAX_WAIT=60 +WAITED=0 +until curl -s -o /dev/null "$BASE/" 2>/dev/null; do + kill -0 "$SERVER_PID" 2>/dev/null || { echo "Server process exited early" >&2; exit 1; } + sleep 1 + WAITED=$((WAITED + 1)) + if [ "$WAITED" -ge "$MAX_WAIT" ]; then + echo "Server did not start within ${MAX_WAIT}s" >&2 + exit 1 + fi +done +echo "==> Server ready (${WAITED}s)" + +# -- Test helpers ------------------------------------------------------------ + +PASS=0 +FAIL=0 + +check() { + local label="$1" expect="$2" actual="$3" + if [ "$actual" = "$expect" ]; then + printf ' PASS %s\n' "$label" + PASS=$((PASS + 1)) + else + printf ' FAIL %s (expected %q, got %q)\n' "$label" "$expect" "$actual" + FAIL=$((FAIL + 1)) + fi +} + +section() { + printf '\n--- %s ---\n' "$1" +} + +# -- Tests ------------------------------------------------------------------- + +section "Health check" +STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/") +check "GET / returns 200" "200" "$STATUS" + +section "Config: keys (all adapters)" +STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/config/greeting") +check "GET /config/greeting returns 200" "200" "$STATUS" + +BODY=$(curl -s "$BASE/config/greeting") +check "greeting value" "hello from config store" "$BODY" + +STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/config/feature.new_checkout") +check "GET /config/feature.new_checkout returns 200" "200" "$STATUS" + +BODY=$(curl -s "$BASE/config/feature.new_checkout") +check "feature.new_checkout value" "false" "$BODY" + +BODY=$(curl -s "$BASE/config/service.timeout_ms") +check "service.timeout_ms value" "1500" "$BODY" + +section "Config: missing key returns 404" +STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/config/does.not.exist") +check "GET /config/does.not.exist returns 404" "404" "$STATUS" + +section "Config: case sensitivity" +STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/config/GREETING") +check "GET /config/GREETING (uppercase) returns 404" "404" "$STATUS" + +# -- Summary ----------------------------------------------------------------- + +printf '\n==============================\n' +printf 'Adapter: %s\n' "$ADAPTER" +printf 'Results: %d passed, %d failed\n' "$PASS" "$FAIL" +printf '==============================\n' + +[ "$FAIL" -eq 0 ] || exit 1 From a1b8d07f1bca0fd8cb5cff5430f58f671dd4f4e6 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 9 Mar 2026 20:03:28 +0530 Subject: [PATCH 2/8] Production hardening for config store and added docs --- .../edgezero-adapter-axum/src/dev_server.rs | 2 +- .../src/config_store.rs | 122 ++++++++++++++---- crates/edgezero-adapter-cloudflare/src/lib.rs | 53 ++++++-- .../src/request.rs | 11 +- .../tests/contract.rs | 72 ++++++++--- .../src/config_store.rs | 45 +++++-- crates/edgezero-adapter-fastly/src/lib.rs | 18 ++- crates/edgezero-adapter-fastly/src/request.rs | 4 +- .../edgezero-adapter-fastly/tests/contract.rs | 2 +- crates/edgezero-core/src/app.rs | 88 +++++++++++++ crates/edgezero-core/src/config_store.rs | 19 +-- crates/edgezero-macros/src/action.rs | 2 +- crates/edgezero-macros/src/app.rs | 38 +++++- docs/guide/adapters/axum.md | 26 +++- docs/guide/adapters/cloudflare.md | 24 ++++ docs/guide/adapters/fastly.md | 31 ++++- docs/guide/configuration.md | 34 +++++ .../app-demo-adapter-cloudflare/src/lib.rs | 8 +- 18 files changed, 498 insertions(+), 101 deletions(-) diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 6ef7f56..3d95d38 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -140,7 +140,7 @@ async fn serve_with_listener( pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { let manifest = ManifestLoader::load_from_str(manifest_src); let m = manifest.manifest(); - let logging = m.logging_or_default("axum"); + let logging = m.logging_or_default(edgezero_core::app::AXUM_ADAPTER); let level: LevelFilter = logging.level.into(); let level = if logging.echo_stdout.unwrap_or(true) { diff --git a/crates/edgezero-adapter-cloudflare/src/config_store.rs b/crates/edgezero-adapter-cloudflare/src/config_store.rs index ce1ffa6..d775462 100644 --- a/crates/edgezero-adapter-cloudflare/src/config_store.rs +++ b/crates/edgezero-adapter-cloudflare/src/config_store.rs @@ -12,16 +12,19 @@ //! names are restricted to JavaScript identifier syntax. use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; use edgezero_core::config_store::ConfigStore; use worker::Env; +type ConfigMap = HashMap; + /// Config store backed by a single Cloudflare JSON string binding. /// /// At construction time the binding value is parsed into a `HashMap`. /// Reads are then O(1) map lookups with no further JS interop. pub struct CloudflareConfigStore { - data: HashMap, + data: Arc, } impl CloudflareConfigStore { @@ -30,29 +33,31 @@ impl CloudflareConfigStore { /// Returns an empty store (graceful fallback) if the binding is absent or /// the value is not valid JSON. pub fn new(env: &Env, binding_name: &str) -> Self { - let raw = env.var(binding_name).ok(); - if raw.is_none() { - log::info!( - "config store binding '{}' is not set in wrangler.toml [vars]; proceeding without config", - binding_name - ); + Self::try_new(env, binding_name).unwrap_or_else(Self::empty) + } + + /// Build a store only when the configured Cloudflare binding exists and parses successfully. + /// + /// Missing bindings or invalid JSON are treated as configuration problems, logged at warn + /// level (once per binding name per isolate lifetime), and return `None` so the adapter + /// can skip injecting the handle. + pub fn try_new(env: &Env, binding_name: &str) -> Option { + Some(Self { + data: lookup_cached(env, binding_name)?, + }) + } + + fn empty() -> Self { + Self { + data: Arc::new(HashMap::new()), + } + } + + #[cfg(test)] + fn from_entries(entries: impl IntoIterator) -> Self { + Self { + data: Arc::new(entries.into_iter().collect()), } - let data = raw - .and_then(|v| { - let s = v.to_string(); - serde_json::from_str(&s) - .map_err(|e| { - log::warn!( - "config store binding '{}' is not valid JSON: {}; proceeding without config", - binding_name, - e - ); - e - }) - .ok() - }) - .unwrap_or_default(); - Self { data } } } @@ -62,7 +67,70 @@ impl ConfigStore for CloudflareConfigStore { } } -// Contract tests cannot run natively: `worker::Env` is only available inside -// the Cloudflare Workers runtime and has no testable mock. Platform-level -// contract coverage is provided by the smoke test -// (`scripts/smoke_test_config.sh cloudflare`) against a live wrangler dev instance. +/// Parse-and-cache the config map for `binding_name`. +/// +/// Keyed only by name: Cloudflare env vars are immutable within an isolate lifetime, +/// so the parsed result for a given binding name never changes. Warnings are emitted +/// only on the first miss for a given name (log-once semantics). +/// +/// # WASM safety +/// `std::sync::Mutex` compiles for `wasm32-unknown-unknown` and is safe here because +/// WASM is single-threaded — the lock can never be contested and poisoning cannot +/// occur via a concurrent thread panic. +fn lookup_cached(env: &Env, binding_name: &str) -> Option> { + // Fast path: already cached. + if let Some(entry) = config_cache() + .lock() + .unwrap_or_else(|p| p.into_inner()) + .get(binding_name) + { + return entry.clone(); + } + + // Cache miss: resolve from the JS env (synchronous interop, safe outside the lock). + let resolved = match env.var(binding_name).ok().map(|v| v.to_string()) { + None => { + log::warn!( + "configured config store binding '{}' is missing from the Worker environment; skipping config-store injection", + binding_name + ); + None + } + Some(raw) => match serde_json::from_str::(&raw) { + Ok(data) => Some(Arc::new(data)), + Err(err) => { + log::warn!( + "configured config store binding '{}' contains invalid JSON: {}; skipping config-store injection", + binding_name, + err + ); + None + } + }, + }; + + config_cache() + .lock() + .unwrap_or_else(|p| p.into_inner()) + .entry(binding_name.to_string()) + .or_insert(resolved) + .clone() +} + +fn config_cache() -> &'static Mutex>>> { + static CACHE: OnceLock>>>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[cfg(test)] +mod tests { + use super::*; + use wasm_bindgen_test::wasm_bindgen_test; + + edgezero_core::config_store_contract_tests!(cloudflare_config_store_contract, #[wasm_bindgen_test], { + CloudflareConfigStore::from_entries([ + ("contract.key.a".to_string(), "value_a".to_string()), + ("contract.key.b".to_string(), "value_b".to_string()), + ]) + }); +} diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index fa4bb4f..548a64a 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -69,13 +69,29 @@ pub async fn run_app( ) -> Result { init_logger().expect("init cloudflare logger"); let app = A::build_app(); - dispatch(&app, req, env, ctx).await + dispatch_app( + &app, + req, + env, + ctx, + A::config_store().map(|cfg| cfg.name_for_adapter(edgezero_core::app::CLOUDFLARE_ADAPTER)), + ) + .await } /// Run the app resolving the config store binding name from `manifest_src`. /// -/// If `[stores.config]` is present in the manifest, injects a -/// [`CloudflareConfigStore`] built from the Cloudflare env before dispatching. +/// Prefers hook metadata from [`edgezero_core::app::Hooks::config_store`] +/// and falls back to resolving `[stores.config]` from `manifest_src`. +/// +/// # Deprecation +/// Apps generated by the `app!` macro already embed config-store metadata in +/// `Hooks::config_store()`. Prefer [`run_app`], which reads that metadata +/// directly and does not require passing the manifest source at runtime. +#[deprecated( + note = "Use run_app instead. Config-store metadata is now embedded by the app!() macro \ + and read via Hooks::config_store(); passing manifest_src at runtime is no longer needed." +)] #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub async fn run_app_with_manifest( manifest_src: &str, @@ -84,12 +100,33 @@ pub async fn run_app_with_manifest( ctx: worker::Context, ) -> Result { init_logger().expect("init cloudflare logger"); - let manifest = edgezero_core::manifest::ManifestLoader::load_from_str(manifest_src); let app = A::build_app(); - if let Some(cfg) = manifest.manifest().stores.config.as_ref() { - let binding_name = cfg.config_store_name("cloudflare").to_string(); - dispatch_with_config(&app, req, env, ctx, &binding_name).await + let binding_name = A::config_store() + .map(|cfg| { + cfg.name_for_adapter(edgezero_core::app::CLOUDFLARE_ADAPTER) + .to_string() + }) + .or_else(|| { + let manifest = edgezero_core::manifest::ManifestLoader::load_from_str(manifest_src); + manifest.manifest().stores.config.as_ref().map(|cfg| { + cfg.config_store_name(edgezero_core::app::CLOUDFLARE_ADAPTER) + .to_string() + }) + }); + dispatch_app(&app, req, env, ctx, binding_name.as_deref()).await +} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +async fn dispatch_app( + app: &edgezero_core::app::App, + req: worker::Request, + env: worker::Env, + ctx: worker::Context, + config_store_name: Option<&str>, +) -> Result { + if let Some(binding_name) = config_store_name { + dispatch_with_config(app, req, env, ctx, binding_name).await } else { - dispatch(&app, req, env, ctx).await + dispatch(app, req, env, ctx).await } } diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index 6620638..a91bf83 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -66,7 +66,8 @@ pub async fn dispatch( /// Dispatch a request with a Cloudflare JSON config store injected. /// /// Reads `binding_name` from `env` (a `[vars]` string whose value is a JSON object), -/// parses it into a `CloudflareConfigStore`, then injects the handle before dispatch. +/// parses it into a `CloudflareConfigStore`, and injects the handle before dispatch +/// when the binding is present and valid. pub async fn dispatch_with_config( app: &App, req: CfRequest, @@ -74,12 +75,14 @@ pub async fn dispatch_with_config( ctx: Context, binding_name: &str, ) -> Result { - let config_handle = - ConfigStoreHandle::new(Arc::new(CloudflareConfigStore::new(&env, binding_name))); + let config_handle = CloudflareConfigStore::try_new(&env, binding_name) + .map(|store| ConfigStoreHandle::new(Arc::new(store))); let mut core_request = into_core_request(req, env, ctx) .await .map_err(edge_error_to_worker)?; - core_request.extensions_mut().insert(config_handle); + if let Some(handle) = config_handle { + core_request.extensions_mut().insert(handle); + } let svc = app.router().clone(); let response = svc.oneshot(core_request).await; from_core_response(response).map_err(edge_error_to_worker) diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index 192885d..57e2453 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -2,22 +2,26 @@ use bytes::Bytes; use edgezero_adapter_cloudflare::{ - dispatch, from_core_response, into_core_request, CloudflareRequestContext, + dispatch, dispatch_with_config, from_core_response, into_core_request, + CloudflareRequestContext, }; use edgezero_core::{ - response_builder, App, Body, EdgeError, Method, RequestContext, RouterService, StatusCode, + app::App, + body::Body, + context::RequestContext, + error::EdgeError, + http::{response_builder, Method, Response, StatusCode}, + router::RouterService, }; use futures::stream; -use wasm_bindgen::JsValue; use wasm_bindgen_test::*; -use worker::{ - Context, Env, Method as CfMethod, Request as CfRequest, RequestInit, Response as CfResponse, -}; +use worker::wasm_bindgen::{JsCast, JsValue}; +use worker::{Context, Env, Method as CfMethod, Request as CfRequest, RequestInit}; wasm_bindgen_test_configure!(run_in_browser); fn build_test_app() -> App { - async fn capture_uri(ctx: RequestContext) -> Result { + async fn capture_uri(ctx: RequestContext) -> Result { let body = Body::text(ctx.request().uri().to_string()); let response = response_builder() .status(StatusCode::OK) @@ -26,7 +30,7 @@ fn build_test_app() -> App { Ok(response) } - async fn mirror_body(ctx: RequestContext) -> Result { + async fn mirror_body(ctx: RequestContext) -> Result { let bytes = ctx.request().body().as_bytes().to_vec(); let response = response_builder() .status(StatusCode::OK) @@ -35,7 +39,20 @@ fn build_test_app() -> App { Ok(response) } - async fn stream_response(_ctx: RequestContext) -> Result { + async fn config_presence(_ctx: RequestContext) -> Result { + let present = if _ctx.config_store().is_some() { + "yes" + } else { + "no" + }; + let response = response_builder() + .status(StatusCode::OK) + .body(Body::text(present)) + .expect("response"); + Ok(response) + } + + async fn stream_response(_ctx: RequestContext) -> Result { let chunks = stream::iter(vec![ Bytes::from_static(b"chunk-1"), Bytes::from_static(b"chunk-2"), @@ -52,18 +69,19 @@ fn build_test_app() -> App { .get("/uri", capture_uri) .post("/mirror", mirror_body) .get("/stream", stream_response) + .get("/has-config", config_presence) .build(); App::new(router) } fn cf_request(method: CfMethod, path: &str, body: Option<&[u8]>) -> CfRequest { - use js_sys::Uint8Array; + use worker::js_sys::Uint8Array; let mut init = RequestInit::new(); init.with_method(method); - let headers = worker::Headers::new().expect("headers"); + let headers = worker::Headers::new(); headers.set("host", "example.com").expect("host header"); headers.set("x-edgezero-test", "1").expect("custom header"); init.with_headers(headers); @@ -78,7 +96,9 @@ fn cf_request(method: CfMethod, path: &str, body: Option<&[u8]>) -> CfRequest { } fn test_env_ctx() -> (Env, Context) { - (Env::default(), Context::default()) + let env = worker::js_sys::Object::new().unchecked_into::(); + let js_context = worker::js_sys::Object::new().unchecked_into::(); + (env, Context::new(js_context)) } #[wasm_bindgen_test] @@ -117,7 +137,7 @@ async fn from_core_response_translates_status_headers_and_streaming_body() { ]))) .expect("response"); - let cf_response = from_core_response(response).expect("cf response"); + let mut cf_response = from_core_response(response).expect("cf response"); assert_eq!(cf_response.status_code(), StatusCode::CREATED.as_u16()); let header = cf_response.headers().get("x-edgezero-res").unwrap(); @@ -133,11 +153,11 @@ async fn dispatch_runs_router_and_returns_response() { let req = cf_request(CfMethod::Get, "/uri", None); let (env, ctx) = test_env_ctx(); - let response = dispatch(&app, req, env, ctx).await.expect("cf response"); + let mut response = dispatch(&app, req, env, ctx).await.expect("cf response"); assert_eq!(response.status_code(), StatusCode::OK.as_u16()); let body = response.text().await.expect("text"); - assert_eq!(body.unwrap(), "https://example.com/uri"); + assert_eq!(body, "https://example.com/uri"); } #[wasm_bindgen_test] @@ -146,7 +166,7 @@ async fn dispatch_streaming_route_preserves_chunks() { let req = cf_request(CfMethod::Get, "/stream", None); let (env, ctx) = test_env_ctx(); - let response = dispatch(&app, req, env, ctx).await.expect("cf response"); + let mut response = dispatch(&app, req, env, ctx).await.expect("cf response"); assert_eq!(response.status_code(), StatusCode::OK.as_u16()); let bytes = response.bytes().await.expect("bytes"); @@ -159,9 +179,27 @@ async fn dispatch_passes_request_body_to_handlers() { let req = cf_request(CfMethod::Post, "/mirror", Some(b"echo")); let (env, ctx) = test_env_ctx(); - let response = dispatch(&app, req, env, ctx).await.expect("cf response"); + let mut response = dispatch(&app, req, env, ctx).await.expect("cf response"); assert_eq!(response.status_code(), StatusCode::OK.as_u16()); let bytes = response.bytes().await.expect("bytes"); assert_eq!(bytes.as_slice(), b"echo"); } + +#[wasm_bindgen_test] +async fn dispatch_with_config_missing_binding_skips_injection() { + // The test env is an empty JS object; any env.var() call returns None. + // dispatch_with_config should log a warning and dispatch without injecting + // a config-store handle, so the handler receives ctx.config_store() == None. + let app = build_test_app(); + let req = cf_request(CfMethod::Get, "/has-config", None); + let (env, ctx) = test_env_ctx(); + + let mut response = dispatch_with_config(&app, req, env, ctx, "nonexistent_binding") + .await + .expect("cf response"); + + assert_eq!(response.status_code(), StatusCode::OK.as_u16()); + let body = response.text().await.expect("text"); + assert_eq!(body, "no"); +} diff --git a/crates/edgezero-adapter-fastly/src/config_store.rs b/crates/edgezero-adapter-fastly/src/config_store.rs index f3ad2a7..022c9d3 100644 --- a/crates/edgezero-adapter-fastly/src/config_store.rs +++ b/crates/edgezero-adapter-fastly/src/config_store.rs @@ -1,10 +1,19 @@ //! Fastly adapter config store: wraps `fastly::ConfigStore`. +#[cfg(test)] +use std::collections::HashMap; + use edgezero_core::config_store::ConfigStore; /// Config store backed by a Fastly Config Store resource link. pub struct FastlyConfigStore { - inner: fastly::ConfigStore, + inner: FastlyConfigStoreBackend, +} + +enum FastlyConfigStoreBackend { + Fastly(fastly::ConfigStore), + #[cfg(test)] + InMemory(HashMap), } impl FastlyConfigStore { @@ -13,19 +22,37 @@ impl FastlyConfigStore { /// Returns `None` if the store is not available (e.g. not configured in /// `fastly.toml`), allowing graceful fallback without panicking. pub fn try_open(name: &str) -> Option { - fastly::ConfigStore::try_open(name) - .ok() - .map(|inner| Self { inner }) + fastly::ConfigStore::try_open(name).ok().map(|inner| Self { + inner: FastlyConfigStoreBackend::Fastly(inner), + }) + } + + #[cfg(test)] + fn from_entries(entries: impl IntoIterator) -> Self { + Self { + inner: FastlyConfigStoreBackend::InMemory(entries.into_iter().collect()), + } } } impl ConfigStore for FastlyConfigStore { fn get(&self, key: &str) -> Option { - self.inner.try_get(key).ok().flatten() + match &self.inner { + FastlyConfigStoreBackend::Fastly(inner) => inner.try_get(key).ok().flatten(), + #[cfg(test)] + FastlyConfigStoreBackend::InMemory(data) => data.get(key).cloned(), + } } } -// Contract tests cannot run natively: `fastly::ConfigStore::try_open` requires -// the Viceroy runtime. Platform-level contract coverage is provided by the -// smoke test (`scripts/smoke_test_config.sh fastly`) which exercises the same -// keys against a live Viceroy instance. +#[cfg(test)] +mod tests { + use super::*; + + edgezero_core::config_store_contract_tests!(fastly_config_store_contract, { + FastlyConfigStore::from_entries([ + ("contract.key.a".to_string(), "value_a".to_string()), + ("contract.key.b".to_string(), "value_b".to_string()), + ]) + }); +} diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index a329c91..e30dcf4 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -83,12 +83,18 @@ pub fn run_app( ) -> Result { let manifest_loader = edgezero_core::manifest::ManifestLoader::load_from_str(manifest_src); let m = manifest_loader.manifest(); - let logging = m.logging_or_default("fastly"); - let config_name = m - .stores - .config - .as_ref() - .map(|cfg| cfg.config_store_name("fastly").to_string()); + let logging = m.logging_or_default(edgezero_core::app::FASTLY_ADAPTER); + let config_name = A::config_store() + .map(|cfg| { + cfg.name_for_adapter(edgezero_core::app::FASTLY_ADAPTER) + .to_string() + }) + .or_else(|| { + m.stores.config.as_ref().map(|cfg| { + cfg.config_store_name(edgezero_core::app::FASTLY_ADAPTER) + .to_string() + }) + }); run_app_with_config::(logging.into(), req, config_name.as_deref()) } diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index 36023d4..e306d80 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -66,8 +66,8 @@ pub fn dispatch_with_config( .insert(ConfigStoreHandle::new(Arc::new(store))); } None => { - log::info!( - "config store '{}' is not available; proceeding without it", + log::warn!( + "configured Fastly config store '{}' is unavailable; skipping config-store injection", store_name ); } diff --git a/crates/edgezero-adapter-fastly/tests/contract.rs b/crates/edgezero-adapter-fastly/tests/contract.rs index f3c25b3..37c0375 100644 --- a/crates/edgezero-adapter-fastly/tests/contract.rs +++ b/crates/edgezero-adapter-fastly/tests/contract.rs @@ -67,7 +67,7 @@ fn fastly_request(method: FastlyMethod, path: &str, body: Option<&[u8]>) -> Fast #[test] fn into_core_request_preserves_method_uri_headers_body_and_context() { - let mut req = fastly_request(FastlyMethod::POST, "/mirror?foo=bar", Some(b"payload")); + let req = fastly_request(FastlyMethod::POST, "/mirror?foo=bar", Some(b"payload")); let expected_ip = req.get_client_ip_addr(); let core_request = into_core_request(req).expect("core request"); diff --git a/crates/edgezero-core/src/app.rs b/crates/edgezero-core/src/app.rs index 9b193ef..07a1900 100644 --- a/crates/edgezero-core/src/app.rs +++ b/crates/edgezero-core/src/app.rs @@ -2,6 +2,69 @@ use crate::router::RouterService; const DEFAULT_APP_NAME: &str = "EdgeZero App"; +/// Canonical adapter name for the Axum adapter. +pub const AXUM_ADAPTER: &str = "axum"; +/// Canonical adapter name for the Cloudflare adapter. +pub const CLOUDFLARE_ADAPTER: &str = "cloudflare"; +/// Canonical adapter name for the Fastly adapter. +pub const FASTLY_ADAPTER: &str = "fastly"; + +/// Adapter-specific config-store override metadata generated from `[stores.config.adapters.*]`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConfigStoreAdapterMetadata { + adapter: &'static str, + name: &'static str, +} + +impl ConfigStoreAdapterMetadata { + pub const fn new(adapter: &'static str, name: &'static str) -> Self { + Self { adapter, name } + } + + pub fn adapter(&self) -> &'static str { + self.adapter + } + + pub fn name(&self) -> &'static str { + self.name + } +} + +/// Provider-neutral config-store metadata generated from `[stores.config]`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConfigStoreMetadata { + default_name: &'static str, + adapters: &'static [ConfigStoreAdapterMetadata], +} + +impl ConfigStoreMetadata { + pub const fn new( + default_name: &'static str, + adapters: &'static [ConfigStoreAdapterMetadata], + ) -> Self { + Self { + default_name, + adapters, + } + } + + pub fn default_name(&self) -> &'static str { + self.default_name + } + + pub fn adapters(&self) -> &'static [ConfigStoreAdapterMetadata] { + self.adapters + } + + pub fn name_for_adapter(&self, adapter: &str) -> &'static str { + self.adapters + .iter() + .find(|entry| entry.adapter.eq_ignore_ascii_case(adapter)) + .map(|entry| entry.name) + .unwrap_or(self.default_name) + } +} + /// Lightweight container around a `RouterService` that can be extended via hook implementations. pub struct App { router: RouterService, @@ -68,6 +131,13 @@ pub trait Hooks { App::default_name() } + /// Structured config-store metadata for the application, if declared. + /// + /// Macro-generated apps derive this from `[stores.config]` in `edgezero.toml`. + fn config_store() -> Option<&'static ConfigStoreMetadata> { + None + } + /// Construct an `App` by wiring the routes and invoking the configuration hook. fn build_app() -> App where @@ -117,12 +187,29 @@ mod tests { fn name() -> &'static str { "hooks-name" } + + fn config_store() -> Option<&'static ConfigStoreMetadata> { + static CONFIG_STORE: ConfigStoreMetadata = ConfigStoreMetadata::new( + "default-config", + &[ConfigStoreAdapterMetadata::new( + CLOUDFLARE_ADAPTER, + "cf-config", + )], + ); + Some(&CONFIG_STORE) + } } #[test] fn build_app_invokes_hooks_for_routes_and_configuration() { let app = TestHooks::build_app(); assert_eq!(app.name(), "configured"); + let config = TestHooks::config_store().expect("config store metadata"); + assert_eq!(config.name_for_adapter(CLOUDFLARE_ADAPTER), "cf-config"); + assert_eq!(config.name_for_adapter("CLOUDFLARE"), "cf-config"); + assert_eq!(config.name_for_adapter(FASTLY_ADAPTER), "default-config"); + assert_eq!(config.default_name(), "default-config"); + assert_eq!(config.adapters().len(), 1); let request = request_builder() .method(Method::GET) @@ -147,6 +234,7 @@ mod tests { fn default_hooks_use_default_name_and_into_router() { let app = DefaultHooks::build_app(); assert_eq!(app.name(), App::default_name()); + assert_eq!(DefaultHooks::config_store(), None); let router = app.into_router(); assert!(router.routes().is_empty()); } diff --git a/crates/edgezero-core/src/config_store.rs b/crates/edgezero-core/src/config_store.rs index 07471d1..eff31ba 100644 --- a/crates/edgezero-core/src/config_store.rs +++ b/crates/edgezero-core/src/config_store.rs @@ -78,44 +78,44 @@ impl ConfigStoreHandle { /// ``` #[macro_export] macro_rules! config_store_contract_tests { - ($mod_name:ident, $factory:expr) => { + ($mod_name:ident, #[$test_attr:meta], $factory:expr $(,)?) => { mod $mod_name { use super::*; use $crate::config_store::ConfigStore; - #[test] + #[$test_attr] fn contract_get_returns_value_for_existing_key() { let store = $factory; assert_eq!(store.get("contract.key.a"), Some("value_a".to_string())); } - #[test] + #[$test_attr] fn contract_get_returns_none_for_missing_key() { let store = $factory; assert_eq!(store.get("contract.key.missing"), None); } - #[test] + #[$test_attr] fn contract_multiple_keys_are_independent() { let store = $factory; assert_eq!(store.get("contract.key.a"), Some("value_a".to_string())); assert_eq!(store.get("contract.key.b"), Some("value_b".to_string())); } - #[test] + #[$test_attr] fn contract_key_lookup_is_case_sensitive() { let store = $factory; // lowercase "contract.key.a" exists; uppercase must not match assert_eq!(store.get("CONTRACT.KEY.A"), None); } - #[test] + #[$test_attr] fn contract_empty_key_returns_none() { let store = $factory; assert_eq!(store.get(""), None); } - #[test] + #[$test_attr] fn contract_handle_wraps_store() { use std::sync::Arc; use $crate::config_store::ConfigStoreHandle; @@ -125,7 +125,7 @@ macro_rules! config_store_contract_tests { assert_eq!(handle.get("contract.key.missing"), None); } - #[test] + #[$test_attr] fn contract_cloned_handle_delegates_consistently() { use std::sync::Arc; use $crate::config_store::ConfigStoreHandle; @@ -140,6 +140,9 @@ macro_rules! config_store_contract_tests { } } }; + ($mod_name:ident, $factory:expr) => { + $crate::config_store_contract_tests!($mod_name, #[test], $factory); + }; } // --------------------------------------------------------------------------- diff --git a/crates/edgezero-macros/src/action.rs b/crates/edgezero-macros/src/action.rs index 4a2bf0b..e905d22 100644 --- a/crates/edgezero-macros/src/action.rs +++ b/crates/edgezero-macros/src/action.rs @@ -107,7 +107,7 @@ fn normalize_request_context_pat(pat: &mut Box) -> syn::Result<()> { let Some(replacement) = extract_request_context_binding(pat.as_ref())? else { return Ok(()); }; - *pat = Box::new(replacement); + **pat = replacement; Ok(()) } diff --git a/crates/edgezero-macros/src/app.rs b/crates/edgezero-macros/src/app.rs index e5f7289..c4b1807 100644 --- a/crates/edgezero-macros/src/app.rs +++ b/crates/edgezero-macros/src/app.rs @@ -14,7 +14,7 @@ mod manifest_definitions { "/../edgezero-core/src/manifest.rs" )); } -use manifest_definitions::Manifest; +use manifest_definitions::{Manifest, DEFAULT_CONFIG_STORE_NAME}; pub fn expand_app(input: TokenStream) -> TokenStream { let args = parse_macro_input!(input as AppArgs); @@ -38,6 +38,7 @@ pub fn expand_app(input: TokenStream) -> TokenStream { let middleware_tokens = build_middleware_tokens(&manifest); let route_tokens = build_route_tokens(&manifest); + let config_store_tokens = build_config_store_tokens(&manifest); let output = quote! { pub struct #app_ident; @@ -50,6 +51,8 @@ pub fn expand_app(input: TokenStream) -> TokenStream { fn name() -> &'static str { #app_name_lit } + + #config_store_tokens } pub fn build_router() -> edgezero_core::router::RouterService { @@ -107,6 +110,39 @@ fn build_middleware_tokens(manifest: &Manifest) -> Vec { .collect() } +fn build_config_store_tokens(manifest: &Manifest) -> TokenStream2 { + let Some(config) = manifest.stores.config.as_ref() else { + return quote! {}; + }; + + let fallback_name = config.name.as_deref().unwrap_or(DEFAULT_CONFIG_STORE_NAME); + let fallback_name_lit = LitStr::new(fallback_name, Span::call_site()); + let override_entries: Vec<_> = config + .adapters + .iter() + .map(|(adapter, cfg)| { + let adapter_lit = LitStr::new(adapter, Span::call_site()); + let name_lit = LitStr::new(&cfg.name, Span::call_site()); + quote! { + edgezero_core::app::ConfigStoreAdapterMetadata::new(#adapter_lit, #name_lit), + } + }) + .collect(); + + quote! { + fn config_store() -> Option<&'static edgezero_core::app::ConfigStoreMetadata> { + static CONFIG_STORE: edgezero_core::app::ConfigStoreMetadata = + edgezero_core::app::ConfigStoreMetadata::new( + #fallback_name_lit, + &[ + #(#override_entries)* + ], + ); + Some(&CONFIG_STORE) + } + } +} + fn parse_handler_path(handler: &str) -> syn::ExprPath { let mut handler_str = handler.trim().to_string(); if handler_str.starts_with("crate::") diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index 82d1b65..25b0076 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -27,20 +27,19 @@ crates/my-app-adapter-axum/ The Axum entrypoint wires the adapter: ```rust -use edgezero_adapter_axum::AxumDevServer; -use edgezero_core::app::Hooks; use my_app_core::App; fn main() { - let app = App::build_app(); - let router = app.router().clone(); - if let Err(err) = AxumDevServer::new(router).run() { + if let Err(err) = edgezero_adapter_axum::run_app::(include_str!("../../../edgezero.toml")) { eprintln!("axum adapter failed: {err}"); std::process::exit(1); } } ``` +`run_app` installs `simple_logger`, builds the app, and wires the local config store from +`[stores.config]` automatically. + ## Development Server The `edgezero dev` command uses the Axum adapter: @@ -136,6 +135,23 @@ cargo test -p my-app-core cargo test -p my-app-adapter-axum ``` +## Config Store + +For local development, the Axum adapter reads config values from a snapshot of the process +environment and falls back to `[stores.config.defaults]` in `edgezero.toml`: + +```toml +[stores.config] +name = "app_config" + +[stores.config.defaults] +"greeting" = "hello from config store" +"feature.new_checkout" = "false" +``` + +Handlers access the injected store through `ctx.config_store()`. Environment variables take +precedence over manifest defaults. + ## Container Deployment Build and deploy as a standard container: diff --git a/docs/guide/adapters/cloudflare.md b/docs/guide/adapters/cloudflare.md index b4f3e72..1d43741 100644 --- a/docs/guide/adapters/cloudflare.md +++ b/docs/guide/adapters/cloudflare.md @@ -48,6 +48,9 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result { } ``` +`run_app` reads config-store metadata generated by `edgezero_core::app!` and injects the configured +Cloudflare binding automatically. No special manifest-aware entrypoint is required. + ## Building Build for Cloudflare's Wasm target: @@ -139,6 +142,27 @@ API_URL = "https://api.example.com" Access in handlers via the Cloudflare context or environment bindings. +## Config Store + +Cloudflare does not expose a Fastly-style mutable config-store product, so EdgeZero maps +`[stores.config]` to a single JSON string binding in `wrangler.toml [vars]`: + +```toml +# edgezero.toml +[stores.config] +name = "app_config" +``` + +```toml +# wrangler.toml +[vars] +app_config = '{"greeting":"hello from config store","feature.new_checkout":"false"}' +``` + +At runtime the adapter parses that JSON object and injects it as `ctx.config_store()`. If the +configured binding is missing or contains invalid JSON, the adapter logs a warning and skips +config-store injection for that request. + ## KV Storage Use Cloudflare KV for edge storage: diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index ead2d83..e5bb4e4 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -41,17 +41,17 @@ authors = ["you@example.com"] The Fastly entrypoint wires the adapter: ```rust -use edgezero_adapter_fastly::dispatch; -use edgezero_core::app::Hooks; use my_app_core::App; #[fastly::main] fn main(req: fastly::Request) -> Result { - let app = App::build_app(); - dispatch(&app, req) + edgezero_adapter_fastly::run_app::(include_str!("../../../edgezero.toml"), req) } ``` +`run_app` reads logging and config-store settings from `edgezero.toml`, builds the app, and injects +the configured Fastly Config Store into request extensions automatically. + ## Building Build for Fastly's Wasm target: @@ -131,6 +131,29 @@ fn main() { Fastly logging is wired when you call `init_logger` (or `run_app`); otherwise no logger is installed. ::: +## Config Store + +Fastly uses a native Config Store resource link for runtime configuration. Declare the logical store +name in `edgezero.toml`: + +```toml +[stores.config] +name = "app_config" +``` + +For local Viceroy testing, mirror that binding in `fastly.toml`: + +```toml +[local_server.config_stores.app_config] +format = "inline-toml" + +[local_server.config_stores.app_config.contents] +greeting = "hello from config store" +``` + +Handlers can then read values through `ctx.config_store()`. If the configured store link is missing, +the adapter logs a warning and continues without injecting a config-store handle. + ## Context Access Access Fastly-specific APIs via the request context extensions: diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index a7a34cb..87a934a 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -137,6 +137,39 @@ Variables with a default `value` are injected when running CLI commands. Secrets must be present in the environment; missing secrets abort CLI commands with an error. +## Stores Section + +Use `[stores.config]` for small read-only runtime configuration such as feature flags, JWKS metadata, +or service settings: + +```toml +[stores.config] +name = "app_config" + +[stores.config.defaults] +"greeting" = "hello from config store" +"service.timeout_ms" = "1500" + +[stores.config.adapters.cloudflare] +name = "APP_CONFIG" +``` + +| Field | Required | Description | +| ---------- | -------- | ------------------------------------------------------------------------------------------------------------ | +| `name` | No | Global store or binding name; if omitted but the section is present, adapters fall back to `EDGEZERO_CONFIG` | +| `adapters` | No | Per-adapter name overrides, keyed by adapter name | +| `defaults` | No | Local default values used by the Axum adapter when env vars are absent | + +Runtime behavior by adapter: + +- Fastly reads from a Fastly Config Store resource link. +- Cloudflare reads from a single JSON string binding in `wrangler.toml [vars]`. +- Axum reads from the process environment and falls back to `defaults`. + +When `[stores.config]` is present, the `app!` macro generates config-store metadata on the `App` +type. The standard adapter `run_app` helpers use that metadata to inject a config-store handle into +request extensions automatically, so handlers can call `ctx.config_store()`. + ## Adapters Section Each adapter has its own configuration block: @@ -299,6 +332,7 @@ The macro: - Parses HTTP triggers - Generates route registration - Wires middleware from the manifest +- Generates config-store metadata from `[stores.config]` when present - Creates the `App` struct that implements `Hooks` (use `App::build_app()`) ### ManifestLoader diff --git a/examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs b/examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs index 9869dea..12ae0f3 100644 --- a/examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs +++ b/examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs @@ -8,11 +8,5 @@ use worker::*; #[cfg(target_arch = "wasm32")] #[event(fetch)] pub async fn main(req: Request, env: Env, ctx: Context) -> Result { - edgezero_adapter_cloudflare::run_app_with_manifest::( - include_str!("../../../edgezero.toml"), - req, - env, - ctx, - ) - .await + edgezero_adapter_cloudflare::run_app::(req, env, ctx).await } From 516c34129ec7ef26d26c0b2ff37fba386ba89feb Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 9 Mar 2026 20:05:11 +0530 Subject: [PATCH 3/8] Fix format --- crates/edgezero-adapter-cloudflare/tests/contract.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index 57e2453..ac61f51 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -2,8 +2,7 @@ use bytes::Bytes; use edgezero_adapter_cloudflare::{ - dispatch, dispatch_with_config, from_core_response, into_core_request, - CloudflareRequestContext, + dispatch, dispatch_with_config, from_core_response, into_core_request, CloudflareRequestContext, }; use edgezero_core::{ app::App, From ba1d1c77b0f94512429aff50e8b6c0db6be99a34 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 12 Mar 2026 12:58:48 +0530 Subject: [PATCH 4/8] Harden config store docs and adapter comments --- .github/workflows/test.yml | 86 ++++++++++++ .../edgezero-adapter-axum/src/config_store.rs | 79 ++++++++--- crates/edgezero-adapter-axum/src/service.rs | 11 +- .../src/config_store.rs | 61 ++++++-- crates/edgezero-adapter-cloudflare/src/lib.rs | 102 +++++++++++--- .../src/request.rs | 47 ++++++- .../tests/contract.rs | 44 +++++- .../src/config_store.rs | 43 ++++-- crates/edgezero-adapter-fastly/src/lib.rs | 11 +- crates/edgezero-adapter-fastly/src/request.rs | 111 ++++++++++++--- .../edgezero-adapter-fastly/tests/contract.rs | 40 +++++- crates/edgezero-core/src/config_store.rs | 132 +++++++++++++++--- crates/edgezero-core/src/context.rs | 12 +- crates/edgezero-core/src/error.rs | 42 ++++++ crates/edgezero-core/src/manifest.rs | 82 ++++++++++- crates/edgezero-macros/src/app.rs | 7 +- docs/guide/adapters/axum.md | 9 +- docs/guide/adapters/cloudflare.md | 10 +- docs/guide/adapters/fastly.md | 3 + docs/guide/architecture.md | 3 +- docs/guide/configuration.md | 9 +- .../crates/app-demo-core/src/handlers.rs | 92 +++++++++--- 22 files changed, 888 insertions(+), 148 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0a9a4e9..df18756 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,3 +57,89 @@ jobs: - name: Check feature compilation run: cargo check --workspace --all-targets --features "fastly cloudflare" + + cloudflare-wasm-tests: + name: cloudflare wasm tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Cache Cargo dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Retrieve Rust version + id: rust-version-cloudflare + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + shell: bash + + - name: Set up Rust tool chain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.rust-version-cloudflare.outputs.rust-version }} + + - name: Add wasm32 target + run: rustup target add wasm32-unknown-unknown + + - name: Install wasm-bindgen test runner + run: cargo install wasm-bindgen-cli --version 0.2.113 --locked + + - name: Fetch dependencies (locked) + run: cargo fetch --locked + + - name: Run Cloudflare wasm tests + env: + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner + run: cargo test -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown + + fastly-wasm-tests: + name: fastly wasm tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Cache Cargo dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Retrieve Rust version + id: rust-version-fastly + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + shell: bash + + - name: Set up Rust tool chain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.rust-version-fastly.outputs.rust-version }} + + - name: Add wasm32-wasi target + run: rustup target add wasm32-wasip1 + + - name: Set up Wasmtime + uses: bytecodealliance/actions/wasmtime/setup@v1 + + - name: Fetch dependencies (locked) + run: cargo fetch --locked + + - name: Run Fastly wasm tests + env: + CARGO_TARGET_WASM32_WASIP1_RUNNER: "wasmtime run --dir=." + run: cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 diff --git a/crates/edgezero-adapter-axum/src/config_store.rs b/crates/edgezero-adapter-axum/src/config_store.rs index fd94af5..2902518 100644 --- a/crates/edgezero-adapter-axum/src/config_store.rs +++ b/crates/edgezero-adapter-axum/src/config_store.rs @@ -2,20 +2,16 @@ use std::collections::HashMap; -use edgezero_core::config_store::ConfigStore; +use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; /// Config store for local dev / Axum. Reads from env vars with manifest /// defaults as fallback. Env vars take precedence over defaults. /// /// # Note on `from_env` /// -/// [`AxumConfigStore::from_env`] snapshots the **entire** process environment -/// at construction time. Any env var name is therefore accessible via -/// `ctx.config_store()?.get("VAR_NAME")`. In practice, manifest config keys -/// use lowercase dotted names (e.g. `feature.new_checkout`) which do not -/// collide with typical uppercase process vars (`PATH`, `HOME`, etc.), so -/// accidental leakage is unlikely. For production deployments use Fastly or -/// Cloudflare adapters, which read only from their respective platform stores. +/// [`AxumConfigStore::from_env`] only reads environment variables for keys +/// declared in `[stores.config.defaults]`. Use an empty-string default when a +/// key should be overrideable from env without carrying a real default value. pub struct AxumConfigStore { env: HashMap, defaults: HashMap, @@ -35,16 +31,29 @@ impl AxumConfigStore { /// Create from the current process environment and manifest defaults. pub fn from_env(defaults: impl IntoIterator) -> Self { - Self::new(std::env::vars(), defaults) + Self::from_lookup(defaults, |key| std::env::var(key).ok()) + } + + fn from_lookup(defaults: impl IntoIterator, mut lookup: F) -> Self + where + F: FnMut(&str) -> Option, + { + let defaults: HashMap = defaults.into_iter().collect(); + let env = defaults + .keys() + .filter_map(|key| lookup(key).map(|value| (key.clone(), value))) + .collect(); + Self { env, defaults } } } impl ConfigStore for AxumConfigStore { - fn get(&self, key: &str) -> Option { - self.env + fn get(&self, key: &str) -> Result, ConfigStoreError> { + Ok(self + .env .get(key) .or_else(|| self.defaults.get(key)) - .cloned() + .cloned()) } } @@ -62,25 +71,63 @@ mod tests { #[test] fn axum_config_store_returns_values() { let s = store(&[("MY_KEY", "my_val")], &[]); - assert_eq!(s.get("MY_KEY"), Some("my_val".to_string())); + assert_eq!( + s.get("MY_KEY").expect("config value"), + Some("my_val".to_string()) + ); } #[test] fn axum_config_store_returns_none_for_missing() { let s = store(&[], &[]); - assert_eq!(s.get("NOPE"), None); + assert_eq!(s.get("NOPE").expect("missing config"), None); } #[test] fn axum_config_store_env_overrides_defaults() { let s = store(&[("KEY", "from_env")], &[("KEY", "from_default")]); - assert_eq!(s.get("KEY"), Some("from_env".to_string())); + assert_eq!( + s.get("KEY").expect("config value"), + Some("from_env".to_string()) + ); } #[test] fn axum_config_store_falls_back_to_defaults() { let s = store(&[], &[("KEY", "default_val")]); - assert_eq!(s.get("KEY"), Some("default_val".to_string())); + assert_eq!( + s.get("KEY").expect("default config"), + Some("default_val".to_string()) + ); + } + + #[test] + fn axum_config_store_from_env_reads_only_declared_keys() { + let s = AxumConfigStore::from_lookup( + [ + ("feature.new_checkout".to_string(), "false".to_string()), + ("service.timeout_ms".to_string(), "1500".to_string()), + ], + |key| match key { + "feature.new_checkout" => Some("true".to_string()), + "DATABASE_URL" => Some("postgres://secret".to_string()), + _ => None, + }, + ); + + assert_eq!( + s.get("feature.new_checkout").expect("allowed env override"), + Some("true".to_string()) + ); + assert_eq!( + s.get("service.timeout_ms").expect("default fallback"), + Some("1500".to_string()) + ); + assert_eq!( + s.get("DATABASE_URL") + .expect("undeclared key should stay hidden"), + None + ); } // Run the shared contract tests against AxumConfigStore (env path). diff --git a/crates/edgezero-adapter-axum/src/service.rs b/crates/edgezero-adapter-axum/src/service.rs index a084814..1560be6 100644 --- a/crates/edgezero-adapter-axum/src/service.rs +++ b/crates/edgezero-adapter-axum/src/service.rs @@ -82,7 +82,7 @@ impl Service> for EdgeZeroAxumService { mod tests { use super::*; use edgezero_core::body::Body; - use edgezero_core::config_store::{ConfigStore, ConfigStoreHandle}; + use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{response_builder, StatusCode}; @@ -92,8 +92,8 @@ mod tests { struct FixedConfigStore(String); impl ConfigStore for FixedConfigStore { - fn get(&self, _key: &str) -> Option { - Some(self.0.clone()) + fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone())) } } @@ -122,7 +122,10 @@ mod tests { let router = RouterService::builder() .get("/check", |ctx: RequestContext| async move { let store = ctx.config_store().expect("config store should be present"); - let val = store.get("any_key").unwrap_or_default(); + let val = store + .get("any_key") + .expect("config lookup should succeed") + .unwrap_or_default(); let response = response_builder() .status(StatusCode::OK) .body(Body::from(val)) diff --git a/crates/edgezero-adapter-cloudflare/src/config_store.rs b/crates/edgezero-adapter-cloudflare/src/config_store.rs index d775462..c557499 100644 --- a/crates/edgezero-adapter-cloudflare/src/config_store.rs +++ b/crates/edgezero-adapter-cloudflare/src/config_store.rs @@ -11,13 +11,14 @@ //! This allows arbitrary string keys (including dots) on a platform whose binding //! names are restricted to JavaScript identifier syntax. -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex, OnceLock}; -use edgezero_core::config_store::ConfigStore; +use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; use worker::Env; type ConfigMap = HashMap; +const CONFIG_CACHE_LIMIT: usize = 64; /// Config store backed by a single Cloudflare JSON string binding. /// @@ -62,16 +63,16 @@ impl CloudflareConfigStore { } impl ConfigStore for CloudflareConfigStore { - fn get(&self, key: &str) -> Option { - self.data.get(key).cloned() + fn get(&self, key: &str) -> Result, ConfigStoreError> { + Ok(self.data.get(key).cloned()) } } /// Parse-and-cache the config map for `binding_name`. /// -/// Keyed only by name: Cloudflare env vars are immutable within an isolate lifetime, -/// so the parsed result for a given binding name never changes. Warnings are emitted -/// only on the first miss for a given name (log-once semantics). +/// Keyed only by name: Cloudflare env vars are immutable within an isolate +/// lifetime, so the parsed result for a given binding name never changes. +/// Warnings are suppressed for recently seen binding names via a bounded cache. /// /// # WASM safety /// `std::sync::Mutex` compiles for `wasm32-unknown-unknown` and is safe here because @@ -84,7 +85,7 @@ fn lookup_cached(env: &Env, binding_name: &str) -> Option> { .unwrap_or_else(|p| p.into_inner()) .get(binding_name) { - return entry.clone(); + return entry; } // Cache miss: resolve from the JS env (synchronous interop, safe outside the lock). @@ -112,14 +113,46 @@ fn lookup_cached(env: &Env, binding_name: &str) -> Option> { config_cache() .lock() .unwrap_or_else(|p| p.into_inner()) - .entry(binding_name.to_string()) - .or_insert(resolved) - .clone() + .insert(binding_name, resolved, CONFIG_CACHE_LIMIT) } -fn config_cache() -> &'static Mutex>>> { - static CACHE: OnceLock>>>> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(HashMap::new())) +fn config_cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(ConfigCache::default())) +} + +#[derive(Default)] +struct ConfigCache { + entries: HashMap>>, + order: VecDeque, +} + +impl ConfigCache { + fn get(&self, key: &str) -> Option>> { + self.entries.get(key).cloned() + } + + fn insert( + &mut self, + key: &str, + value: Option>, + limit: usize, + ) -> Option> { + if let Some(existing) = self.entries.get(key) { + return existing.clone(); + } + + if limit > 0 && self.order.len() >= limit { + if let Some(oldest) = self.order.pop_front() { + self.entries.remove(&oldest); + } + } + + let key = key.to_string(); + self.order.push_back(key.clone()); + self.entries.insert(key, value.clone()); + value + } } #[cfg(test)] diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index 548a64a..a594b30 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -1,5 +1,10 @@ //! Adapter helpers for Cloudflare Workers. +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use std::collections::{HashMap, VecDeque}; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use std::sync::{Mutex, OnceLock}; + #[cfg(feature = "cli")] pub mod cli; @@ -21,7 +26,8 @@ pub use context::CloudflareRequestContext; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub use proxy::CloudflareProxyClient; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -pub use request::{dispatch, dispatch_with_config, into_core_request}; +#[allow(deprecated)] +pub use request::{dispatch, dispatch_with_config, dispatch_with_config_store, into_core_request}; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub use response::from_core_response; @@ -37,6 +43,9 @@ pub fn init_logger() -> Result<(), log::SetLoggerError> { #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub trait AppExt { + #[deprecated( + note = "AppExt::dispatch() is the low-level manual path and does not inject config-store metadata; prefer run_app(), dispatch_with_config(), or dispatch_with_config_store()" + )] fn dispatch<'a>( &'a self, req: worker::Request, @@ -49,6 +58,7 @@ pub trait AppExt { #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] impl AppExt for edgezero_core::app::App { + #[allow(deprecated)] fn dispatch<'a>( &'a self, req: worker::Request, @@ -57,7 +67,7 @@ impl AppExt for edgezero_core::app::App { ) -> ::core::pin::Pin< Box> + 'a>, > { - Box::pin(crate::request::dispatch(self, req, env, ctx)) + Box::pin(crate::request::dispatch_raw(self, req, env, ctx)) } } @@ -84,14 +94,6 @@ pub async fn run_app( /// Prefers hook metadata from [`edgezero_core::app::Hooks::config_store`] /// and falls back to resolving `[stores.config]` from `manifest_src`. /// -/// # Deprecation -/// Apps generated by the `app!` macro already embed config-store metadata in -/// `Hooks::config_store()`. Prefer [`run_app`], which reads that metadata -/// directly and does not require passing the manifest source at runtime. -#[deprecated( - note = "Use run_app instead. Config-store metadata is now embedded by the app!() macro \ - and read via Hooks::config_store(); passing manifest_src at runtime is no longer needed." -)] #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub async fn run_app_with_manifest( manifest_src: &str, @@ -106,13 +108,7 @@ pub async fn run_app_with_manifest( cfg.name_for_adapter(edgezero_core::app::CLOUDFLARE_ADAPTER) .to_string() }) - .or_else(|| { - let manifest = edgezero_core::manifest::ManifestLoader::load_from_str(manifest_src); - manifest.manifest().stores.config.as_ref().map(|cfg| { - cfg.config_store_name(edgezero_core::app::CLOUDFLARE_ADAPTER) - .to_string() - }) - }); + .or_else(|| resolve_manifest_config_store_name(manifest_src)); dispatch_app(&app, req, env, ctx, binding_name.as_deref()).await } @@ -127,6 +123,76 @@ async fn dispatch_app( if let Some(binding_name) = config_store_name { dispatch_with_config(app, req, env, ctx, binding_name).await } else { - dispatch(app, req, env, ctx).await + crate::request::dispatch_raw(app, req, env, ctx).await + } +} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +fn resolve_manifest_config_store_name(manifest_src: &str) -> Option { + const MANIFEST_NAME_CACHE_LIMIT: usize = 8; + + if let Some(binding_name) = manifest_name_cache() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(manifest_src) + { + return binding_name; + } + + let manifest = edgezero_core::manifest::ManifestLoader::load_from_str(manifest_src); + let binding_name = manifest.manifest().stores.config.as_ref().map(|cfg| { + cfg.config_store_name(edgezero_core::app::CLOUDFLARE_ADAPTER) + .to_string() + }); + + manifest_name_cache() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert( + manifest_src, + binding_name.clone(), + MANIFEST_NAME_CACHE_LIMIT, + ) +} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +fn manifest_name_cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(ManifestNameCache::default())) +} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +#[derive(Default)] +struct ManifestNameCache { + entries: HashMap>, + order: VecDeque, +} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +impl ManifestNameCache { + fn get(&self, manifest_src: &str) -> Option> { + self.entries.get(manifest_src).cloned() + } + + fn insert( + &mut self, + manifest_src: &str, + binding_name: Option, + limit: usize, + ) -> Option { + if let Some(existing) = self.entries.get(manifest_src) { + return existing.clone(); + } + + if limit > 0 && self.order.len() >= limit { + if let Some(oldest) = self.order.pop_front() { + self.entries.remove(&oldest); + } + } + + let manifest_src = manifest_src.to_string(); + self.order.push_back(manifest_src.clone()); + self.entries.insert(manifest_src, binding_name.clone()); + binding_name } } diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index a91bf83..3745082 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -49,18 +49,47 @@ pub async fn into_core_request( Ok(request) } +pub(crate) async fn dispatch_raw( + app: &App, + req: CfRequest, + env: Env, + ctx: Context, +) -> Result { + let core_request = into_core_request(req, env, ctx) + .await + .map_err(edge_error_to_worker)?; + dispatch_core_request(app, core_request, None).await +} + +/// Low-level manual dispatch. +/// +/// This path does not resolve or inject config-store metadata from a manifest. +/// Prefer `run_app`, `dispatch_with_config`, or `dispatch_with_config_store` +/// for config-store-aware dispatch. +#[deprecated( + note = "dispatch() is the low-level manual path and does not inject config-store metadata; prefer run_app(), dispatch_with_config(), or dispatch_with_config_store()" +)] pub async fn dispatch( app: &App, req: CfRequest, env: Env, ctx: Context, +) -> Result { + dispatch_raw(app, req, env, ctx).await +} + +/// Dispatch a request with a prepared config-store handle injected. +pub async fn dispatch_with_config_store( + app: &App, + req: CfRequest, + env: Env, + ctx: Context, + config_store_handle: ConfigStoreHandle, ) -> Result { let core_request = into_core_request(req, env, ctx) .await .map_err(edge_error_to_worker)?; - let svc = app.router().clone(); - let response = svc.oneshot(core_request).await; - from_core_response(response).map_err(edge_error_to_worker) + dispatch_core_request(app, core_request, Some(config_store_handle)).await } /// Dispatch a request with a Cloudflare JSON config store injected. @@ -77,10 +106,18 @@ pub async fn dispatch_with_config( ) -> Result { let config_handle = CloudflareConfigStore::try_new(&env, binding_name) .map(|store| ConfigStoreHandle::new(Arc::new(store))); - let mut core_request = into_core_request(req, env, ctx) + let core_request = into_core_request(req, env, ctx) .await .map_err(edge_error_to_worker)?; - if let Some(handle) = config_handle { + dispatch_core_request(app, core_request, config_handle).await +} + +async fn dispatch_core_request( + app: &App, + mut core_request: Request, + config_store_handle: Option, +) -> Result { + if let Some(handle) = config_store_handle { core_request.extensions_mut().insert(handle); } let svc = app.router().clone(); diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index ac61f51..9a0b47c 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -1,24 +1,37 @@ #![cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +// Keep coverage for the deprecated low-level dispatch path while it remains public. +#![allow(deprecated)] use bytes::Bytes; use edgezero_adapter_cloudflare::{ - dispatch, dispatch_with_config, from_core_response, into_core_request, CloudflareRequestContext, + dispatch, dispatch_with_config, dispatch_with_config_store, from_core_response, + into_core_request, CloudflareRequestContext, }; use edgezero_core::{ app::App, body::Body, + config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}, context::RequestContext, error::EdgeError, http::{response_builder, Method, Response, StatusCode}, router::RouterService, }; use futures::stream; +use std::sync::Arc; use wasm_bindgen_test::*; use worker::wasm_bindgen::{JsCast, JsValue}; use worker::{Context, Env, Method as CfMethod, Request as CfRequest, RequestInit}; wasm_bindgen_test_configure!(run_in_browser); +struct FixedConfigStore(&'static str); + +impl ConfigStore for FixedConfigStore { + fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some(self.0.to_string())) + } +} + fn build_test_app() -> App { async fn capture_uri(ctx: RequestContext) -> Result { let body = Body::text(ctx.request().uri().to_string()); @@ -64,11 +77,24 @@ fn build_test_app() -> App { Ok(response) } + async fn config_value(ctx: RequestContext) -> Result { + let value = ctx + .config_store() + .and_then(|store| store.get("greeting").ok().flatten()) + .unwrap_or_else(|| "missing".to_string()); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::text(value)) + .expect("response"); + Ok(response) + } + let router = RouterService::builder() .get("/uri", capture_uri) .post("/mirror", mirror_body) .get("/stream", stream_response) .get("/has-config", config_presence) + .get("/config-value", config_value) .build(); App::new(router) @@ -202,3 +228,19 @@ async fn dispatch_with_config_missing_binding_skips_injection() { let body = response.text().await.expect("text"); assert_eq!(body, "no"); } + +#[wasm_bindgen_test] +async fn dispatch_with_config_store_injects_handle() { + let app = build_test_app(); + let req = cf_request(CfMethod::Get, "/config-value", None); + let (env, ctx) = test_env_ctx(); + let handle = ConfigStoreHandle::new(Arc::new(FixedConfigStore("hello from cf test"))); + + let mut response = dispatch_with_config_store(&app, req, env, ctx, handle) + .await + .expect("cf response"); + + assert_eq!(response.status_code(), StatusCode::OK.as_u16()); + let body = response.text().await.expect("text"); + assert_eq!(body, "hello from cf test"); +} diff --git a/crates/edgezero-adapter-fastly/src/config_store.rs b/crates/edgezero-adapter-fastly/src/config_store.rs index 022c9d3..62b9a1c 100644 --- a/crates/edgezero-adapter-fastly/src/config_store.rs +++ b/crates/edgezero-adapter-fastly/src/config_store.rs @@ -3,7 +3,7 @@ #[cfg(test)] use std::collections::HashMap; -use edgezero_core::config_store::ConfigStore; +use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; /// Config store backed by a Fastly Config Store resource link. pub struct FastlyConfigStore { @@ -19,10 +19,9 @@ enum FastlyConfigStoreBackend { impl FastlyConfigStore { /// Open a Fastly Config Store by resource link name. /// - /// Returns `None` if the store is not available (e.g. not configured in - /// `fastly.toml`), allowing graceful fallback without panicking. - pub fn try_open(name: &str) -> Option { - fastly::ConfigStore::try_open(name).ok().map(|inner| Self { + /// Returns an error if the configured store cannot be opened. + pub fn try_open(name: &str) -> Result { + fastly::ConfigStore::try_open(name).map(|inner| Self { inner: FastlyConfigStoreBackend::Fastly(inner), }) } @@ -36,15 +35,31 @@ impl FastlyConfigStore { } impl ConfigStore for FastlyConfigStore { - fn get(&self, key: &str) -> Option { + fn get(&self, key: &str) -> Result, ConfigStoreError> { match &self.inner { - FastlyConfigStoreBackend::Fastly(inner) => inner.try_get(key).ok().flatten(), + FastlyConfigStoreBackend::Fastly(inner) => inner.try_get(key).map_err(map_lookup_error), #[cfg(test)] - FastlyConfigStoreBackend::InMemory(data) => data.get(key).cloned(), + FastlyConfigStoreBackend::InMemory(data) => Ok(data.get(key).cloned()), } } } +fn map_lookup_error(err: fastly::config_store::LookupError) -> ConfigStoreError { + match err { + fastly::config_store::LookupError::KeyInvalid + | fastly::config_store::LookupError::KeyTooLong => { + ConfigStoreError::invalid_key("invalid config key") + } + fastly::config_store::LookupError::ConfigStoreInvalid + | fastly::config_store::LookupError::TooManyLookups + | fastly::config_store::LookupError::ValueTooLong + | fastly::config_store::LookupError::Other => { + ConfigStoreError::unavailable(format!("Fastly config store lookup failed: {err}")) + } + _ => ConfigStoreError::unavailable(format!("Fastly config store lookup failed: {err}")), + } +} + #[cfg(test)] mod tests { use super::*; @@ -55,4 +70,16 @@ mod tests { ("contract.key.b".to_string(), "value_b".to_string()), ]) }); + + #[test] + fn key_invalid_maps_to_invalid_key_error() { + let err = map_lookup_error(fastly::config_store::LookupError::KeyInvalid); + assert!(matches!(err, ConfigStoreError::InvalidKey { .. })); + } + + #[test] + fn key_too_long_maps_to_invalid_key_error() { + let err = map_lookup_error(fastly::config_store::LookupError::KeyTooLong); + assert!(matches!(err, ConfigStoreError::InvalidKey { .. })); + } } diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index e30dcf4..986508c 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -21,7 +21,8 @@ pub use context::FastlyRequestContext; #[cfg(feature = "fastly")] pub use proxy::FastlyProxyClient; #[cfg(feature = "fastly")] -pub use request::{dispatch, dispatch_with_config, into_core_request}; +#[allow(deprecated)] +pub use request::{dispatch, dispatch_with_config, dispatch_with_config_store, into_core_request}; #[cfg(feature = "fastly")] pub use response::from_core_response; @@ -66,13 +67,17 @@ pub fn init_logger( #[cfg(feature = "fastly")] pub trait AppExt { + #[deprecated( + note = "AppExt::dispatch() is the low-level manual path and does not inject config-store metadata; prefer run_app(), dispatch_with_config(), or dispatch_with_config_store()" + )] fn dispatch(&self, req: fastly::Request) -> Result; } #[cfg(feature = "fastly")] impl AppExt for edgezero_core::app::App { + #[allow(deprecated)] fn dispatch(&self, req: fastly::Request) -> Result { - dispatch(self, req) + crate::request::dispatch_raw(self, req) } } @@ -114,7 +119,7 @@ pub fn run_app_with_config( if let Some(name) = config_store_name { dispatch_with_config(&app, req, name) } else { - dispatch(&app, req) + crate::request::dispatch_raw(&app, req) } } diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index e306d80..7c9c822 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -1,5 +1,7 @@ +use std::collections::{HashSet, VecDeque}; use std::io::Read; use std::sync::Arc; +use std::sync::{Mutex, OnceLock}; use edgezero_core::app::App; use edgezero_core::body::Body; @@ -15,6 +17,8 @@ use crate::proxy::FastlyProxyClient; use crate::response::{from_core_response, parse_uri}; use crate::FastlyRequestContext; +const WARNED_STORE_CACHE_LIMIT: usize = 64; + pub fn into_core_request(mut req: FastlyRequest) -> Result { let method = req.get_method().clone(); let uri = parse_uri(req.get_url_str())?; @@ -43,40 +47,113 @@ pub fn into_core_request(mut req: FastlyRequest) -> Result { Ok(request) } +pub(crate) fn dispatch_raw(app: &App, req: FastlyRequest) -> Result { + let core_request = into_core_request(req).map_err(map_edge_error)?; + dispatch_core_request(app, core_request, None) +} + +/// Low-level manual dispatch. +/// +/// This path does not resolve or inject config-store metadata from a manifest. +/// Prefer `run_app`, `dispatch_with_config`, or `dispatch_with_config_store` +/// for config-store-aware dispatch. +#[deprecated( + note = "dispatch() is the low-level manual path and does not inject config-store metadata; prefer run_app(), dispatch_with_config(), or dispatch_with_config_store()" +)] pub fn dispatch(app: &App, req: FastlyRequest) -> Result { + dispatch_raw(app, req) +} + +/// Dispatch a request with a prepared config-store handle injected into extensions. +pub fn dispatch_with_config_store( + app: &App, + req: FastlyRequest, + config_store_handle: ConfigStoreHandle, +) -> Result { let core_request = into_core_request(req).map_err(map_edge_error)?; - let response = executor::block_on(app.router().oneshot(core_request)); - from_core_response(response).map_err(map_edge_error) + dispatch_core_request(app, core_request, Some(config_store_handle)) } /// Dispatch a request with a Fastly Config Store injected into extensions. /// -/// If the named store is not available, logs at info level and dispatches without it. +/// If the named store is not available, suppresses repeated warnings for +/// recently seen store names and dispatches without it. pub fn dispatch_with_config( app: &App, req: FastlyRequest, store_name: &str, ) -> Result { - let mut core_request = into_core_request(req).map_err(map_edge_error)?; - - match FastlyConfigStore::try_open(store_name) { - Some(store) => { - core_request - .extensions_mut() - .insert(ConfigStoreHandle::new(Arc::new(store))); + let config_store_handle = match FastlyConfigStore::try_open(store_name) { + Ok(store) => Some(ConfigStoreHandle::new(Arc::new(store))), + Err(err) => { + warn_missing_store_once(store_name, &err.to_string()); + None } - None => { - log::warn!( - "configured Fastly config store '{}' is unavailable; skipping config-store injection", - store_name - ); - } - } + }; + let core_request = into_core_request(req).map_err(map_edge_error)?; + dispatch_core_request(app, core_request, config_store_handle) +} +fn dispatch_core_request( + app: &App, + mut core_request: Request, + config_store_handle: Option, +) -> Result { + if let Some(handle) = config_store_handle { + core_request.extensions_mut().insert(handle); + } let response = executor::block_on(app.router().oneshot(core_request)); from_core_response(response).map_err(map_edge_error) } +fn warn_missing_store_once(store_name: &str, detail: &str) { + let warned = warned_store_cache().get_or_init(|| Mutex::new(RecentStringSet::default())); + let mut warned = warned + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if warned.insert(store_name, WARNED_STORE_CACHE_LIMIT) { + log::warn!( + "configured Fastly config store '{}' is unavailable ({}); skipping config-store injection", + store_name, + detail + ); + } +} + +fn warned_store_cache() -> &'static OnceLock> { + static WARNED_STORES: OnceLock> = OnceLock::new(); + &WARNED_STORES +} + +#[derive(Default)] +struct RecentStringSet { + keys: HashSet, + order: VecDeque, +} + +impl RecentStringSet { + fn insert(&mut self, key: &str, limit: usize) -> bool { + if self.keys.contains(key) { + return false; + } + + if limit == 0 { + return true; + } + + if self.order.len() >= limit { + if let Some(oldest) = self.order.pop_front() { + self.keys.remove(&oldest); + } + } + + let key = key.to_string(); + self.keys.insert(key.clone()); + self.order.push_back(key); + true + } +} + fn map_edge_error(err: EdgeError) -> FastlyError { FastlyError::msg(err.to_string()) } diff --git a/crates/edgezero-adapter-fastly/tests/contract.rs b/crates/edgezero-adapter-fastly/tests/contract.rs index 37c0375..417ec40 100644 --- a/crates/edgezero-adapter-fastly/tests/contract.rs +++ b/crates/edgezero-adapter-fastly/tests/contract.rs @@ -1,11 +1,15 @@ #![cfg(all(feature = "fastly", target_arch = "wasm32"))] +// Keep coverage for the deprecated low-level dispatch path while it remains public. +#![allow(deprecated)] use bytes::Bytes; use edgezero_adapter_fastly::{ - dispatch, from_core_response, into_core_request, FastlyRequestContext, + dispatch, dispatch_with_config_store, from_core_response, into_core_request, + FastlyRequestContext, }; use edgezero_core::app::App; use edgezero_core::body::Body; +use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{response_builder, Method, Response, StatusCode}; @@ -13,6 +17,15 @@ use edgezero_core::router::RouterService; use fastly::http::{Method as FastlyMethod, StatusCode as FastlyStatus}; use fastly::Request as FastlyRequest; use futures::stream; +use std::sync::Arc; + +struct FixedConfigStore(&'static str); + +impl ConfigStore for FixedConfigStore { + fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some(self.0.to_string())) + } +} fn build_test_app() -> App { async fn capture_uri(ctx: RequestContext) -> Result { @@ -46,10 +59,23 @@ fn build_test_app() -> App { Ok(response) } + async fn config_value(ctx: RequestContext) -> Result { + let value = ctx + .config_store() + .and_then(|store| store.get("greeting").ok().flatten()) + .unwrap_or_else(|| "missing".to_string()); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::text(value)) + .expect("response"); + Ok(response) + } + let router = RouterService::builder() .get("/uri", capture_uri) .post("/mirror", mirror_body) .get("/stream", stream_response) + .get("/config", config_value) .build(); App::new(router) @@ -141,3 +167,15 @@ fn dispatch_passes_request_body_to_handlers() { assert_eq!(response.get_status(), FastlyStatus::OK); assert_eq!(response.take_body_bytes(), b"echo"); } + +#[test] +fn dispatch_with_config_store_injects_handle() { + let app = build_test_app(); + let req = fastly_request(FastlyMethod::GET, "/config", None); + let handle = ConfigStoreHandle::new(Arc::new(FixedConfigStore("hello from fastly test"))); + + let mut response = dispatch_with_config_store(&app, req, handle).expect("fastly response"); + + assert_eq!(response.get_status(), FastlyStatus::OK); + assert_eq!(response.take_body_bytes(), b"hello from fastly test"); +} diff --git a/crates/edgezero-core/src/config_store.rs b/crates/edgezero-core/src/config_store.rs index eff31ba..0905f5d 100644 --- a/crates/edgezero-core/src/config_store.rs +++ b/crates/edgezero-core/src/config_store.rs @@ -6,6 +6,9 @@ use std::fmt; use std::sync::Arc; +use anyhow::Error as AnyError; +use thiserror::Error; + // --------------------------------------------------------------------------- // Trait // --------------------------------------------------------------------------- @@ -16,9 +19,52 @@ use std::sync::Arc; /// - `AxumConfigStore` (axum adapter) — env vars + in-memory defaults for dev /// - `FastlyConfigStore` (fastly adapter) — Fastly Config Store /// - `CloudflareConfigStore` (cloudflare adapter) — Cloudflare env bindings +/// +/// Errors returned by config-store backends. +/// +/// Missing keys are represented as `Ok(None)` from [`ConfigStore::get`]. +#[derive(Debug, Error)] +pub enum ConfigStoreError { + /// The caller asked for a key that is malformed for the active backend. + #[error("{message}")] + InvalidKey { message: String }, + /// The configured backend cannot currently serve requests. + #[error("config store unavailable: {message}")] + Unavailable { message: String }, + /// An unexpected backend or provider failure occurred. + #[error("config store error: {source}")] + Internal { source: AnyError }, +} + +impl ConfigStoreError { + /// Create an error for malformed or backend-invalid keys. + pub fn invalid_key(message: impl Into) -> Self { + Self::InvalidKey { + message: message.into(), + } + } + + /// Create an error for temporarily unavailable backends. + pub fn unavailable(message: impl Into) -> Self { + Self::Unavailable { + message: message.into(), + } + } + + /// Wrap an unexpected backend or provider failure. + pub fn internal(error: E) -> Self + where + E: Into, + { + Self::Internal { + source: error.into(), + } + } +} + pub trait ConfigStore: Send + Sync { /// Retrieve a config value by key. Returns `None` if the key does not exist. - fn get(&self, key: &str) -> Option; + fn get(&self, key: &str) -> Result, ConfigStoreError>; } // --------------------------------------------------------------------------- @@ -44,7 +90,7 @@ impl ConfigStoreHandle { } /// Get a config value by key. - pub fn get(&self, key: &str) -> Option { + pub fn get(&self, key: &str) -> Result, ConfigStoreError> { self.store.get(key) } } @@ -86,33 +132,42 @@ macro_rules! config_store_contract_tests { #[$test_attr] fn contract_get_returns_value_for_existing_key() { let store = $factory; - assert_eq!(store.get("contract.key.a"), Some("value_a".to_string())); + assert_eq!( + store.get("contract.key.a").expect("config value"), + Some("value_a".to_string()) + ); } #[$test_attr] fn contract_get_returns_none_for_missing_key() { let store = $factory; - assert_eq!(store.get("contract.key.missing"), None); + assert_eq!(store.get("contract.key.missing").expect("config miss"), None); } #[$test_attr] fn contract_multiple_keys_are_independent() { let store = $factory; - assert_eq!(store.get("contract.key.a"), Some("value_a".to_string())); - assert_eq!(store.get("contract.key.b"), Some("value_b".to_string())); + assert_eq!( + store.get("contract.key.a").expect("first config value"), + Some("value_a".to_string()) + ); + assert_eq!( + store.get("contract.key.b").expect("second config value"), + Some("value_b".to_string()) + ); } #[$test_attr] fn contract_key_lookup_is_case_sensitive() { let store = $factory; // lowercase "contract.key.a" exists; uppercase must not match - assert_eq!(store.get("CONTRACT.KEY.A"), None); + assert_eq!(store.get("CONTRACT.KEY.A").expect("case-sensitive miss"), None); } #[$test_attr] fn contract_empty_key_returns_none() { let store = $factory; - assert_eq!(store.get(""), None); + assert_eq!(store.get("").expect("empty key miss"), None); } #[$test_attr] @@ -121,8 +176,11 @@ macro_rules! config_store_contract_tests { use $crate::config_store::ConfigStoreHandle; let handle = ConfigStoreHandle::new(Arc::new($factory)); - assert_eq!(handle.get("contract.key.a"), Some("value_a".to_string())); - assert_eq!(handle.get("contract.key.missing"), None); + assert_eq!( + handle.get("contract.key.a").expect("handle value"), + Some("value_a".to_string()) + ); + assert_eq!(handle.get("contract.key.missing").expect("handle miss"), None); } #[$test_attr] @@ -132,10 +190,13 @@ macro_rules! config_store_contract_tests { let h1 = ConfigStoreHandle::new(Arc::new($factory)); let h2 = h1.clone(); - assert_eq!(h1.get("contract.key.a"), h2.get("contract.key.a")); assert_eq!( - h1.get("contract.key.missing"), - h2.get("contract.key.missing") + h1.get("contract.key.a").expect("first handle value"), + h2.get("contract.key.a").expect("second handle value") + ); + assert_eq!( + h1.get("contract.key.missing").expect("first handle miss"), + h2.get("contract.key.missing").expect("second handle miss") ); } } @@ -170,8 +231,8 @@ mod tests { } impl ConfigStore for TestConfigStore { - fn get(&self, key: &str) -> Option { - self.data.get(key).cloned() + fn get(&self, key: &str) -> Result, ConfigStoreError> { + Ok(self.data.get(key).cloned()) } } @@ -182,34 +243,46 @@ mod tests { #[test] fn config_store_get_returns_value_for_existing_key() { let h = handle(&[("feature.checkout", "true")]); - assert_eq!(h.get("feature.checkout"), Some("true".to_string())); + assert_eq!( + h.get("feature.checkout").expect("config value"), + Some("true".to_string()) + ); } #[test] fn config_store_get_returns_none_for_missing_key() { let h = handle(&[]); - assert_eq!(h.get("nonexistent"), None); + assert_eq!(h.get("nonexistent").expect("missing config"), None); } #[test] fn config_store_handle_wraps_and_delegates() { let h = handle(&[("timeout_ms", "1500")]); - assert_eq!(h.get("timeout_ms"), Some("1500".to_string())); - assert_eq!(h.get("missing"), None); + assert_eq!( + h.get("timeout_ms").expect("config value"), + Some("1500".to_string()) + ); + assert_eq!(h.get("missing").expect("missing config"), None); } #[test] fn config_store_handle_is_cloneable() { let h1 = handle(&[("key", "val")]); let h2 = h1.clone(); - assert_eq!(h1.get("key"), h2.get("key")); + assert_eq!( + h1.get("key").expect("first handle value"), + h2.get("key").expect("second handle value") + ); } #[test] fn config_store_handle_new_accepts_arc() { let store = Arc::new(TestConfigStore::new(&[("a", "1")])); let h = ConfigStoreHandle::new(store); - assert_eq!(h.get("a"), Some("1".to_string())); + assert_eq!( + h.get("a").expect("arc-backed config"), + Some("1".to_string()) + ); } #[test] @@ -219,6 +292,23 @@ mod tests { assert!(debug.contains("ConfigStoreHandle")); } + struct FailingConfigStore; + + impl ConfigStore for FailingConfigStore { + fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Err(ConfigStoreError::unavailable("backend offline")) + } + } + + #[test] + fn config_store_handle_propagates_backend_errors() { + let handle = ConfigStoreHandle::new(Arc::new(FailingConfigStore)); + let err = handle + .get("feature.checkout") + .expect_err("expected backend error"); + assert!(matches!(err, ConfigStoreError::Unavailable { .. })); + } + // Run the shared contract tests against TestConfigStore. crate::config_store_contract_tests!( test_config_store_contract, diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index 799858e..95c8e9b 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -337,8 +337,11 @@ mod tests { struct FixedStore; impl ConfigStore for FixedStore { - fn get(&self, _key: &str) -> Option { - Some("value".to_string()) + fn get( + &self, + _key: &str, + ) -> Result, crate::config_store::ConfigStoreError> { + Ok(Some("value".to_string())) } } @@ -354,7 +357,10 @@ mod tests { let ctx = RequestContext::new(request, PathParams::default()); assert!(ctx.config_store().is_some()); assert_eq!( - ctx.config_store().unwrap().get("any"), + ctx.config_store() + .unwrap() + .get("any") + .expect("config value"), Some("value".to_string()) ); } diff --git a/crates/edgezero-core/src/error.rs b/crates/edgezero-core/src/error.rs index 448bd8c..8b3f6d8 100644 --- a/crates/edgezero-core/src/error.rs +++ b/crates/edgezero-core/src/error.rs @@ -4,6 +4,7 @@ use serde_json::json; use thiserror::Error; use crate::body::Body; +use crate::config_store::ConfigStoreError; use crate::http::{header::CONTENT_TYPE, HeaderValue, Method, Response, StatusCode}; use crate::response::{response_with_body, IntoResponse}; @@ -18,6 +19,8 @@ pub enum EdgeError { MethodNotAllowed { method: Method, allowed: String }, #[error("validation error: {message}")] Validation { message: String }, + #[error("{message}")] + ServiceUnavailable { message: String }, #[error("internal error: {source}")] Internal { #[from] @@ -68,12 +71,19 @@ impl EdgeError { } } + pub fn service_unavailable(message: impl Into) -> Self { + EdgeError::ServiceUnavailable { + message: message.into(), + } + } + pub fn status(&self) -> StatusCode { match self { EdgeError::BadRequest { .. } => StatusCode::BAD_REQUEST, EdgeError::Validation { .. } => StatusCode::UNPROCESSABLE_ENTITY, EdgeError::NotFound { .. } => StatusCode::NOT_FOUND, EdgeError::MethodNotAllowed { .. } => StatusCode::METHOD_NOT_ALLOWED, + EdgeError::ServiceUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE, EdgeError::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR, } } @@ -86,6 +96,7 @@ impl EdgeError { EdgeError::MethodNotAllowed { method, allowed } => { format!("method {} not allowed; allowed: {}", method, allowed) } + EdgeError::ServiceUnavailable { message } => message.clone(), EdgeError::Internal { source } => format!("internal error: {}", source), } } @@ -98,6 +109,16 @@ impl EdgeError { } } +impl From for EdgeError { + fn from(err: ConfigStoreError) -> Self { + match err { + ConfigStoreError::InvalidKey { message } => EdgeError::bad_request(message), + ConfigStoreError::Unavailable { message } => EdgeError::service_unavailable(message), + ConfigStoreError::Internal { source } => EdgeError::internal(source), + } + } +} + fn json_or_text(payload: &T) -> Body { Body::json(payload).unwrap_or_else(|_| Body::text("internal error")) } @@ -170,6 +191,27 @@ mod tests { assert!(err.message().contains("(none)")); } + #[test] + fn service_unavailable_sets_status_and_message() { + let err = EdgeError::service_unavailable("config store unavailable"); + assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(err.message(), "config store unavailable"); + } + + #[test] + fn config_store_error_unavailable_maps_to_service_unavailable() { + let err = EdgeError::from(ConfigStoreError::unavailable("backend offline")); + assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(err.message(), "backend offline"); + } + + #[test] + fn config_store_error_invalid_key_maps_to_bad_request() { + let err = EdgeError::from(ConfigStoreError::invalid_key("invalid config key")); + assert_eq!(err.status(), StatusCode::BAD_REQUEST); + assert_eq!(err.message(), "invalid config key"); + } + #[test] fn json_or_text_falls_back_on_serialization_error() { struct FailingSerialize; diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 5205f08..9efd375 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -1,10 +1,10 @@ use log::LevelFilter; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc; -use validator::Validate; +use validator::{Validate, ValidationError}; pub struct ManifestLoader { manifest: Arc, @@ -54,6 +54,7 @@ fn resolve_root_path(path: &Path, cwd: &Path) -> PathBuf { } pub const DEFAULT_CONFIG_STORE_NAME: &str = "EDGEZERO_CONFIG"; +const SUPPORTED_CONFIG_STORE_ADAPTERS: &[&str] = &["axum", "cloudflare", "fastly"]; #[derive(Debug, Deserialize, Validate)] pub struct Manifest { @@ -120,7 +121,7 @@ impl Manifest { &self.environment } - fn finalize(&mut self) { + pub(crate) fn finalize(&mut self) { let mut resolved = BTreeMap::new(); for (adapter, cfg) in &self.adapters { @@ -329,9 +330,11 @@ pub struct ManifestConfigStoreConfig { #[serde(default)] #[validate(length(min = 1))] pub name: Option, - /// Per-adapter name overrides, keyed by lowercase adapter name. + /// Per-adapter name overrides, keyed by supported lowercase adapter name + /// (`axum`, `cloudflare`, or `fastly`). #[serde(default)] #[validate(nested)] + #[validate(custom(function = "validate_config_store_adapter_keys"))] pub adapters: BTreeMap, /// Optional default values used for local dev (Axum adapter). #[serde(default)] @@ -339,12 +342,53 @@ pub struct ManifestConfigStoreConfig { } /// `[stores.config.adapters.]` override. -#[derive(Debug, Deserialize, Validate)] +#[derive(Debug, Deserialize, Serialize, Validate)] pub struct ManifestConfigAdapterConfig { #[validate(length(min = 1))] pub name: String, } +fn validate_config_store_adapter_keys( + adapters: &BTreeMap, +) -> Result<(), ValidationError> { + let mixed_case_keys = adapters + .keys() + .filter(|key| key.as_str() != key.to_ascii_lowercase()) + .cloned() + .collect::>(); + if !mixed_case_keys.is_empty() { + let mut error = ValidationError::new("config_store_adapter_keys_lowercase"); + error.message = Some( + format!( + "config store adapter override keys must be lowercase: {}", + mixed_case_keys.join(", ") + ) + .into(), + ); + return Err(error); + } + + let unknown_keys = adapters + .keys() + .filter(|key| !SUPPORTED_CONFIG_STORE_ADAPTERS.contains(&key.as_str())) + .cloned() + .collect::>(); + if unknown_keys.is_empty() { + return Ok(()); + } + + let mut error = ValidationError::new("config_store_adapter_keys_known"); + error.message = Some( + format!( + "config store adapter override keys must match supported adapters ({}): {}", + SUPPORTED_CONFIG_STORE_ADAPTERS.join(", "), + unknown_keys.join(", ") + ) + .into(), + ); + Err(error) +} + impl ManifestConfigStoreConfig { /// Resolve the config store name for a given adapter. /// @@ -1210,6 +1254,34 @@ name = "fastly-store" assert_eq!(config.config_store_name("fastly"), "fastly-store"); } + #[test] + fn config_store_mixed_case_adapter_key_fails_validation() { + let src = r#" +[stores.config.adapters.Fastly] +name = "fastly-store" +"#; + let manifest: Manifest = toml::from_str(src).expect("should parse"); + let result = manifest.validate(); + assert!( + result.is_err(), + "mixed-case config store adapter key should fail validation" + ); + } + + #[test] + fn config_store_unknown_adapter_key_fails_validation() { + let src = r#" +[stores.config.adapters.clouflare] +name = "APP_CONFIG" +"#; + let manifest: Manifest = toml::from_str(src).expect("should parse"); + let result = manifest.validate(); + assert!( + result.is_err(), + "unknown config store adapter key should fail validation" + ); + } + #[test] fn config_store_defaults_accessible() { let toml = r#" diff --git a/crates/edgezero-macros/src/app.rs b/crates/edgezero-macros/src/app.rs index c4b1807..7196d99 100644 --- a/crates/edgezero-macros/src/app.rs +++ b/crates/edgezero-macros/src/app.rs @@ -6,6 +6,7 @@ use std::fs; use std::path::PathBuf; use syn::parse::{Parse, ParseStream}; use syn::{parse_macro_input, Ident, LitStr, Token}; +use validator::Validate; #[allow(dead_code)] mod manifest_definitions { @@ -23,8 +24,12 @@ pub fn expand_app(input: TokenStream) -> TokenStream { let manifest_source = fs::read_to_string(&manifest_path) .unwrap_or_else(|err| panic!("failed to read {}: {err}", manifest_path.display())); - let manifest: Manifest = toml::from_str(&manifest_source) + let mut manifest: Manifest = toml::from_str(&manifest_source) .unwrap_or_else(|err| panic!("failed to parse {}: {err}", manifest_path.display())); + manifest + .validate() + .unwrap_or_else(|err| panic!("failed to validate {}: {err}", manifest_path.display())); + manifest.finalize(); let app_ident = args .app_ident diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index 25b0076..fd3b47c 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -137,8 +137,8 @@ cargo test -p my-app-adapter-axum ## Config Store -For local development, the Axum adapter reads config values from a snapshot of the process -environment and falls back to `[stores.config.defaults]` in `edgezero.toml`: +For local development, the Axum adapter only reads environment variables for keys declared in +`[stores.config.defaults]`, then falls back to those defaults in `edgezero.toml`: ```toml [stores.config] @@ -147,10 +147,13 @@ name = "app_config" [stores.config.defaults] "greeting" = "hello from config store" "feature.new_checkout" = "false" +"service.timeout_ms" = "" ``` Handlers access the injected store through `ctx.config_store()`. Environment variables take -precedence over manifest defaults. +precedence over manifest defaults. If a key should be overrideable from env without carrying a real +default value, declare it with an empty-string placeholder. Do not pass raw user input straight to +`ctx.config_store()?.get(...)` in production handlers; validate or allowlist keys first. ## Container Deployment diff --git a/docs/guide/adapters/cloudflare.md b/docs/guide/adapters/cloudflare.md index 1d43741..1b332a7 100644 --- a/docs/guide/adapters/cloudflare.md +++ b/docs/guide/adapters/cloudflare.md @@ -49,7 +49,11 @@ pub async fn main(req: Request, env: Env, ctx: Context) -> Result { ``` `run_app` reads config-store metadata generated by `edgezero_core::app!` and injects the configured -Cloudflare binding automatically. No special manifest-aware entrypoint is required. +Cloudflare binding automatically. If you implement `Hooks` manually and need runtime manifest +fallbacks, use `run_app_with_manifest`. + +The low-level `dispatch()` helper remains available only for fully manual wiring and does not inject +config-store metadata. Prefer `run_app`, `dispatch_with_config`, or `dispatch_with_config_store`. ## Building @@ -199,10 +203,12 @@ See the [Streaming guide](/guide/streaming) for examples and patterns. Run contract tests for the Cloudflare adapter: ```bash +cargo install wasm-bindgen-cli --version 0.2.113 --locked +export CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner cargo test -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown ``` -Note: Some tests require `wasm-bindgen-test-runner` for execution. +These tests use `wasm-bindgen-test-runner` and execute the adapter's real wasm32 request path. ## Manifest Configuration diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index e5bb4e4..b2b9f0b 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -52,6 +52,9 @@ fn main(req: fastly::Request) -> Result { `run_app` reads logging and config-store settings from `edgezero.toml`, builds the app, and injects the configured Fastly Config Store into request extensions automatically. +The low-level `dispatch()` helper remains available only for fully manual wiring and does not inject +config-store metadata. Prefer `run_app`, `dispatch_with_config`, or `dispatch_with_config_store`. + ## Building Build for Fastly's Wasm target: diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index 50599a7..8096b81 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -95,7 +95,8 @@ Adapters translate between provider-specific types and the portable core model: │ Adapter │ │ - into_core_request(): Provider Request → Core Request │ │ - from_core_response(): Core Response → Provider Response │ -│ - dispatch(): Full request lifecycle │ +│ - run_app()/dispatch_with_config(): Canonical lifecycle │ +│ - dispatch(): Low-level manual lifecycle │ └─────────────────────────────────────────────────────────────┘ │ ▼ diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 87a934a..5b2e691 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -157,19 +157,22 @@ name = "APP_CONFIG" | Field | Required | Description | | ---------- | -------- | ------------------------------------------------------------------------------------------------------------ | | `name` | No | Global store or binding name; if omitted but the section is present, adapters fall back to `EDGEZERO_CONFIG` | -| `adapters` | No | Per-adapter name overrides, keyed by adapter name | -| `defaults` | No | Local default values used by the Axum adapter when env vars are absent | +| `adapters` | No | Per-adapter name overrides, keyed by supported lowercase adapter name (`axum`, `cloudflare`, `fastly`) | +| `defaults` | No | Local default values used by the Axum adapter when env vars are absent; this key set is also Axum's env allowlist | Runtime behavior by adapter: - Fastly reads from a Fastly Config Store resource link. - Cloudflare reads from a single JSON string binding in `wrangler.toml [vars]`. -- Axum reads from the process environment and falls back to `defaults`. +- Axum reads only the env vars declared in `defaults`, then falls back to `defaults`. When `[stores.config]` is present, the `app!` macro generates config-store metadata on the `App` type. The standard adapter `run_app` helpers use that metadata to inject a config-store handle into request extensions automatically, so handlers can call `ctx.config_store()`. +Treat config-store keys like API surface: validate or allowlist any user-controlled lookup before +calling `ctx.config_store()?.get(...)`. + ## Adapters Section Each adapter has its own configuration block: diff --git a/examples/app-demo/crates/app-demo-core/src/handlers.rs b/examples/app-demo/crates/app-demo-core/src/handlers.rs index f0ccb17..f4d35e9 100644 --- a/examples/app-demo/crates/app-demo-core/src/handlers.rs +++ b/examples/app-demo/crates/app-demo-core/src/handlers.rs @@ -10,6 +10,7 @@ use edgezero_core::response::Text; use futures::{stream, StreamExt}; const DEFAULT_PROXY_BASE: &str = "https://httpbin.org"; +const ALLOWED_CONFIG_KEYS: &[&str] = &["greeting", "feature.new_checkout", "service.timeout_ms"]; #[derive(serde::Deserialize)] pub(crate) struct EchoParams { @@ -115,26 +116,37 @@ fn proxy_not_available_response() -> Result { .map_err(EdgeError::internal) } +fn text_response(status: StatusCode, message: impl Into) -> Result { + http::response_builder() + .status(status) + .header("content-type", "text/plain; charset=utf-8") + .body(Body::text(message.into())) + .map_err(EdgeError::internal) +} + #[action] pub(crate) async fn config_get(RequestContext(ctx): RequestContext) -> Result { let params: ConfigParams = ctx.path()?; - match ctx.config_store().and_then(|s| s.get(¶ms.name)) { - Some(value) => { - let body = Body::text(value); - http::response_builder() - .status(StatusCode::OK) - .header("content-type", "text/plain; charset=utf-8") - .body(body) - .map_err(EdgeError::internal) - } - None => { - let body = Body::text(format!("config key '{}' not found", params.name)); - http::response_builder() - .status(StatusCode::NOT_FOUND) - .header("content-type", "text/plain; charset=utf-8") - .body(body) - .map_err(EdgeError::internal) - } + if !ALLOWED_CONFIG_KEYS.contains(¶ms.name.as_str()) { + return text_response( + StatusCode::NOT_FOUND, + format!("config key '{}' is not exposed by the demo", params.name), + ); + } + + let Some(store) = ctx.config_store() else { + return text_response( + StatusCode::SERVICE_UNAVAILABLE, + "config store is unavailable for this adapter", + ); + }; + + match store.get(¶ms.name)? { + Some(value) => text_response(StatusCode::OK, value), + None => text_response( + StatusCode::NOT_FOUND, + format!("config key '{}' not found", params.name), + ), } } @@ -143,7 +155,7 @@ mod tests { use super::*; use async_trait::async_trait; use edgezero_core::body::Body; - use edgezero_core::config_store::{ConfigStore, ConfigStoreHandle}; + use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; use edgezero_core::context::RequestContext; use edgezero_core::http::header::{HeaderName, HeaderValue}; use edgezero_core::http::{request_builder, Method, StatusCode, Uri}; @@ -314,8 +326,16 @@ mod tests { struct MapConfigStore(HashMap); impl ConfigStore for MapConfigStore { - fn get(&self, key: &str) -> Option { - self.0.get(key).cloned() + fn get(&self, key: &str) -> Result, ConfigStoreError> { + Ok(self.0.get(key).cloned()) + } + } + + struct UnavailableConfigStore; + + impl ConfigStore for UnavailableConfigStore { + fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Err(ConfigStoreError::unavailable("backend offline")) } } @@ -339,6 +359,20 @@ mod tests { RequestContext::new(request, PathParams::new(params)) } + fn context_with_unavailable_config_store(key: &str) -> RequestContext { + let mut request = request_builder() + .method(Method::GET) + .uri(format!("/config/{key}")) + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(ConfigStoreHandle::new(Arc::new(UnavailableConfigStore))); + let mut params = HashMap::new(); + params.insert("name".to_string(), key.to_string()); + RequestContext::new(request, PathParams::new(params)) + } + #[test] fn config_get_returns_value_when_key_exists() { let ctx = context_with_config_key("greeting", &[("greeting", "hello from config store")]); @@ -358,9 +392,23 @@ mod tests { } #[test] - fn config_get_returns_404_when_no_store_injected() { - let ctx = context_with_params("/config/greeting", &[("name", "greeting")]); + fn config_get_returns_404_for_keys_outside_demo_allowlist() { + let ctx = context_with_config_key("missing.key", &[("missing.key", "value")]); let response = block_on(config_get(ctx)).expect("handler ok"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } + + #[test] + fn config_get_returns_503_when_no_store_injected() { + let ctx = context_with_params("/config/greeting", &[("name", "greeting")]); + let response = block_on(config_get(ctx)).expect("handler ok"); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[test] + fn config_get_returns_503_when_store_lookup_fails() { + let ctx = context_with_unavailable_config_store("greeting"); + let err = block_on(config_get(ctx)).expect_err("expected store error"); + assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); + } } From 6bca9760b60046822a589f1b58c1c24e4b085b67 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 12 Mar 2026 13:02:01 +0530 Subject: [PATCH 5/8] Format docs --- docs/guide/configuration.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 5b2e691..a12ccd5 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -154,10 +154,10 @@ name = "app_config" name = "APP_CONFIG" ``` -| Field | Required | Description | -| ---------- | -------- | ------------------------------------------------------------------------------------------------------------ | -| `name` | No | Global store or binding name; if omitted but the section is present, adapters fall back to `EDGEZERO_CONFIG` | -| `adapters` | No | Per-adapter name overrides, keyed by supported lowercase adapter name (`axum`, `cloudflare`, `fastly`) | +| Field | Required | Description | +| ---------- | -------- | ----------------------------------------------------------------------------------------------------------------- | +| `name` | No | Global store or binding name; if omitted but the section is present, adapters fall back to `EDGEZERO_CONFIG` | +| `adapters` | No | Per-adapter name overrides, keyed by supported lowercase adapter name (`axum`, `cloudflare`, `fastly`) | | `defaults` | No | Local default values used by the Axum adapter when env vars are absent; this key set is also Axum's env allowlist | Runtime behavior by adapter: From 70e55d03ee08fde736ad29b240ce3bacfcc51d27 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 12 Mar 2026 13:28:14 +0530 Subject: [PATCH 6/8] Fix explicit wasm test jobs in ci --- .github/workflows/test.yml | 29 +++++++++++++++++++++++------ docs/guide/adapters/cloudflare.md | 18 +++++++++++++++--- docs/guide/adapters/fastly.md | 16 ++++++++++------ docs/guide/adapters/overview.md | 13 ++++++++++--- 4 files changed, 58 insertions(+), 18 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index df18756..bcb14a1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -90,8 +90,25 @@ jobs: - name: Add wasm32 target run: rustup target add wasm32-unknown-unknown + - name: Resolve wasm-bindgen CLI version + id: wasm-bindgen-version + shell: bash + run: | + version="$( + awk ' + $1 == "name" && $3 == "\"wasm-bindgen\"" { in_pkg=1; next } + in_pkg && $1 == "version" { + gsub(/"/, "", $3) + print $3 + exit + } + ' Cargo.lock + )" + test -n "$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Install wasm-bindgen test runner - run: cargo install wasm-bindgen-cli --version 0.2.113 --locked + run: cargo install wasm-bindgen-cli --version "${{ steps.wasm-bindgen-version.outputs.version }}" --locked --force - name: Fetch dependencies (locked) run: cargo fetch --locked @@ -99,7 +116,7 @@ jobs: - name: Run Cloudflare wasm tests env: CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner - run: cargo test -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown + run: cargo test -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown --test contract fastly-wasm-tests: name: fastly wasm tests @@ -133,13 +150,13 @@ jobs: - name: Add wasm32-wasi target run: rustup target add wasm32-wasip1 - - name: Set up Wasmtime - uses: bytecodealliance/actions/wasmtime/setup@v1 + - name: Setup Viceroy + run: cargo install viceroy --locked - name: Fetch dependencies (locked) run: cargo fetch --locked - name: Run Fastly wasm tests env: - CARGO_TARGET_WASM32_WASIP1_RUNNER: "wasmtime run --dir=." - run: cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 + CARGO_TARGET_WASM32_WASIP1_RUNNER: "viceroy run" + run: cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --test contract diff --git a/docs/guide/adapters/cloudflare.md b/docs/guide/adapters/cloudflare.md index 1b332a7..630f18a 100644 --- a/docs/guide/adapters/cloudflare.md +++ b/docs/guide/adapters/cloudflare.md @@ -203,12 +203,24 @@ See the [Streaming guide](/guide/streaming) for examples and patterns. Run contract tests for the Cloudflare adapter: ```bash -cargo install wasm-bindgen-cli --version 0.2.113 --locked +WASM_BINDGEN_VERSION=$( + awk ' + $1 == "name" && $3 == "\"wasm-bindgen\"" { in_pkg=1; next } + in_pkg && $1 == "version" { + gsub(/"/, "", $3) + print $3 + exit + } + ' Cargo.lock +) +cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked --force export CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner -cargo test -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown +cargo test -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown --test contract ``` -These tests use `wasm-bindgen-test-runner` and execute the adapter's real wasm32 request path. +These tests use `wasm-bindgen-test-runner` and execute the adapter's real +wasm32 request path. The CLI version must exactly match the workspace's +`wasm-bindgen` version from `Cargo.lock`. ## Manifest Configuration diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index b2b9f0b..c477bca 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -6,7 +6,7 @@ Deploy EdgeZero applications to Fastly's Compute@Edge platform using WebAssembly - [Fastly CLI](https://developer.fastly.com/learning/compute/#install-the-fastly-cli) - Rust `wasm32-wasip1` target: `rustup target add wasm32-wasip1` -- [Wasmtime](https://wasmtime.dev/) or [Viceroy](https://github.com/fastly/Viceroy) for local testing +- [Viceroy](https://github.com/fastly/Viceroy) for local execution and testing ## Project Setup @@ -187,15 +187,19 @@ See the [Streaming guide](/guide/streaming) for examples and patterns. Run contract tests for the Fastly adapter: ```bash -# Set up the Wasm runner -export CARGO_TARGET_WASM32_WASIP1_RUNNER="wasmtime run --dir=." +cargo install viceroy --locked +export CARGO_TARGET_WASM32_WASIP1_RUNNER="viceroy run" # Run tests -cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 +cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --test contract ``` -::: tip Viceroy Issues -If Viceroy reports keychain access errors on macOS, use Wasmtime as the test runner instead. +Fastly SDK-linked Wasm binaries require Viceroy for execution; plain Wasmtime +does not provide the `fastly_*` host imports needed by the adapter tests. + +::: tip Local Execution +If Viceroy reports native certificate or keychain errors on macOS, use `--no-run` +locally and rely on Linux CI for execution. ::: ## Manifest Configuration diff --git a/docs/guide/adapters/overview.md b/docs/guide/adapters/overview.md index e705f9d..5022228 100644 --- a/docs/guide/adapters/overview.md +++ b/docs/guide/adapters/overview.md @@ -71,10 +71,13 @@ Because the Fastly SDK links against the Compute@Edge host functions, the contra ```bash rustup target add wasm32-wasip1 -cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --tests +cargo install viceroy --locked +export CARGO_TARGET_WASM32_WASIP1_RUNNER="viceroy run" +cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --test contract ``` -Provide a Wasm runner (Wasmtime or Viceroy) via `CARGO_TARGET_WASM32_WASIP1_RUNNER` if you want to execute the binaries instead of running `--no-run`. +Fastly's SDK-linked test binaries need Viceroy for execution; plain Wasmtime +does not provide the required `fastly_*` host imports. ### Cloudflare Tests @@ -82,9 +85,13 @@ Cloudflare's adapter relies on `wasm32-unknown-unknown`. The contract suite uses ```bash rustup target add wasm32-unknown-unknown -cargo test -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown --tests +export CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner +cargo test -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown --test contract ``` +Install a `wasm-bindgen-cli` version that matches the workspace's `wasm-bindgen` +entry in `Cargo.lock` before running the Cloudflare tests. + ## Onboarding New Adapters When bringing up another adapter: From 88fa2652237d525a8b071d56b7dfd680d9b9c7bf Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 12 Mar 2026 13:40:22 +0530 Subject: [PATCH 7/8] fix fastly wasm contract tests --- crates/edgezero-adapter-fastly/tests/contract.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-adapter-fastly/tests/contract.rs b/crates/edgezero-adapter-fastly/tests/contract.rs index 417ec40..b41bd0a 100644 --- a/crates/edgezero-adapter-fastly/tests/contract.rs +++ b/crates/edgezero-adapter-fastly/tests/contract.rs @@ -82,7 +82,9 @@ fn build_test_app() -> App { } fn fastly_request(method: FastlyMethod, path: &str, body: Option<&[u8]>) -> FastlyRequest { - let mut req = FastlyRequest::new(method, path); + // Viceroy validates Fastly request URLs at construction time, so the + // contract tests must use absolute URLs instead of path-only strings. + let mut req = FastlyRequest::new(method, format!("http://example.com{path}")); req.set_header("host", "example.com"); req.set_header("x-edgezero-test", "1"); if let Some(bytes) = body { From e99b8b07009b1341c00be937786d49ee0c9c42b5 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 12 Mar 2026 15:27:37 +0530 Subject: [PATCH 8/8] Clarify config-aware dispatch APIs --- crates/edgezero-adapter-cloudflare/src/lib.rs | 4 ++-- crates/edgezero-adapter-cloudflare/src/request.rs | 12 ++++++++---- crates/edgezero-adapter-cloudflare/tests/contract.rs | 6 +++--- crates/edgezero-adapter-fastly/src/lib.rs | 4 ++-- crates/edgezero-adapter-fastly/src/request.rs | 12 ++++++++---- crates/edgezero-adapter-fastly/tests/contract.rs | 6 +++--- docs/guide/adapters/cloudflare.md | 4 +++- docs/guide/adapters/fastly.md | 4 +++- 8 files changed, 32 insertions(+), 20 deletions(-) diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index a594b30..474f60d 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -27,7 +27,7 @@ pub use context::CloudflareRequestContext; pub use proxy::CloudflareProxyClient; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] #[allow(deprecated)] -pub use request::{dispatch, dispatch_with_config, dispatch_with_config_store, into_core_request}; +pub use request::{dispatch, dispatch_with_config, dispatch_with_config_handle, into_core_request}; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub use response::from_core_response; @@ -44,7 +44,7 @@ pub fn init_logger() -> Result<(), log::SetLoggerError> { #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub trait AppExt { #[deprecated( - note = "AppExt::dispatch() is the low-level manual path and does not inject config-store metadata; prefer run_app(), dispatch_with_config(), or dispatch_with_config_store()" + note = "AppExt::dispatch() is the low-level manual path and does not inject config-store metadata; prefer run_app(), dispatch_with_config(), or dispatch_with_config_handle()" )] fn dispatch<'a>( &'a self, diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index 3745082..0bf3395 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -64,10 +64,11 @@ pub(crate) async fn dispatch_raw( /// Low-level manual dispatch. /// /// This path does not resolve or inject config-store metadata from a manifest. -/// Prefer `run_app`, `dispatch_with_config`, or `dispatch_with_config_store` -/// for config-store-aware dispatch. +/// Prefer `run_app` or `dispatch_with_config` for normal config-store-aware +/// dispatch. Use `dispatch_with_config_handle` only when you already have a +/// prepared `ConfigStoreHandle`. #[deprecated( - note = "dispatch() is the low-level manual path and does not inject config-store metadata; prefer run_app(), dispatch_with_config(), or dispatch_with_config_store()" + note = "dispatch() is the low-level manual path and does not inject config-store metadata; prefer run_app(), dispatch_with_config(), or dispatch_with_config_handle()" )] pub async fn dispatch( app: &App, @@ -79,7 +80,10 @@ pub async fn dispatch( } /// Dispatch a request with a prepared config-store handle injected. -pub async fn dispatch_with_config_store( +/// +/// This is the advanced/manual path. Prefer `dispatch_with_config` when you +/// want the adapter to resolve the configured backend for you. +pub async fn dispatch_with_config_handle( app: &App, req: CfRequest, env: Env, diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index 9a0b47c..dbb7a21 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -4,7 +4,7 @@ use bytes::Bytes; use edgezero_adapter_cloudflare::{ - dispatch, dispatch_with_config, dispatch_with_config_store, from_core_response, + dispatch, dispatch_with_config, dispatch_with_config_handle, from_core_response, into_core_request, CloudflareRequestContext, }; use edgezero_core::{ @@ -230,13 +230,13 @@ async fn dispatch_with_config_missing_binding_skips_injection() { } #[wasm_bindgen_test] -async fn dispatch_with_config_store_injects_handle() { +async fn dispatch_with_config_handle_injects_handle() { let app = build_test_app(); let req = cf_request(CfMethod::Get, "/config-value", None); let (env, ctx) = test_env_ctx(); let handle = ConfigStoreHandle::new(Arc::new(FixedConfigStore("hello from cf test"))); - let mut response = dispatch_with_config_store(&app, req, env, ctx, handle) + let mut response = dispatch_with_config_handle(&app, req, env, ctx, handle) .await .expect("cf response"); diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index 986508c..e0551ce 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -22,7 +22,7 @@ pub use context::FastlyRequestContext; pub use proxy::FastlyProxyClient; #[cfg(feature = "fastly")] #[allow(deprecated)] -pub use request::{dispatch, dispatch_with_config, dispatch_with_config_store, into_core_request}; +pub use request::{dispatch, dispatch_with_config, dispatch_with_config_handle, into_core_request}; #[cfg(feature = "fastly")] pub use response::from_core_response; @@ -68,7 +68,7 @@ pub fn init_logger( #[cfg(feature = "fastly")] pub trait AppExt { #[deprecated( - note = "AppExt::dispatch() is the low-level manual path and does not inject config-store metadata; prefer run_app(), dispatch_with_config(), or dispatch_with_config_store()" + note = "AppExt::dispatch() is the low-level manual path and does not inject config-store metadata; prefer run_app(), dispatch_with_config(), or dispatch_with_config_handle()" )] fn dispatch(&self, req: fastly::Request) -> Result; } diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index 7c9c822..836312c 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -55,17 +55,21 @@ pub(crate) fn dispatch_raw(app: &App, req: FastlyRequest) -> Result Result { dispatch_raw(app, req) } /// Dispatch a request with a prepared config-store handle injected into extensions. -pub fn dispatch_with_config_store( +/// +/// This is the advanced/manual path. Prefer `dispatch_with_config` when you +/// want the adapter to resolve the configured backend for you. +pub fn dispatch_with_config_handle( app: &App, req: FastlyRequest, config_store_handle: ConfigStoreHandle, diff --git a/crates/edgezero-adapter-fastly/tests/contract.rs b/crates/edgezero-adapter-fastly/tests/contract.rs index b41bd0a..55d81f3 100644 --- a/crates/edgezero-adapter-fastly/tests/contract.rs +++ b/crates/edgezero-adapter-fastly/tests/contract.rs @@ -4,7 +4,7 @@ use bytes::Bytes; use edgezero_adapter_fastly::{ - dispatch, dispatch_with_config_store, from_core_response, into_core_request, + dispatch, dispatch_with_config_handle, from_core_response, into_core_request, FastlyRequestContext, }; use edgezero_core::app::App; @@ -171,12 +171,12 @@ fn dispatch_passes_request_body_to_handlers() { } #[test] -fn dispatch_with_config_store_injects_handle() { +fn dispatch_with_config_handle_injects_handle() { let app = build_test_app(); let req = fastly_request(FastlyMethod::GET, "/config", None); let handle = ConfigStoreHandle::new(Arc::new(FixedConfigStore("hello from fastly test"))); - let mut response = dispatch_with_config_store(&app, req, handle).expect("fastly response"); + let mut response = dispatch_with_config_handle(&app, req, handle).expect("fastly response"); assert_eq!(response.get_status(), FastlyStatus::OK); assert_eq!(response.take_body_bytes(), b"hello from fastly test"); diff --git a/docs/guide/adapters/cloudflare.md b/docs/guide/adapters/cloudflare.md index 630f18a..eb91c63 100644 --- a/docs/guide/adapters/cloudflare.md +++ b/docs/guide/adapters/cloudflare.md @@ -53,7 +53,9 @@ Cloudflare binding automatically. If you implement `Hooks` manually and need run fallbacks, use `run_app_with_manifest`. The low-level `dispatch()` helper remains available only for fully manual wiring and does not inject -config-store metadata. Prefer `run_app`, `dispatch_with_config`, or `dispatch_with_config_store`. +config-store metadata. Prefer `run_app` or `dispatch_with_config` for normal use. +`dispatch_with_config_handle` exists for advanced/manual cases where you already have a prepared +`ConfigStoreHandle`. ## Building diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index c477bca..4db5621 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -53,7 +53,9 @@ fn main(req: fastly::Request) -> Result { the configured Fastly Config Store into request extensions automatically. The low-level `dispatch()` helper remains available only for fully manual wiring and does not inject -config-store metadata. Prefer `run_app`, `dispatch_with_config`, or `dispatch_with_config_store`. +config-store metadata. Prefer `run_app` or `dispatch_with_config` for normal use. +`dispatch_with_config_handle` exists for advanced/manual cases where you already have a prepared +`ConfigStoreHandle`. ## Building