diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0a9a4e9..bcb14a1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,3 +57,106 @@ 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: 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 "${{ steps.wasm-bindgen-version.outputs.version }}" --locked --force + + - 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 --test contract + + 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: 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: "viceroy run" + run: cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --test contract 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..2902518 --- /dev/null +++ b/crates/edgezero-adapter-axum/src/config_store.rs @@ -0,0 +1,154 @@ +//! Axum adapter config store: env vars with in-memory defaults fallback. + +use std::collections::HashMap; + +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`] 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, +} + +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::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) -> Result, ConfigStoreError> { + Ok(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").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").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").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").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). + 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..3d95d38 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(edgezero_core::app::AXUM_ADAPTER); 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..1560be6 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, ConfigStoreError, 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) -> Result, ConfigStoreError> { + Ok(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,64 @@ 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") + .expect("config lookup should succeed") + .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..c557499 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/config_store.rs @@ -0,0 +1,169 @@ +//! 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, VecDeque}; +use std::sync::{Arc, Mutex, OnceLock}; + +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. +/// +/// 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: Arc, +} + +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 { + 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()), + } + } +} + +impl ConfigStore for CloudflareConfigStore { + 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 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 +/// 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; + } + + // 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()) + .insert(binding_name, resolved, CONFIG_CACHE_LIMIT) +} + +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)] +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 0c4dcba..474f60d 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -1,8 +1,15 @@ //! 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; +#[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 +19,15 @@ 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}; +#[allow(deprecated)] +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; @@ -33,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_handle()" + )] fn dispatch<'a>( &'a self, req: worker::Request, @@ -45,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, @@ -53,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)) } } @@ -65,5 +79,120 @@ 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`. +/// +/// Prefers hook metadata from [`edgezero_core::app::Hooks::config_store`] +/// and falls back to resolving `[stores.config]` from `manifest_src`. +/// +#[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 app = A::build_app(); + let binding_name = A::config_store() + .map(|cfg| { + cfg.name_for_adapter(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 +} + +#[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 { + 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 bd30427..0bf3395 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; @@ -45,15 +49,81 @@ 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` 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_handle()" +)] 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. +/// +/// 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, + ctx: Context, + config_store_handle: ConfigStoreHandle, +) -> Result { + let core_request = into_core_request(req, env, ctx) + .await + .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. +/// +/// 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. +pub async fn dispatch_with_config( + app: &App, + req: CfRequest, + env: Env, + ctx: Context, + binding_name: &str, +) -> Result { + let config_handle = CloudflareConfigStore::try_new(&env, binding_name) + .map(|store| ConfigStoreHandle::new(Arc::new(store))); let core_request = into_core_request(req, env, ctx) .await .map_err(edge_error_to_worker)?; + 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(); 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..dbb7a21 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -1,23 +1,39 @@ #![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, from_core_response, into_core_request, CloudflareRequestContext, + dispatch, dispatch_with_config, dispatch_with_config_handle, from_core_response, + into_core_request, CloudflareRequestContext, }; use edgezero_core::{ - response_builder, App, Body, EdgeError, Method, RequestContext, RouterService, StatusCode, + 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 wasm_bindgen::JsValue; +use std::sync::Arc; 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); +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 { + 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 +42,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 +51,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"), @@ -48,22 +77,36 @@ 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) } 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 +121,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 +162,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 +178,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 +191,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 +204,43 @@ 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"); +} + +#[wasm_bindgen_test] +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_handle(&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 new file mode 100644 index 0000000..62b9a1c --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/config_store.rs @@ -0,0 +1,85 @@ +//! Fastly adapter config store: wraps `fastly::ConfigStore`. + +#[cfg(test)] +use std::collections::HashMap; + +use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; + +/// Config store backed by a Fastly Config Store resource link. +pub struct FastlyConfigStore { + inner: FastlyConfigStoreBackend, +} + +enum FastlyConfigStoreBackend { + Fastly(fastly::ConfigStore), + #[cfg(test)] + InMemory(HashMap), +} + +impl FastlyConfigStore { + /// Open a Fastly Config Store by resource link name. + /// + /// 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), + }) + } + + #[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) -> Result, ConfigStoreError> { + match &self.inner { + FastlyConfigStoreBackend::Fastly(inner) => inner.try_get(key).map_err(map_lookup_error), + #[cfg(test)] + 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::*; + + 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()), + ]) + }); + + #[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 5603831..e0551ce 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,14 @@ 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}; +#[allow(deprecated)] +pub use request::{dispatch, dispatch_with_config, dispatch_with_config_handle, into_core_request}; #[cfg(feature = "fastly")] pub use response::from_core_response; @@ -62,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_handle()" + )] 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) } } @@ -78,14 +87,28 @@ 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(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()) } +/// 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 +116,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 { + crate::request::dispatch_raw(&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..836312c 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -1,17 +1,24 @@ +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; +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; +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())?; @@ -40,12 +47,117 @@ 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` 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_handle()" +)] pub fn dispatch(app: &App, req: FastlyRequest) -> Result { + dispatch_raw(app, req) +} + +/// Dispatch a request with a prepared config-store handle injected into extensions. +/// +/// 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, +) -> Result { let core_request = into_core_request(req).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, 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 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 + } + }; + 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 f3c25b3..55d81f3 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_handle, 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,17 +59,32 @@ 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) } 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 { @@ -67,7 +95,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"); @@ -141,3 +169,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_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_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/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 new file mode 100644 index 0000000..0905f5d --- /dev/null +++ b/crates/edgezero-core/src/config_store.rs @@ -0,0 +1,317 @@ +//! 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; + +use anyhow::Error as AnyError; +use thiserror::Error; + +// --------------------------------------------------------------------------- +// 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 +/// +/// 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) -> Result, ConfigStoreError>; +} + +// --------------------------------------------------------------------------- +// 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) -> Result, ConfigStoreError> { + 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, #[$test_attr:meta], $factory:expr $(,)?) => { + mod $mod_name { + use super::*; + use $crate::config_store::ConfigStore; + + #[$test_attr] + fn contract_get_returns_value_for_existing_key() { + let store = $factory; + 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").expect("config miss"), None); + } + + #[$test_attr] + fn contract_multiple_keys_are_independent() { + let store = $factory; + 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").expect("case-sensitive miss"), None); + } + + #[$test_attr] + fn contract_empty_key_returns_none() { + let store = $factory; + assert_eq!(store.get("").expect("empty key miss"), None); + } + + #[$test_attr] + 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").expect("handle value"), + Some("value_a".to_string()) + ); + assert_eq!(handle.get("contract.key.missing").expect("handle miss"), None); + } + + #[$test_attr] + 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").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") + ); + } + } + }; + ($mod_name:ident, $factory:expr) => { + $crate::config_store_contract_tests!($mod_name, #[test], $factory); + }; +} + +// --------------------------------------------------------------------------- +// 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) -> Result, ConfigStoreError> { + Ok(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").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").expect("missing config"), None); + } + + #[test] + fn config_store_handle_wraps_and_delegates() { + let h = handle(&[("timeout_ms", "1500")]); + 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").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").expect("arc-backed config"), + Some("1".to_string()) + ); + } + + #[test] + fn config_store_handle_debug_output() { + let h = handle(&[]); + let debug = format!("{:?}", h); + 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, + 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..95c8e9b 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,45 @@ 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, + ) -> Result, crate::config_store::ConfigStoreError> { + Ok(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") + .expect("config value"), + 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/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/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..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, @@ -53,6 +53,9 @@ 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 { #[serde(default)] @@ -70,6 +73,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)] @@ -115,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 { @@ -306,6 +312,108 @@ 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 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)] + pub defaults: BTreeMap, +} + +/// `[stores.config.adapters.]` override. +#[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. + /// + /// 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 +1183,145 @@ 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_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#" +[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/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..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 { @@ -14,7 +15,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); @@ -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 @@ -38,6 +43,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 +56,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 +115,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..fd3b47c 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,26 @@ cargo test -p my-app-core cargo test -p my-app-adapter-axum ``` +## Config Store + +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] +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. 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 Build and deploy as a standard container: diff --git a/docs/guide/adapters/cloudflare.md b/docs/guide/adapters/cloudflare.md index b4f3e72..eb91c63 100644 --- a/docs/guide/adapters/cloudflare.md +++ b/docs/guide/adapters/cloudflare.md @@ -48,6 +48,15 @@ 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. 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` or `dispatch_with_config` for normal use. +`dispatch_with_config_handle` exists for advanced/manual cases where you already have a prepared +`ConfigStoreHandle`. + ## Building Build for Cloudflare's Wasm target: @@ -139,6 +148,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: @@ -175,10 +205,24 @@ See the [Streaming guide](/guide/streaming) for examples and patterns. Run contract tests for the Cloudflare adapter: ```bash -cargo test -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown +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 --test contract ``` -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. 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 ead2d83..4db5621 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 @@ -41,17 +41,22 @@ 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. + +The low-level `dispatch()` helper remains available only for fully manual wiring and does not inject +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 Build for Fastly's Wasm target: @@ -131,6 +136,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: @@ -161,15 +189,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: 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 a7a34cb..a12ccd5 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -137,6 +137,42 @@ 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 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 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: @@ -299,6 +335,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/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/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..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,12 +10,18 @@ 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 { pub(crate) name: String, } +#[derive(serde::Deserialize)] +struct ConfigParams { + name: String, +} + #[derive(serde::Deserialize)] pub(crate) struct EchoBody { pub(crate) name: String, @@ -110,11 +116,46 @@ 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()?; + 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), + ), + } +} + #[cfg(test)] mod tests { use super::*; use async_trait::async_trait; use edgezero_core::body::Body; + 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}; @@ -124,6 +165,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 +322,93 @@ mod tests { .expect("request"); RequestContext::new(request, PathParams::default()) } + + struct MapConfigStore(HashMap); + + impl ConfigStore for MapConfigStore { + 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")) + } + } + + 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)) + } + + 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")]); + 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_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); + } } 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