diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0b862d9..e5af8df 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,6 +61,109 @@ 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 wasm targets + run: rustup target add wasm32-wasip1 wasm32-unknown-unknown + + - 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 + - name: Check Fastly wasm target run: cargo check -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 diff --git a/.gitignore b/.gitignore index 515cbf4..9729496 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ target/ .wrangler/ .edgezero/ +# Node +node_modules/ + # env .env diff --git a/Cargo.lock b/Cargo.lock index 725a6d6..b245489 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -707,6 +707,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 a984cdb..2d8fc28 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -8,11 +8,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; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -41,6 +43,7 @@ impl Default for AxumDevServerConfig { pub struct AxumDevServer { router: RouterService, config: AxumDevServerConfig, + config_store_handle: Option, } impl AxumDevServer { @@ -48,11 +51,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<()> { @@ -65,7 +79,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) @@ -77,7 +95,14 @@ 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, + None, + ) + .await } #[cfg(test)] @@ -86,8 +111,20 @@ impl AxumDevServer { listener: tokio::net::TcpListener, kv_path: &str, ) -> anyhow::Result<()> { - let AxumDevServer { router, config } = self; - serve_with_listener_and_kv_path(router, listener, config.enable_ctrl_c, Some(kv_path)).await + let AxumDevServer { + router, + config, + config_store_handle, + } = self; + let kv_handle = Some(kv_handle_from_path(Path::new(kv_path))?); + serve_with_listener( + router, + listener, + config.enable_ctrl_c, + config_store_handle, + kv_handle, + ) + .await } } @@ -179,38 +216,16 @@ async fn serve_with_listener( router: RouterService, listener: tokio::net::TcpListener, enable_ctrl_c: bool, -) -> anyhow::Result<()> { - // No KV store is attached here — this path is used by `AxumDevServer::run()` - // which is the manifest-unaware embedding API. Callers that need KV should - // use `run_app()` (manifest-driven) or attach a `KvHandle` directly via - // `EdgeZeroAxumService::with_kv_handle`. - serve_with_listener_and_kv_path(router, listener, enable_ctrl_c, None).await -} - -async fn serve_with_listener_and_kv_path( - router: RouterService, - listener: tokio::net::TcpListener, - enable_ctrl_c: bool, - kv_path: Option<&str>, -) -> anyhow::Result<()> { - let kv_handle = kv_path - .map(|kv_path| kv_handle_from_path(Path::new(kv_path))) - .transpose()?; - serve_with_listener_and_kv_handle(router, listener, enable_ctrl_c, kv_handle).await -} - -async fn serve_with_listener_and_kv_handle( - router: RouterService, - listener: tokio::net::TcpListener, - enable_ctrl_c: bool, + config_store_handle: Option, kv_handle: Option, ) -> anyhow::Result<()> { let mut service = EdgeZeroAxumService::new(router); + if let Some(handle) = config_store_handle { + service = service.with_config_store_handle(handle); + } if let Some(kv_handle) = kv_handle { service = service.with_kv_handle(kv_handle); } - - let service = service; let router = Router::new().fallback_service(service_fn(move |req| { let mut svc = service.clone(); async move { svc.call(req).await } @@ -238,10 +253,12 @@ async fn serve_with_listener_and_kv_handle( pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { let manifest = ManifestLoader::load_from_str(manifest_src); - let manifest = manifest.manifest(); - let logging = manifest.logging_or_default("axum"); - let kv_init_requirement = kv_init_requirement(manifest); - let kv_store_name = manifest.kv_store_name("axum").to_string(); + let m = manifest.manifest(); + let logging = m.logging_or_default(edgezero_core::app::AXUM_ADAPTER); + let kv_init_requirement = kv_init_requirement(m); + let kv_store_name = m + .kv_store_name(edgezero_core::app::AXUM_ADAPTER) + .to_string(); let kv_path = kv_store_path(&kv_store_name); let level: LevelFilter = logging.level.into(); @@ -255,7 +272,6 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { let app = A::build_app(); let router = app.router().clone(); - let runtime = RuntimeBuilder::new_multi_thread() .enable_all() .build() @@ -294,7 +310,19 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { } } }; - serve_with_listener_and_kv_handle(router, listener, config.enable_ctrl_c, kv_handle).await + let config_store_handle = m.stores.config.as_ref().map(|cfg| { + let defaults = cfg.config_store_defaults().clone(); + let store = AxumConfigStore::from_env(defaults); + ConfigStoreHandle::new(std::sync::Arc::new(store)) + }); + serve_with_listener( + router, + listener, + config.enable_ctrl_c, + config_store_handle, + kv_handle, + ) + .await }) } diff --git a/crates/edgezero-adapter-axum/src/lib.rs b/crates/edgezero-adapter-axum/src/lib.rs index ef78ffe..971d788 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")] @@ -18,6 +20,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 e273aea..dd993db 100644 --- a/crates/edgezero-adapter-axum/src/service.rs +++ b/crates/edgezero-adapter-axum/src/service.rs @@ -5,12 +5,12 @@ use std::task::{Context, Poll}; use axum::body::Body as AxumBody; use axum::http::{Request, Response}; +use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::http::StatusCode; -use tokio::{runtime::Handle, task}; -use tower::Service; - use edgezero_core::key_value_store::KvHandle; use edgezero_core::router::RouterService; +use tokio::{runtime::Handle, task}; +use tower::Service; use crate::request::into_core_request; use crate::response::into_axum_response; @@ -19,6 +19,7 @@ use crate::response::into_axum_response; #[derive(Clone)] pub struct EdgeZeroAxumService { router: RouterService, + config_store_handle: Option, kv_handle: Option, } @@ -26,10 +27,21 @@ impl EdgeZeroAxumService { pub fn new(router: RouterService) -> Self { Self { router, + config_store_handle: None, kv_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 + } + /// Attach a shared KV store to this service. /// /// The handle is cloned into every request's extensions, making @@ -52,6 +64,7 @@ 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(); let kv_handle = self.kv_handle.clone(); Box::pin(async move { let mut core_request = match into_core_request(request).await { @@ -64,6 +77,10 @@ impl Service> for EdgeZeroAxumService { } }; + if let Some(handle) = config_store_handle { + core_request.extensions_mut().insert(handle); + } + if let Some(handle) = kv_handle { core_request.extensions_mut().insert(handle); } @@ -81,11 +98,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() @@ -104,12 +131,43 @@ mod tests { 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 with_kv_handle_injects_into_request() { use crate::key_value_store::PersistentKvStore; - use std::sync::Arc; - // Pre-seed the store with a value so the handler can verify injection let temp_dir = tempfile::tempdir().unwrap(); let db_path = temp_dir.path().join("test.redb"); let store = Arc::new(PersistentKvStore::new(db_path).unwrap()); @@ -142,6 +200,33 @@ mod tests { 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"); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn service_without_kv_handle_still_works() { let router = RouterService::builder() @@ -154,7 +239,6 @@ mod tests { Ok::<_, EdgeError>(response) }) .build(); - // No with_kv_handle call — KV is optional let mut service = EdgeZeroAxumService::new(router); let request = Request::builder() diff --git a/crates/edgezero-adapter-cloudflare/Cargo.toml b/crates/edgezero-adapter-cloudflare/Cargo.toml index 43f4f1e..e4433da 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", @@ -29,9 +29,8 @@ futures = { workspace = true } futures-util = { workspace = true } log = { workspace = true } ctor = { workspace = true, optional = true } -worker = { version = "0.7", default-features = false, features = [ - "http", -], 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 ec28382..dca6ddf 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -3,6 +3,8 @@ #[cfg(feature = "cli")] pub mod cli; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +pub mod config_store; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] mod context; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] @@ -14,12 +16,18 @@ 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, dispatch_with_kv, into_core_request, DEFAULT_KV_BINDING}; +#[allow(deprecated)] +pub use request::{ + dispatch, dispatch_with_config, dispatch_with_config_handle, dispatch_with_kv, + into_core_request, DEFAULT_KV_BINDING, +}; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub use response::from_core_response; @@ -35,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, @@ -47,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, @@ -55,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)) } } @@ -69,10 +81,28 @@ pub async fn run_app( init_logger().expect("init cloudflare logger"); let manifest_loader = edgezero_core::manifest::ManifestLoader::load_from_str(manifest_src); let manifest = manifest_loader.manifest(); - let kv_binding = manifest.kv_store_name("cloudflare"); + let kv_binding = manifest.kv_store_name(edgezero_core::app::CLOUDFLARE_ADAPTER); let kv_required = manifest.stores.kv.is_some(); + let config_binding = A::config_store() + .map(|cfg| cfg.name_for_adapter(edgezero_core::app::CLOUDFLARE_ADAPTER)) + .or_else(|| { + manifest + .stores + .config + .as_ref() + .map(|cfg| cfg.config_store_name(edgezero_core::app::CLOUDFLARE_ADAPTER)) + }); let app = A::build_app(); - dispatch_with_kv(&app, req, env, ctx, kv_binding, kv_required).await + crate::request::dispatch_with_bindings( + &app, + req, + env, + ctx, + config_binding, + kv_binding, + kv_required, + ) + .await } /// Deprecated: use [`run_app`] which now takes `manifest_src` directly. diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index 86604d7..4145168 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -1,11 +1,13 @@ use std::collections::BTreeSet; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; +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::key_value_store::KvHandle; @@ -53,7 +55,7 @@ pub async fn into_core_request( Ok(request) } -pub async fn dispatch( +pub(crate) async fn dispatch_raw( app: &App, req: CfRequest, env: Env, @@ -62,6 +64,24 @@ pub async fn dispatch( dispatch_with_kv(app, req, env, ctx, DEFAULT_KV_BINDING, false).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 Cloudflare Worker request with a custom KV binding name. /// /// `kv_required` should be `true` when `[stores.kv]` is explicitly present @@ -75,25 +95,81 @@ pub async fn dispatch_with_kv( kv_binding: &str, kv_required: bool, ) -> Result { - // Try to open the KV binding from `env` before consuming it in `into_core_request`. - // We borrow `env` here; `into_core_request` takes ownership afterwards. - let kv_handle = match crate::key_value_store::CloudflareKvStore::from_env(&env, kv_binding) { - Ok(store) => Some(KvHandle::new(std::sync::Arc::new(store))), - Err(e) => { - if kv_required { - return Err(WorkerError::RustError(format!( - "KV binding '{}' is explicitly configured but could not be opened: {}", - kv_binding, e - ))); - } - warn_missing_kv_binding_once(kv_binding, &e); - None - } - }; + let kv_handle = resolve_kv_handle(&env, kv_binding, kv_required)?; + dispatch_with_handles(app, req, env, ctx, None, kv_handle).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 { + dispatch_with_handles(app, req, env, ctx, Some(config_store_handle), None).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_store_handle = CloudflareConfigStore::try_new(&env, binding_name) + .map(|store| ConfigStoreHandle::new(Arc::new(store))); + dispatch_with_handles(app, req, env, ctx, config_store_handle, None).await +} + +pub(crate) async fn dispatch_with_bindings( + app: &App, + req: CfRequest, + env: Env, + ctx: Context, + config_binding: Option<&str>, + kv_binding: &str, + kv_required: bool, +) -> Result { + let config_store_handle = config_binding.and_then(|binding_name| { + CloudflareConfigStore::try_new(&env, binding_name) + .map(|store| ConfigStoreHandle::new(Arc::new(store))) + }); + let kv_handle = resolve_kv_handle(&env, kv_binding, kv_required)?; + dispatch_with_handles(app, req, env, ctx, config_store_handle, kv_handle).await +} - let mut core_request = into_core_request(req, env, ctx) +async fn dispatch_with_handles( + app: &App, + req: CfRequest, + env: Env, + ctx: Context, + config_store_handle: Option, + kv_handle: Option, +) -> Result { + let core_request = into_core_request(req, env, ctx) .await .map_err(edge_error_to_worker)?; + dispatch_core_request(app, core_request, config_store_handle, kv_handle).await +} + +async fn dispatch_core_request( + app: &App, + mut core_request: Request, + config_store_handle: Option, + kv_handle: Option, +) -> Result { + if let Some(handle) = config_store_handle { + core_request.extensions_mut().insert(handle); + } if let Some(handle) = kv_handle { core_request.extensions_mut().insert(handle); @@ -104,6 +180,26 @@ pub async fn dispatch_with_kv( from_core_response(response).map_err(edge_error_to_worker) } +fn resolve_kv_handle( + env: &Env, + kv_binding: &str, + kv_required: bool, +) -> Result, WorkerError> { + match crate::key_value_store::CloudflareKvStore::from_env(env, kv_binding) { + Ok(store) => Ok(Some(KvHandle::new(Arc::new(store)))), + Err(err) => { + if kv_required { + return Err(WorkerError::RustError(format!( + "KV binding '{}' is explicitly configured but could not be opened: {}", + kv_binding, err + ))); + } + warn_missing_kv_binding_once(kv_binding, &err); + Ok(None) + } + } +} + fn edge_error_to_worker(err: EdgeError) -> WorkerError { WorkerError::RustError(err.to_string()) } 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 20ba5cd..902119b 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")] pub mod key_value_store; @@ -15,11 +17,17 @@ 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, dispatch_with_kv, into_core_request, DEFAULT_KV_STORE_NAME}; +#[allow(deprecated)] +pub use request::{ + dispatch, dispatch_with_config, dispatch_with_config_handle, dispatch_with_kv, + into_core_request, DEFAULT_KV_STORE_NAME, +}; #[cfg(feature = "fastly")] pub use response::from_core_response; @@ -64,13 +72,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) } } @@ -81,16 +93,61 @@ pub fn run_app( ) -> Result { let manifest_loader = edgezero_core::manifest::ManifestLoader::load_from_str(manifest_src); let manifest = manifest_loader.manifest(); - let logging = manifest.logging_or_default("fastly"); - let kv_name = manifest.kv_store_name("fastly").to_string(); + let logging = manifest.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(|| { + manifest.stores.config.as_ref().map(|cfg| { + cfg.config_store_name(edgezero_core::app::FASTLY_ADAPTER) + .to_string() + }) + }); + let kv_name = manifest + .kv_store_name(edgezero_core::app::FASTLY_ADAPTER) + .to_string(); let kv_required = manifest.stores.kv.is_some(); - run_app_with_logging::(logging.into(), req, &kv_name, kv_required) + run_app_with_stores::( + logging.into(), + req, + config_name.as_deref(), + &kv_name, + kv_required, + ) +} + +/// Dispatch with a config store. Prefer this over `run_app_with_logging` for new code. +#[cfg(feature = "fastly")] +pub fn run_app_with_config( + logging: FastlyLogging, + req: fastly::Request, + config_store_name: Option<&str>, +) -> Result { + run_app_with_stores::( + logging, + req, + config_store_name, + DEFAULT_KV_STORE_NAME, + false, + ) +} + +/// 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_stores::(logging, req, None, DEFAULT_KV_STORE_NAME, false) } #[cfg(feature = "fastly")] -pub(crate) fn run_app_with_logging( +fn run_app_with_stores( logging: FastlyLogging, req: fastly::Request, + config_store_name: Option<&str>, kv_store_name: &str, kv_required: bool, ) -> Result { @@ -100,7 +157,13 @@ pub(crate) fn run_app_with_logging( } let app = A::build_app(); - dispatch_with_kv(&app, req, kv_store_name, kv_required) + crate::request::dispatch_with_store_names( + &app, + req, + config_store_name, + kv_store_name, + kv_required, + ) } #[cfg(all(test, feature = "fastly"))] diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index 670b698..3ad1e7f 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -1,9 +1,10 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashSet, VecDeque}; use std::io::Read; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Arc, 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::key_value_store::KvHandle; @@ -11,11 +12,14 @@ 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::key_value_store::FastlyKvStore; use crate::proxy::FastlyProxyClient; use crate::response::{from_core_response, parse_uri}; use crate::FastlyRequestContext; +const WARNED_STORE_CACHE_LIMIT: usize = 64; + /// Default Fastly KV Store name. /// /// If a KV Store with this name exists in your Fastly service, it will @@ -50,10 +54,54 @@ pub fn into_core_request(mut req: FastlyRequest) -> Result { Ok(request) } -pub fn dispatch(app: &App, req: FastlyRequest) -> Result { +pub(crate) fn dispatch_raw(app: &App, req: FastlyRequest) -> Result { dispatch_with_kv(app, req, DEFAULT_KV_STORE_NAME, false) } +/// 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 { + dispatch_with_handles(app, req, Some(config_store_handle), None) +} + +/// 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 + } + }; + dispatch_with_handles(app, req, config_store_handle, None) +} + /// Dispatch a Fastly request with a custom KV store name. /// /// `kv_required` should be `true` when `[stores.kv]` is explicitly present @@ -65,26 +113,124 @@ pub fn dispatch_with_kv( kv_store_name: &str, kv_required: bool, ) -> Result { - let mut core_request = into_core_request(req).map_err(map_edge_error)?; + let kv_handle = resolve_kv_handle(kv_store_name, kv_required)?; + dispatch_with_handles(app, req, None, kv_handle) +} + +pub(crate) fn dispatch_with_store_names( + app: &App, + req: FastlyRequest, + config_store_name: Option<&str>, + kv_store_name: &str, + kv_required: bool, +) -> Result { + let config_store_handle = match config_store_name { + Some(store_name) => match FastlyConfigStore::try_open(store_name) { + Ok(store) => Some(ConfigStoreHandle::new(Arc::new(store))), + Err(err) => { + warn_missing_store_once(store_name, &err.to_string()); + None + } + }, + None => None, + }; + let kv_handle = resolve_kv_handle(kv_store_name, kv_required)?; + dispatch_with_handles(app, req, config_store_handle, kv_handle) +} + +pub(crate) fn dispatch_with_handles( + app: &App, + req: FastlyRequest, + config_store_handle: Option, + kv_handle: Option, +) -> Result { + let core_request = into_core_request(req).map_err(map_edge_error)?; + dispatch_core_request(app, core_request, config_store_handle, kv_handle) +} +fn dispatch_core_request( + app: &App, + mut core_request: Request, + config_store_handle: Option, + kv_handle: Option, +) -> Result { + if let Some(handle) = config_store_handle { + core_request.extensions_mut().insert(handle); + } + + if let Some(handle) = kv_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 resolve_kv_handle( + kv_store_name: &str, + kv_required: bool, +) -> Result, FastlyError> { match FastlyKvStore::open(kv_store_name) { - Ok(store) => { - let handle = KvHandle::new(std::sync::Arc::new(store)); - core_request.extensions_mut().insert(handle); - } - Err(e) => { + Ok(store) => Ok(Some(KvHandle::new(Arc::new(store)))), + Err(err) => { if kv_required { return Err(FastlyError::msg(format!( "KV store '{}' is explicitly configured but could not be opened: {}", - kv_store_name, e + kv_store_name, err ))); } - warn_missing_kv_store_once(kv_store_name, &e); + warn_missing_kv_store_once(kv_store_name, &err); + Ok(None) } } +} - 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 { 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 67efdef..1192c19 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::key_value_store::KvHandle; @@ -85,6 +86,13 @@ impl RequestContext { self.request.extensions().get::().cloned() } + pub fn config_store(&self) -> Option { + self.request + .extensions() + .get::() + .cloned() + } + pub fn kv_handle(&self) -> Option { self.request.extensions().get::().cloned() } @@ -327,6 +335,47 @@ mod tests { 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()); + } + #[test] fn kv_handle_is_retrieved_when_present() { use crate::key_value_store::{KvHandle, NoopKvStore}; diff --git a/crates/edgezero-core/src/error.rs b/crates/edgezero-core/src/error.rs index 3a815af..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,7 +19,7 @@ pub enum EdgeError { MethodNotAllowed { method: Method, allowed: String }, #[error("validation error: {message}")] Validation { message: String }, - #[error("service unavailable: {message}")] + #[error("{message}")] ServiceUnavailable { message: String }, #[error("internal error: {source}")] Internal { @@ -61,12 +62,6 @@ impl EdgeError { } } - pub fn service_unavailable(message: impl Into) -> Self { - EdgeError::ServiceUnavailable { - message: message.into(), - } - } - pub fn internal(error: E) -> Self where E: Into, @@ -76,6 +71,12 @@ 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, @@ -108,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")) } @@ -180,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 bc6fe81..fd552a9 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 0efb690..fd3bb0b 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)] @@ -118,31 +121,7 @@ impl Manifest { &self.environment } - /// Returns the KV store name for a given adapter. - /// - /// Resolution order: - /// 1. Per-adapter override (`[stores.kv.adapters.]`) - /// 2. Global name (`[stores.kv] name = "..."`) - /// 3. Default: `"EDGEZERO_KV"` - pub fn kv_store_name(&self, adapter: &str) -> &str { - const DEFAULT: &str = "EDGEZERO_KV"; - match &self.stores.kv { - Some(kv) => { - let adapter_lower = adapter.to_ascii_lowercase(); - if let Some(adapter_cfg) = kv - .adapters - .iter() - .find(|(k, _)| k.eq_ignore_ascii_case(&adapter_lower)) - { - return &adapter_cfg.1.name; - } - &kv.name - } - None => DEFAULT, - } - } - - fn finalize(&mut self) { + pub(crate) fn finalize(&mut self) { let mut resolved = BTreeMap::new(); for (adapter, cfg) in &self.adapters { @@ -162,6 +141,29 @@ impl Manifest { self.logging_resolved = resolved; } + + /// Returns the KV store name for a given adapter. + /// + /// Resolution order: + /// 1. Per-adapter override (`[stores.kv.adapters.]`) + /// 2. Global name (`[stores.kv] name = "..."`) + /// 3. Default: `"EDGEZERO_KV"` + pub fn kv_store_name(&self, adapter: &str) -> &str { + match &self.stores.kv { + Some(kv) => { + let adapter_lower = adapter.to_ascii_lowercase(); + if let Some(adapter_cfg) = kv + .adapters + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(&adapter_lower)) + { + return &adapter_cfg.1.name; + } + &kv.name + } + None => DEFAULT_KV_STORE_NAME, + } + } } #[derive(Debug, Default, Deserialize, Validate)] @@ -333,6 +335,112 @@ 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 { + #[serde(default)] + #[validate(nested)] + pub config: Option, + #[serde(default)] + #[validate(nested)] + pub kv: 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)] @@ -397,24 +505,6 @@ fn default_kv_name() -> String { DEFAULT_KV_STORE_NAME.to_string() } -/// Configuration for external stores (e.g., KV, object storage). -/// -/// ```toml -/// [stores.kv] -/// name = "MY_KV" # global default -/// -/// [stores.kv.adapters.cloudflare] -/// name = "CF_BINDING" # per-adapter override -/// ``` -#[derive(Debug, Default, Deserialize, Validate)] -pub struct ManifestStores { - /// KV store configuration. When absent, the default - /// name `EDGEZERO_KV` is used. - #[serde(default)] - #[validate(nested)] - pub kv: Option, -} - /// Global KV store configuration. #[derive(Debug, Deserialize, Validate)] pub struct ManifestKvConfig { @@ -1148,6 +1238,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/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 8399736..d8bbcba 100644 --- a/examples/app-demo/Cargo.lock +++ b/examples/app-demo/Cargo.lock @@ -539,6 +539,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 28ac887..9877065 100644 --- a/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml +++ b/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml @@ -5,6 +5,11 @@ 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"}' + # KV namespace binding — used by KV demo handlers. # For local dev (`wrangler dev`), this creates a local KV store automatically. # For production, replace `id` with the output of: 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 8d5c4ac..843ca4b 100644 --- a/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml +++ b/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml @@ -7,6 +7,16 @@ 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" + [local_server.kv_stores] [[local_server.kv_stores.EDGEZERO_KV]] @@ -21,6 +31,5 @@ data = "" [setup.kv_stores.EDGEZERO_KV] description = "KV store for EdgeZero demo" - [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 1eb44dc..051d463 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, @@ -40,6 +46,9 @@ pub(crate) struct NoteIdPath { pub(crate) id: String, } +/// Maximum request body size (25 MB, matches KV value limit). +const MAX_BODY_SIZE: usize = 25 * 1024 * 1024; + #[action] pub(crate) async fn root() -> Text<&'static str> { Text::new("app-demo app") @@ -123,9 +132,39 @@ fn proxy_not_available_response() -> Result { .map_err(EdgeError::internal) } -// --------------------------------------------------------------------------- -// KV-powered handlers — demonstrate platform-neutral key-value storage. -// --------------------------------------------------------------------------- +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), + ), + } +} /// Increment and return a visit counter stored in KV. #[action] @@ -159,9 +198,6 @@ pub(crate) async fn kv_note_put( .map_err(EdgeError::internal) } -/// Maximum request body size (25 MB, matches KV value limit). -const MAX_BODY_SIZE: usize = 25 * 1024 * 1024; - /// Read a note by id. #[action] pub(crate) async fn kv_note_get( @@ -196,14 +232,17 @@ 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}; + use edgezero_core::key_value_store::{KvError, KvHandle, KvPage, KvStore}; use edgezero_core::params::PathParams; use edgezero_core::proxy::{ProxyClient, ProxyHandle, ProxyResponse}; use edgezero_core::response::IntoResponse; use futures::{executor::block_on, StreamExt}; - use std::collections::HashMap; + use std::collections::{BTreeMap, HashMap}; + use std::sync::{Arc, Mutex}; #[test] fn root_returns_static_body() { @@ -354,15 +393,99 @@ mod tests { RequestContext::new(request, PathParams::default()) } - // -- KV handler tests -------------------------------------------------- + struct MapConfigStore(HashMap); - use edgezero_core::key_value_store::{KvError, KvHandle, KvPage, KvStore}; - use std::collections::BTreeMap; - use std::sync::{Arc, Mutex}; + 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); + } struct MockKv { data: Mutex>, } + impl MockKv { fn new() -> Self { Self { @@ -376,10 +499,12 @@ mod tests { async fn get_bytes(&self, key: &str) -> Result, KvError> { Ok(self.data.lock().unwrap().get(key).cloned()) } + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { self.data.lock().unwrap().insert(key.to_string(), value); Ok(()) } + async fn put_bytes_with_ttl( &self, key: &str, @@ -389,6 +514,7 @@ mod tests { self.data.lock().unwrap().insert(key.to_string(), value); Ok(()) } + async fn delete(&self, key: &str) -> Result<(), KvError> { self.data.lock().unwrap().remove(key); Ok(()) @@ -460,7 +586,6 @@ mod tests { let resp = block_on(kv_note_put(ctx)).expect("response"); assert_eq!(resp.status(), StatusCode::CREATED); - // Now read back via get let (ctx2, _) = { let mut request = request_builder() .method(Method::GET) diff --git a/examples/app-demo/edgezero.toml b/examples/app-demo/edgezero.toml index a187197..96e34dd 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"] + # -- KV demo routes -------------------------------------------------------- [[triggers.http]] @@ -110,6 +117,14 @@ description = "Delete a note by id" # 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