Skip to main content

cachekit/
intents.rs

1//! Intent-based cache presets.
2//!
3//! Pre-configured factory methods that build a [`CacheKit`] client from a
4//! single declarative call. Each intent sets sensible defaults for a specific
5//! use case and returns a [`CacheKitBuilder`] so callers can override any
6//! setting before building.
7//!
8//! The preset matrix and per-intent resilience contract live in the
9//! crate-level docs (`lib.rs`) — the single rustdoc-rendered copy. This
10//! module is private, so docs here reach source readers only; the per-method
11//! docs below are what docs.rs renders.
12//!
13//! Reliability defaults come from
14//! [`ReliabilityConfig::default()`](crate::reliability::ReliabilityConfig)
15//! (requires the `reliability` feature, on by default). Override via
16//! [`CacheKitBuilder::reliability`];
17//! [`ReliabilityConfig::disabled()`](crate::reliability::ReliabilityConfig::disabled)
18//! turns the stack off entirely.
19
20use std::time::Duration;
21
22use crate::client::{CacheKit, CacheKitBuilder, SharedBackend};
23use crate::error::CachekitError;
24
25// ── SharedBackend wrapping ───────────────────────────────────────────────────
26
27#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
28fn wrap(b: impl crate::backend::Backend + 'static) -> SharedBackend {
29    std::sync::Arc::new(b)
30}
31
32#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
33fn wrap(b: impl crate::backend::Backend + 'static) -> SharedBackend {
34    std::rc::Rc::new(b)
35}
36
37// ── Intent presets ───────────────────────────────────────────────────────────
38
39/// `minimal` builder defaults, split from the eager Redis connect so the
40/// L1-on / SWR-off contract (`protocol/spec/intent-presets.md` § L1 Posture)
41/// is unit-testable without a live server.
42#[cfg(feature = "redis")]
43fn minimal_defaults(backend: SharedBackend) -> CacheKitBuilder {
44    let builder = CacheKitBuilder::default()
45        .backend(backend)
46        .default_ttl(Duration::from_secs(300))
47        .l1_capacity(1000);
48    // Builder default is SWR-on; spec § L1 Posture says minimal MUST NOT.
49    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
50    let builder = builder.swr_enabled(false);
51    builder
52}
53
54/// `secure` builder defaults and key resolution, split from the eager Redis
55/// connect so the key path is unit-testable without a live server. `source`
56/// names the key's origin in error messages.
57#[cfg(all(feature = "redis", feature = "encryption"))]
58fn secure_defaults(master_key_hex: &str, source: &str) -> Result<CacheKitBuilder, CachekitError> {
59    let master_key = crate::config::decode_master_key_hex(master_key_hex, source)?;
60    let builder = CacheKitBuilder::default()
61        .default_ttl(Duration::from_secs(600))
62        .l1_capacity(1000)
63        .encryption_from_bytes(&master_key, "default")?;
64    #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
65    let builder = builder.reliability(crate::reliability::ReliabilityConfig::default());
66    Ok(builder)
67}
68
69/// `CACHEKIT_MASTER_KEY` for [`CacheKit::secure_from_env`]; unset and empty
70/// are both an error, never plaintext.
71#[cfg(all(feature = "redis", feature = "encryption"))]
72fn master_key_hex_from_env() -> Result<zeroize::Zeroizing<String>, CachekitError> {
73    std::env::var("CACHEKIT_MASTER_KEY")
74        .ok()
75        .map(zeroize::Zeroizing::new)
76        .filter(|k| !k.is_empty())
77        .ok_or_else(|| {
78            CachekitError::Config(
79                "CACHEKIT_MASTER_KEY is unset or empty: set it to a 64-hex-char key or pass \
80                 the key to CacheKit::secure(url, master_key_hex)"
81                    .to_owned(),
82            )
83        })
84}
85
86/// Attach an eagerly connected, auto-reconnecting Redis backend to a
87/// `secure` builder. Takes the builder, not the key, so key validation has
88/// already happened: a bad key must not be masked by (or pay for) Redis I/O.
89#[cfg(all(feature = "redis", feature = "encryption"))]
90async fn connect_secure(
91    redis_url: &str,
92    builder: CacheKitBuilder,
93) -> Result<CacheKitBuilder, CachekitError> {
94    let backend = crate::backend::redis::RedisBackend::builder()
95        .url(redis_url)
96        .auto_reconnect()
97        .build()?;
98    drop(backend.connect().await?);
99    Ok(builder.backend(wrap(backend)))
100}
101
102impl CacheKit {
103    /// **Minimal** — speed-first Redis cache, no extras.
104    ///
105    /// * Backend: Redis (connects eagerly; **fails fast** — a dropped
106    ///   connection is not re-established)
107    /// * L1 cache: **on** (1 000 entries, **no SWR / invalidation**) — an L1
108    ///   hit is served as-is until it expires, so a read may return an entry
109    ///   up to its TTL (300 s by default) after another process changed it
110    ///   in Redis. Chain
111    ///   [`.no_l1()`](CacheKitBuilder::no_l1) to read through every time.
112    /// * Encryption: **no**
113    /// * Reliability: **off** — no retry, no circuit breaker, no
114    ///   backpressure; every backend error propagates on first failure and
115    ///   backend concurrency is unbounded
116    /// * Default TTL: **300 s**
117    ///
118    /// Good for: product catalogs, public data, development.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`CachekitError`] if the URL is invalid or Redis is unreachable.
123    ///
124    /// # Example
125    ///
126    /// ```no_run
127    /// # async fn example() -> Result<(), cachekit::CachekitError> {
128    /// let cache = cachekit::CacheKit::minimal("redis://localhost:6379").await?
129    ///     .namespace("myapp")
130    ///     .build()?;
131    /// # Ok(())
132    /// # }
133    /// ```
134    #[cfg(feature = "redis")]
135    pub async fn minimal(redis_url: &str) -> Result<CacheKitBuilder, CachekitError> {
136        let backend = crate::backend::redis::RedisBackend::builder()
137            .url(redis_url)
138            .build()?;
139        drop(backend.connect().await?);
140
141        Ok(minimal_defaults(wrap(backend)))
142    }
143
144    /// **Production** — reliability-first Redis cache with L1.
145    ///
146    /// * Backend: Redis (connects eagerly, failing fast if unreachable;
147    ///   **auto-reconnects** after a dropped connection with exponential
148    ///   backoff, 100 ms → 30 s, retrying indefinitely)
149    /// * L1 cache: **on** (1 000 entries)
150    /// * Encryption: **no**
151    /// * Reliability: **on** — retry with backoff + jitter, circuit
152    ///   breaker, backpressure (max 100 concurrent backend ops)
153    /// * Default TTL: **600 s**
154    ///
155    /// Good for: user sessions, API responses, production services.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`CachekitError`] if the URL is invalid or Redis is unreachable.
160    ///
161    /// # Example
162    ///
163    /// ```no_run
164    /// # async fn example() -> Result<(), cachekit::CachekitError> {
165    /// let cache = cachekit::CacheKit::production("redis://localhost:6379").await?
166    ///     .namespace("api")
167    ///     .build()?;
168    /// # Ok(())
169    /// # }
170    /// ```
171    #[cfg(feature = "redis")]
172    pub async fn production(redis_url: &str) -> Result<CacheKitBuilder, CachekitError> {
173        let backend = crate::backend::redis::RedisBackend::builder()
174            .url(redis_url)
175            .auto_reconnect()
176            .build()?;
177        drop(backend.connect().await?);
178
179        let builder = CacheKitBuilder::default()
180            .backend(wrap(backend))
181            .default_ttl(Duration::from_secs(600))
182            .l1_capacity(1000);
183        #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
184        let builder = builder.reliability(crate::reliability::ReliabilityConfig::default());
185        Ok(builder)
186    }
187
188    /// **Secure** — zero-knowledge encrypted Redis cache.
189    ///
190    /// * Backend: Redis (connects eagerly, failing fast if unreachable;
191    ///   **auto-reconnects** after a dropped connection with exponential
192    ///   backoff, 100 ms → 30 s, retrying indefinitely)
193    /// * L1 cache: **on** (1 000 entries, stores ciphertext)
194    /// * Encryption: **AES-256-GCM** with HKDF-SHA256
195    /// * Reliability: **on** — retry with backoff + jitter, circuit
196    ///   breaker, backpressure (max 100 concurrent backend ops)
197    /// * Default TTL: **600 s**
198    /// * Tenant ID: `"default"` for both key derivation and AAD (override via
199    ///   [`.encryption()`](CacheKitBuilder::encryption))
200    ///
201    /// Good for: PII, payments, GDPR/HIPAA-sensitive data.
202    ///
203    /// `master_key_hex` is the master key as a **hex string** — the same
204    /// string `CACHEKIT_MASTER_KEY` holds, decoded to the same key bytes by
205    /// every CacheKit SDK. This is the cross-SDK-portable key path. Use
206    /// exactly **32 bytes (64 hex chars)**: shorter is rejected, and longer
207    /// is accepted here but not by every SDK. Generate one with
208    /// `openssl rand -hex 32`.
209    ///
210    /// Use [`CacheKit::secure_from_env`] to read the key from
211    /// `CACHEKIT_MASTER_KEY`. There is no raw-bytes preset: decoded key bytes
212    /// go to [`CacheKitBuilder::encryption_from_bytes`], and the ASCII bytes
213    /// of a hex string must never go there — they derive a key no other SDK
214    /// derives.
215    ///
216    /// The key is validated **before** any Redis connection is attempted — a
217    /// bad key is a deterministic local error, never masked by (or paying
218    /// for) network I/O.
219    ///
220    /// # Errors
221    ///
222    /// Returns [`CachekitError::Config`] if `master_key_hex` is not hex or
223    /// decodes to fewer than 32 bytes, or if the URL is invalid; another
224    /// [`CachekitError`] if Redis is unreachable.
225    ///
226    /// # Example
227    ///
228    /// ```no_run
229    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
230    /// // 64 hex chars (32 bytes) from your secret store — `openssl rand -hex 32`.
231    /// let master_key_hex = std::env::var("APP_CACHE_MASTER_KEY")?;
232    /// let cache = cachekit::CacheKit::secure("redis://localhost:6379", &master_key_hex)
233    ///     .await?
234    ///     .build()?;
235    /// let secure = cache.secure_cache()?;
236    /// # Ok(())
237    /// # }
238    /// ```
239    #[cfg(all(feature = "redis", feature = "encryption"))]
240    pub async fn secure(
241        redis_url: &str,
242        master_key_hex: &str,
243    ) -> Result<CacheKitBuilder, CachekitError> {
244        connect_secure(
245            redis_url,
246            secure_defaults(master_key_hex, "master_key_hex")?,
247        )
248        .await
249    }
250
251    /// **Secure**, master key from the environment — [`CacheKit::secure`]
252    /// with the hex key read from `CACHEKIT_MASTER_KEY`.
253    ///
254    /// Identical preset to [`secure`](CacheKit::secure), same hex decoding,
255    /// so the same `CACHEKIT_MASTER_KEY` value derives the same key bytes as
256    /// every other CacheKit SDK. Use exactly **32 bytes (64 hex chars)**, the
257    /// only length every SDK accepts. Never falls back to plaintext.
258    ///
259    /// # Errors
260    ///
261    /// Returns [`CachekitError::Config`] when `CACHEKIT_MASTER_KEY` is unset,
262    /// empty, not hex, or shorter than 32 bytes — all before any Redis I/O.
263    /// Otherwise as [`secure`](CacheKit::secure).
264    ///
265    /// # Example
266    ///
267    /// ```no_run
268    /// # async fn example() -> Result<(), cachekit::CachekitError> {
269    /// let cache = cachekit::CacheKit::secure_from_env("redis://localhost:6379")
270    ///     .await?
271    ///     .build()?;
272    /// let secure = cache.secure_cache()?;
273    /// # Ok(())
274    /// # }
275    /// ```
276    #[cfg(all(feature = "redis", feature = "encryption"))]
277    pub async fn secure_from_env(redis_url: &str) -> Result<CacheKitBuilder, CachekitError> {
278        let master_key_hex = master_key_hex_from_env()?;
279        connect_secure(
280            redis_url,
281            secure_defaults(&master_key_hex, "CACHEKIT_MASTER_KEY")?,
282        )
283        .await
284    }
285
286    /// **CachekitIO** — managed SaaS cache, zero infrastructure.
287    ///
288    /// * Backend: [cachekit.io](https://cachekit.io) HTTP API
289    /// * L1 cache: **on** (1 000 entries)
290    /// * Encryption: **no** (add via
291    ///   [`.encryption()`](CacheKitBuilder::encryption))
292    /// * Reliability: **on** — retry with backoff + jitter, circuit
293    ///   breaker, backpressure (max 100 concurrent backend ops)
294    /// * Default TTL: **3 600 s**
295    ///
296    /// Good for: serverless, edge compute, managed caching without Redis.
297    ///
298    /// Use [`CacheKit::io_from_env`] to read the key from `CACHEKIT_API_KEY`.
299    ///
300    /// # Errors
301    ///
302    /// Returns [`CachekitError::Config`] if `api_key` is empty.
303    ///
304    /// # Example
305    ///
306    /// ```no_run
307    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
308    /// let api_key = std::env::var("CACHEKIT_API_KEY")?;
309    /// let cache = cachekit::CacheKit::io(&api_key)?
310    ///     .namespace("edge")
311    ///     .build()?;
312    /// # Ok(())
313    /// # }
314    /// ```
315    #[cfg(all(feature = "cachekitio", not(target_arch = "wasm32")))]
316    pub fn io(api_key: &str) -> Result<CacheKitBuilder, CachekitError> {
317        let backend = crate::backend::cachekitio::CachekitIO::builder()
318            .api_key(api_key)
319            .build()?;
320
321        let builder = CacheKitBuilder::default()
322            .backend(wrap(backend))
323            .default_ttl(Duration::from_secs(3600))
324            .l1_capacity(1000);
325        #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
326        let builder = builder.reliability(crate::reliability::ReliabilityConfig::default());
327        Ok(builder)
328    }
329
330    /// **CachekitIO**, API key from the environment — [`CacheKit::io`] with
331    /// the key read from `CACHEKIT_API_KEY`.
332    ///
333    /// Identical preset to [`io`](CacheKit::io). Reads **only**
334    /// `CACHEKIT_API_KEY` — unlike [`CacheKit::from_env`], never
335    /// `CACHEKIT_MASTER_KEY`, so no encryption is activated.
336    ///
337    /// # Errors
338    ///
339    /// Returns [`CachekitError::Config`] when `CACHEKIT_API_KEY` is unset or
340    /// empty.
341    ///
342    /// # Example
343    ///
344    /// ```no_run
345    /// # fn example() -> Result<(), cachekit::CachekitError> {
346    /// let cache = cachekit::CacheKit::io_from_env()?
347    ///     .namespace("edge")
348    ///     .build()?;
349    /// # Ok(())
350    /// # }
351    /// ```
352    #[cfg(all(feature = "cachekitio", not(target_arch = "wasm32")))]
353    pub fn io_from_env() -> Result<CacheKitBuilder, CachekitError> {
354        let api_key = std::env::var("CACHEKIT_API_KEY")
355            .ok()
356            .map(zeroize::Zeroizing::new)
357            .filter(|k| !k.is_empty())
358            .ok_or_else(|| {
359                CachekitError::Config(
360                    "CACHEKIT_API_KEY is unset or empty: set it or pass the key to CacheKit::io(api_key)"
361                        .to_owned(),
362                )
363            })?;
364        Self::io(&api_key)
365    }
366}
367
368#[cfg(all(test, feature = "redis", feature = "l1"))]
369#[allow(clippy::expect_used)] // test-only: a failing build here should panic loudly
370mod tests {
371    /// `RedisBackend::build()` does not connect, so the preset's defaults
372    /// are observable without a live server.
373    #[test]
374    fn minimal_enables_l1_without_swr() {
375        let backend = crate::backend::redis::RedisBackend::builder()
376            .url("redis://localhost:6379")
377            .build()
378            .expect("valid URL");
379        let cache = super::minimal_defaults(super::wrap(backend))
380            .build()
381            .expect("minimal defaults must build");
382
383        assert!(
384            cache.l1.is_some(),
385            "spec/intent-presets.md § L1 Posture: minimal MUST enable L1"
386        );
387        #[cfg(all(not(feature = "unsync"), not(target_arch = "wasm32")))]
388        assert!(
389            !cache.swr_enabled,
390            "spec/intent-presets.md § L1 Posture: minimal MUST NOT enable SWR"
391        );
392    }
393}
394
395#[cfg(all(test, feature = "redis", feature = "encryption"))]
396#[allow(clippy::expect_used)] // test-only: a failing build here should panic loudly
397mod secure_tests {
398    // protocol test-vectors/encryption.json v1.2.0, `default_tenant.vectors[0]`
399    // (`default_tenant_interop`): spec/intent-presets.md § Master Key Input
400    // rule 5 — with no tenant configured, the preset must derive and bind AAD
401    // under the literal "default", byte-for-byte with every other SDK.
402    const MASTER_KEY_HEX: &str = "6161616161616161616161616161616161616161616161616161616161616161";
403    const CACHE_KEY: &str =
404        "users:get_user:61598716255080080f6456eb065c2e51badfaa4320b0efe97469c29cffee8875";
405    const CIPHERTEXT_HEX: &str =
406        "0d0e0f1011121314151617183c29af318238925ee76d081934adce133c0b4a7c5eb5704102b04582dcbf278ffd"; // pragma: allowlist secret
407    const PLAINTEXT_HEX: &str = "82a36167651ea46e616d65a5616c696365"; // pragma: allowlist secret
408
409    fn assert_decrypts_default_tenant_vector(builder: crate::CacheKitBuilder) {
410        let layer = builder
411            .encryption
412            .expect("secure preset must configure encryption");
413        assert_eq!(layer.tenant_id(), "default");
414        let ciphertext = hex::decode(CIPHERTEXT_HEX).expect("vector hex");
415        let plaintext = layer
416            .decrypt(&ciphertext, CACHE_KEY)
417            .expect("default-tenant vector must decrypt");
418        assert_eq!(hex::encode(plaintext), PLAINTEXT_HEX);
419    }
420
421    #[test]
422    fn hex_path_decrypts_default_tenant_vector() {
423        assert_decrypts_default_tenant_vector(
424            super::secure_defaults(MASTER_KEY_HEX, "master_key_hex").expect("valid key"),
425        );
426    }
427
428    #[test]
429    #[serial_test::serial]
430    fn env_path_decrypts_default_tenant_vector() {
431        let saved = std::env::var_os("CACHEKIT_MASTER_KEY");
432        std::env::set_var("CACHEKIT_MASTER_KEY", MASTER_KEY_HEX);
433        let resolved = super::master_key_hex_from_env();
434        // Restore before anything can panic.
435        match saved {
436            Some(v) => std::env::set_var("CACHEKIT_MASTER_KEY", v),
437            None => std::env::remove_var("CACHEKIT_MASTER_KEY"),
438        }
439        let master_key_hex = resolved.expect("CACHEKIT_MASTER_KEY is set");
440        assert_decrypts_default_tenant_vector(
441            super::secure_defaults(&master_key_hex, "CACHEKIT_MASTER_KEY").expect("valid key"),
442        );
443    }
444}