1use std::collections::HashMap;
2use std::sync::OnceLock;
3use std::time::Duration;
4
5use async_trait::async_trait;
6use zeroize::Zeroizing;
7
8use crate::backend::{encode_key, Backend, HealthStatus, LockableBackend};
9use crate::error::{BackendError, BackendErrorKind};
10use crate::metrics::{metrics_headers, MetricsProvider};
11use crate::session::session_headers;
12use crate::url_validator::validate_cachekitio_url;
13
14pub struct CachekitIO {
18 client: reqwest::Client,
19 api_key: Zeroizing<String>,
20 api_url: String,
21 metrics_provider: OnceLock<MetricsProvider>,
25}
26
27impl std::fmt::Debug for CachekitIO {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 f.debug_struct("CachekitIO")
31 .field("api_url", &self.api_url)
32 .field("api_key", &"<redacted>")
33 .finish()
34 }
35}
36
37impl CachekitIO {
38 pub fn builder() -> CachekitIOBuilder {
40 CachekitIOBuilder::default()
41 }
42
43 pub fn api_url(&self) -> &str {
45 &self.api_url
46 }
47
48 pub(crate) fn client(&self) -> &reqwest::Client {
50 &self.client
51 }
52
53 pub(crate) fn api_key_str(&self) -> &str {
55 self.api_key.as_str()
56 }
57
58 fn url(&self, key: &str) -> Result<String, BackendError> {
68 Ok(format!("{}/v1/cache/{}", self.api_url, encode_key(key)?))
69 }
70
71 pub(crate) fn ttl_url(&self, key: &str) -> Result<String, BackendError> {
77 Ok(format!("{}/ttl", self.url(key)?))
78 }
79
80 pub(crate) fn lock_url(&self, key: &str) -> Result<String, BackendError> {
85 Ok(format!("{}/lock", self.url(key)?))
86 }
87
88 fn health_url(&self) -> String {
90 format!("{}/v1/cache/health", self.api_url)
91 }
92
93 pub(crate) fn with_standard_headers(
95 &self,
96 mut req: reqwest::RequestBuilder,
97 ) -> reqwest::RequestBuilder {
98 for (name, value) in session_headers() {
99 req = req.header(name, value);
100 }
101 for (name, value) in metrics_headers(self.metrics_provider.get()) {
102 req = req.header(name, value);
103 }
104 req
105 }
106
107 pub(crate) async fn error_from_response(&self, resp: reqwest::Response) -> BackendError {
109 let status = resp.status().as_u16();
110 let body = resp.bytes().await.unwrap_or_default();
111 from_http_status_sanitized(status, &body, self.api_key.as_str())
112 }
113}
114
115pub(crate) fn reqwest_err_sanitized(e: reqwest::Error, api_key: &str) -> BackendError {
119 let kind = if e.is_timeout() {
120 BackendErrorKind::Timeout
121 } else {
122 BackendErrorKind::Transient
123 };
124 BackendError {
125 kind,
126 message: BackendError::sanitize_message(&e.to_string(), api_key),
127 source: Some(Box::new(e)),
128 }
129}
130
131pub(crate) fn from_http_status_sanitized(status: u16, body: &[u8], api_key: &str) -> BackendError {
133 let sanitized =
134 BackendError::sanitize_message(std::str::from_utf8(body).unwrap_or(""), api_key);
135 BackendError::from_http_status(status, sanitized.as_bytes())
136}
137
138#[cfg(not(target_arch = "wasm32"))]
141#[cfg_attr(not(feature = "unsync"), async_trait)]
142#[cfg_attr(feature = "unsync", async_trait(?Send))]
143impl Backend for CachekitIO {
144 fn attach_metrics(&self, provider: MetricsProvider) {
145 self.metrics_provider.get_or_init(|| provider);
147 }
148
149 async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError> {
150 let req = self.with_standard_headers(
151 self.client
152 .get(self.url(key)?)
153 .bearer_auth(self.api_key.as_str()),
154 );
155
156 let resp = req
157 .send()
158 .await
159 .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
160
161 match resp.status().as_u16() {
162 200 => {
163 let bytes = resp
164 .bytes()
165 .await
166 .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
167 Ok(Some(bytes.to_vec()))
168 }
169 404 => Ok(None),
170 _ => Err(self.error_from_response(resp).await),
171 }
172 }
173
174 async fn set(
175 &self,
176 key: &str,
177 value: Vec<u8>,
178 ttl: Option<Duration>,
179 ) -> Result<(), BackendError> {
180 let mut req = self
181 .client
182 .put(self.url(key)?)
183 .bearer_auth(self.api_key.as_str())
184 .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
185 .body(value);
186
187 if let Some(ttl) = ttl {
188 req = req.header("X-TTL", ttl.as_secs().to_string());
189 }
190
191 let req = self.with_standard_headers(req);
192
193 let resp = req
194 .send()
195 .await
196 .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
197
198 let status = resp.status().as_u16();
199 if (200..300).contains(&status) {
200 Ok(())
201 } else {
202 Err(self.error_from_response(resp).await)
203 }
204 }
205
206 async fn delete(&self, key: &str) -> Result<bool, BackendError> {
207 let req = self.with_standard_headers(
208 self.client
209 .delete(self.url(key)?)
210 .bearer_auth(self.api_key.as_str()),
211 );
212
213 let resp = req
214 .send()
215 .await
216 .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
217
218 match resp.status().as_u16() {
219 200 | 204 => Ok(true),
220 404 => Ok(false),
221 _ => Err(self.error_from_response(resp).await),
222 }
223 }
224
225 async fn exists(&self, key: &str) -> Result<bool, BackendError> {
226 let req = self.with_standard_headers(
227 self.client
228 .head(self.url(key)?)
229 .bearer_auth(self.api_key.as_str()),
230 );
231
232 let resp = req
233 .send()
234 .await
235 .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
236
237 match resp.status().as_u16() {
238 200 => Ok(true),
239 404 => Ok(false),
240 status => Err(BackendError::from_http_status(status, &[])),
241 }
242 }
243
244 async fn health(&self) -> Result<HealthStatus, BackendError> {
245 let start = std::time::Instant::now();
246
247 let req = self.with_standard_headers(
248 self.client
249 .get(self.health_url())
250 .bearer_auth(self.api_key.as_str()),
251 );
252
253 let resp = req
254 .send()
255 .await
256 .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
257
258 let latency = start.elapsed();
259 let status = resp.status().as_u16();
260
261 if (200..300).contains(&status) {
262 let mut details = HashMap::new();
263 details.insert("http_status".to_string(), status.to_string());
264 Ok(HealthStatus {
265 is_healthy: true,
266 latency_ms: latency.as_secs_f64() * 1000.0,
267 backend_type: "cachekitio".to_string(),
268 details,
269 })
270 } else {
271 Err(self.error_from_response(resp).await)
272 }
273 }
274
275 fn as_lockable(&self) -> Option<&dyn LockableBackend> {
276 Some(self)
277 }
278}
279
280#[derive(Default)]
284#[must_use]
285pub struct CachekitIOBuilder {
286 api_key: Option<Zeroizing<String>>,
287 api_url: Option<String>,
288 allow_custom_host: bool,
289 metrics_provider: Option<MetricsProvider>,
290}
291
292impl CachekitIOBuilder {
293 pub fn api_key(mut self, key: impl Into<String>) -> Self {
295 self.api_key = Some(Zeroizing::new(key.into()));
296 self
297 }
298
299 pub fn api_url(mut self, url: impl Into<String>) -> Self {
301 self.api_url = Some(url.into());
302 self
303 }
304
305 pub fn allow_custom_host(mut self, allow: bool) -> Self {
307 self.allow_custom_host = allow;
308 self
309 }
310
311 pub fn metrics_provider(mut self, provider: MetricsProvider) -> Self {
317 self.metrics_provider = Some(provider);
318 self
319 }
320
321 pub fn build(self) -> Result<CachekitIO, crate::error::CachekitError> {
330 use crate::error::CachekitError;
331
332 let api_key = self
333 .api_key
334 .filter(|k| !k.is_empty())
335 .ok_or_else(|| CachekitError::Config("api_key is required".to_string()))?;
336
337 let api_url = self
338 .api_url
339 .unwrap_or_else(|| "https://api.cachekit.io".to_string());
340
341 validate_cachekitio_url(&api_url, self.allow_custom_host)?;
343
344 let api_url = api_url.trim_end_matches('/').to_string();
346
347 let client = reqwest::Client::builder();
348
349 #[cfg(not(target_arch = "wasm32"))]
350 let client = client
351 .use_rustls_tls()
352 .redirect(reqwest::redirect::Policy::none())
353 .timeout(Duration::from_secs(30))
354 .connect_timeout(Duration::from_secs(10));
355
356 let client = client
357 .build()
358 .map_err(|e| CachekitError::Config(format!("failed to build HTTP client: {e}")))?;
359
360 Ok(CachekitIO {
361 client,
362 api_key,
363 api_url,
364 metrics_provider: self
365 .metrics_provider
366 .map_or_else(OnceLock::new, OnceLock::from),
367 })
368 }
369}
370
371#[cfg(test)]
374#[allow(clippy::expect_used)] mod metrics_wiring_tests {
376 use std::sync::Arc;
377
378 use super::CachekitIO;
379 use crate::backend::Backend;
380 use crate::metrics::{metrics_headers, L1Stats, MetricsProvider};
381
382 fn provider(l1_hits: u64) -> MetricsProvider {
383 Arc::new(move || {
384 Some(L1Stats {
385 l1_hits,
386 l2_hits: 0,
387 misses: 0,
388 l1_enabled: true,
389 })
390 })
391 }
392
393 fn l1_hits_header(backend: &CachekitIO) -> Option<String> {
394 metrics_headers(backend.metrics_provider.get())
395 .into_iter()
396 .find(|h| h.0 == "X-CacheKit-L1-Hits")
397 .map(|h| h.1)
398 }
399
400 #[test]
401 fn attach_fills_an_unset_provider() {
402 let backend = CachekitIO::builder()
403 .api_key("ck_test_key")
404 .build()
405 .expect("builder succeeds");
406 assert_eq!(l1_hits_header(&backend), None, "nothing attached yet");
407
408 backend.attach_metrics(provider(7));
409 assert_eq!(l1_hits_header(&backend).as_deref(), Some("7"));
410 }
411
412 #[test]
413 fn builder_provider_is_retained_over_attach() {
414 let backend = CachekitIO::builder()
415 .api_key("ck_test_key")
416 .metrics_provider(provider(1))
417 .build()
418 .expect("builder succeeds");
419
420 backend.attach_metrics(provider(99));
421 assert_eq!(
422 l1_hits_header(&backend).as_deref(),
423 Some("1"),
424 "user-supplied provider wins"
425 );
426 }
427}
428
429#[cfg(test)]
432#[allow(clippy::expect_used)] mod path_encoding_tests {
434 use super::CachekitIO;
435 use url::Url;
436
437 const API: &str = "https://api.cachekit.io";
438
439 fn backend() -> CachekitIO {
440 CachekitIO::builder()
441 .api_url(API)
442 .api_key("ck_test_key")
443 .build()
444 .expect("builder should succeed for the canonical host")
445 }
446
447 #[test]
453 fn repro_raw_dot_key_escapes_the_cache_prefix() {
454 let cases = [
455 ("..", "", "/v1/"),
456 ("..", "/ttl", "/v1/ttl"),
457 ("..", "/lock", "/v1/lock"),
458 (".", "", "/v1/cache/"),
459 ];
460 for (key, suffix, escaped) in cases {
461 let raw = format!("{API}/v1/cache/{}{suffix}", urlencoding::encode(key));
462 let parsed = Url::parse(&raw).expect("parses");
463 assert_eq!(
464 parsed.path(),
465 escaped,
466 "raw {key:?}{suffix} should collapse to {escaped}"
467 );
468 let pct = key.replace('.', "%2E");
470 let enc = format!("{API}/v1/cache/{pct}{suffix}");
471 assert_eq!(
472 Url::parse(&enc).expect("parses").path(),
473 escaped,
474 "%2E-encoded {key:?}{suffix} also collapses — encoding cannot fix this in rust-url"
475 );
476 }
477 }
478
479 #[test]
483 fn reserved_segments_rejected_by_every_builder() {
484 let b = backend();
485 for key in [".", "..", "health", "ttl", "lock"] {
486 assert!(b.url(key).is_err(), "url({key:?}) must be rejected");
487 assert!(b.ttl_url(key).is_err(), "ttl_url({key:?}) must be rejected");
488 assert!(
489 b.lock_url(key).is_err(),
490 "lock_url({key:?}) must be rejected"
491 );
492 }
493 }
494
495 #[test]
500 fn safe_keys_never_escape_the_cache_prefix() {
501 let b = backend();
502 let vectors = [
503 "a:..",
504 "default:../../admin",
505 "k?x=1#f",
506 "a b",
507 "healthy", "x/../../health", "ns:default:func:m.f:args:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef:",
510 ];
511 for key in vectors {
512 let base = Url::parse(&b.url(key).expect("url")).expect("parse base");
513 let ttl = Url::parse(&b.ttl_url(key).expect("ttl_url")).expect("parse ttl");
514 let lock = Url::parse(&b.lock_url(key).expect("lock_url")).expect("parse lock");
515
516 assert!(
517 base.path().starts_with("/v1/cache/") && base.path().len() > "/v1/cache/".len(),
518 "base path {} escaped prefix for {key:?}",
519 base.path()
520 );
521 assert_eq!(
522 base.path(),
523 format!("/v1/cache/{}", urlencoding::encode(key)),
524 "base wire path mismatch for {key:?}"
525 );
526 assert!(
527 ttl.path().starts_with("/v1/cache/") && ttl.path().ends_with("/ttl"),
528 "ttl path {} escaped prefix for {key:?}",
529 ttl.path()
530 );
531 assert!(
532 lock.path().starts_with("/v1/cache/") && lock.path().ends_with("/lock"),
533 "lock path {} escaped prefix for {key:?}",
534 lock.path()
535 );
536 }
537 }
538}