diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0a9a4e9..0b862d9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,11 +43,14 @@ jobs: with: toolchain: ${{ steps.rust-version.outputs.rust-version }} - - name: Add wasm32-wasi target - run: rustup target add wasm32-wasip1 + - name: Add wasm targets + run: rustup target add wasm32-wasip1 wasm32-unknown-unknown - name: Setup Viceroy - run: cargo install viceroy --locked + run: | + if ! command -v viceroy &>/dev/null; then + cargo install viceroy --locked + fi - name: Fetch dependencies (locked) run: cargo fetch --locked @@ -57,3 +60,9 @@ jobs: - name: Check feature compilation run: cargo check --workspace --all-targets --features "fastly cloudflare" + + - name: Check Fastly wasm target + run: cargo check -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 + + - name: Check Cloudflare wasm target + run: cargo check -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown diff --git a/.gitignore b/.gitignore index 6aef111..515cbf4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,12 @@ +# node +node_modules/ + # compiled output bin/ pkg/ target/ .wrangler/ +.edgezero/ # env .env diff --git a/Cargo.lock b/Cargo.lock index d96761a..a67f371 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,6 +105,8 @@ dependencies = [ "edgezero-core", "futures", "serde", + "serde_json", + "validator", ] [[package]] @@ -677,7 +679,9 @@ dependencies = [ "futures-util", "http", "log", + "redb", "reqwest", + "serde", "simple_logger", "tempfile", "thiserror 2.0.18", @@ -692,6 +696,7 @@ dependencies = [ name = "edgezero-adapter-cloudflare" version = "0.1.0" dependencies = [ + "anyhow", "async-trait", "brotli", "bytes", @@ -712,6 +717,7 @@ dependencies = [ name = "edgezero-adapter-fastly" version = "0.1.0" dependencies = [ + "anyhow", "async-stream", "async-trait", "brotli", @@ -1901,6 +1907,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "redb" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae323eb086579a3769daa2c753bb96deb95993c534711e0dbe881b5192906a06" +dependencies = [ + "libc", +] + [[package]] name = "regex" version = "1.12.3" diff --git a/Cargo.toml b/Cargo.toml index 5a95130..4fbadf3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ log = "0.4" log-fastly = "0.11" matchit = "0.9" once_cell = "1" +redb = "3.1.0" reqwest = { version = "0.13", default-features = false, features = ["rustls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/edgezero-adapter-axum/Cargo.toml b/crates/edgezero-adapter-axum/Cargo.toml index c7bb1b7..10356ee 100644 --- a/crates/edgezero-adapter-axum/Cargo.toml +++ b/crates/edgezero-adapter-axum/Cargo.toml @@ -7,11 +7,26 @@ license = { workspace = true } [features] default = ["axum"] -axum = ["dep:axum", "dep:tokio", "dep:tower", "dep:futures-util", "dep:reqwest"] -cli = ["dep:edgezero-adapter", "edgezero-adapter/cli", "dep:ctor", "dep:toml", "dep:walkdir"] +axum = [ + "dep:axum", + "dep:tokio", + "dep:tower", + "dep:futures-util", + "dep:reqwest", + "dep:redb", +] +cli = [ + "dep:edgezero-adapter", + "edgezero-adapter/cli", + "dep:ctor", + "dep:toml", + "dep:walkdir", +] [dependencies] -edgezero-adapter = { path = "../edgezero-adapter", optional = true, features = ["cli"] } +edgezero-adapter = { path = "../edgezero-adapter", optional = true, features = [ + "cli", +] } edgezero-core = { path = "../edgezero-core" } anyhow = { workspace = true } async-trait = { workspace = true } @@ -22,6 +37,7 @@ futures = { workspace = true } futures-util = { workspace = true, optional = true } http = { workspace = true } log = { workspace = true } +redb = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } simple_logger = { workspace = true } thiserror = { workspace = true } @@ -34,5 +50,6 @@ walkdir = { workspace = true, optional = true } [dev-dependencies] async-trait = { workspace = true } axum = { workspace = true, features = ["macros"] } +serde = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 1d611f8..a984cdb 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -1,4 +1,5 @@ use std::net::{SocketAddr, TcpListener as StdTcpListener}; +use std::path::{Path, PathBuf}; use anyhow::Context; use axum::Router; @@ -14,6 +15,12 @@ use simple_logger::SimpleLogger; use crate::service::EdgeZeroAxumService; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum KvInitRequirement { + Optional, + Required, +} + /// Configuration used when running the dev server embedding EdgeZero into Axum. #[derive(Clone)] pub struct AxumDevServerConfig { @@ -74,18 +81,136 @@ impl AxumDevServer { } #[cfg(test)] - async fn run_with_listener(self, listener: tokio::net::TcpListener) -> anyhow::Result<()> { + async fn run_with_listener( + self, + listener: tokio::net::TcpListener, + kv_path: &str, + ) -> anyhow::Result<()> { let AxumDevServer { router, config } = self; - serve_with_listener(router, listener, config.enable_ctrl_c).await + serve_with_listener_and_kv_path(router, listener, config.enable_ctrl_c, Some(kv_path)).await + } +} + +fn kv_init_requirement(manifest: &edgezero_core::manifest::Manifest) -> KvInitRequirement { + if manifest.stores.kv.is_some() { + KvInitRequirement::Required + } else { + KvInitRequirement::Optional + } +} + +fn kv_store_path(store_name: &str) -> PathBuf { + if store_name == edgezero_core::manifest::DEFAULT_KV_STORE_NAME { + return PathBuf::from(".edgezero/kv.redb"); + } + + PathBuf::from(".edgezero").join(format!( + "kv-{}-{:016x}.redb", + store_name_slug(store_name), + stable_store_name_hash(store_name) + )) +} + +fn store_name_slug(store_name: &str) -> String { + const MAX_SLUG_LEN: usize = 24; + + let mut slug = String::with_capacity(MAX_SLUG_LEN); + let mut last_was_separator = false; + for ch in store_name.chars() { + let mapped = if ch.is_ascii_alphanumeric() { + Some(ch.to_ascii_lowercase()) + } else { + None + }; + + match mapped { + Some(ch) => { + if slug.len() == MAX_SLUG_LEN { + break; + } + slug.push(ch); + last_was_separator = false; + } + None if !slug.is_empty() && !last_was_separator => { + if slug.len() == MAX_SLUG_LEN { + break; + } + slug.push('-'); + last_was_separator = true; + } + None => {} + } + } + + while slug.ends_with('-') { + slug.pop(); + } + + if slug.is_empty() { + "store".to_string() + } else { + slug } } +fn stable_store_name_hash(store_name: &str) -> u64 { + // Deterministic FNV-1a keeps local KV file names stable across processes. + let mut hash = 0xcbf29ce484222325u64; + for byte in store_name.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +fn kv_handle_from_path(kv_path: &Path) -> anyhow::Result { + if let Some(parent) = kv_path.parent() { + std::fs::create_dir_all(parent).context("failed to create KV store directory")?; + } + let kv_store = std::sync::Arc::new( + crate::key_value_store::PersistentKvStore::new(kv_path) + .context("failed to create KV store")?, + ); + log::info!("KV store: {}", kv_path.display()); + Ok(edgezero_core::key_value_store::KvHandle::new(kv_store)) +} + async fn serve_with_listener( router: RouterService, listener: tokio::net::TcpListener, enable_ctrl_c: bool, ) -> anyhow::Result<()> { - let service = EdgeZeroAxumService::new(router); + // 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, + kv_handle: Option, +) -> anyhow::Result<()> { + let mut service = EdgeZeroAxumService::new(router); + 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 } @@ -113,7 +238,11 @@ async fn serve_with_listener( pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { let manifest = ManifestLoader::load_from_str(manifest_src); - let logging = manifest.manifest().logging_or_default("axum"); + let 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 kv_path = kv_store_path(&kv_store_name); let level: LevelFilter = logging.level.into(); let level = if logging.echo_stdout.unwrap_or(true) { @@ -127,7 +256,46 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { let app = A::build_app(); let router = app.router().clone(); - AxumDevServer::new(router).run() + let runtime = RuntimeBuilder::new_multi_thread() + .enable_all() + .build() + .context("failed to build tokio runtime")?; + + runtime.block_on(async move { + let config = AxumDevServerConfig::default(); + let listener = StdTcpListener::bind(config.addr) + .with_context(|| format!("failed to bind dev server to {}", config.addr))?; + listener + .set_nonblocking(true) + .context("failed to set listener to non-blocking")?; + let listener = tokio::net::TcpListener::from_std(listener) + .context("failed to adopt std listener into tokio")?; + + let kv_handle = match kv_handle_from_path(&kv_path) { + Ok(handle) => Some(handle), + Err(err) => { + match kv_init_requirement { + KvInitRequirement::Optional => { + log::warn!( + "KV store '{}' could not be initialized at {}: {}", + kv_store_name, + kv_path.display(), + err + ); + None + } + KvInitRequirement::Required => { + return Err(err.context(format!( + "KV store '{}' is explicitly configured for axum but could not be initialized at {}", + kv_store_name, + kv_path.display() + ))); + } + } + } + }; + serve_with_listener_and_kv_handle(router, listener, config.enable_ctrl_c, kv_handle).await + }) } #[cfg(test)] @@ -191,6 +359,69 @@ mod tests { assert_eq!(server.config.addr.port(), 9000); assert!(!server.config.enable_ctrl_c); } + + #[test] + fn default_store_name_uses_legacy_kv_path() { + assert_eq!( + kv_store_path(edgezero_core::manifest::DEFAULT_KV_STORE_NAME), + PathBuf::from(".edgezero/kv.redb") + ); + } + + #[test] + fn implicit_default_kv_is_optional() { + let manifest = ManifestLoader::load_from_str(""); + assert_eq!( + kv_init_requirement(manifest.manifest()), + KvInitRequirement::Optional + ); + } + + #[test] + fn explicit_kv_config_is_required() { + let manifest = ManifestLoader::load_from_str( + r#" +[stores.kv] +name = "EDGEZERO_KV" +"#, + ); + assert_eq!( + kv_init_requirement(manifest.manifest()), + KvInitRequirement::Required + ); + } + + #[test] + fn custom_store_name_uses_stable_bounded_path() { + let path = kv_store_path("../Prod KV"); + let expected = format!( + "kv-prod-kv-{:016x}.redb", + stable_store_name_hash("../Prod KV") + ); + assert_eq!(path.parent(), Some(Path::new(".edgezero"))); + assert_eq!( + path.file_name().and_then(|name| name.to_str()), + Some(expected.as_str()) + ); + } + + #[test] + fn custom_store_names_remain_distinct_across_case() { + assert_ne!(kv_store_path("Store"), kv_store_path("store")); + } + + #[test] + fn custom_store_path_length_is_bounded() { + let path = kv_store_path(&"a".repeat(4_096)); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .expect("file name"); + assert!( + file_name.len() <= 64, + "unexpected file name length: {file_name}" + ); + } } #[cfg(test)] @@ -204,6 +435,7 @@ mod integration_tests { struct TestServer { base_url: String, handle: tokio::task::JoinHandle<()>, + _temp_dir: tempfile::TempDir, } async fn start_test_server(router: RouterService) -> TestServer { @@ -217,13 +449,19 @@ mod integration_tests { }; let server = AxumDevServer::with_config(router, config); + // Use a unique temp directory for each test server + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let kv_path = temp_dir.path().join("kv.redb"); + let kv_path_str = kv_path.to_str().expect("valid path").to_string(); + let handle = tokio::spawn(async move { - let _ = server.run_with_listener(listener).await; + let _ = server.run_with_listener(listener, &kv_path_str).await; }); TestServer { base_url: format!("http://{}", addr), handle, + _temp_dir: temp_dir, } } @@ -358,4 +596,189 @@ mod integration_tests { drop(listener); } + + #[tokio::test(flavor = "multi_thread")] + async fn kv_store_persists_across_requests() { + async fn write_handler(ctx: RequestContext) -> Result<&'static str, EdgeError> { + let store = ctx.kv_handle().expect("kv configured"); + store.put("counter", &42i32).await?; + Ok("written") + } + + async fn read_handler(ctx: RequestContext) -> Result { + let store = ctx.kv_handle().expect("kv configured"); + let val: i32 = store.get_or("counter", 0).await?; + Ok(val.to_string()) + } + + let router = RouterService::builder() + .post("/write", write_handler) + .get("/read", read_handler) + .build(); + let server = start_test_server(router).await; + + let client = reqwest::Client::new(); + + // Write a value + let write_url = format!("{}/write", server.base_url); + let response = send_with_retry(&client, |client| client.post(write_url.as_str())).await; + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!(response.text().await.unwrap(), "written"); + + // Read it back — proves shared state across requests + let read_url = format!("{}/read", server.base_url); + let response = send_with_retry(&client, |client| client.get(read_url.as_str())).await; + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!(response.text().await.unwrap(), "42"); + + server.handle.abort(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn kv_store_delete_across_requests() { + async fn write_handler(ctx: RequestContext) -> Result<&'static str, EdgeError> { + let kv = ctx.kv_handle().expect("kv configured"); + kv.put("temp", &"to_delete").await?; + Ok("written") + } + + async fn delete_handler(ctx: RequestContext) -> Result<&'static str, EdgeError> { + let kv = ctx.kv_handle().expect("kv configured"); + kv.delete("temp").await?; + Ok("deleted") + } + + async fn check_handler(ctx: RequestContext) -> Result { + let kv = ctx.kv_handle().expect("kv configured"); + let exists = kv.exists("temp").await?; + Ok(format!("exists={exists}")) + } + + let router = RouterService::builder() + .post("/write", write_handler) + .post("/delete", delete_handler) + .get("/check", check_handler) + .build(); + let server = start_test_server(router).await; + let client = reqwest::Client::new(); + + // Write + let url = format!("{}/write", server.base_url); + send_with_retry(&client, |c| c.post(url.as_str())).await; + + // Verify exists + let url = format!("{}/check", server.base_url); + let resp = send_with_retry(&client, |c| c.get(url.as_str())).await; + assert_eq!(resp.text().await.unwrap(), "exists=true"); + + // Delete + let url = format!("{}/delete", server.base_url); + send_with_retry(&client, |c| c.post(url.as_str())).await; + + // Verify gone + let url = format!("{}/check", server.base_url); + let resp = send_with_retry(&client, |c| c.get(url.as_str())).await; + assert_eq!(resp.text().await.unwrap(), "exists=false"); + + server.handle.abort(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn kv_store_update_across_requests() { + async fn increment_handler(ctx: RequestContext) -> Result { + let kv = ctx.kv_handle().expect("kv configured"); + let val = kv.read_modify_write("counter", 0i32, |n| n + 1).await?; + Ok(val.to_string()) + } + + let router = RouterService::builder() + .post("/inc", increment_handler) + .build(); + let server = start_test_server(router).await; + let client = reqwest::Client::new(); + let url = format!("{}/inc", server.base_url); + + // Increment 5 times, each should return incremented value + for expected in 1..=5i32 { + let resp = send_with_retry(&client, |c| c.post(url.as_str())).await; + assert_eq!( + resp.text().await.unwrap(), + expected.to_string(), + "increment #{expected}" + ); + } + + server.handle.abort(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn kv_store_returns_not_found_gracefully() { + async fn read_handler(ctx: RequestContext) -> Result { + let kv = ctx.kv_handle().expect("kv configured"); + let val: i32 = kv.get_or("nonexistent", -1).await?; + Ok(val.to_string()) + } + + let router = RouterService::builder().get("/read", read_handler).build(); + let server = start_test_server(router).await; + let client = reqwest::Client::new(); + + let url = format!("{}/read", server.base_url); + let resp = send_with_retry(&client, |c| c.get(url.as_str())).await; + assert_eq!(resp.status(), reqwest::StatusCode::OK); + assert_eq!(resp.text().await.unwrap(), "-1"); + + server.handle.abort(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn kv_store_handles_typed_data() { + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize, PartialEq, Debug)] + struct UserProfile { + name: String, + age: u32, + active: bool, + } + + async fn write_handler(ctx: RequestContext) -> Result<&'static str, EdgeError> { + let kv = ctx.kv_handle().expect("kv configured"); + let profile = UserProfile { + name: "Alice".to_string(), + age: 30, + active: true, + }; + kv.put("user:alice", &profile).await?; + Ok("saved") + } + + async fn read_handler(ctx: RequestContext) -> Result { + let kv = ctx.kv_handle().expect("kv configured"); + let profile: Option = kv.get("user:alice").await?; + match profile { + Some(p) => Ok(format!("{}:{}", p.name, p.age)), + None => Ok("not found".to_string()), + } + } + + let router = RouterService::builder() + .post("/save", write_handler) + .get("/load", read_handler) + .build(); + let server = start_test_server(router).await; + let client = reqwest::Client::new(); + + // Save profile + let url = format!("{}/save", server.base_url); + let resp = send_with_retry(&client, |c| c.post(url.as_str())).await; + assert_eq!(resp.text().await.unwrap(), "saved"); + + // Load profile + let url = format!("{}/load", server.base_url); + let resp = send_with_retry(&client, |c| c.get(url.as_str())).await; + assert_eq!(resp.text().await.unwrap(), "Alice:30"); + + server.handle.abort(); + } } diff --git a/crates/edgezero-adapter-axum/src/key_value_store.rs b/crates/edgezero-adapter-axum/src/key_value_store.rs new file mode 100644 index 0000000..190bf6a --- /dev/null +++ b/crates/edgezero-adapter-axum/src/key_value_store.rs @@ -0,0 +1,608 @@ +//! Persistent KV store for local development and testing. +//! +//! Values are stored in a `redb` embedded database with TTL support. +//! Data persists across server restarts, providing parity with edge provider +//! KV stores (Cloudflare Workers KV, Fastly KV Store). +//! +//! ## Storage Location +//! +//! By default, the development server stores data at `.edgezero/kv.redb` +//! in your project directory. Custom store names get their own derived +//! database file under `.edgezero/`. Add this path to your `.gitignore`: +//! +//! ```gitignore +//! .edgezero/ +//! ``` +//! +//! ## TTL and Cleanup +//! +//! Expired entries are lazily evicted when accessed via `get_bytes`. +//! Entries that are never accessed after expiration will remain in the +//! database until explicitly deleted. +//! +//! ## Database File Management +//! +//! The redb database file will grow over time and does not automatically +//! shrink after deletions. For development, this is typically not an issue. +//! To reclaim space, delete the corresponding file in `.edgezero/` +//! (data will be lost). +//! +//! ## Concurrent Access +//! +//! The database uses exclusive file locking. Only one process can access +//! a database file at a time. If you need to run multiple dev servers +//! simultaneously, use different database paths (e.g., by running them +//! in separate project directories). +//! +//! Within a single process, the store is thread-safe and supports +//! concurrent access via redb's transaction system. +//! +//! ## Performance Notes +//! +//! - All operations are ACID-compliant via redb's transaction system. +//! - The database file path acts as the namespace identifier, similar to +//! how Cloudflare uses bindings and Fastly uses store names. + +use std::ops::Bound; +use std::path::Path; +use std::time::Duration; + +use async_trait::async_trait; +use bytes::Bytes; +use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; +use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}; +use std::time::SystemTime; + +/// Table definition for the KV store. +/// Key: String, Value: (Bytes, Option) +const KV_TABLE: TableDefinition<&str, (&[u8], Option)> = TableDefinition::new("kv"); + +/// Type alias for a writable KV table handle. +type KvTable<'txn> = redb::Table<'txn, &'static str, (&'static [u8], Option)>; + +/// A persistent KV store backed by `redb`. +/// +/// Suitable for local development where data persistence across restarts is needed. +/// TTL-expired entries are lazily evicted (checked on read/list). +pub struct PersistentKvStore { + db: Database, +} + +impl PersistentKvStore { + const LIST_SCAN_BATCH_SIZE: usize = 256; + /// Maximum number of scan batches before returning a partial page. + /// + /// Each batch scans up to `LIST_SCAN_BATCH_SIZE` entries, so this caps + /// a single `list_keys_page` call at scanning ~25,600 entries regardless + /// of how many are expired. Without this guard, a database that has + /// accumulated large numbers of expired entries (common in long-running + /// dev sessions) can produce unbounded scan latency. + /// + /// When the limit is hit the partial page is returned with the last + /// live cursor, so callers can resume pagination normally on the next + /// call. A warning is logged once so operators know cleanup is needed. + const MAX_SCAN_BATCHES: usize = 100; + + /// Create a new persistent KV store at the given path. + /// + /// # Behavior + /// + /// - If the file does not exist, a new database will be initialized + /// - If the file exists and is a valid redb database, it will be opened with existing data preserved + /// - If the file exists but is not a valid redb database, returns an error + pub fn new>(path: P) -> Result { + let db_path = path.as_ref().to_path_buf(); + let db = Database::create(path).map_err(|e| { + KvError::Internal(anyhow::anyhow!( + "Failed to open KV database at {:?}. If the file is corrupted or locked \ + by another process, try deleting it and restarting: {}", + db_path, + e + )) + })?; + + // Initialize the table + let store = Self { db }; + let write_txn = store.begin_write()?; + { + let _table = Self::open_table(&write_txn)?; + } + Self::commit(write_txn)?; + + Ok(store) + } + + /// Check if an entry is expired based on its expiration timestamp. + /// + /// If the system clock is before UNIX epoch (highly unlikely), treats entries + /// as not expired to avoid incorrectly deleting data. + fn is_expired(expires_at_millis: Option) -> bool { + if let Some(exp) = expires_at_millis { + match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { + Ok(now) => now.as_millis() >= exp, + Err(_) => { + // System clock is before UNIX epoch - treat as not expired + // to avoid incorrectly deleting data + false + } + } + } else { + false + } + } + + /// Convert SystemTime to milliseconds since UNIX epoch. + /// + /// Returns 0 if the time is before UNIX epoch (should never happen in practice). + fn system_time_to_millis(time: SystemTime) -> u128 { + time.duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) + } + + // -- Transaction helpers ------------------------------------------------ + + fn begin_write(&self) -> Result { + self.db + .begin_write() + .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to begin write txn: {}", e))) + } + + fn open_table<'txn>(txn: &'txn redb::WriteTransaction) -> Result, KvError> { + txn.open_table(KV_TABLE) + .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to open table: {}", e))) + } + + fn commit(txn: redb::WriteTransaction) -> Result<(), KvError> { + txn.commit() + .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to commit: {}", e))) + } + + fn cleanup_expired_keys(&self, expired_keys: &[String]) -> Result<(), KvError> { + if expired_keys.is_empty() { + return Ok(()); + } + + let write_txn = self.begin_write()?; + { + let mut table = Self::open_table(&write_txn)?; + for key in expired_keys { + let still_expired = table + .get(key.as_str()) + .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to get key: {}", e)))? + .is_some_and(|entry| { + let (_, expires_at) = entry.value(); + Self::is_expired(expires_at) + }); + if still_expired { + table.remove(key.as_str()).map_err(|e| { + KvError::Internal(anyhow::anyhow!("failed to remove: {}", e)) + })?; + } + } + } + Self::commit(write_txn) + } +} + +#[async_trait(?Send)] +impl KvStore for PersistentKvStore { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + let read_txn = self + .db + .begin_read() + .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to begin read txn: {}", e)))?; + + let table = read_txn + .open_table(KV_TABLE) + .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to open table: {}", e)))?; + + if let Some(entry) = table + .get(key) + .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to get key: {}", e)))? + { + let (value_bytes, expires_at) = entry.value(); + + // Check if expired + if Self::is_expired(expires_at) { + // Drop read transaction before write + drop(table); + drop(read_txn); + + // Delete the expired key + let write_txn = self.begin_write()?; + { + let mut table = Self::open_table(&write_txn)?; + // Re-check expiry inside write txn to avoid TOCTOU race: + // a concurrent put_bytes may have overwritten the key with + // a fresh value between our read and this write. + let still_expired = table + .get(key) + .map_err(|e| { + KvError::Internal(anyhow::anyhow!("failed to get key: {}", e)) + })? + .is_some_and(|entry| { + let (_, exp) = entry.value(); + Self::is_expired(exp) + }); + if still_expired { + table.remove(key).map_err(|e| { + KvError::Internal(anyhow::anyhow!("failed to remove: {}", e)) + })?; + } + } + Self::commit(write_txn)?; + + return Ok(None); + } + + Ok(Some(Bytes::copy_from_slice(value_bytes))) + } else { + Ok(None) + } + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + let write_txn = self.begin_write()?; + { + let mut table = Self::open_table(&write_txn)?; + table + .insert(key, (value.as_ref(), None)) + .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to insert: {}", e)))?; + } + Self::commit(write_txn) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + ttl: Duration, + ) -> Result<(), KvError> { + let expires_at = SystemTime::now() + ttl; + let expires_at_millis = Self::system_time_to_millis(expires_at); + + let write_txn = self.begin_write()?; + { + let mut table = Self::open_table(&write_txn)?; + table + .insert(key, (value.as_ref(), Some(expires_at_millis))) + .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to insert: {}", e)))?; + } + Self::commit(write_txn) + } + + async fn delete(&self, key: &str) -> Result<(), KvError> { + let write_txn = self.begin_write()?; + { + let mut table = Self::open_table(&write_txn)?; + table + .remove(key) + .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to remove: {}", e)))?; + } + Self::commit(write_txn) + } + + async fn list_keys_page( + &self, + prefix: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result { + let mut live_keys = Vec::with_capacity(limit.saturating_add(1)); + let mut scan_cursor = cursor.map(str::to_string); + let mut reached_end = false; + let mut batch_count: usize = 0; + + while live_keys.len() < limit + 1 && !reached_end { + if batch_count >= Self::MAX_SCAN_BATCHES { + log::warn!( + "list_keys_page: scanned {} batches ({} entries) without filling the \ + requested page; the database likely contains a large number of expired \ + entries. Returning partial page. Run a KV cleanup to improve performance.", + Self::MAX_SCAN_BATCHES, + Self::MAX_SCAN_BATCHES * Self::LIST_SCAN_BATCH_SIZE, + ); + break; + } + batch_count += 1; + let mut expired_keys = Vec::new(); + + { + let read_txn = self.db.begin_read().map_err(|e| { + KvError::Internal(anyhow::anyhow!("failed to begin read txn: {}", e)) + })?; + + let table = read_txn.open_table(KV_TABLE).map_err(|e| { + KvError::Internal(anyhow::anyhow!("failed to open table: {}", e)) + })?; + + let mut iter = if prefix.is_empty() { + match scan_cursor.as_deref() { + Some(cursor) => { + table.range::<&str>((Bound::Excluded(cursor), Bound::Unbounded)) + } + None => table.iter(), + } + } else { + match scan_cursor.as_deref() { + Some(cursor) if cursor >= prefix => { + table.range::<&str>((Bound::Excluded(cursor), Bound::Unbounded)) + } + _ => table.range(prefix..), + } + } + .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to create range: {}", e)))?; + + for _ in 0..Self::LIST_SCAN_BATCH_SIZE { + let Some(entry) = iter.next() else { + reached_end = true; + break; + }; + + let (key, value) = entry.map_err(|e| { + KvError::Internal(anyhow::anyhow!("failed to read range entry: {}", e)) + })?; + let key = key.value().to_string(); + + if !prefix.is_empty() && !key.starts_with(prefix) { + reached_end = true; + break; + } + + scan_cursor = Some(key.clone()); + let (_, expires_at) = value.value(); + + if Self::is_expired(expires_at) { + expired_keys.push(key); + continue; + } + + live_keys.push(key); + if live_keys.len() == limit + 1 { + break; + } + } + } + + self.cleanup_expired_keys(&expired_keys)?; + } + + let has_more = live_keys.len() > limit; + if has_more { + live_keys.truncate(limit); + } + + Ok(KvPage { + cursor: has_more.then(|| live_keys.last().cloned()).flatten(), + keys: live_keys, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use edgezero_core::key_value_store::KvHandle; + use std::sync::Arc; + + fn store() -> (KvHandle, tempfile::TempDir) { + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("test.redb"); + let store = PersistentKvStore::new(db_path).unwrap(); + (KvHandle::new(Arc::new(store)), temp_dir) + } + + // -- Raw bytes ----------------------------------------------------------- + + #[tokio::test] + async fn put_and_get_bytes() { + let (s, _dir) = store(); + s.put_bytes("k", Bytes::from("hello")).await.unwrap(); + assert_eq!(s.get_bytes("k").await.unwrap(), Some(Bytes::from("hello"))); + } + + #[tokio::test] + async fn get_missing_key_returns_none() { + let (s, _dir) = store(); + assert_eq!(s.get_bytes("missing").await.unwrap(), None); + } + + #[tokio::test] + async fn put_overwrites_existing() { + let (s, _dir) = store(); + s.put_bytes("k", Bytes::from("first")).await.unwrap(); + s.put_bytes("k", Bytes::from("second")).await.unwrap(); + assert_eq!(s.get_bytes("k").await.unwrap(), Some(Bytes::from("second"))); + } + + #[tokio::test] + async fn delete_removes_key() { + let (s, _dir) = store(); + s.put_bytes("k", Bytes::from("v")).await.unwrap(); + s.delete("k").await.unwrap(); + assert_eq!(s.get_bytes("k").await.unwrap(), None); + } + + #[tokio::test] + async fn delete_nonexistent_is_ok() { + let (s, _dir) = store(); + s.delete("nope").await.unwrap(); + } + + #[tokio::test] + async fn ttl_expires_entry() { + // Use the store impl directly to bypass validation limits (min TTL 60s) + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("test.redb"); + let s = PersistentKvStore::new(db_path).unwrap(); + s.put_bytes_with_ttl("temp", Bytes::from("val"), Duration::from_millis(1)) + .await + .unwrap(); + // 200ms gives the OS scheduler enough headroom on busy CI runners. + std::thread::sleep(Duration::from_millis(200)); + assert_eq!(s.get_bytes("temp").await.unwrap(), None); + } + + #[tokio::test] + async fn ttl_not_expired_returns_value() { + let (s, _dir) = store(); + s.put_bytes_with_ttl("temp", Bytes::from("val"), Duration::from_secs(60)) + .await + .unwrap(); + assert_eq!(s.get_bytes("temp").await.unwrap(), Some(Bytes::from("val"))); + } + + #[tokio::test] + async fn list_keys_page_skips_expired_entries() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("test.redb"); + let s = PersistentKvStore::new(db_path).unwrap(); + + s.put_bytes("app/live", Bytes::from("value")).await.unwrap(); + s.put_bytes_with_ttl("app/expired", Bytes::from("gone"), Duration::from_millis(1)) + .await + .unwrap(); + + std::thread::sleep(Duration::from_millis(200)); + + let page = s.list_keys_page("app/", None, 10).await.unwrap(); + assert_eq!(page.keys, vec!["app/live".to_string()]); + assert_eq!(page.cursor, None); + } + + #[tokio::test] + async fn cleanup_expired_keys_does_not_delete_fresh_overwrite() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("test.redb"); + let s = PersistentKvStore::new(db_path).unwrap(); + + s.put_bytes_with_ttl("race/key", Bytes::from("stale"), Duration::from_millis(1)) + .await + .unwrap(); + std::thread::sleep(Duration::from_millis(200)); + s.put_bytes("race/key", Bytes::from("fresh")).await.unwrap(); + + s.cleanup_expired_keys(&["race/key".to_string()]).unwrap(); + + assert_eq!( + s.get_bytes("race/key").await.unwrap(), + Some(Bytes::from("fresh")) + ); + } + + // -- Typed helpers via KvHandle ---------------------------------------- + + #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)] + struct Config { + name: String, + enabled: bool, + } + + #[tokio::test] + async fn typed_roundtrip() { + let (s, _dir) = store(); + let cfg = Config { + name: "test".into(), + enabled: true, + }; + s.put("config", &cfg).await.unwrap(); + let out: Option = s.get("config").await.unwrap(); + assert_eq!(out, Some(cfg)); + } + + #[tokio::test] + async fn update_helper() { + let (s, _dir) = store(); + s.put("counter", &0i32).await.unwrap(); + let val = s + .read_modify_write("counter", 0i32, |n| n + 5) + .await + .unwrap(); + assert_eq!(val, 5); + } + + #[tokio::test] + async fn exists_helper() { + let (s, _dir) = store(); + assert!(!s.exists("nope").await.unwrap()); + s.put_bytes("k", Bytes::from("v")).await.unwrap(); + assert!(s.exists("k").await.unwrap()); + } + + #[tokio::test] + async fn new_store_is_empty() { + let (s, _dir) = store(); + assert!(!s.exists("anything").await.unwrap()); + } + + #[test] + fn concurrent_writes_dont_panic() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("test.redb"); + let s = PersistentKvStore::new(db_path).unwrap(); + let handle = KvHandle::new(Arc::new(s)); + + // KvHandle futures are !Send (async_trait(?Send) for WASM compat), so + // tokio::spawn is off-limits. Use OS threads instead — KvHandle is + // Send + Sync, so each thread moves its own clone and runs its own + // executor. This is genuinely concurrent at the OS level. + let threads: Vec<_> = (0..100i32) + .map(|i| { + let h = handle.clone(); + std::thread::spawn(move || { + futures::executor::block_on(async move { + let key = format!("key:{i}"); + h.put(&key, &i).await.unwrap(); + }); + }) + }) + .collect(); + + for t in threads { + t.join().expect("writer thread panicked"); + } + + // Verify all 100 keys survived concurrent writes with correct values. + futures::executor::block_on(async { + for i in 0..100i32 { + let key = format!("key:{i}"); + let val: i32 = handle.get_or(&key, -1).await.unwrap(); + assert_eq!(val, i, "key:{i} has wrong value after concurrent writes"); + } + }); + } + + #[tokio::test] + async fn data_persists_across_reopens() { + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("test.redb"); + + // Write data + { + let store = PersistentKvStore::new(&db_path).unwrap(); + store + .put_bytes("persistent", Bytes::from("value")) + .await + .unwrap(); + } + + // Reopen and verify data persists + { + let store = PersistentKvStore::new(&db_path).unwrap(); + let value = store.get_bytes("persistent").await.unwrap(); + assert_eq!(value, Some(Bytes::from("value"))); + } + } + + // Run the shared contract tests against PersistentKvStore. + // `Box::leak` intentionally extends the TempDir's lifetime to 'static so + // it remains alive for the duration of the test process. The directory is + // deleted when the process exits, unlike `.keep()` which leaves it behind + // permanently. + edgezero_core::key_value_store_contract_tests!(persistent_kv_contract, { + let dir = Box::leak(Box::new(tempfile::tempdir().unwrap())); + let db_path = dir.path().join("contract.redb"); + PersistentKvStore::new(db_path).unwrap() + }); +} diff --git a/crates/edgezero-adapter-axum/src/lib.rs b/crates/edgezero-adapter-axum/src/lib.rs index 0be160d..ef78ffe 100644 --- a/crates/edgezero-adapter-axum/src/lib.rs +++ b/crates/edgezero-adapter-axum/src/lib.rs @@ -5,6 +5,8 @@ mod context; #[cfg(feature = "axum")] mod dev_server; #[cfg(feature = "axum")] +pub mod key_value_store; +#[cfg(feature = "axum")] mod proxy; #[cfg(feature = "axum")] mod request; @@ -21,6 +23,8 @@ pub use context::AxumRequestContext; #[cfg(feature = "axum")] pub use dev_server::{run_app, AxumDevServer, AxumDevServerConfig}; #[cfg(feature = "axum")] +pub use key_value_store::PersistentKvStore; +#[cfg(feature = "axum")] pub use proxy::AxumProxyClient; #[cfg(feature = "axum")] pub use request::into_core_request; diff --git a/crates/edgezero-adapter-axum/src/request.rs b/crates/edgezero-adapter-axum/src/request.rs index b85ebaf..e1e973d 100644 --- a/crates/edgezero-adapter-axum/src/request.rs +++ b/crates/edgezero-adapter-axum/src/request.rs @@ -4,10 +4,10 @@ use axum::body::Body as AxumBody; use axum::extract::connect_info::ConnectInfo; use axum::http::Request; use edgezero_core::body::Body; +use edgezero_core::http::header::CONTENT_TYPE; +use edgezero_core::http::HeaderValue; use edgezero_core::http::Request as CoreRequest; use edgezero_core::proxy::ProxyHandle; -use http::header::CONTENT_TYPE; -use http::HeaderValue; use crate::context::AxumRequestContext; use crate::proxy::AxumProxyClient; diff --git a/crates/edgezero-adapter-axum/src/service.rs b/crates/edgezero-adapter-axum/src/service.rs index 9c04bfe..e273aea 100644 --- a/crates/edgezero-adapter-axum/src/service.rs +++ b/crates/edgezero-adapter-axum/src/service.rs @@ -3,13 +3,13 @@ use std::future::Future; use std::pin::Pin; use std::task::{Context, Poll}; -use axum::body::Body; use axum::body::Body as AxumBody; use axum::http::{Request, Response}; -use http::StatusCode; +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 crate::request::into_core_request; @@ -19,11 +19,25 @@ use crate::response::into_axum_response; #[derive(Clone)] pub struct EdgeZeroAxumService { router: RouterService, + kv_handle: Option, } impl EdgeZeroAxumService { pub fn new(router: RouterService) -> Self { - Self { router } + Self { + router, + kv_handle: None, + } + } + + /// Attach a shared KV store to this service. + /// + /// The handle is cloned into every request's extensions, making + /// the `Kv` extractor available in handlers. + #[must_use] + pub fn with_kv_handle(mut self, handle: KvHandle) -> Self { + self.kv_handle = Some(handle); + self } } @@ -38,16 +52,22 @@ impl Service> for EdgeZeroAxumService { fn call(&mut self, request: Request) -> Self::Future { let router = self.router.clone(); + let kv_handle = self.kv_handle.clone(); Box::pin(async move { - let core_request = match into_core_request(request).await { + let mut core_request = match into_core_request(request).await { Ok(req) => req, Err(e) => { - let mut err_response = Response::new(Body::from(e.to_string())); + let mut err_response = Response::new(AxumBody::from(e.to_string())); *err_response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; return Ok(err_response); } }; + + if let Some(handle) = kv_handle { + core_request.extensions_mut().insert(handle); + } + let core_response = task::block_in_place(move || { Handle::current().block_on(router.oneshot(core_request)) }); @@ -83,4 +103,70 @@ mod tests { let response = service.ready().await.unwrap().call(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn with_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()); + let handle = KvHandle::new(store.clone()); + handle.put("test_key", &"injected").await.unwrap(); + + let router = RouterService::builder() + .get("/check", |ctx: RequestContext| async move { + let kv = ctx.kv_handle().expect("kv handle should be present"); + let val: String = kv.get_or("test_key", String::new()).await.unwrap(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from(val)) + .expect("response"); + Ok::<_, EdgeError>(response) + }) + .build(); + let mut service = EdgeZeroAxumService::new(router).with_kv_handle(handle); + + let request = Request::builder() + .uri("/check") + .body(AxumBody::empty()) + .unwrap(); + let response = service.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(&body[..], b"injected"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn service_without_kv_handle_still_works() { + let router = RouterService::builder() + .get("/no-kv", |ctx: RequestContext| async move { + let has_kv = ctx.kv_handle().is_some(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from(format!("has_kv={has_kv}"))) + .expect("response"); + Ok::<_, EdgeError>(response) + }) + .build(); + // No with_kv_handle call — KV is optional + let mut service = EdgeZeroAxumService::new(router); + + let request = Request::builder() + .uri("/no-kv") + .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_kv=false"); + } } diff --git a/crates/edgezero-adapter-cloudflare/Cargo.toml b/crates/edgezero-adapter-cloudflare/Cargo.toml index 2be81bd..43f4f1e 100644 --- a/crates/edgezero-adapter-cloudflare/Cargo.toml +++ b/crates/edgezero-adapter-cloudflare/Cargo.toml @@ -8,11 +8,19 @@ license = { workspace = true } [features] default = [] cloudflare = ["dep:worker"] -cli = ["dep:edgezero-adapter", "edgezero-adapter/cli", "dep:ctor", "dep:walkdir"] +cli = [ + "dep:edgezero-adapter", + "edgezero-adapter/cli", + "dep:ctor", + "dep:walkdir", +] [dependencies] +anyhow = { workspace = true } edgezero-core = { path = "../edgezero-core" } -edgezero-adapter = { path = "../edgezero-adapter", optional = true, features = ["cli"] } +edgezero-adapter = { path = "../edgezero-adapter", optional = true, features = [ + "cli", +] } async-trait = { workspace = true } brotli = { workspace = true } bytes = { workspace = true } @@ -21,9 +29,18 @@ 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 } +worker = { version = "0.7", default-features = false, features = [ + "http", +], optional = true } walkdir = { workspace = true, optional = true } wasm-bindgen-test = "0.3" [dev-dependencies] -web-sys = { version = "0.3", features = ["Window", "Response", "Request", "Headers", "ReadableStream", "Blob"] } +web-sys = { version = "0.3", features = [ + "Window", + "Response", + "Request", + "Headers", + "ReadableStream", + "Blob", +] } diff --git a/crates/edgezero-adapter-cloudflare/src/key_value_store.rs b/crates/edgezero-adapter-cloudflare/src/key_value_store.rs new file mode 100644 index 0000000..2256691 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/key_value_store.rs @@ -0,0 +1,122 @@ +//! Cloudflare Workers KV adapter. +//! +//! Wraps `worker::kv::KvStore` to implement the `edgezero_core::key_value_store::KvStore` trait. +//! +//! # Note +//! +//! This module is only compiled when the `cloudflare` feature is enabled +//! and the target is `wasm32`. + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use async_trait::async_trait; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use bytes::Bytes; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use std::time::Duration; + +/// KV store backed by Cloudflare Workers KV. +/// +/// Wraps a `worker::kv::KvStore` handle obtained via the environment binding. +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +pub struct CloudflareKvStore { + store: worker::kv::KvStore, +} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +impl CloudflareKvStore { + /// Create a new Cloudflare KV store from the environment binding name. + /// + /// The `binding` must match a KV namespace binding in `wrangler.toml`. + /// Uses `env.kv(binding)` which is the idiomatic `worker` 0.7+ API. + pub fn from_env(env: &worker::Env, binding: &str) -> Result { + let store = env + .kv(binding) + .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to open kv binding: {e}")))?; + Ok(Self { store }) + } +} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +#[async_trait(?Send)] +impl KvStore for CloudflareKvStore { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + let result = self + .store + .get(key) + .bytes() + .await + .map_err(|e| KvError::Internal(anyhow::anyhow!("get failed: {e}")))?; + Ok(result.map(Bytes::from)) + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.store + .put_bytes(key, value.as_ref()) + .map_err(|e| KvError::Internal(anyhow::anyhow!("put failed: {e}")))? + .execute() + .await + .map_err(|e| KvError::Internal(anyhow::anyhow!("put execute failed: {e}"))) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + ttl: Duration, + ) -> Result<(), KvError> { + // `KvHandle::validate_ttl` enforces a minimum of 60s, so sub-second + // truncation via `as_secs()` cannot produce a zero TTL here. + let ttl_secs = ttl.as_secs(); + + self.store + .put_bytes(key, value.as_ref()) + .map_err(|e| KvError::Internal(anyhow::anyhow!("put failed: {e}")))? + .expiration_ttl(ttl_secs) + .execute() + .await + .map_err(|e| KvError::Internal(anyhow::anyhow!("put with ttl execute failed: {e}"))) + } + + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.store + .delete(key) + .await + .map_err(|e| KvError::Internal(anyhow::anyhow!("delete failed: {e}"))) + } + + async fn list_keys_page( + &self, + prefix: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result { + let limit = u64::try_from(limit) + .map_err(|_| KvError::Validation("list limit exceeds u64".to_string()))?; + let mut request = self.store.list().limit(limit); + + if !prefix.is_empty() { + request = request.prefix(prefix.to_string()); + } + if let Some(cursor) = cursor.filter(|cursor| !cursor.is_empty()) { + request = request.cursor(cursor.to_string()); + } + + let response = request + .execute() + .await + .map_err(|e| KvError::Internal(anyhow::anyhow!("list execute failed: {e}")))?; + + Ok(KvPage { + keys: response.keys.into_iter().map(|key| key.name).collect(), + cursor: (!response.list_complete) + .then_some(response.cursor) + .flatten() + .filter(|cursor| !cursor.is_empty()), + }) + } +} + +// TODO: integration tests require a wasm32 target + wrangler. +// Test `CloudflareKvStore` as part of the Cloudflare adapter E2E test suite. diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index 0c4dcba..ec28382 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -6,6 +6,8 @@ pub mod cli; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] mod context; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +pub mod key_value_store; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] mod proxy; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] mod request; @@ -17,7 +19,7 @@ pub use context::CloudflareRequestContext; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub use proxy::CloudflareProxyClient; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -pub use request::{dispatch, into_core_request}; +pub use request::{dispatch, dispatch_with_kv, into_core_request, DEFAULT_KV_BINDING}; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub use response::from_core_response; @@ -59,11 +61,28 @@ impl AppExt for edgezero_core::app::App { #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub async fn run_app( + manifest_src: &str, req: worker::Request, env: worker::Env, ctx: worker::Context, ) -> Result { 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_required = manifest.stores.kv.is_some(); let app = A::build_app(); - dispatch(&app, req, env, ctx).await + dispatch_with_kv(&app, req, env, ctx, kv_binding, kv_required).await +} + +/// Deprecated: use [`run_app`] which now takes `manifest_src` directly. +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +#[deprecated(note = "use run_app instead, which now takes manifest_src")] +pub async fn run_app_with_manifest( + manifest_src: &str, + req: worker::Request, + env: worker::Env, + ctx: worker::Context, +) -> Result { + run_app::(manifest_src, req, env, ctx).await } diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index bd30427..86604d7 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -1,3 +1,6 @@ +use std::collections::BTreeSet; +use std::sync::{Mutex, OnceLock}; + use crate::proxy::CloudflareProxyClient; use crate::response::from_core_response; use crate::CloudflareRequestContext; @@ -5,12 +8,17 @@ use edgezero_core::app::App; use edgezero_core::body::Body; use edgezero_core::error::EdgeError; use edgezero_core::http::{request_builder, Method as CoreMethod, Request, Uri}; +use edgezero_core::key_value_store::KvHandle; use edgezero_core::proxy::ProxyHandle; use worker::{ Context, Env, Error as WorkerError, Method, Request as CfRequest, Response as CfResponse, }; -use wasm_bindgen_test::wasm_bindgen_test; +/// Default Cloudflare Workers KV binding name. +/// +/// If a KV namespace with this binding exists in your `wrangler.toml`, +/// it will be automatically available to handlers via the `Kv` extractor. +pub const DEFAULT_KV_BINDING: &str = edgezero_core::manifest::DEFAULT_KV_STORE_NAME; pub async fn into_core_request( mut req: CfRequest, @@ -51,9 +59,46 @@ pub async fn dispatch( env: Env, ctx: Context, ) -> Result { - let core_request = into_core_request(req, env, ctx) + dispatch_with_kv(app, req, env, ctx, DEFAULT_KV_BINDING, false).await +} + +/// Dispatch a Cloudflare Worker request with a custom KV binding name. +/// +/// `kv_required` should be `true` when `[stores.kv]` is explicitly present +/// in the manifest, causing the request to fail if the binding is unavailable +/// rather than silently degrading. +pub async fn dispatch_with_kv( + app: &App, + req: CfRequest, + env: Env, + ctx: Context, + 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 mut core_request = into_core_request(req, env, ctx) .await .map_err(edge_error_to_worker)?; + + if let Some(handle) = kv_handle { + core_request.extensions_mut().insert(handle); + } + let svc = app.router().clone(); let response = svc.oneshot(core_request).await; from_core_response(response).map_err(edge_error_to_worker) @@ -63,12 +108,38 @@ fn edge_error_to_worker(err: EdgeError) -> WorkerError { WorkerError::RustError(err.to_string()) } +fn warn_missing_kv_binding_once(kv_binding: &str, error: &impl std::fmt::Display) { + static WARNED_BINDINGS: OnceLock>> = OnceLock::new(); + let warned_bindings = WARNED_BINDINGS.get_or_init(|| Mutex::new(BTreeSet::new())); + + match warned_bindings.lock() { + Ok(mut warned_bindings) => { + if !warned_bindings.insert(kv_binding.to_string()) { + return; + } + log::warn!("KV binding '{}' not available: {}", kv_binding, error); + } + Err(_) => { + log::warn!("KV binding '{}' not available: {}", kv_binding, error); + } + } +} + fn into_core_method(method: Method) -> CoreMethod { - CoreMethod::from_bytes(method.as_ref().as_bytes()).unwrap_or(CoreMethod::GET) + let bytes = method.as_ref().as_bytes(); + CoreMethod::from_bytes(bytes).unwrap_or_else(|_| { + log::warn!( + "unknown HTTP method {:?}, defaulting to GET", + method.as_ref() + ); + CoreMethod::GET + }) } +#[cfg(test)] mod tests { use super::*; + use wasm_bindgen_test::wasm_bindgen_test; #[wasm_bindgen_test] fn into_http_method_maps_known_methods() { diff --git a/crates/edgezero-adapter-cloudflare/src/response.rs b/crates/edgezero-adapter-cloudflare/src/response.rs index 08e8a4f..43d82fa 100644 --- a/crates/edgezero-adapter-cloudflare/src/response.rs +++ b/crates/edgezero-adapter-cloudflare/src/response.rs @@ -8,6 +8,9 @@ pub fn from_core_response(response: Response) -> Result { let (parts, body) = response.into_parts(); let cf_response = match body { + Body::Once(bytes) if bytes.is_empty() => { + CfResponse::empty().map_err(EdgeError::internal)? + } Body::Once(bytes) => CfResponse::from_bytes(bytes.to_vec()).map_err(EdgeError::internal)?, Body::Stream(stream) => { let worker_stream = stream @@ -41,7 +44,7 @@ mod tests { use futures_util::{stream, StreamExt}; #[test] - #[ignore] + #[ignore] // Requires worker runtime — cannot construct worker::Response in unit tests fn propagates_status_and_headers() { let response = response_builder() .status(201) diff --git a/crates/edgezero-adapter-fastly/Cargo.toml b/crates/edgezero-adapter-fastly/Cargo.toml index 5602404..fe112df 100644 --- a/crates/edgezero-adapter-fastly/Cargo.toml +++ b/crates/edgezero-adapter-fastly/Cargo.toml @@ -7,12 +7,20 @@ license = { workspace = true } [features] default = [] -cli = ["dep:edgezero-adapter", "edgezero-adapter/cli", "dep:ctor", "dep:walkdir"] +cli = [ + "dep:edgezero-adapter", + "edgezero-adapter/cli", + "dep:ctor", + "dep:walkdir", +] fastly = ["dep:fastly", "dep:log-fastly"] [dependencies] +anyhow = { workspace = true } edgezero-core = { path = "../edgezero-core" } -edgezero-adapter = { path = "../edgezero-adapter", optional = true, features = ["cli"] } +edgezero-adapter = { path = "../edgezero-adapter", optional = true, features = [ + "cli", +] } async-stream = { workspace = true } async-trait = { workspace = true } brotli = { workspace = true } diff --git a/crates/edgezero-adapter-fastly/src/key_value_store.rs b/crates/edgezero-adapter-fastly/src/key_value_store.rs new file mode 100644 index 0000000..98d7d47 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/key_value_store.rs @@ -0,0 +1,109 @@ +//! Fastly KV Store adapter. +//! +//! Wraps `fastly::kv_store::KVStore` to implement the `edgezero_core::key_value_store::KvStore` trait. +//! +//! # Note +//! +//! This module is only compiled when the `fastly` feature is enabled. + +#[cfg(feature = "fastly")] +use async_trait::async_trait; +#[cfg(feature = "fastly")] +use bytes::Bytes; +#[cfg(feature = "fastly")] +use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; +#[cfg(feature = "fastly")] +use std::time::Duration; + +/// KV store backed by Fastly's KV Store API. +/// +/// Wraps a `fastly::kv_store::KVStore` handle obtained via `KVStore::open(name)`. +#[cfg(feature = "fastly")] +pub struct FastlyKvStore { + store: fastly::kv_store::KVStore, +} + +#[cfg(feature = "fastly")] +impl FastlyKvStore { + /// Open a Fastly KV Store by name. + /// + /// Returns `KvError::Unavailable` if the store does not exist. + pub fn open(name: &str) -> Result { + let store = fastly::kv_store::KVStore::open(name) + .map_err(|e| KvError::Internal(anyhow::anyhow!("failed to open kv store: {e}")))? + .ok_or(KvError::Unavailable)?; + Ok(Self { store }) + } +} + +#[cfg(feature = "fastly")] +#[async_trait(?Send)] +impl KvStore for FastlyKvStore { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + match self.store.lookup(key) { + Ok(mut response) => { + let bytes = response.take_body_bytes(); + Ok(Some(Bytes::from(bytes))) + } + Err(fastly::kv_store::KVStoreError::ItemNotFound) => Ok(None), + Err(e) => Err(KvError::Internal(anyhow::anyhow!("lookup failed: {e}"))), + } + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.store + .insert(key, value.as_ref()) + .map_err(|e| KvError::Internal(anyhow::anyhow!("insert failed: {e}"))) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + ttl: Duration, + ) -> Result<(), KvError> { + self.store + .build_insert() + .time_to_live(ttl) + .execute(key, value.as_ref()) + .map_err(|e| KvError::Internal(anyhow::anyhow!("insert with ttl failed: {e}"))) + } + + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.store + .delete(key) + .map_err(|e| KvError::Internal(anyhow::anyhow!("delete failed: {e}"))) + } + + async fn list_keys_page( + &self, + prefix: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result { + let limit = u32::try_from(limit) + .map_err(|_| KvError::Validation("list limit exceeds u32".to_string()))?; + + let mut request = self.store.build_list().limit(limit); + + if !prefix.is_empty() { + request = request.prefix(prefix); + } + if let Some(cursor) = cursor.filter(|cursor| !cursor.is_empty()) { + request = request.cursor(cursor); + } + + let page = request + .execute() + .map_err(|e| KvError::Internal(anyhow::anyhow!("list failed: {e}")))?; + let cursor = page.next_cursor().filter(|cursor| !cursor.is_empty()); + + Ok(KvPage { + keys: page.into_keys(), + cursor, + }) + } +} + +// TODO: integration tests require the Fastly compute environment. +// Test `FastlyKvStore` as part of the Fastly adapter E2E test suite. diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index 5603831..20ba5cd 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -5,6 +5,8 @@ pub mod cli; mod context; #[cfg(feature = "fastly")] +pub mod key_value_store; +#[cfg(feature = "fastly")] mod logger; #[cfg(feature = "fastly")] mod proxy; @@ -17,7 +19,7 @@ pub use context::FastlyRequestContext; #[cfg(feature = "fastly")] pub use proxy::FastlyProxyClient; #[cfg(feature = "fastly")] -pub use request::{dispatch, into_core_request}; +pub use request::{dispatch, dispatch_with_kv, into_core_request, DEFAULT_KV_STORE_NAME}; #[cfg(feature = "fastly")] pub use response::from_core_response; @@ -78,14 +80,19 @@ pub fn run_app( req: fastly::Request, ) -> Result { let manifest_loader = edgezero_core::manifest::ManifestLoader::load_from_str(manifest_src); - let logging = manifest_loader.manifest().logging_or_default("fastly"); - run_app_with_logging::(logging.into(), req) + let manifest = manifest_loader.manifest(); + let logging = manifest.logging_or_default("fastly"); + let kv_name = manifest.kv_store_name("fastly").to_string(); + let kv_required = manifest.stores.kv.is_some(); + run_app_with_logging::(logging.into(), req, &kv_name, kv_required) } #[cfg(feature = "fastly")] -pub fn run_app_with_logging( +pub(crate) fn run_app_with_logging( logging: FastlyLogging, req: fastly::Request, + kv_store_name: &str, + kv_required: bool, ) -> Result { if logging.use_fastly_logger { let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); @@ -93,7 +100,7 @@ pub fn run_app_with_logging( } let app = A::build_app(); - dispatch(&app, req) + dispatch_with_kv(&app, req, 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 8a2cdb5..670b698 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -1,17 +1,27 @@ +use std::collections::BTreeSet; use std::io::Read; +use std::sync::{Mutex, OnceLock}; use edgezero_core::app::App; use edgezero_core::body::Body; use edgezero_core::error::EdgeError; use edgezero_core::http::{request_builder, Request}; +use edgezero_core::key_value_store::KvHandle; use edgezero_core::proxy::ProxyHandle; use fastly::{Error as FastlyError, Request as FastlyRequest, Response as FastlyResponse}; use futures::executor; +use crate::key_value_store::FastlyKvStore; use crate::proxy::FastlyProxyClient; use crate::response::{from_core_response, parse_uri}; use crate::FastlyRequestContext; +/// Default Fastly KV Store name. +/// +/// If a KV Store with this name exists in your Fastly service, it will +/// be automatically available to handlers via the `Kv` extractor. +pub const DEFAULT_KV_STORE_NAME: &str = edgezero_core::manifest::DEFAULT_KV_STORE_NAME; + pub fn into_core_request(mut req: FastlyRequest) -> Result { let method = req.get_method().clone(); let uri = parse_uri(req.get_url_str())?; @@ -41,7 +51,38 @@ pub fn into_core_request(mut req: FastlyRequest) -> Result { } pub fn dispatch(app: &App, req: FastlyRequest) -> Result { - let core_request = into_core_request(req).map_err(map_edge_error)?; + dispatch_with_kv(app, req, DEFAULT_KV_STORE_NAME, false) +} + +/// Dispatch a Fastly request with a custom KV store name. +/// +/// `kv_required` should be `true` when `[stores.kv]` is explicitly present +/// in the manifest, causing the request to fail if the store is unavailable +/// rather than silently degrading. +pub fn dispatch_with_kv( + app: &App, + req: FastlyRequest, + kv_store_name: &str, + kv_required: bool, +) -> Result { + let mut core_request = into_core_request(req).map_err(map_edge_error)?; + + 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) => { + if kv_required { + return Err(FastlyError::msg(format!( + "KV store '{}' is explicitly configured but could not be opened: {}", + kv_store_name, e + ))); + } + warn_missing_kv_store_once(kv_store_name, &e); + } + } + let response = executor::block_on(app.router().oneshot(core_request)); from_core_response(response).map_err(map_edge_error) } @@ -49,3 +90,20 @@ pub fn dispatch(app: &App, req: FastlyRequest) -> Result FastlyError { FastlyError::msg(err.to_string()) } + +fn warn_missing_kv_store_once(kv_store_name: &str, error: &impl std::fmt::Display) { + static WARNED_STORES: OnceLock>> = OnceLock::new(); + let warned_stores = WARNED_STORES.get_or_init(|| Mutex::new(BTreeSet::new())); + + match warned_stores.lock() { + Ok(mut warned_stores) => { + if !warned_stores.insert(kv_store_name.to_string()) { + return; + } + log::warn!("KV store '{}' not available: {}", kv_store_name, error); + } + Err(_) => { + log::warn!("KV store '{}' not available: {}", kv_store_name, error); + } + } +} diff --git a/crates/edgezero-core/Cargo.toml b/crates/edgezero-core/Cargo.toml index 9ddd1fe..1bbf05b 100644 --- a/crates/edgezero-core/Cargo.toml +++ b/crates/edgezero-core/Cargo.toml @@ -26,8 +26,18 @@ tower-service = { workspace = true } tracing = { workspace = true } validator = { workspace = true } log = { workspace = true } +# `web-time` is intentionally unconditional: `std::time::Instant` is +# unavailable on `wasm32-unknown-unknown` (Cloudflare Workers target). +# `web_time::Instant` is a zero-cost drop-in on native and a JS-backed +# polyfill on WASM. Used by `RequestLogger` in `middleware.rs`. web-time = { workspace = true } +[features] +# Exposes `NoopKvStore` for use in downstream adapter and integration tests +# that need a `KvHandle` without real storage. Add this feature to your crate's +# `[dev-dependencies]` entry for `edgezero-core` to use it. +test-utils = [] + [dev-dependencies] brotli = { workspace = true } flate2 = { workspace = true } diff --git a/crates/edgezero-core/src/body.rs b/crates/edgezero-core/src/body.rs index 69af028..f933bae 100644 --- a/crates/edgezero-core/src/body.rs +++ b/crates/edgezero-core/src/body.rs @@ -70,6 +70,38 @@ impl Body { matches!(self, Body::Stream(_)) } + /// Drain the body into a single `Bytes` buffer, enforcing `max_size`. + /// + /// Works for both buffered and streaming variants. Returns an error if + /// the body exceeds `max_size` bytes. + pub async fn into_bytes_bounded( + self, + max_size: usize, + ) -> Result { + if self.is_stream() { + let mut stream = self.into_stream().expect("checked is_stream"); + let mut buf = Vec::new(); + while let Some(chunk) = StreamExt::next(&mut stream).await { + let chunk = chunk.map_err(crate::error::EdgeError::internal)?; + buf.extend_from_slice(&chunk); + if buf.len() > max_size { + return Err(crate::error::EdgeError::bad_request( + "request body too large", + )); + } + } + Ok(Bytes::from(buf)) + } else { + let bytes = self.into_bytes(); + if bytes.len() > max_size { + return Err(crate::error::EdgeError::bad_request( + "request body too large", + )); + } + Ok(bytes) + } + } + pub fn text(text: S) -> Self where S: Into, @@ -246,4 +278,38 @@ mod tests { let body = Body::from(vec![1u8, 2u8, 3u8]); assert_eq!(body.as_bytes(), &[1u8, 2u8, 3u8]); } + + #[test] + fn into_bytes_bounded_buffered_ok() { + let body = Body::from("hello"); + let result = block_on(body.into_bytes_bounded(100)); + assert_eq!(result.unwrap(), Bytes::from("hello")); + } + + #[test] + fn into_bytes_bounded_buffered_too_large() { + let body = Body::from("hello"); + let result = block_on(body.into_bytes_bounded(3)); + assert!(result.is_err()); + } + + #[test] + fn into_bytes_bounded_stream_ok() { + let body = Body::stream(futures_util::stream::iter(vec![ + Bytes::from_static(b"ab"), + Bytes::from_static(b"cd"), + ])); + let result = block_on(body.into_bytes_bounded(100)); + assert_eq!(result.unwrap(), Bytes::from("abcd")); + } + + #[test] + fn into_bytes_bounded_stream_too_large() { + let body = Body::stream(futures_util::stream::iter(vec![ + Bytes::from_static(b"ab"), + Bytes::from_static(b"cd"), + ])); + let result = block_on(body.into_bytes_bounded(3)); + assert!(result.is_err()); + } } diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index 4038c33..67efdef 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -1,6 +1,7 @@ use crate::body::Body; use crate::error::EdgeError; use crate::http::Request; +use crate::key_value_store::KvHandle; use crate::params::PathParams; use crate::proxy::ProxyHandle; use serde::de::DeserializeOwned; @@ -83,6 +84,10 @@ impl RequestContext { pub fn proxy_handle(&self) -> Option { self.request.extensions().get::().cloned() } + + pub fn kv_handle(&self) -> Option { + self.request.extensions().get::().cloned() + } } #[cfg(test)] @@ -321,4 +326,28 @@ mod tests { let response = futures::executor::block_on(handle.forward(request)).expect("response"); assert_eq!(response.status(), StatusCode::OK); } + + #[test] + fn kv_handle_is_retrieved_when_present() { + use crate::key_value_store::{KvHandle, NoopKvStore}; + use std::sync::Arc; + + let mut request = request_builder() + .method(Method::GET) + .uri("/kv") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(KvHandle::new(Arc::new(NoopKvStore))); + + let ctx = RequestContext::new(request, PathParams::default()); + assert!(ctx.kv_handle().is_some()); + } + + #[test] + fn kv_handle_returns_none_when_absent() { + let ctx = ctx("/test", Body::empty(), PathParams::default()); + assert!(ctx.kv_handle().is_none()); + } } diff --git a/crates/edgezero-core/src/error.rs b/crates/edgezero-core/src/error.rs index 448bd8c..3a815af 100644 --- a/crates/edgezero-core/src/error.rs +++ b/crates/edgezero-core/src/error.rs @@ -18,6 +18,8 @@ pub enum EdgeError { MethodNotAllowed { method: Method, allowed: String }, #[error("validation error: {message}")] Validation { message: String }, + #[error("service unavailable: {message}")] + ServiceUnavailable { message: String }, #[error("internal error: {source}")] Internal { #[from] @@ -59,6 +61,12 @@ impl EdgeError { } } + pub fn service_unavailable(message: impl Into) -> Self { + EdgeError::ServiceUnavailable { + message: message.into(), + } + } + pub fn internal(error: E) -> Self where E: Into, @@ -74,6 +82,7 @@ impl EdgeError { EdgeError::Validation { .. } => StatusCode::UNPROCESSABLE_ENTITY, EdgeError::NotFound { .. } => StatusCode::NOT_FOUND, EdgeError::MethodNotAllowed { .. } => StatusCode::METHOD_NOT_ALLOWED, + EdgeError::ServiceUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE, EdgeError::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR, } } @@ -86,6 +95,7 @@ impl EdgeError { EdgeError::MethodNotAllowed { method, allowed } => { format!("method {} not allowed; allowed: {}", method, allowed) } + EdgeError::ServiceUnavailable { message } => message.clone(), EdgeError::Internal { source } => format!("internal error: {}", source), } } diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index 63957a8..2c58d9b 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -401,6 +401,53 @@ impl ValidatedForm { } } +/// Extracts the [`KvHandle`] from the request context. +/// +/// Returns `EdgeError::Internal` if no KV store was configured for this request. +/// +/// # Example +/// ```ignore +/// #[action] +/// pub async fn handler(Kv(store): Kv) -> Result { +/// let count: i32 = store.get_or("visits", 0).await?; +/// store.put("visits", &(count + 1)).await?; +/// Ok(format!("visits: {}", count + 1)) +/// } +/// ``` +#[derive(Debug)] +pub struct Kv(pub crate::key_value_store::KvHandle); + +#[async_trait(?Send)] +impl FromRequest for Kv { + async fn from_request(ctx: &RequestContext) -> Result { + ctx.kv_handle().map(Kv).ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "no kv store configured -- check [stores.kv] in edgezero.toml and platform bindings" + )) + }) + } +} + +impl std::ops::Deref for Kv { + type Target = crate::key_value_store::KvHandle; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for Kv { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Kv { + pub fn into_inner(self) -> crate::key_value_store::KvHandle { + self.0 + } +} + #[cfg(test)] mod tests { use super::*; @@ -909,4 +956,57 @@ mod tests { let inner = host.into_inner(); assert_eq!(inner, "example.com"); } + + // -- Kv extractor ------------------------------------------------------- + + #[test] + fn kv_extractor_returns_handle_when_configured() { + use crate::key_value_store::{KvHandle, NoopKvStore}; + use std::sync::Arc; + + let mut request = request_builder() + .method(Method::GET) + .uri("/kv") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(KvHandle::new(Arc::new(NoopKvStore))); + + let ctx = RequestContext::new(request, PathParams::default()); + let kv = block_on(Kv::from_request(&ctx)); + assert!(kv.is_ok()); + } + + #[test] + fn kv_extractor_returns_error_when_not_configured() { + let request = request_builder() + .method(Method::GET) + .uri("/kv") + .body(Body::empty()) + .expect("request"); + let ctx = RequestContext::new(request, PathParams::default()); + let err = block_on(Kv::from_request(&ctx)).expect_err("expected error"); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(err.message().contains("check [stores.kv]")); + } + + #[test] + fn kv_deref_and_into_inner() { + use crate::key_value_store::{KvHandle, NoopKvStore}; + use std::sync::Arc; + + let handle = KvHandle::new(Arc::new(NoopKvStore)); + let kv = Kv(handle); + + // Debug works + let debug = format!("{:?}", kv); + assert!(debug.contains("Kv")); + + // Deref works + let _: &KvHandle = &kv; + + // into_inner works + let _inner: KvHandle = kv.into_inner(); + } } diff --git a/crates/edgezero-core/src/key_value_store.rs b/crates/edgezero-core/src/key_value_store.rs new file mode 100644 index 0000000..1e7b535 --- /dev/null +++ b/crates/edgezero-core/src/key_value_store.rs @@ -0,0 +1,1364 @@ +//! Provider-neutral Key-Value store abstraction. +//! +//! # Architecture +//! +//! ```text +//! Handler code KvHandle (generic get/put) +//! │ │ +//! └── Kv extractor ──────►│ serde_json layer +//! │ +//! Arc (object-safe, Bytes) +//! │ +//! ┌──────────────┼──────────────┐ +//! ▼ ▼ ▼ +//! PersistentKvStore FastlyKvStore CloudflareKvStore +//! ``` +//! +//! # Consistency Model +//! +//! Both Fastly and Cloudflare KV stores are **eventually consistent**. +//! A value written at one edge location may not be immediately visible +//! at another. Design handlers accordingly — do not assume +//! read-after-write consistency across locations. +//! +//! # Usage +//! +//! Access the KV store in a handler via [`crate::context::RequestContext::kv_handle`]: +//! +//! ```rust,ignore +//! async fn visit_counter(ctx: RequestContext) -> Result { +//! let kv = ctx.kv_handle().expect("kv store configured"); +//! let count: i32 = kv.read_modify_write("visits", 0, |n| n + 1).await?; +//! Ok(format!("Visit #{count}")) +//! } +//! ``` +//! +//! Or use the [`crate::extractor::Kv`] extractor with the `#[action]` macro: +//! +//! ```rust,ignore +//! #[action] +//! async fn visit_counter(Kv(store): Kv) -> Result { +//! let count: i32 = store.read_modify_write("visits", 0, |n| n + 1).await?; +//! Ok(format!("Visit #{count}")) +//! } +//! ``` + +use std::fmt; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use bytes::Bytes; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use crate::error::EdgeError; + +// --------------------------------------------------------------------------- +// Error +// --------------------------------------------------------------------------- + +/// Errors returned by KV store operations. +#[derive(Debug, thiserror::Error)] +pub enum KvError { + /// The requested key was not found (used by `delete` when strict). + #[error("key not found: {key}")] + NotFound { key: String }, + + /// The KV store backend is temporarily unavailable. + #[error("kv store unavailable")] + Unavailable, + + /// A validation error (e.g., invalid key or value). + #[error("validation error: {0}")] + Validation(String), + + /// A serialization or deserialization error. + #[error("serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + /// A general internal error. + #[error("kv store error: {0}")] + Internal(#[from] anyhow::Error), +} + +/// A single page of keys from a KV listing operation. +/// +/// The `cursor` is opaque. Pass it back to `list_keys_page` to continue +/// listing from the next page. `None` means the current page is the last page. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct KvPage { + pub keys: Vec, + pub cursor: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct KvCursorEnvelope { + prefix: String, + cursor: String, +} + +impl From for EdgeError { + fn from(err: KvError) -> Self { + match err { + KvError::NotFound { key } => EdgeError::not_found(format!("kv key: {key}")), + KvError::Unavailable => EdgeError::service_unavailable("kv store unavailable"), + KvError::Validation(e) => EdgeError::bad_request(format!("kv validation error: {e}")), + KvError::Serialization(e) => { + EdgeError::internal(anyhow::anyhow!("kv serialization error: {e}")) + } + KvError::Internal(e) => EdgeError::internal(e), + } + } +} + +// --------------------------------------------------------------------------- +// Trait +// --------------------------------------------------------------------------- + +/// Object-safe interface for KV store backends. +/// +/// All methods take `&self` — backends handle concurrency internally +/// (e.g., platform APIs, or `Mutex` for in-memory stores). +/// +/// # Pre-validation contract +/// +/// This trait is always called through [`KvHandle`], which validates all +/// inputs (key length/format, value size, TTL bounds, list limits) before +/// delegating here. Implementations may therefore assume that: +/// - Keys are non-empty and within [`KvHandle::MAX_KEY_SIZE`] +/// - Values are within [`KvHandle::MAX_VALUE_SIZE`] +/// - TTLs are within `[MIN_TTL, MAX_TTL]` +/// - List limits are within `[1, MAX_LIST_PAGE_SIZE]` +/// +/// Do **not** call trait methods directly in production code; always go +/// through [`KvHandle`] to ensure validation is applied. +/// +/// Implementations exist per adapter: +/// - `PersistentKvStore` (axum adapter) — local dev / tests with persistent storage +/// - `FastlyKvStore` (fastly adapter) — Fastly KV Store +/// - `CloudflareKvStore` (cloudflare adapter) — Cloudflare Workers KV +#[async_trait(?Send)] +pub trait KvStore: Send + Sync { + /// Retrieve raw bytes for a key. Returns `Ok(None)` if the key does not exist. + async fn get_bytes(&self, key: &str) -> Result, KvError>; + + /// Store raw bytes for a key, overwriting any existing value. + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError>; + + /// Store raw bytes with a time-to-live. After `ttl` has elapsed the key + /// should be treated as expired. Eviction timing is backend-specific: + /// - **Axum (`PersistentKvStore`)**: lazy eviction — expired keys are removed + /// on the next `get_bytes` call for that key. Keys never accessed after + /// expiration remain in the database until deleted, so `.edgezero/kv.redb` + /// grows without bound on long-running dev servers. + /// - **Fastly/Cloudflare**: eviction is managed by the platform and is not + /// guaranteed to be immediate. + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + ttl: Duration, + ) -> Result<(), KvError>; + + /// Delete a key. Returns `Ok(())` even if the key did not exist. + async fn delete(&self, key: &str) -> Result<(), KvError>; + + /// List keys in lexicographic order, returning at most `limit` keys. + /// + /// The `cursor` is opaque. Pass the cursor from a previous page back to + /// continue listing. Implementations should keep memory usage bounded to a + /// single page worth of keys. + async fn list_keys_page( + &self, + prefix: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result; + + /// Check whether a key exists. + /// + /// The default implementation delegates to `get_bytes`. Backends that + /// support a cheaper existence check should override this. + async fn exists(&self, key: &str) -> Result { + Ok(self.get_bytes(key).await?.is_some()) + } +} + +// --------------------------------------------------------------------------- +// Test-only no-op store +// --------------------------------------------------------------------------- + +/// A no-op [`KvStore`] for tests that only need a [`KvHandle`] to exist +/// without storing real data. +/// +/// All reads return `None` / empty; all writes succeed silently. +/// +/// Available in `#[cfg(test)]` builds within this crate, and in any downstream +/// crate that enables the `test-utils` feature on `edgezero-core`: +/// +/// ```toml +/// [dev-dependencies] +/// edgezero-core = { path = "...", features = ["test-utils"] } +/// ``` +#[cfg(any(test, feature = "test-utils"))] +pub struct NoopKvStore; + +#[cfg(any(test, feature = "test-utils"))] +#[async_trait(?Send)] +impl KvStore for NoopKvStore { + async fn get_bytes(&self, _key: &str) -> Result, KvError> { + Ok(None) + } + async fn put_bytes(&self, _key: &str, _value: Bytes) -> Result<(), KvError> { + Ok(()) + } + async fn put_bytes_with_ttl( + &self, + _key: &str, + _value: Bytes, + _ttl: Duration, + ) -> Result<(), KvError> { + Ok(()) + } + async fn delete(&self, _key: &str) -> Result<(), KvError> { + Ok(()) + } + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(KvPage::default()) + } +} + +// --------------------------------------------------------------------------- +// Handle +// --------------------------------------------------------------------------- + +/// A cloneable, ergonomic handle to a KV store. +/// +/// Provides generic `get` / `put` helpers that serialize via JSON, +/// while delegating to the object-safe `KvStore` trait underneath. +/// +/// # Examples +/// +/// ```ignore +/// #[action] +/// async fn handler(Kv(store): Kv) -> Result { +/// let count: i32 = store.get_or("visits", 0).await?; +/// store.put("visits", &(count + 1)).await?; +/// Ok(format!("visits: {}", count + 1)) +/// } +/// ``` +#[derive(Clone)] +pub struct KvHandle { + store: Arc, +} + +impl fmt::Debug for KvHandle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("KvHandle").finish_non_exhaustive() + } +} + +impl KvHandle { + /// Maximum key size in bytes (Cloudflare limit). + pub const MAX_KEY_SIZE: usize = 512; + + /// Maximum value size in bytes (Standard limit). + pub const MAX_VALUE_SIZE: usize = 25 * 1024 * 1024; + + /// Minimum TTL in seconds (Cloudflare limit). + pub const MIN_TTL: Duration = Duration::from_secs(60); + + /// Maximum TTL (1 year). Prevents overflow when adding to `SystemTime::now()`. + pub const MAX_TTL: Duration = Duration::from_secs(365 * 24 * 60 * 60); + + /// Maximum number of keys returned from a single page. + pub const MAX_LIST_PAGE_SIZE: usize = 1_000; + + /// Create a new handle wrapping a KV store implementation. + pub fn new(store: Arc) -> Self { + Self { store } + } + + // -- Validation --------------------------------------------------------- + + fn validate_key(key: &str) -> Result<(), KvError> { + if key.is_empty() { + return Err(KvError::Validation("key cannot be empty".to_string())); + } + if key.len() > Self::MAX_KEY_SIZE { + return Err(KvError::Validation(format!( + "key length {} exceeds limit of {} bytes", + key.len(), + Self::MAX_KEY_SIZE + ))); + } + if key == "." || key == ".." { + return Err(KvError::Validation( + "key cannot be exactly '.' or '..'".to_string(), + )); + } + if key.chars().any(|c| c.is_control()) { + return Err(KvError::Validation( + "key contains invalid control characters".to_string(), + )); + } + Ok(()) + } + + fn validate_value(value: &[u8]) -> Result<(), KvError> { + if value.len() > Self::MAX_VALUE_SIZE { + return Err(KvError::Validation(format!( + "value size {} exceeds limit of {} bytes", + value.len(), + Self::MAX_VALUE_SIZE + ))); + } + Ok(()) + } + + fn validate_ttl(ttl: Duration) -> Result<(), KvError> { + if ttl < Self::MIN_TTL { + return Err(KvError::Validation(format!( + "TTL {:?} is less than minimum of at least 60 seconds", + ttl + ))); + } + if ttl > Self::MAX_TTL { + return Err(KvError::Validation(format!( + "TTL {:?} exceeds maximum of 1 year", + ttl + ))); + } + Ok(()) + } + + fn validate_prefix(prefix: &str) -> Result<(), KvError> { + if prefix.len() > Self::MAX_KEY_SIZE { + return Err(KvError::Validation(format!( + "prefix length {} exceeds limit of {} bytes", + prefix.len(), + Self::MAX_KEY_SIZE + ))); + } + if prefix.chars().any(|c| c.is_control()) { + return Err(KvError::Validation( + "prefix contains invalid control characters".to_string(), + )); + } + Ok(()) + } + + fn validate_list_limit(limit: usize) -> Result<(), KvError> { + if limit == 0 { + return Err(KvError::Validation( + "list limit must be greater than zero".to_string(), + )); + } + if limit > Self::MAX_LIST_PAGE_SIZE { + return Err(KvError::Validation(format!( + "list limit {} exceeds maximum of {}", + limit, + Self::MAX_LIST_PAGE_SIZE + ))); + } + Ok(()) + } + + fn decode_list_cursor(prefix: &str, cursor: Option<&str>) -> Result, KvError> { + let Some(cursor) = cursor else { + return Ok(None); + }; + + let envelope: KvCursorEnvelope = serde_json::from_str(cursor) + .map_err(|_| KvError::Validation("list cursor is invalid or corrupted".to_string()))?; + + if envelope.prefix != prefix { + return Err(KvError::Validation( + "list cursor does not match the requested prefix".to_string(), + )); + } + if envelope.cursor.is_empty() { + return Err(KvError::Validation( + "list cursor payload cannot be empty".to_string(), + )); + } + + Ok(Some(envelope.cursor)) + } + + fn encode_list_cursor(prefix: &str, cursor: Option) -> Result, KvError> { + cursor + .map(|cursor| { + serde_json::to_string(&KvCursorEnvelope { + prefix: prefix.to_string(), + cursor, + }) + .map_err(KvError::from) + }) + .transpose() + } + + // -- Typed helpers (JSON) ----------------------------------------------- + + /// Get a value by key, deserializing from JSON. + /// + /// Returns `Ok(None)` if the key does not exist. + pub async fn get(&self, key: &str) -> Result, KvError> { + Self::validate_key(key)?; + match self.store.get_bytes(key).await? { + Some(bytes) => { + let val = serde_json::from_slice(&bytes)?; + Ok(Some(val)) + } + None => Ok(None), + } + } + + /// Get a value by key, returning `default` if the key does not exist. + pub async fn get_or(&self, key: &str, default: T) -> Result { + Ok(self.get(key).await?.unwrap_or(default)) + } + + /// Put a value, serializing it to JSON. + pub async fn put(&self, key: &str, value: &T) -> Result<(), KvError> { + Self::validate_key(key)?; + let bytes = serde_json::to_vec(value)?; + Self::validate_value(&bytes)?; + self.store.put_bytes(key, Bytes::from(bytes)).await + } + + /// Put a value with a TTL, serializing it to JSON. + pub async fn put_with_ttl( + &self, + key: &str, + value: &T, + ttl: Duration, + ) -> Result<(), KvError> { + Self::validate_key(key)?; + Self::validate_ttl(ttl)?; + let bytes = serde_json::to_vec(value)?; + Self::validate_value(&bytes)?; + self.store + .put_bytes_with_ttl(key, Bytes::from(bytes), ttl) + .await + } + + /// Read-modify-write: get the current value (or `default`), + /// apply `f`, and write the result back. + /// + /// Returns the **new** (post-mutation) value. If you also need the + /// previous value, read it separately before calling this method. + /// + /// # Warning + /// + /// This operation is **not atomic**. The read and write are separate + /// calls to the backend. Concurrent calls on the same key may cause + /// lost writes. Use this only when eventual consistency is acceptable + /// (e.g., approximate counters). + pub async fn read_modify_write(&self, key: &str, default: T, f: F) -> Result + where + T: DeserializeOwned + Serialize, + F: FnOnce(T) -> T, + { + // Validation happens in get_or and put + let current = self.get_or(key, default).await?; + let updated = f(current); + self.put(key, &updated).await?; + Ok(updated) + } + + // -- Raw bytes ---------------------------------------------------------- + + /// Get raw bytes for a key. + pub async fn get_bytes(&self, key: &str) -> Result, KvError> { + Self::validate_key(key)?; + self.store.get_bytes(key).await + } + + /// Put raw bytes for a key. + pub async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + Self::validate_key(key)?; + Self::validate_value(&value)?; + self.store.put_bytes(key, value).await + } + + /// Put raw bytes with a TTL. + pub async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + ttl: Duration, + ) -> Result<(), KvError> { + Self::validate_key(key)?; + Self::validate_ttl(ttl)?; + Self::validate_value(&value)?; + self.store.put_bytes_with_ttl(key, value, ttl).await + } + + // -- Other operations --------------------------------------------------- + + /// Check whether a key exists without deserializing its value. + pub async fn exists(&self, key: &str) -> Result { + Self::validate_key(key)?; + self.store.exists(key).await + } + + /// Delete a key. + pub async fn delete(&self, key: &str) -> Result<(), KvError> { + Self::validate_key(key)?; + self.store.delete(key).await + } + + /// List keys in a bounded, paginated fashion. + /// + /// The cursor is opaque, prefix-bound, and should be passed back unchanged + /// with the same prefix to retrieve the next page. Listings are not atomic + /// snapshots and may reflect concurrent writes or provider-level eventual + /// consistency. + pub async fn list_keys_page( + &self, + prefix: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result { + Self::validate_prefix(prefix)?; + Self::validate_list_limit(limit)?; + let decoded_cursor = Self::decode_list_cursor(prefix, cursor)?; + let page = self + .store + .list_keys_page(prefix, decoded_cursor.as_deref(), limit) + .await?; + + Ok(KvPage { + keys: page.keys, + cursor: Self::encode_list_cursor(prefix, page.cursor)?, + }) + } +} + +// --------------------------------------------------------------------------- +// Contract test macro +// --------------------------------------------------------------------------- + +/// Generate a suite of contract tests for any [`KvStore`] implementation. +/// +/// The macro takes the module name and a factory expression that produces a +/// fresh store instance (implementing `KvStore`). It generates a module +/// containing tests that verify the fundamental behaviours every backend +/// must satisfy. +/// +/// # Example +/// +/// ```rust,ignore +/// edgezero_core::key_value_store_contract_tests!(persistent_kv_contract, { +/// let db_path = std::env::temp_dir().join(format!( +/// "edgezero-contract-{}-{:?}.redb", +/// std::process::id(), +/// std::thread::current().id() +/// )); +/// PersistentKvStore::new(db_path).unwrap() +/// }); +/// ``` +#[macro_export] +macro_rules! key_value_store_contract_tests { + ($mod_name:ident, $factory:expr) => { + mod $mod_name { + use super::*; + use bytes::Bytes; + use $crate::key_value_store::KvStore; + + fn run(f: F) -> F::Output { + futures::executor::block_on(f) + } + + #[test] + fn contract_put_and_get() { + let store = $factory; + run(async { + store.put_bytes("k", Bytes::from("v")).await.unwrap(); + assert_eq!(store.get_bytes("k").await.unwrap(), Some(Bytes::from("v"))); + }); + } + + #[test] + fn contract_get_missing_returns_none() { + let store = $factory; + run(async { + assert_eq!(store.get_bytes("missing").await.unwrap(), None); + }); + } + + #[test] + fn contract_put_overwrites() { + let store = $factory; + run(async { + store.put_bytes("k", Bytes::from("first")).await.unwrap(); + store.put_bytes("k", Bytes::from("second")).await.unwrap(); + assert_eq!( + store.get_bytes("k").await.unwrap(), + Some(Bytes::from("second")) + ); + }); + } + + #[test] + fn contract_delete_removes_key() { + let store = $factory; + run(async { + store.put_bytes("k", Bytes::from("v")).await.unwrap(); + store.delete("k").await.unwrap(); + assert_eq!(store.get_bytes("k").await.unwrap(), None); + }); + } + + #[test] + fn contract_delete_nonexistent_ok() { + let store = $factory; + run(async { + store.delete("nope").await.unwrap(); + }); + } + + #[test] + fn contract_exists() { + let store = $factory; + run(async { + assert!(!store.exists("k").await.unwrap()); + store.put_bytes("k", Bytes::from("v")).await.unwrap(); + assert!(store.exists("k").await.unwrap()); + store.delete("k").await.unwrap(); + assert!(!store.exists("k").await.unwrap()); + }); + } + + #[test] + fn contract_put_with_ttl_stores_value() { + let store = $factory; + run(async { + store + .put_bytes_with_ttl( + "ttl_key", + Bytes::from("ttl_val"), + std::time::Duration::from_secs(300), + ) + .await + .unwrap(); + assert_eq!( + store.get_bytes("ttl_key").await.unwrap(), + Some(Bytes::from("ttl_val")) + ); + }); + } + + // `std::thread::sleep` is not available on `wasm32` targets (no + // thread support). The TTL eviction contract is verified on native + // targets only; WASM adapters are expected to delegate eviction to + // the platform runtime (Cloudflare/Fastly), which does not expose a + // synchronous sleep primitive in test environments. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn contract_ttl_expires() { + let store = $factory; + run(async { + // Uses a sub-second TTL intentionally. Contract tests call + // `KvStore` directly (not `KvHandle`), so the 60-second + // minimum TTL validation is bypassed. This lets us verify + // that the backend actually evicts expired entries. + store + .put_bytes_with_ttl( + "ephemeral", + Bytes::from("gone_soon"), + std::time::Duration::from_millis(1), + ) + .await + .unwrap(); + // Allow the TTL to elapse. 200ms gives the OS scheduler + // enough headroom on busy CI runners. + std::thread::sleep(std::time::Duration::from_millis(200)); + assert_eq!(store.get_bytes("ephemeral").await.unwrap(), None); + }); + } + + #[test] + fn contract_list_keys_page_is_paginated() { + let store = $factory; + run(async { + let expected = vec![ + "app/one".to_string(), + "app/two".to_string(), + "other/three".to_string(), + ]; + for key in &expected { + store + .put_bytes(key, Bytes::from(key.clone())) + .await + .unwrap(); + } + + let mut cursor = None; + let mut seen = std::collections::HashSet::new(); + let mut collected = Vec::new(); + + for _ in 0..expected.len() { + let page = store + .list_keys_page("", cursor.as_deref(), 1) + .await + .unwrap(); + assert!(page.keys.len() <= 1); + for key in &page.keys { + assert!( + seen.insert(key.clone()), + "duplicate key in pagination: {key}" + ); + collected.push(key.clone()); + } + + cursor = page.cursor; + if cursor.is_none() { + break; + } + } + + collected.sort(); + let mut expected_sorted = expected.clone(); + expected_sorted.sort(); + assert_eq!(collected, expected_sorted); + }); + } + + #[test] + fn contract_list_keys_page_respects_prefix() { + let store = $factory; + run(async { + store + .put_bytes("prefix/a", Bytes::from_static(b"a")) + .await + .unwrap(); + store + .put_bytes("prefix/b", Bytes::from_static(b"b")) + .await + .unwrap(); + store + .put_bytes("other/c", Bytes::from_static(b"c")) + .await + .unwrap(); + + let first = store.list_keys_page("prefix/", None, 1).await.unwrap(); + assert_eq!(first.keys.len(), 1); + assert!(first.keys[0].starts_with("prefix/")); + + let second = store + .list_keys_page("prefix/", first.cursor.as_deref(), 1) + .await + .unwrap(); + assert!(second.keys.iter().all(|key| key.starts_with("prefix/"))); + assert!(first + .keys + .iter() + .chain(second.keys.iter()) + .all(|key| key.starts_with("prefix/"))); + }); + } + } + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::StatusCode; + use std::collections::HashMap; + use std::sync::Mutex; + use std::time::SystemTime; + + // In-memory store with TTL support for contract testing. + // Uses `SystemTime` instead of `Instant` for WASM compatibility. + struct MockStore { + data: Mutex)>>, + } + + impl MockStore { + fn new() -> Self { + Self { + data: Mutex::new(HashMap::new()), + } + } + } + + #[async_trait(?Send)] + impl KvStore for MockStore { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + let mut data = self.data.lock().unwrap(); + if let Some((_, Some(exp))) = data.get(key) { + if SystemTime::now() >= *exp { + data.remove(key); + return Ok(None); + } + } + Ok(data.get(key).map(|(v, _)| v.clone())) + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + let mut data = self.data.lock().unwrap(); + data.insert(key.to_string(), (value, None)); + Ok(()) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + ttl: Duration, + ) -> Result<(), KvError> { + let mut data = self.data.lock().unwrap(); + data.insert(key.to_string(), (value, Some(SystemTime::now() + ttl))); + Ok(()) + } + + async fn delete(&self, key: &str) -> Result<(), KvError> { + let mut data = self.data.lock().unwrap(); + data.remove(key); + Ok(()) + } + + async fn list_keys_page( + &self, + prefix: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result { + let mut data = self.data.lock().unwrap(); + let now = SystemTime::now(); + data.retain(|_, (_, expires_at)| expires_at.is_none_or(|exp| now < exp)); + + let mut keys = data + .keys() + .filter(|key| { + key.starts_with(prefix) && cursor.is_none_or(|cursor| key.as_str() > cursor) + }) + .cloned() + .collect::>(); + keys.sort(); + + let has_more = keys.len() > limit; + keys.truncate(limit); + + Ok(KvPage { + cursor: has_more.then(|| keys.last().cloned()).flatten(), + keys, + }) + } + } + + fn handle() -> KvHandle { + KvHandle::new(Arc::new(MockStore::new())) + } + + // -- Raw bytes ---------------------------------------------------------- + + #[test] + fn raw_bytes_roundtrip() { + let h = handle(); + futures::executor::block_on(async { + h.put_bytes("k", Bytes::from("hello")).await.unwrap(); + assert_eq!(h.get_bytes("k").await.unwrap(), Some(Bytes::from("hello"))); + }); + } + + #[test] + fn raw_bytes_missing_key_returns_none() { + let h = handle(); + futures::executor::block_on(async { + assert_eq!(h.get_bytes("missing").await.unwrap(), None); + }); + } + + #[test] + fn raw_bytes_overwrite() { + let h = handle(); + futures::executor::block_on(async { + h.put_bytes("k", Bytes::from("a")).await.unwrap(); + h.put_bytes("k", Bytes::from("b")).await.unwrap(); + assert_eq!(h.get_bytes("k").await.unwrap(), Some(Bytes::from("b"))); + }); + } + + // -- Typed JSON --------------------------------------------------------- + + #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)] + struct Counter { + count: i32, + } + + #[test] + fn typed_get_put_roundtrip() { + let h = handle(); + futures::executor::block_on(async { + let data = Counter { count: 42 }; + h.put("counter", &data).await.unwrap(); + let out: Option = h.get("counter").await.unwrap(); + assert_eq!(out, Some(data)); + }); + } + + #[test] + fn typed_get_missing_returns_none() { + let h = handle(); + futures::executor::block_on(async { + let out: Option = h.get("nope").await.unwrap(); + assert_eq!(out, None); + }); + } + + #[test] + fn typed_get_or_returns_default() { + let h = handle(); + futures::executor::block_on(async { + let count: i32 = h.get_or("visits", 0).await.unwrap(); + assert_eq!(count, 0); + }); + } + + #[test] + fn typed_get_or_returns_existing() { + let h = handle(); + futures::executor::block_on(async { + h.put("visits", &99).await.unwrap(); + let count: i32 = h.get_or("visits", 0).await.unwrap(); + assert_eq!(count, 99); + }); + } + + #[test] + fn typed_get_bad_json_returns_serialization_error() { + let h = handle(); + futures::executor::block_on(async { + h.put_bytes("bad", Bytes::from("not json")).await.unwrap(); + let err = h.get::("bad").await.unwrap_err(); + assert!(matches!(err, KvError::Serialization(_))); + }); + } + + // -- Update ------------------------------------------------------------- + + #[test] + fn update_increments_counter() { + let h = handle(); + futures::executor::block_on(async { + h.put("c", &0i32).await.unwrap(); + let val = h.read_modify_write("c", 0i32, |n| n + 1).await.unwrap(); + assert_eq!(val, 1); + let val = h.read_modify_write("c", 0i32, |n| n + 1).await.unwrap(); + assert_eq!(val, 2); + }); + } + + #[test] + fn update_uses_default_when_missing() { + let h = handle(); + futures::executor::block_on(async { + let val = h.read_modify_write("new", 10i32, |n| n * 2).await.unwrap(); + assert_eq!(val, 20); + }); + } + + // -- Exists ------------------------------------------------------------- + + #[test] + fn exists_returns_false_for_missing() { + let h = handle(); + futures::executor::block_on(async { + assert!(!h.exists("nope").await.unwrap()); + }); + } + + #[test] + fn exists_returns_true_for_present() { + let h = handle(); + futures::executor::block_on(async { + h.put_bytes("k", Bytes::from("v")).await.unwrap(); + assert!(h.exists("k").await.unwrap()); + }); + } + + // -- Delete ------------------------------------------------------------- + + #[test] + fn delete_removes_key() { + let h = handle(); + futures::executor::block_on(async { + h.put_bytes("k", Bytes::from("v")).await.unwrap(); + h.delete("k").await.unwrap(); + assert_eq!(h.get_bytes("k").await.unwrap(), None); + }); + } + + #[test] + fn delete_missing_key_is_ok() { + let h = handle(); + futures::executor::block_on(async { + h.delete("nope").await.unwrap(); + }); + } + + #[test] + fn list_keys_page_roundtrip() { + let h = handle(); + futures::executor::block_on(async { + h.put("app/a", &1i32).await.unwrap(); + h.put("app/b", &2i32).await.unwrap(); + h.put("app/c", &3i32).await.unwrap(); + h.put("other/d", &4i32).await.unwrap(); + + let first = h.list_keys_page("app/", None, 2).await.unwrap(); + assert_eq!(first.keys, vec!["app/a".to_string(), "app/b".to_string()]); + assert!(first.cursor.is_some()); + assert_ne!(first.cursor.as_deref(), Some("app/b")); + + let second = h + .list_keys_page("app/", first.cursor.as_deref(), 2) + .await + .unwrap(); + assert_eq!(second.keys, vec!["app/c".to_string()]); + assert_eq!(second.cursor, None); + }); + } + + // -- TTL ---------------------------------------------------------------- + + #[test] + fn put_with_ttl_stores_value() { + let h = handle(); + futures::executor::block_on(async { + h.put_with_ttl("session", &"token123", Duration::from_secs(60)) + .await + .unwrap(); + let val: Option = h.get("session").await.unwrap(); + assert_eq!(val, Some("token123".to_string())); + }); + } + + // -- KvError -> EdgeError ----------------------------------------------- + + #[test] + fn kv_error_not_found_converts_to_not_found() { + let kv_err = KvError::NotFound { key: "test".into() }; + let edge_err: EdgeError = kv_err.into(); + assert_eq!(edge_err.status(), StatusCode::NOT_FOUND); + assert!(edge_err.message().contains("kv key")); + } + + #[test] + fn kv_error_unavailable_converts_to_service_unavailable() { + let kv_err = KvError::Unavailable; + let edge_err: EdgeError = kv_err.into(); + assert_eq!(edge_err.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[test] + fn kv_error_internal_converts_to_internal() { + let kv_err = KvError::Internal(anyhow::anyhow!("boom")); + let edge_err: EdgeError = kv_err.into(); + assert_eq!(edge_err.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(edge_err.message().contains("boom")); + } + + // -- Clone handle ------------------------------------------------------- + + #[test] + fn handle_is_cloneable_and_shares_state() { + let h1 = handle(); + let h2 = h1.clone(); + futures::executor::block_on(async { + h1.put("shared", &42i32).await.unwrap(); + let val: i32 = h2.get_or("shared", 0).await.unwrap(); + assert_eq!(val, 42); + }); + } + + // -- Edge cases --------------------------------------------------------- + + #[test] + fn empty_key_rejected() { + let h = handle(); + futures::executor::block_on(async { + let err = h.put("", &"empty key").await.unwrap_err(); + assert!(matches!(err, KvError::Validation(_))); + assert!(format!("{}", err).contains("cannot be empty")); + }); + } + + #[test] + fn unicode_key_roundtrip() { + let h = handle(); + futures::executor::block_on(async { + h.put("日本語キー", &"value").await.unwrap(); + let val: Option = h.get("日本語キー").await.unwrap(); + assert_eq!(val, Some("value".to_string())); + }); + } + + #[test] + fn large_value_roundtrip() { + let h = handle(); + futures::executor::block_on(async { + let large = "x".repeat(1_000_000); // 1MB string + h.put("big", &large).await.unwrap(); + let val: Option = h.get("big").await.unwrap(); + assert_eq!(val.as_deref(), Some(large.as_str())); + }); + } + + #[test] + fn put_with_ttl_typed_helper() { + let h = handle(); + futures::executor::block_on(async { + let data = Counter { count: 7 }; + h.put_with_ttl("ttl_key", &data, Duration::from_secs(600)) + .await + .unwrap(); + let val: Option = h.get("ttl_key").await.unwrap(); + assert_eq!(val, Some(Counter { count: 7 })); + }); + } + + #[test] + fn get_or_with_complex_default() { + let h = handle(); + futures::executor::block_on(async { + let default = Counter { count: 100 }; + let val: Counter = h.get_or("missing_struct", default).await.unwrap(); + assert_eq!(val.count, 100); + }); + } + + #[test] + fn update_with_struct() { + let h = handle(); + futures::executor::block_on(async { + let val = h + .read_modify_write("counter_struct", Counter { count: 0 }, |mut c| { + c.count += 10; + c + }) + .await + .unwrap(); + assert_eq!(val.count, 10); + + let val = h + .read_modify_write("counter_struct", Counter { count: 0 }, |mut c| { + c.count += 5; + c + }) + .await + .unwrap(); + assert_eq!(val.count, 15); + }); + } + + #[test] + fn kv_error_serialization_converts_to_internal() { + let json_err: serde_json::Error = serde_json::from_str::("not json").unwrap_err(); + let kv_err = KvError::Serialization(json_err); + let edge_err: EdgeError = kv_err.into(); + assert_eq!(edge_err.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(edge_err.message().contains("serialization")); + } + + #[test] + fn kv_handle_debug_output() { + let h = handle(); + let debug = format!("{:?}", h); + assert!(debug.contains("KvHandle")); + } + + // -- Validation Tests --------------------------------------------------- + + #[test] + fn validation_rejects_long_keys() { + let h = handle(); + futures::executor::block_on(async { + let long_key = "a".repeat(KvHandle::MAX_KEY_SIZE + 1); + let err = h.get::(&long_key).await.unwrap_err(); + assert!(matches!(err, KvError::Validation(_))); + assert!(format!("{}", err).contains("key length")); + }); + } + + #[test] + fn validation_rejects_dot_keys() { + let h = handle(); + futures::executor::block_on(async { + let err = h.get::(".").await.unwrap_err(); + assert!(matches!(err, KvError::Validation(_))); + assert!(format!("{}", err).contains("cannot be exactly")); + + let err = h.get::("..").await.unwrap_err(); + assert!(matches!(err, KvError::Validation(_))); + assert!(format!("{}", err).contains("cannot be exactly")); + }); + } + + #[test] + fn validation_rejects_control_chars() { + let h = handle(); + futures::executor::block_on(async { + let err = h.get::("key\nwith\nnewline").await.unwrap_err(); + assert!(matches!(err, KvError::Validation(_))); + assert!(format!("{}", err).contains("control characters")); + }); + } + + #[test] + fn validation_rejects_large_values() { + let h = handle(); + futures::executor::block_on(async { + let large_val = vec![0u8; KvHandle::MAX_VALUE_SIZE + 1]; + let err = h + .put_bytes("large", Bytes::from(large_val)) + .await + .unwrap_err(); + assert!(matches!(err, KvError::Validation(_))); + assert!(format!("{}", err).contains("value size")); + }); + } + + #[test] + fn validation_rejects_short_ttl() { + let h = handle(); + futures::executor::block_on(async { + let err = h + .put_with_ttl("short", &"val", Duration::from_secs(10)) + .await + .unwrap_err(); + assert!(matches!(err, KvError::Validation(_))); + assert!(format!("{}", err).contains("at least 60 seconds")); + }); + } + + #[test] + fn validation_rejects_long_ttl() { + let h = handle(); + futures::executor::block_on(async { + let err = h + .put_with_ttl("long", &"val", KvHandle::MAX_TTL + Duration::from_secs(1)) + .await + .unwrap_err(); + assert!(matches!(err, KvError::Validation(_))); + assert!(format!("{}", err).contains("exceeds maximum")); + }); + } + + #[test] + fn validation_rejects_zero_list_limit() { + let h = handle(); + futures::executor::block_on(async { + let err = h.list_keys_page("", None, 0).await.unwrap_err(); + assert!(matches!(err, KvError::Validation(_))); + assert!(format!("{}", err).contains("greater than zero")); + }); + } + + #[test] + fn validation_rejects_large_list_limit() { + let h = handle(); + futures::executor::block_on(async { + let err = h + .list_keys_page("", None, KvHandle::MAX_LIST_PAGE_SIZE + 1) + .await + .unwrap_err(); + assert!(matches!(err, KvError::Validation(_))); + assert!(format!("{}", err).contains("list limit")); + }); + } + + #[test] + fn validation_rejects_long_prefix() { + let h = handle(); + futures::executor::block_on(async { + let prefix = "a".repeat(KvHandle::MAX_KEY_SIZE + 1); + let err = h.list_keys_page(&prefix, None, 1).await.unwrap_err(); + assert!(matches!(err, KvError::Validation(_))); + assert!(format!("{}", err).contains("prefix length")); + }); + } + + #[test] + fn validation_rejects_control_chars_in_prefix() { + let h = handle(); + futures::executor::block_on(async { + let err = h.list_keys_page("bad\nprefix", None, 1).await.unwrap_err(); + assert!(matches!(err, KvError::Validation(_))); + assert!(format!("{}", err).contains("control characters")); + }); + } + + #[test] + fn validation_rejects_malformed_list_cursor() { + let h = handle(); + futures::executor::block_on(async { + let err = h + .list_keys_page("app/", Some("not-json"), 1) + .await + .unwrap_err(); + assert!(matches!(err, KvError::Validation(_))); + assert!(format!("{}", err).contains("cursor")); + }); + } + + #[test] + fn validation_rejects_cursor_for_different_prefix() { + let h = handle(); + futures::executor::block_on(async { + h.put("app/a", &1i32).await.unwrap(); + h.put("app/b", &2i32).await.unwrap(); + + let page = h.list_keys_page("app/", None, 1).await.unwrap(); + let err = h + .list_keys_page("other/", page.cursor.as_deref(), 1) + .await + .unwrap_err(); + assert!(matches!(err, KvError::Validation(_))); + assert!(format!("{}", err).contains("requested prefix")); + }); + } + + #[test] + fn exists_returns_false_after_delete() { + let h = handle(); + futures::executor::block_on(async { + h.put_bytes("ephemeral", Bytes::from("v")).await.unwrap(); + assert!(h.exists("ephemeral").await.unwrap()); + h.delete("ephemeral").await.unwrap(); + assert!(!h.exists("ephemeral").await.unwrap()); + }); + } + + #[test] + fn put_overwrite_changes_type() { + let h = handle(); + futures::executor::block_on(async { + h.put("flex", &42i32).await.unwrap(); + let val: i32 = h.get_or("flex", 0).await.unwrap(); + assert_eq!(val, 42); + + // Overwrite with a different type + h.put("flex", &"now a string").await.unwrap(); + let val: String = h.get_or("flex", String::new()).await.unwrap(); + assert_eq!(val, "now a string"); + }); + } + + // Run the shared contract tests against MockStore. + crate::key_value_store_contract_tests!(mock_store_contract, MockStore::new()); +} diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index 2af1b9c..bc6fe81 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -8,6 +8,7 @@ pub mod error; pub mod extractor; pub mod handler; pub mod http; +pub mod key_value_store; pub mod manifest; pub mod middleware; pub mod params; @@ -17,3 +18,4 @@ pub mod response; pub mod router; pub use edgezero_macros::{action, app}; +pub use key_value_store::{KvError, KvHandle, KvPage, KvStore}; diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 6f4d464..0efb690 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -66,6 +66,9 @@ pub struct Manifest { pub environment: ManifestEnvironment, #[serde(default)] #[validate(nested)] + pub stores: ManifestStores, + #[serde(default)] + #[validate(nested)] pub adapters: BTreeMap, #[serde(default)] #[validate(nested)] @@ -115,6 +118,30 @@ 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) { let mut resolved = BTreeMap::new(); @@ -363,6 +390,52 @@ impl ManifestLoggingConfig { } } +/// Default KV store / binding name used when `[stores.kv]` is omitted. +pub const DEFAULT_KV_STORE_NAME: &str = "EDGEZERO_KV"; + +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 { + /// Store / binding name (default: `"EDGEZERO_KV"`). + #[serde(default = "default_kv_name")] + #[validate(length(min = 1))] + pub name: String, + + /// Per-adapter name overrides. + #[serde(default)] + #[validate(nested)] + pub adapters: BTreeMap, +} + +/// Per-adapter KV binding / store name override. +#[derive(Debug, Deserialize, Validate)] +pub struct ManifestKvAdapterConfig { + #[validate(length(min = 1))] + pub name: String, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum HttpMethod { Get, @@ -1101,4 +1174,69 @@ body-mode = "buffered" ); assert_eq!(trigger.body_mode, Some(BodyMode::Buffered)); } + + // -- KV store config --------------------------------------------------- + + #[test] + fn kv_store_name_defaults_when_omitted() { + let toml_str = r#" +[app] +name = "test" +"#; + let loader = ManifestLoader::load_from_str(toml_str); + let manifest = loader.manifest(); + assert_eq!(manifest.kv_store_name("fastly"), "EDGEZERO_KV"); + assert_eq!(manifest.kv_store_name("cloudflare"), "EDGEZERO_KV"); + } + + #[test] + fn kv_store_name_uses_global_name() { + let toml_str = r#" +[app] +name = "test" + +[stores.kv] +name = "MY_KV" +"#; + let loader = ManifestLoader::load_from_str(toml_str); + let manifest = loader.manifest(); + assert_eq!(manifest.kv_store_name("fastly"), "MY_KV"); + assert_eq!(manifest.kv_store_name("cloudflare"), "MY_KV"); + } + + #[test] + fn kv_store_name_adapter_override() { + let toml_str = r#" +[app] +name = "test" + +[stores.kv] +name = "GLOBAL_KV" + +[stores.kv.adapters.cloudflare] +name = "CF_BINDING" +"#; + let loader = ManifestLoader::load_from_str(toml_str); + let manifest = loader.manifest(); + assert_eq!(manifest.kv_store_name("cloudflare"), "CF_BINDING"); + assert_eq!(manifest.kv_store_name("fastly"), "GLOBAL_KV"); + } + + #[test] + fn kv_store_name_case_insensitive() { + let toml_str = r#" +[app] +name = "test" + +[stores.kv] +name = "DEFAULT" + +[stores.kv.adapters.Fastly] +name = "FASTLY_STORE" +"#; + let loader = ManifestLoader::load_from_str(toml_str); + let manifest = loader.manifest(); + assert_eq!(manifest.kv_store_name("fastly"), "FASTLY_STORE"); + assert_eq!(manifest.kv_store_name("FASTLY"), "FASTLY_STORE"); + } } diff --git a/crates/edgezero-macros/src/action.rs b/crates/edgezero-macros/src/action.rs index 4a2bf0b..e905d22 100644 --- a/crates/edgezero-macros/src/action.rs +++ b/crates/edgezero-macros/src/action.rs @@ -107,7 +107,7 @@ fn normalize_request_context_pat(pat: &mut Box) -> syn::Result<()> { let Some(replacement) = extract_request_context_binding(pat.as_ref())? else { return Ok(()); }; - *pat = Box::new(replacement); + **pat = replacement; Ok(()) } diff --git a/docs/guide/kv.md b/docs/guide/kv.md new file mode 100644 index 0000000..7716d98 --- /dev/null +++ b/docs/guide/kv.md @@ -0,0 +1,156 @@ +# Key-Value Store + +EdgeZero provides a unified interface for Key-Value (KV) storage, abstracting differences between Fastly KV Store and Cloudflare Workers KV. + +## End-to-End Example + +This example implements a simple visit counter. It retrieves the current count, increments it, and returns the new value. + +```rust +use edgezero_core::action; +use edgezero_core::error::EdgeError; +use edgezero_core::extractor::Kv; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Default)] +struct VisitData { + count: u64, +} + +#[action] +async fn visit_counter(Kv(store): Kv) -> Result { + // Read-modify-write helper (Note: not atomic!) + let data = store + .read_modify_write("visits", VisitData::default(), |mut d| { + d.count += 1; + d + }) + .await?; + + Ok(format!("Visit #{}", data.count)) +} +``` + +## Usage + +### 1. Configure the Store Name + +In your `edgezero.toml`: + +```toml +[stores.kv] +name = "EDGEZERO_KV" # Default name for all adapters +``` + +### 2. Access the Store + +You can access the store using the `Kv` extractor (recommended) or via `RequestContext`. + +**Using Extractor:** + +```rust +async fn handler(Kv(store): Kv) { ... } +``` + +**Using Context:** + +```rust +async fn handler(ctx: RequestContext) { + let store = ctx.kv_handle().expect("kv configured"); + ... +} +``` + +### 3. Operations + +The `KvHandle` provides typed helpers that automatically serialize/deserialize JSON: + +- `get(key)`: Returns `Option`. +- `get_or(key, default)`: Returns the value or a fallback. +- `put(key, value)`: Stores a value. +- `put_with_ttl(key, value, ttl)`: Stores a value that expires after `ttl`. +- `delete(key)`: Removes a value. +- `exists(key)`: Checks if a key is present. +- `list_keys_page(prefix, cursor, limit)`: Lists keys in a bounded page. Pass the returned cursor back unchanged with the same prefix to fetch the next page. +- `read_modify_write(key, default, f)`: Read-modify-write (**not atomic** — see warning below). + +It also supports raw bytes via `get_bytes`, `put_bytes`, etc. + +::: warning Non-atomic read-modify-write +`read_modify_write` performs a read and a write as **two separate backend calls**. +Concurrent calls on the same key from different requests can interleave, causing +lost writes. For example, two requests that both read `counter = 5` and write `6` +will end with `counter = 6` instead of `7`. + +Use it only when approximate values are acceptable (e.g. visit counters, feature flags). +For strict correctness, use a transactional data store. +::: + +Key listing is paginated by design. This avoids buffering an unbounded number of keys in memory and matches the underlying provider APIs. + +## Platform Specifics + +### Local Development + +- **Axum**: Uses a persistent `redb` embedded database stored under `.edgezero/`. The default store name uses `.edgezero/kv.redb`; custom store names get their own derived file. Data persists across restarts (add `.edgezero/` to your `.gitignore`). +- **Fastly (Viceroy)**: Requires a `[local_server.kv_stores]` entry in `fastly.toml`. + + ```toml + [[local_server.kv_stores.EDGEZERO_KV]] + key = "__init__" + data = "" + + [setup.kv_stores.EDGEZERO_KV] + description = "Application KV store" + ``` + +- **Cloudflare (Workerd)**: Requires a KV namespace and a binding in `wrangler.toml`. + 1. Create the namespace (run once per environment): + + ```sh + wrangler kv namespace create EDGEZERO_KV + wrangler kv namespace create EDGEZERO_KV --preview + ``` + + Each command prints an `id` — copy them into `wrangler.toml`: + + 2. Add the binding to `wrangler.toml`: + ```toml + [[kv_namespaces]] + binding = "EDGEZERO_KV" + id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # from step 1 + preview_id = "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" # from step 1 --preview + ``` + + The `binding` name MUST match the store name configured in `edgezero.toml` (default: `"EDGEZERO_KV"`). + +### Consistency + +Both Fastly and Cloudflare KV stores are **eventually consistent**. + +- A value written at one edge location may not be immediately visible at another. +- `read_modify_write()` is **not atomic**. Concurrent updates to the same key may result in lost writes. +- **TTL**: `put_with_ttl` enforces a minimum of **60 seconds** and a maximum of **1 year** across all adapters. + +## Limits & Validation + +To ensure portability across all providers, `KvHandle` enforces the +strictest common limits: + +| Rule | Limit | +| ------------- | ------------------------------------ | +| Key size | Max **512 bytes** (Cloudflare limit) | +| Value size | Max **25 MB** | +| TTL minimum | **60 seconds** | +| TTL maximum | **1 year** | +| List page max | **1000 keys** | +| Empty keys | Rejected | +| Reserved keys | `.` and `..` rejected | +| Control chars | Rejected in keys | + +Violating any of these returns a `KvError::Validation`, which maps to +`400 Bad Request`. + +## Next Steps + +- Check out the [demo app](https://github.com/stackpop/edgezero/tree/main/examples/app-demo) for a full working example. diff --git a/examples/app-demo/Cargo.lock b/examples/app-demo/Cargo.lock index e4cbc2f..8399736 100644 --- a/examples/app-demo/Cargo.lock +++ b/examples/app-demo/Cargo.lock @@ -94,6 +94,7 @@ dependencies = [ "futures", "serde", "serde_json", + "validator", ] [[package]] @@ -516,6 +517,7 @@ dependencies = [ "futures-util", "http", "log", + "redb", "reqwest", "simple_logger 5.1.0", "thiserror 2.0.18", @@ -528,6 +530,7 @@ dependencies = [ name = "edgezero-adapter-cloudflare" version = "0.1.0" dependencies = [ + "anyhow", "async-trait", "brotli", "bytes", @@ -544,6 +547,7 @@ dependencies = [ name = "edgezero-adapter-fastly" version = "0.1.0" dependencies = [ + "anyhow", "async-stream", "async-trait", "brotli", @@ -1550,6 +1554,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "redb" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae323eb086579a3769daa2c753bb96deb95993c534711e0dbe881b5192906a06" +dependencies = [ + "libc", +] + [[package]] name = "regex" version = "1.12.3" diff --git a/examples/app-demo/Cargo.toml b/examples/app-demo/Cargo.toml index 12a794b..8432ccb 100644 --- a/examples/app-demo/Cargo.toml +++ b/examples/app-demo/Cargo.toml @@ -25,6 +25,7 @@ log = "0.4" once_cell = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +validator = { version = "0.20", features = ["derive"] } simple_logger = "4" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tracing = "0.1" diff --git a/examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs b/examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs index 12ae0f3..43d2c58 100644 --- a/examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs +++ b/examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs @@ -8,5 +8,11 @@ use worker::*; #[cfg(target_arch = "wasm32")] #[event(fetch)] pub async fn main(req: Request, env: Env, ctx: Context) -> Result { - edgezero_adapter_cloudflare::run_app::(req, env, ctx).await + edgezero_adapter_cloudflare::run_app::( + include_str!("../../../edgezero.toml"), + req, + env, + ctx, + ) + .await } diff --git a/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml b/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml index e971cb4..28ac887 100644 --- a/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml +++ b/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml @@ -4,3 +4,11 @@ compatibility_date = "2023-05-01" [build] command = "worker-build --release" + +# 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: +# wrangler kv:namespace create EDGEZERO_KV +[[kv_namespaces]] +binding = "EDGEZERO_KV" +id = "local-dev-placeholder" diff --git a/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml b/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml index 3ac4b3e..8d5c4ac 100644 --- a/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml +++ b/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml @@ -7,5 +7,20 @@ service_id = "" [local_server] +[local_server.kv_stores] + +[[local_server.kv_stores.EDGEZERO_KV]] +# We use a dummy key to initialize the store. +# 'data' provides inline content (empty string here). +# 'path' would load content from a file (e.g. path="./README.md"), but we don't need that. +key = "__init__" +data = "" + +[setup] +[setup.kv_stores] +[setup.kv_stores.EDGEZERO_KV] +description = "KV store for EdgeZero demo" + + [scripts] - build = "cargo build --profile release --target wasm32-wasip1" +build = "cargo build --profile release --target wasm32-wasip1" diff --git a/examples/app-demo/crates/app-demo-adapter-fastly/rust-toolchain.toml b/examples/app-demo/crates/app-demo-adapter-fastly/rust-toolchain.toml new file mode 100644 index 0000000..e5ca0d6 --- /dev/null +++ b/examples/app-demo/crates/app-demo-adapter-fastly/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.91.1" +targets = ["wasm32-wasip1"] diff --git a/examples/app-demo/crates/app-demo-core/Cargo.toml b/examples/app-demo/crates/app-demo-core/Cargo.toml index baa71bd..b356b4e 100644 --- a/examples/app-demo/crates/app-demo-core/Cargo.toml +++ b/examples/app-demo/crates/app-demo-core/Cargo.toml @@ -10,7 +10,8 @@ bytes = { workspace = true } edgezero-core = { workspace = true } futures = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } +validator = { workspace = true } [dev-dependencies] async-trait = { workspace = true } -serde_json = { workspace = true } diff --git a/examples/app-demo/crates/app-demo-core/src/handlers.rs b/examples/app-demo/crates/app-demo-core/src/handlers.rs index dbf4ca9..1eb44dc 100644 --- a/examples/app-demo/crates/app-demo-core/src/handlers.rs +++ b/examples/app-demo/crates/app-demo-core/src/handlers.rs @@ -3,7 +3,7 @@ use edgezero_core::action; use edgezero_core::body::Body; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; -use edgezero_core::extractor::{Headers, Json, Path}; +use edgezero_core::extractor::{Headers, Json, Kv, Path, ValidatedPath}; use edgezero_core::http::{self, Response, StatusCode, Uri}; use edgezero_core::proxy::ProxyRequest; use edgezero_core::response::Text; @@ -27,6 +27,19 @@ struct ProxyPath { rest: String, } +// 512 (KV key limit) - 5 (len of "note:") = 507 +const MAX_NOTE_ID_LEN: u64 = 507; + +#[derive(serde::Deserialize, validator::Validate)] +pub(crate) struct NoteIdPath { + #[validate(length( + min = 1, + max = "MAX_NOTE_ID_LEN", + message = "note id must be 1–507 bytes" + ))] + pub(crate) id: String, +} + #[action] pub(crate) async fn root() -> Text<&'static str> { Text::new("app-demo app") @@ -68,7 +81,8 @@ pub(crate) async fn proxy_demo(RequestContext(ctx): RequestContext) -> Result Result Result { - let base = std::env::var("API_BASE_URL").unwrap_or_else(|_| DEFAULT_PROXY_BASE.to_string()); +fn build_proxy_target(base: &str, rest: &str, original_uri: &Uri) -> Result { let mut target = base.trim_end_matches('/').to_string(); let trimmed_rest = rest.trim_start_matches('/'); if !trimmed_rest.is_empty() { @@ -110,6 +123,74 @@ fn proxy_not_available_response() -> Result { .map_err(EdgeError::internal) } +// --------------------------------------------------------------------------- +// KV-powered handlers — demonstrate platform-neutral key-value storage. +// --------------------------------------------------------------------------- + +/// Increment and return a visit counter stored in KV. +#[action] +pub(crate) async fn kv_counter(Kv(store): Kv) -> Result { + let count: i64 = store + .read_modify_write("demo:counter", 0i64, |n| n + 1) + .await?; + let body = serde_json::json!({ "count": count }).to_string(); + http::response_builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body(Body::text(body)) + .map_err(EdgeError::internal) +} + +/// Store a note by id (body = note text). +#[action] +pub(crate) async fn kv_note_put( + Kv(store): Kv, + ValidatedPath(path): ValidatedPath, + RequestContext(ctx): RequestContext, +) -> Result { + let body = ctx.into_request().into_body(); + let body_bytes = body.into_bytes_bounded(MAX_BODY_SIZE).await?; + store + .put_bytes(&format!("note:{}", path.id), body_bytes) + .await?; + http::response_builder() + .status(StatusCode::CREATED) + .body(Body::empty()) + .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( + Kv(store): Kv, + ValidatedPath(path): ValidatedPath, +) -> Result { + match store.get_bytes(&format!("note:{}", path.id)).await? { + Some(data) => http::response_builder() + .status(StatusCode::OK) + .header("content-type", "text/plain; charset=utf-8") + .body(Body::from(data.to_vec())) + .map_err(EdgeError::internal), + None => Err(EdgeError::not_found(format!("note:{}", path.id))), + } +} + +/// Delete a note by id. +#[action] +pub(crate) async fn kv_note_delete( + Kv(store): Kv, + ValidatedPath(path): ValidatedPath, +) -> Result { + store.delete(&format!("note:{}", path.id)).await?; + http::response_builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty()) + .map_err(EdgeError::internal) +} + #[cfg(test)] mod tests { use super::*; @@ -123,7 +204,6 @@ mod tests { use edgezero_core::response::IntoResponse; use futures::{executor::block_on, StreamExt}; use std::collections::HashMap; - use std::env; #[test] fn root_returns_static_body() { @@ -187,23 +267,20 @@ mod tests { #[test] fn build_proxy_target_merges_segments_and_query() { - env::set_var("API_BASE_URL", "https://example.com/api"); let original = Uri::from_static("/proxy/status?foo=bar"); - let target = build_proxy_target("status/200", &original).expect("target uri"); + let target = build_proxy_target("https://example.com/api", "status/200", &original) + .expect("target uri"); assert_eq!( target.to_string(), "https://example.com/api/status/200?foo=bar" ); - env::remove_var("API_BASE_URL"); } #[test] fn proxy_demo_without_handle_returns_placeholder() { - env::set_var("API_BASE_URL", "https://example.com/api"); let ctx = context_with_params("/proxy/status/200", &[("rest", "status/200")]); let response = block_on(proxy_demo(ctx)).expect("response"); assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); - env::remove_var("API_BASE_URL"); } struct TestProxyClient; @@ -219,8 +296,6 @@ mod tests { #[test] fn proxy_demo_uses_injected_handle() { - env::set_var("API_BASE_URL", "https://example.com/api"); - let mut request = request_builder() .method(Method::GET) .uri("/proxy/status/201") @@ -236,8 +311,6 @@ mod tests { let response = block_on(proxy_demo(ctx)).expect("response"); assert_eq!(response.status(), StatusCode::CREATED); - - env::remove_var("API_BASE_URL"); } fn empty_context(path: &str) -> RequestContext { @@ -280,4 +353,167 @@ mod tests { .expect("request"); RequestContext::new(request, PathParams::default()) } + + // -- KV handler tests -------------------------------------------------- + + use edgezero_core::key_value_store::{KvError, KvHandle, KvPage, KvStore}; + use std::collections::BTreeMap; + use std::sync::{Arc, Mutex}; + + struct MockKv { + data: Mutex>, + } + impl MockKv { + fn new() -> Self { + Self { + data: Mutex::new(BTreeMap::new()), + } + } + } + + #[async_trait(?Send)] + impl KvStore for MockKv { + 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, + value: Bytes, + _ttl: std::time::Duration, + ) -> Result<(), KvError> { + 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(()) + } + + async fn list_keys_page( + &self, + prefix: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result { + let data = self.data.lock().unwrap(); + let mut keys = data + .keys() + .filter(|key| { + key.starts_with(prefix) && cursor.is_none_or(|cursor| key.as_str() > cursor) + }) + .cloned() + .collect::>(); + let has_more = keys.len() > limit; + keys.truncate(limit); + + Ok(KvPage { + cursor: has_more.then(|| keys.last().cloned()).flatten(), + keys, + }) + } + } + + fn context_with_kv( + path: &str, + method: Method, + body: Body, + params: &[(&str, &str)], + ) -> (RequestContext, KvHandle) { + let kv = Arc::new(MockKv::new()); + let handle = KvHandle::new(kv); + let mut request = request_builder() + .method(method) + .uri(path) + .body(body) + .expect("request"); + request.extensions_mut().insert(handle.clone()); + let map = params + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect::>(); + (RequestContext::new(request, PathParams::new(map)), handle) + } + + #[test] + fn kv_counter_increments() { + let (ctx, _) = context_with_kv("/kv/counter", Method::POST, Body::empty(), &[]); + let resp = block_on(kv_counter(ctx)).expect("response"); + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().into_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["count"], 1); + } + + #[test] + fn kv_note_put_and_get() { + let (ctx, handle) = context_with_kv( + "/kv/notes/abc", + Method::POST, + Body::from("hello world"), + &[("id", "abc")], + ); + 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) + .uri("/kv/notes/abc") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(handle.clone()); + let mut map = HashMap::new(); + map.insert("id".to_string(), "abc".to_string()); + ( + RequestContext::new(request, PathParams::new(map)), + handle.clone(), + ) + }; + let resp = block_on(kv_note_get(ctx2)).expect("response"); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.into_body().into_bytes().as_ref(), b"hello world"); + } + + #[test] + fn kv_note_get_missing_returns_404() { + let (ctx, _) = context_with_kv( + "/kv/notes/xyz", + Method::GET, + Body::empty(), + &[("id", "xyz")], + ); + let err = block_on(kv_note_get(ctx)).expect_err("should be NotFound"); + assert_eq!(err.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn kv_note_delete_returns_no_content() { + let (ctx, handle) = context_with_kv( + "/kv/notes/del", + Method::POST, + Body::from("to-delete"), + &[("id", "del")], + ); + block_on(kv_note_put(ctx)).unwrap(); + + let (ctx2, _) = { + let mut request = request_builder() + .method(Method::DELETE) + .uri("/kv/notes/del") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(handle.clone()); + let mut map = HashMap::new(); + map.insert("id".to_string(), "del".to_string()); + (RequestContext::new(request, PathParams::new(map)), handle) + }; + let resp = block_on(kv_note_delete(ctx2)).expect("response"); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + } } diff --git a/examples/app-demo/edgezero.toml b/examples/app-demo/edgezero.toml index dd320ac..a187197 100644 --- a/examples/app-demo/edgezero.toml +++ b/examples/app-demo/edgezero.toml @@ -52,6 +52,50 @@ methods = ["GET", "POST"] handler = "app_demo_core::handlers::proxy_demo" adapters = ["axum", "cloudflare", "fastly"] +# -- KV demo routes -------------------------------------------------------- + +[[triggers.http]] +id = "kv_counter" +path = "/kv/counter" +methods = ["POST"] +handler = "app_demo_core::handlers::kv_counter" +adapters = ["axum", "cloudflare", "fastly"] +description = "Increment and return a visit counter stored in KV" + +[[triggers.http]] +id = "kv_note_put" +path = "/kv/notes/{id}" +methods = ["POST"] +handler = "app_demo_core::handlers::kv_note_put" +adapters = ["axum", "cloudflare", "fastly"] +description = "Store a note by id" + +[[triggers.http]] +id = "kv_note_get" +path = "/kv/notes/{id}" +methods = ["GET"] +handler = "app_demo_core::handlers::kv_note_get" +adapters = ["axum", "cloudflare", "fastly"] +description = "Read a note by id" + +[[triggers.http]] +id = "kv_note_delete" +path = "/kv/notes/{id}" +methods = ["DELETE"] +handler = "app_demo_core::handlers::kv_note_delete" +adapters = ["axum", "cloudflare", "fastly"] +description = "Delete a note by id" + +# -- Stores ---------------------------------------------------------------- + +[stores.kv] +# Uses the default name "EDGEZERO_KV". Uncomment to customise: +# name = "MY_CUSTOM_KV" +# +# Per-adapter overrides: +# [stores.kv.adapters.cloudflare] +# name = "CF_KV_BINDING" + # [environment] # # [[environment.variables]] diff --git a/scripts/smoke_test_kv.sh b/scripts/smoke_test_kv.sh new file mode 100755 index 0000000..f2d0c1a --- /dev/null +++ b/scripts/smoke_test_kv.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Smoke-test the KV demo handlers by starting an adapter, running checks, +# and tearing it down automatically. +# +# Usage: +# ./scripts/smoke_test_kv.sh # defaults to axum +# ./scripts/smoke_test_kv.sh axum +# ./scripts/smoke_test_kv.sh fastly +# ./scripts/smoke_test_kv.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)..." + # Kill the process and its children (useful for wrangler/workerd) + 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 %s, got %s)\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 "KV Counter" +STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/kv/counter") +check "POST /kv/counter returns 200" "200" "$STATUS" + +BODY=$(curl -s -X POST "$BASE/kv/counter") +FIRST_COUNT=$(echo "$BODY" | grep -o '"count":[0-9]*' | head -1 | cut -d: -f2) +BODY=$(curl -s -X POST "$BASE/kv/counter") +SECOND_COUNT=$(echo "$BODY" | grep -o '"count":[0-9]*' | head -1 | cut -d: -f2) +check \ + "Counter increments" \ + "true" \ + "$([ -n "$FIRST_COUNT" ] && [ -n "$SECOND_COUNT" ] && [ "$SECOND_COUNT" -eq $((FIRST_COUNT + 1)) ] 2>/dev/null && echo true || echo false)" + +section "KV Notes: PUT + GET" +STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/kv/notes/smoke-test" -d "hello from smoke test") +check "POST /kv/notes/smoke-test returns 201" "201" "$STATUS" + +BODY=$(curl -s "$BASE/kv/notes/smoke-test") +check "GET /kv/notes/smoke-test returns note" "hello from smoke test" "$BODY" + +section "KV Notes: DELETE" +STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE "$BASE/kv/notes/smoke-test") +check "DELETE /kv/notes/smoke-test returns 204" "204" "$STATUS" + +STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/kv/notes/smoke-test") +check "GET deleted note returns 404" "404" "$STATUS" + +section "KV Notes: GET missing key" +STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/kv/notes/does-not-exist") +check "GET /kv/notes/does-not-exist 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