diff --git a/crates/cachekit/src/intents.rs b/crates/cachekit/src/intents.rs new file mode 100644 index 0000000..b53f1d9 --- /dev/null +++ b/crates/cachekit/src/intents.rs @@ -0,0 +1,188 @@ +//! 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(not(any(target_arch = "wasm32", feature = "unsync")))] +fn wrap(b: impl crate::backend::Backend + 'static) -> SharedBackend { + std::sync::Arc::new(b) +} + +#[cfg(any(target_arch = "wasm32", feature = "unsync"))] +fn wrap(b: impl crate::backend::Backend + 'static) -> 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(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(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 32 raw bytes. + /// + /// # 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(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(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..50b11eb 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`, `::encrypted`, `::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()); + } +}