From 43c755511da21af74626ddfbd5d61b8c51d9a4d0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 9 Mar 2026 18:08:30 +0530 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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 From bd078a2ff4ef42acb6a48baad7b04072dd529c87 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 19 Mar 2026 23:38:30 +0530 Subject: [PATCH 09/10] Fix ci failure for missing target --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 24eb0ee..e5af8df 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -150,8 +150,8 @@ jobs: with: toolchain: ${{ steps.rust-version-fastly.outputs.rust-version }} - - name: Add wasm32-wasi target - run: rustup target add wasm32-wasip1 + - name: Add wasm targets + run: rustup target add wasm32-wasip1 wasm32-unknown-unknown - name: Setup Viceroy run: cargo install viceroy --locked From 68e3744078b8f10947b780324e9f50b0babd2a01 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 25 Mar 2026 10:08:47 +0530 Subject: [PATCH 10/10] Fix KV injection and API clarity in config-store dispatch paths dispatch_with_config and dispatch_with_config_handle in both the Cloudflare and Fastly adapters were passing None for the KV handle, silently dropping KV access for callers on those paths. Both now resolve the default KV binding/store (non-required) alongside the config store. Additional cleanup from review: - Document why run_app has two config-name resolution paths (macro-generated vs. manual Hooks impls) - Rename CloudflareConfigStore::new() to new_or_empty() to make the silent fallback-to-empty behavior explicit - Fix _ctx prefix on an actively-read variable in cloudflare contract tests - Move wasm-bindgen-test to [dev-dependencies] --- crates/edgezero-adapter-cloudflare/Cargo.toml | 2 +- .../edgezero-adapter-cloudflare/src/config_store.rs | 7 ++++--- crates/edgezero-adapter-cloudflare/src/lib.rs | 5 +++++ crates/edgezero-adapter-cloudflare/src/request.rs | 12 ++++++++++-- crates/edgezero-adapter-cloudflare/tests/contract.rs | 4 ++-- crates/edgezero-adapter-fastly/src/lib.rs | 5 +++++ crates/edgezero-adapter-fastly/src/request.rs | 12 ++++++++++-- 7 files changed, 37 insertions(+), 10 deletions(-) diff --git a/crates/edgezero-adapter-cloudflare/Cargo.toml b/crates/edgezero-adapter-cloudflare/Cargo.toml index e4433da..c0d7173 100644 --- a/crates/edgezero-adapter-cloudflare/Cargo.toml +++ b/crates/edgezero-adapter-cloudflare/Cargo.toml @@ -32,9 +32,9 @@ 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" [dev-dependencies] +wasm-bindgen-test = "0.3" web-sys = { version = "0.3", features = [ "Window", "Response", diff --git a/crates/edgezero-adapter-cloudflare/src/config_store.rs b/crates/edgezero-adapter-cloudflare/src/config_store.rs index c557499..644ce08 100644 --- a/crates/edgezero-adapter-cloudflare/src/config_store.rs +++ b/crates/edgezero-adapter-cloudflare/src/config_store.rs @@ -31,9 +31,10 @@ pub struct CloudflareConfigStore { 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 { + /// Returns an empty store (every key returns `None`) if the binding is absent or + /// its value is not valid JSON. Use [`Self::try_new`] when you need to distinguish + /// a missing/invalid binding from a valid but empty config. + pub fn new_or_empty(env: &Env, binding_name: &str) -> Self { Self::try_new(env, binding_name).unwrap_or_else(Self::empty) } diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index dca6ddf..98bff38 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -83,6 +83,11 @@ pub async fn run_app( let manifest = manifest_loader.manifest(); let kv_binding = manifest.kv_store_name(edgezero_core::app::CLOUDFLARE_ADAPTER); let kv_required = manifest.stores.kv.is_some(); + // Two-path resolution: `A::config_store()` is set at compile time by the + // `#[app]` macro and is the common case. The manifest fallback handles + // callers that implement `Hooks` manually without the macro — in that case + // `A::config_store()` returns `None` while `[stores.config]` in + // `edgezero.toml` may still be present. let config_binding = A::config_store() .map(|cfg| cfg.name_for_adapter(edgezero_core::app::CLOUDFLARE_ADAPTER)) .or_else(|| { diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index 4145168..f41d0f1 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -103,6 +103,9 @@ pub async fn dispatch_with_kv( /// /// This is the advanced/manual path. Prefer `dispatch_with_config` when you /// want the adapter to resolve the configured backend for you. +/// +/// The KV namespace bound to [`DEFAULT_KV_BINDING`] is also resolved and injected +/// (non-required: missing bindings are silently skipped). pub async fn dispatch_with_config_handle( app: &App, req: CfRequest, @@ -110,7 +113,8 @@ pub async fn dispatch_with_config_handle( ctx: Context, config_store_handle: ConfigStoreHandle, ) -> Result { - dispatch_with_handles(app, req, env, ctx, Some(config_store_handle), None).await + let kv_handle = resolve_kv_handle(&env, DEFAULT_KV_BINDING, false)?; + dispatch_with_handles(app, req, env, ctx, Some(config_store_handle), kv_handle).await } /// Dispatch a request with a Cloudflare JSON config store injected. @@ -118,6 +122,9 @@ pub async fn dispatch_with_config_handle( /// Reads `binding_name` from `env` (a `[vars]` string whose value is a JSON object), /// parses it into a `CloudflareConfigStore`, and injects the handle before dispatch /// when the binding is present and valid. +/// +/// The KV namespace bound to [`DEFAULT_KV_BINDING`] is also resolved and injected +/// (non-required: missing bindings are silently skipped). pub async fn dispatch_with_config( app: &App, req: CfRequest, @@ -127,7 +134,8 @@ pub async fn dispatch_with_config( ) -> Result { let config_store_handle = CloudflareConfigStore::try_new(&env, binding_name) .map(|store| ConfigStoreHandle::new(Arc::new(store))); - dispatch_with_handles(app, req, env, ctx, config_store_handle, None).await + let kv_handle = resolve_kv_handle(&env, DEFAULT_KV_BINDING, false)?; + dispatch_with_handles(app, req, env, ctx, config_store_handle, kv_handle).await } pub(crate) async fn dispatch_with_bindings( diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index dbb7a21..c8198db 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -51,8 +51,8 @@ fn build_test_app() -> App { Ok(response) } - async fn config_presence(_ctx: RequestContext) -> Result { - let present = if _ctx.config_store().is_some() { + async fn config_presence(ctx: RequestContext) -> Result { + let present = if ctx.config_store().is_some() { "yes" } else { "no" diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index 902119b..03184c2 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -94,6 +94,11 @@ pub fn run_app( let manifest_loader = edgezero_core::manifest::ManifestLoader::load_from_str(manifest_src); let manifest = manifest_loader.manifest(); let logging = manifest.logging_or_default(edgezero_core::app::FASTLY_ADAPTER); + // Two-path resolution: `A::config_store()` is set at compile time by the + // `#[app]` macro and is the common case. The manifest fallback handles + // callers that implement `Hooks` manually without the macro — in that case + // `A::config_store()` returns `None` while `[stores.config]` in + // `edgezero.toml` may still be present. let config_name = A::config_store() .map(|cfg| { cfg.name_for_adapter(edgezero_core::app::FASTLY_ADAPTER) diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index 3ad1e7f..ee4f247 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -75,18 +75,25 @@ pub fn dispatch(app: &App, req: FastlyRequest) -> Result Result { - dispatch_with_handles(app, req, Some(config_store_handle), None) + let kv_handle = resolve_kv_handle(DEFAULT_KV_STORE_NAME, false)?; + dispatch_with_handles(app, req, Some(config_store_handle), kv_handle) } /// Dispatch a request with a Fastly Config Store injected into extensions. /// /// If the named store is not available, suppresses repeated warnings for /// recently seen store names and dispatches without it. +/// +/// The KV store named [`DEFAULT_KV_STORE_NAME`] is also resolved and injected +/// (non-required: unavailable stores are silently skipped). pub fn dispatch_with_config( app: &App, req: FastlyRequest, @@ -99,7 +106,8 @@ pub fn dispatch_with_config( None } }; - dispatch_with_handles(app, req, config_store_handle, None) + let kv_handle = resolve_kv_handle(DEFAULT_KV_STORE_NAME, false)?; + dispatch_with_handles(app, req, config_store_handle, kv_handle) } /// Dispatch a Fastly request with a custom KV store name.