From a936aceeddcd22f6f96933506ce8ecb0f1512692 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 26 Apr 2026 20:55:46 +1000 Subject: [PATCH 1/2] feat: intent-based cache API (minimal, production, encrypted, io) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-configured factory methods that declare caching intent rather than manually composing backends, L1, and encryption: CacheKit::minimal(url).await? — Redis, no L1, 300s TTL CacheKit::production(url).await? — Redis + L1, 600s TTL CacheKit::encrypted(url, key).await? — Redis + L1 + AES-256-GCM, 600s TTL CacheKit::io(api_key)? — SaaS + L1, 3600s TTL Each returns a CacheKitBuilder so callers can override any default. Redis intents are async (eager connect via fred). io() is sync (HTTP). Named `encrypted` instead of `secure` to avoid collision with the existing CacheKit::secure() method that returns SecureCache. Closes cachekit-io/cachekit-rs#13 --- crates/cachekit/src/intents.rs | 209 ++++++++++++++++++++++++++ crates/cachekit/src/lib.rs | 3 + crates/cachekit/tests/intent_tests.rs | 98 ++++++++++++ 3 files changed, 310 insertions(+) create mode 100644 crates/cachekit/src/intents.rs create mode 100644 crates/cachekit/tests/intent_tests.rs diff --git a/crates/cachekit/src/intents.rs b/crates/cachekit/src/intents.rs new file mode 100644 index 0000000..edfabf0 --- /dev/null +++ b/crates/cachekit/src/intents.rs @@ -0,0 +1,209 @@ +//! Intent-based cache presets. +//! +//! Pre-configured factory methods that build a [`CacheKit`] client from a +//! single declarative call. Each intent sets sensible defaults for a specific +//! use case and returns a [`CacheKitBuilder`] so callers can override any +//! setting before building. +//! +//! | Intent | Backend | L1 | Encryption | Default TTL | +//! |------------|-----------|------|------------|-------------| +//! | `minimal` | Redis | Off | No | 300 s | +//! | `production` | Redis | On | No | 600 s | +//! | `encrypted` | Redis | On | AES-256-GCM | 600 s | +//! | `io` | cachekit.io | On | No | 3 600 s | + +use std::time::Duration; + +use crate::client::{CacheKit, CacheKitBuilder, SharedBackend}; +use crate::error::CachekitError; + +// ── SharedBackend wrapping ─────────────────────────────────────────────────── + +#[cfg(all( + feature = "redis", + not(any(target_arch = "wasm32", feature = "unsync")) +))] +fn wrap_redis(b: crate::backend::redis::RedisBackend) -> SharedBackend { + std::sync::Arc::new(b) +} + +#[cfg(all(feature = "redis", any(target_arch = "wasm32", feature = "unsync")))] +fn wrap_redis(b: crate::backend::redis::RedisBackend) -> SharedBackend { + std::rc::Rc::new(b) +} + +#[cfg(all( + feature = "cachekitio", + not(target_arch = "wasm32"), + not(feature = "unsync") +))] +fn wrap_cachekitio(b: crate::backend::cachekitio::CachekitIO) -> SharedBackend { + std::sync::Arc::new(b) +} + +#[cfg(all( + feature = "cachekitio", + not(target_arch = "wasm32"), + feature = "unsync" +))] +fn wrap_cachekitio(b: crate::backend::cachekitio::CachekitIO) -> SharedBackend { + std::rc::Rc::new(b) +} + +// ── Intent presets ─────────────────────────────────────────────────────────── + +impl CacheKit { + /// **Minimal** — speed-first Redis cache, no extras. + /// + /// * Backend: Redis (connects eagerly) + /// * L1 cache: **off** + /// * Encryption: **no** + /// * Default TTL: **300 s** + /// + /// Good for: product catalogs, public data, development. + /// + /// # Errors + /// + /// Returns [`CachekitError`] if the URL is invalid or Redis is unreachable. + /// + /// # Example + /// + /// ```no_run + /// # async fn example() -> Result<(), cachekit::CachekitError> { + /// let cache = cachekit::CacheKit::minimal("redis://localhost:6379").await? + /// .namespace("myapp") + /// .build()?; + /// # Ok(()) + /// # } + /// ``` + #[cfg(feature = "redis")] + pub async fn minimal(redis_url: &str) -> Result { + let backend = crate::backend::redis::RedisBackend::builder() + .url(redis_url) + .build()?; + drop(backend.connect().await?); + + Ok(CacheKitBuilder::default() + .backend(wrap_redis(backend)) + .default_ttl(Duration::from_secs(300)) + .no_l1()) + } + + /// **Production** — reliability-first Redis cache with L1. + /// + /// * Backend: Redis (connects eagerly) + /// * L1 cache: **on** (1 000 entries) + /// * Encryption: **no** + /// * Default TTL: **600 s** + /// + /// Good for: user sessions, API responses, production services. + /// + /// # Errors + /// + /// Returns [`CachekitError`] if the URL is invalid or Redis is unreachable. + /// + /// # Example + /// + /// ```no_run + /// # async fn example() -> Result<(), cachekit::CachekitError> { + /// let cache = cachekit::CacheKit::production("redis://localhost:6379").await? + /// .namespace("api") + /// .build()?; + /// # Ok(()) + /// # } + /// ``` + #[cfg(feature = "redis")] + pub async fn production(redis_url: &str) -> Result { + let backend = crate::backend::redis::RedisBackend::builder() + .url(redis_url) + .build()?; + drop(backend.connect().await?); + + Ok(CacheKitBuilder::default() + .backend(wrap_redis(backend)) + .default_ttl(Duration::from_secs(600)) + .l1_capacity(1000)) + } + + /// **Encrypted** — zero-knowledge encrypted Redis cache. + /// + /// * Backend: Redis (connects eagerly) + /// * L1 cache: **on** (1 000 entries, stores ciphertext) + /// * Encryption: **AES-256-GCM** with HKDF-SHA256 + /// * Default TTL: **600 s** + /// * Tenant ID: `"default"` (override via + /// [`.encryption_from_bytes()`](CacheKitBuilder::encryption_from_bytes)) + /// + /// Good for: PII, payments, GDPR/HIPAA-sensitive data. + /// + /// `master_key` must be at least 16 raw bytes (32 recommended). + /// + /// # Errors + /// + /// Returns [`CachekitError`] if the URL is invalid, Redis is unreachable, + /// or the master key is too short. + /// + /// # Example + /// + /// ```no_run + /// # async fn example() -> Result<(), cachekit::CachekitError> { + /// let key = b"my_32_byte_production_key_here!!"; + /// let cache = cachekit::CacheKit::encrypted("redis://localhost:6379", key).await? + /// .build()?; + /// let encrypted = cache.secure()?; + /// # Ok(()) + /// # } + /// ``` + #[cfg(all(feature = "redis", feature = "encryption"))] + pub async fn encrypted( + redis_url: &str, + master_key: &[u8], + ) -> Result { + let backend = crate::backend::redis::RedisBackend::builder() + .url(redis_url) + .build()?; + drop(backend.connect().await?); + + CacheKitBuilder::default() + .backend(wrap_redis(backend)) + .default_ttl(Duration::from_secs(600)) + .l1_capacity(1000) + .encryption_from_bytes(master_key, "default") + } + + /// **CachekitIO** — managed SaaS cache, zero infrastructure. + /// + /// * Backend: [cachekit.io](https://cachekit.io) HTTP API + /// * L1 cache: **on** (1 000 entries) + /// * Encryption: **no** (add via + /// [`.encryption()`](CacheKitBuilder::encryption)) + /// * Default TTL: **3 600 s** + /// + /// Good for: serverless, edge compute, managed caching without Redis. + /// + /// # Errors + /// + /// Returns [`CachekitError`] if `api_key` is empty. + /// + /// # Example + /// + /// ```no_run + /// # fn example() -> Result<(), cachekit::CachekitError> { + /// let cache = cachekit::CacheKit::io("ck_live_abc123")? + /// .namespace("edge") + /// .build()?; + /// # Ok(()) + /// # } + /// ``` + #[cfg(all(feature = "cachekitio", not(target_arch = "wasm32")))] + pub fn io(api_key: &str) -> Result { + let backend = crate::backend::cachekitio::CachekitIO::builder() + .api_key(api_key) + .build()?; + + Ok(CacheKitBuilder::default() + .backend(wrap_cachekitio(backend)) + .default_ttl(Duration::from_secs(3600)) + .l1_capacity(1000)) + } +} diff --git a/crates/cachekit/src/lib.rs b/crates/cachekit/src/lib.rs index fe56cb1..75c2e29 100644 --- a/crates/cachekit/src/lib.rs +++ b/crates/cachekit/src/lib.rs @@ -35,6 +35,9 @@ pub mod session; /// SSRF-safe URL validation for CachekitIO endpoints. pub mod url_validator; +/// Intent-based cache presets (`CacheKit::minimal`, `::production`, `::secure`, `::io`). +mod intents; + /// Client-side AES-256-GCM encryption with HKDF key derivation. #[cfg(feature = "encryption")] pub mod encryption; diff --git a/crates/cachekit/tests/intent_tests.rs b/crates/cachekit/tests/intent_tests.rs new file mode 100644 index 0000000..fd368df --- /dev/null +++ b/crates/cachekit/tests/intent_tests.rs @@ -0,0 +1,98 @@ +//! Tests for intent-based cache presets. +//! +//! The async intents (minimal, production, encrypted) require a Redis +//! connection, so we test parameter validation through the underlying +//! builders. The sync io() intent can be tested end-to-end. +//! +//! Run with: +//! cargo test --test intent_tests --features redis,encryption,cachekitio,l1 + +mod common; + +use std::time::Duration; + +// ── minimal / production (Redis URL validation) ────────────────────────────── + +#[cfg(feature = "redis")] +mod redis_intents { + #[test] + fn accepts_valid_url() { + let backend = cachekit::backend::redis::RedisBackend::builder() + .url("redis://localhost:6379") + .build(); + assert!(backend.is_ok()); + } + + #[test] + fn accepts_url_with_password() { + let backend = cachekit::backend::redis::RedisBackend::builder() + .url("redis://:secret@host:6379/0") + .build(); + assert!(backend.is_ok()); + } + + #[test] + fn rejects_empty_url() { + let backend = cachekit::backend::redis::RedisBackend::builder() + .url("") + .build(); + assert!(backend.is_err()); + } +} + +// ── encrypted (encryption key validation) ──────────────────────────────────── + +#[cfg(all(feature = "redis", feature = "encryption"))] +mod encrypted_intent { + use crate::common::MockBackend; + + #[test] + fn rejects_short_master_key() { + let result = cachekit::CacheKitBuilder::default() + .backend(MockBackend::shared()) + .encryption_from_bytes(b"too_short", "tenant"); + assert!( + result.is_err(), + "master key under 16 bytes must be rejected" + ); + } + + #[test] + fn accepts_valid_master_key() { + let result = cachekit::CacheKitBuilder::default() + .backend(MockBackend::shared()) + .encryption_from_bytes(b"test_master_key_32_bytes_long!!!", "tenant"); + assert!(result.is_ok()); + } +} + +// ── io (full end-to-end, no network needed) ────────────────────────────────── + +#[cfg(all(feature = "cachekitio", not(target_arch = "wasm32")))] +mod io_intent { + use super::*; + use cachekit::CacheKit; + + #[test] + fn builds_with_valid_key() { + let builder = CacheKit::io("ck_live_test123"); + assert!(builder.is_ok()); + assert!(builder.unwrap().build().is_ok()); + } + + #[test] + fn rejects_empty_key() { + assert!(CacheKit::io("").is_err()); + } + + #[test] + fn allows_overrides() { + let cache = CacheKit::io("ck_live_test123") + .unwrap() + .default_ttl(Duration::from_secs(60)) + .namespace("test") + .no_l1() + .build(); + assert!(cache.is_ok()); + } +} From 1ebbf974e1661c29100b916215a02a24e0cb1152 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 26 Apr 2026 21:12:44 +1000 Subject: [PATCH 2/2] fix: address expert panel findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix doc comment in lib.rs: ::secure → ::encrypted - Fix master key doc: "at least 16 bytes" → "at least 32 bytes" - DRY: replace 4 cfg-gated wrap_redis/wrap_cachekitio fns with single generic wrap() (2 cfg variants instead of 4) --- crates/cachekit/src/intents.rs | 39 ++++++++-------------------------- crates/cachekit/src/lib.rs | 2 +- 2 files changed, 10 insertions(+), 31 deletions(-) diff --git a/crates/cachekit/src/intents.rs b/crates/cachekit/src/intents.rs index edfabf0..b53f1d9 100644 --- a/crates/cachekit/src/intents.rs +++ b/crates/cachekit/src/intents.rs @@ -19,34 +19,13 @@ use crate::error::CachekitError; // ── SharedBackend wrapping ─────────────────────────────────────────────────── -#[cfg(all( - feature = "redis", - not(any(target_arch = "wasm32", feature = "unsync")) -))] -fn wrap_redis(b: crate::backend::redis::RedisBackend) -> SharedBackend { +#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))] +fn wrap(b: impl crate::backend::Backend + 'static) -> SharedBackend { std::sync::Arc::new(b) } -#[cfg(all(feature = "redis", any(target_arch = "wasm32", feature = "unsync")))] -fn wrap_redis(b: crate::backend::redis::RedisBackend) -> SharedBackend { - std::rc::Rc::new(b) -} - -#[cfg(all( - feature = "cachekitio", - not(target_arch = "wasm32"), - not(feature = "unsync") -))] -fn wrap_cachekitio(b: crate::backend::cachekitio::CachekitIO) -> SharedBackend { - std::sync::Arc::new(b) -} - -#[cfg(all( - feature = "cachekitio", - not(target_arch = "wasm32"), - feature = "unsync" -))] -fn wrap_cachekitio(b: crate::backend::cachekitio::CachekitIO) -> SharedBackend { +#[cfg(any(target_arch = "wasm32", feature = "unsync"))] +fn wrap(b: impl crate::backend::Backend + 'static) -> SharedBackend { std::rc::Rc::new(b) } @@ -84,7 +63,7 @@ impl CacheKit { drop(backend.connect().await?); Ok(CacheKitBuilder::default() - .backend(wrap_redis(backend)) + .backend(wrap(backend)) .default_ttl(Duration::from_secs(300)) .no_l1()) } @@ -120,7 +99,7 @@ impl CacheKit { drop(backend.connect().await?); Ok(CacheKitBuilder::default() - .backend(wrap_redis(backend)) + .backend(wrap(backend)) .default_ttl(Duration::from_secs(600)) .l1_capacity(1000)) } @@ -136,7 +115,7 @@ impl CacheKit { /// /// Good for: PII, payments, GDPR/HIPAA-sensitive data. /// - /// `master_key` must be at least 16 raw bytes (32 recommended). + /// `master_key` must be at least 32 raw bytes. /// /// # Errors /// @@ -165,7 +144,7 @@ impl CacheKit { drop(backend.connect().await?); CacheKitBuilder::default() - .backend(wrap_redis(backend)) + .backend(wrap(backend)) .default_ttl(Duration::from_secs(600)) .l1_capacity(1000) .encryption_from_bytes(master_key, "default") @@ -202,7 +181,7 @@ impl CacheKit { .build()?; Ok(CacheKitBuilder::default() - .backend(wrap_cachekitio(backend)) + .backend(wrap(backend)) .default_ttl(Duration::from_secs(3600)) .l1_capacity(1000)) } diff --git a/crates/cachekit/src/lib.rs b/crates/cachekit/src/lib.rs index 75c2e29..50b11eb 100644 --- a/crates/cachekit/src/lib.rs +++ b/crates/cachekit/src/lib.rs @@ -35,7 +35,7 @@ pub mod session; /// SSRF-safe URL validation for CachekitIO endpoints. pub mod url_validator; -/// Intent-based cache presets (`CacheKit::minimal`, `::production`, `::secure`, `::io`). +/// Intent-based cache presets (`CacheKit::minimal`, `::production`, `::encrypted`, `::io`). mod intents; /// Client-side AES-256-GCM encryption with HKDF key derivation.