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}