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, Freshness, 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> {
64 Ok(format!("{}/v1/cache/{}", self.api_url, encode_key(key)?))
65 }
66
67 pub(crate) fn ttl_url(&self, key: &str) -> Result<String, BackendError> {
73 Ok(format!("{}/ttl", self.url(key)?))
74 }
75
76 pub(crate) fn lock_url(&self, key: &str) -> Result<String, BackendError> {
81 Ok(format!("{}/lock", self.url(key)?))
82 }
83
84 fn health_url(&self) -> String {
86 format!("{}/v1/cache/health", self.api_url)
87 }
88
89 pub(crate) fn with_standard_headers(
91 &self,
92 mut req: reqwest::RequestBuilder,
93 ) -> reqwest::RequestBuilder {
94 for (name, value) in session_headers() {
95 req = req.header(name, value);
96 }
97 for (name, value) in metrics_headers(self.metrics_provider.get()) {
98 req = req.header(name, value);
99 }
100 req
101 }
102
103 pub(crate) async fn error_from_response(&self, resp: reqwest::Response) -> BackendError {
105 let status = resp.status().as_u16();
106 let body = resp.bytes().await.unwrap_or_default();
107 from_http_status_sanitized(status, &body, self.api_key.as_str())
108 }
109}
110
111pub(crate) fn reqwest_err_sanitized(e: reqwest::Error, api_key: &str) -> BackendError {
115 let kind = if e.is_timeout() {
116 BackendErrorKind::Timeout
117 } else {
118 BackendErrorKind::Transient
119 };
120 BackendError {
121 kind,
122 message: BackendError::sanitize_message(&e.to_string(), api_key),
123 source: Some(Box::new(e)),
124 }
125}
126
127pub(crate) fn from_http_status_sanitized(status: u16, body: &[u8], api_key: &str) -> BackendError {
129 let sanitized =
130 BackendError::sanitize_message(std::str::from_utf8(body).unwrap_or(""), api_key);
131 BackendError::from_http_status(status, sanitized.as_bytes())
132}
133
134#[cfg(not(target_arch = "wasm32"))]
145pub(crate) fn freshness_from_headers(headers: &reqwest::header::HeaderMap) -> Freshness {
146 let is_stale = headers
147 .get_all("x-cachekit-freshness")
148 .iter()
149 .any(|value| value != "fresh");
150 let mut fresh_for = headers.get_all("x-cachekit-fresh-for").iter();
151 let fresh_for = match (fresh_for.next(), fresh_for.next()) {
152 (None, _) => None,
153 (Some(value), None) => Some(parse_fresh_for(value.as_bytes())),
154 (Some(_), Some(_)) => Some(Duration::ZERO),
155 };
156 Freshness {
157 is_stale,
158 fresh_for,
159 }
160}
161
162#[cfg(not(target_arch = "wasm32"))]
167fn parse_fresh_for(value: &[u8]) -> Duration {
168 const MAX_FRESH_FOR_SECS: u64 = 2_592_000;
169 if !(1..=7).contains(&value.len()) || !value.iter().all(u8::is_ascii_digit) {
170 return Duration::ZERO;
171 }
172 match std::str::from_utf8(value).map(str::parse::<u64>) {
173 Ok(Ok(secs)) if secs <= MAX_FRESH_FOR_SECS => Duration::from_secs(secs),
174 _ => Duration::ZERO,
175 }
176}
177
178#[cfg(not(target_arch = "wasm32"))]
181#[cfg_attr(not(feature = "unsync"), async_trait)]
182#[cfg_attr(feature = "unsync", async_trait(?Send))]
183impl Backend for CachekitIO {
184 fn attach_metrics(&self, provider: MetricsProvider) {
185 self.metrics_provider.get_or_init(|| provider);
187 }
188
189 async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError> {
190 Ok(self.get_with_freshness(key).await?.map(|(bytes, _)| bytes))
191 }
192
193 async fn get_with_freshness(
194 &self,
195 key: &str,
196 ) -> Result<Option<(Vec<u8>, Freshness)>, BackendError> {
197 let req = self.with_standard_headers(
198 self.client
199 .get(self.url(key)?)
200 .bearer_auth(self.api_key.as_str()),
201 );
202
203 let resp = req
204 .send()
205 .await
206 .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
207
208 match resp.status().as_u16() {
209 200 => {
210 let freshness = freshness_from_headers(resp.headers());
211 let bytes = resp
212 .bytes()
213 .await
214 .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
215 Ok(Some((bytes.to_vec(), freshness)))
216 }
217 404 => Ok(None),
218 _ => Err(self.error_from_response(resp).await),
219 }
220 }
221
222 async fn set(
223 &self,
224 key: &str,
225 value: Vec<u8>,
226 ttl: Option<Duration>,
227 ) -> Result<(), BackendError> {
228 let mut req = self
229 .client
230 .put(self.url(key)?)
231 .bearer_auth(self.api_key.as_str())
232 .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
233 .body(value);
234
235 if let Some(ttl) = ttl {
236 req = req.header("X-TTL", ttl.as_secs().to_string());
237 }
238
239 let req = self.with_standard_headers(req);
240
241 let resp = req
242 .send()
243 .await
244 .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
245
246 let status = resp.status().as_u16();
247 if (200..300).contains(&status) {
248 Ok(())
249 } else {
250 Err(self.error_from_response(resp).await)
251 }
252 }
253
254 async fn delete(&self, key: &str) -> Result<bool, BackendError> {
255 let req = self.with_standard_headers(
256 self.client
257 .delete(self.url(key)?)
258 .bearer_auth(self.api_key.as_str()),
259 );
260
261 let resp = req
262 .send()
263 .await
264 .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
265
266 match resp.status().as_u16() {
267 200 | 204 => Ok(true),
268 404 => Ok(false),
269 _ => Err(self.error_from_response(resp).await),
270 }
271 }
272
273 async fn exists(&self, key: &str) -> Result<bool, BackendError> {
274 let req = self.with_standard_headers(
275 self.client
276 .head(self.url(key)?)
277 .bearer_auth(self.api_key.as_str()),
278 );
279
280 let resp = req
281 .send()
282 .await
283 .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
284
285 match resp.status().as_u16() {
286 200 => Ok(true),
287 404 => Ok(false),
288 status => Err(BackendError::from_http_status(status, &[])),
289 }
290 }
291
292 async fn health(&self) -> Result<HealthStatus, BackendError> {
293 let start = std::time::Instant::now();
294
295 let req = self.with_standard_headers(
296 self.client
297 .get(self.health_url())
298 .bearer_auth(self.api_key.as_str()),
299 );
300
301 let resp = req
302 .send()
303 .await
304 .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
305
306 let latency = start.elapsed();
307 let status = resp.status().as_u16();
308
309 if (200..300).contains(&status) {
310 let mut details = HashMap::new();
311 details.insert("http_status".to_string(), status.to_string());
312 Ok(HealthStatus {
313 is_healthy: true,
314 latency_ms: latency.as_secs_f64() * 1000.0,
315 backend_type: "cachekitio".to_string(),
316 details,
317 })
318 } else {
319 Err(self.error_from_response(resp).await)
320 }
321 }
322
323 fn as_lockable(&self) -> Option<&dyn LockableBackend> {
324 Some(self)
325 }
326}
327
328#[derive(Default)]
332#[must_use]
333pub struct CachekitIOBuilder {
334 api_key: Option<Zeroizing<String>>,
335 api_url: Option<String>,
336 allow_custom_host: bool,
337 metrics_provider: Option<MetricsProvider>,
338}
339
340impl CachekitIOBuilder {
341 pub fn api_key(mut self, key: impl Into<String>) -> Self {
343 self.api_key = Some(Zeroizing::new(key.into()));
344 self
345 }
346
347 pub fn api_url(mut self, url: impl Into<String>) -> Self {
349 self.api_url = Some(url.into());
350 self
351 }
352
353 pub fn allow_custom_host(mut self, allow: bool) -> Self {
355 self.allow_custom_host = allow;
356 self
357 }
358
359 pub fn metrics_provider(mut self, provider: MetricsProvider) -> Self {
365 self.metrics_provider = Some(provider);
366 self
367 }
368
369 pub fn build(self) -> Result<CachekitIO, crate::error::CachekitError> {
378 use crate::error::CachekitError;
379
380 let api_key = self
381 .api_key
382 .filter(|k| !k.is_empty())
383 .ok_or_else(|| CachekitError::Config("api_key is required".to_string()))?;
384
385 let api_url = self
386 .api_url
387 .unwrap_or_else(|| "https://api.cachekit.io".to_string());
388
389 validate_cachekitio_url(&api_url, self.allow_custom_host)?;
391
392 let api_url = api_url.trim_end_matches('/').to_string();
394
395 let client = reqwest::Client::builder();
396
397 #[cfg(not(target_arch = "wasm32"))]
398 let client = client
399 .use_rustls_tls()
400 .redirect(reqwest::redirect::Policy::none())
401 .timeout(Duration::from_secs(30))
402 .connect_timeout(Duration::from_secs(10));
403
404 let client = client
405 .build()
406 .map_err(|e| CachekitError::Config(format!("failed to build HTTP client: {e}")))?;
407
408 Ok(CachekitIO {
409 client,
410 api_key,
411 api_url,
412 metrics_provider: self
413 .metrics_provider
414 .map_or_else(OnceLock::new, OnceLock::from),
415 })
416 }
417}
418
419#[cfg(all(test, not(target_arch = "wasm32")))]
422#[allow(clippy::expect_used)] mod freshness_header_tests {
424 use std::time::Duration;
425
426 use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
427
428 use super::freshness_from_headers;
429 use crate::backend::Freshness;
430
431 fn headers(pairs: &[(&str, &[u8])]) -> HeaderMap {
434 let mut map = HeaderMap::new();
435 for (name, value) in pairs {
436 map.append(
437 HeaderName::from_bytes(name.as_bytes()).expect("valid header name"),
438 HeaderValue::from_bytes(value).expect("valid header value"),
439 );
440 }
441 map
442 }
443
444 fn label(values: &[&[u8]]) -> bool {
445 let pairs: Vec<_> = values
446 .iter()
447 .map(|v| ("X-CacheKit-Freshness", *v))
448 .collect();
449 freshness_from_headers(&headers(&pairs)).is_stale
450 }
451
452 fn fresh_for(values: &[&[u8]]) -> Option<Duration> {
453 let pairs: Vec<_> = values
454 .iter()
455 .map(|v| ("X-CacheKit-Fresh-For", *v))
456 .collect();
457 freshness_from_headers(&headers(&pairs)).fresh_for
458 }
459
460 #[test]
461 fn absent_headers_are_a_fresh_unbounded_read() {
462 assert_eq!(
463 freshness_from_headers(&HeaderMap::new()),
464 Freshness::default()
465 );
466 }
467
468 #[test]
469 fn only_an_exact_fresh_label_is_fresh() {
470 assert!(!label(&[b"fresh"]));
471 assert!(!label(&[b"fresh", b"fresh"]));
472 for stale in [&b"stale"[..], b"revalidating", b"Fresh", b"", b"fresh "] {
475 assert!(
476 label(&[stale]),
477 "{:?} must read as stale",
478 String::from_utf8_lossy(stale)
479 );
480 }
481 assert!(label(&[b"fresh", b"stale"]));
482 }
483
484 #[test]
485 fn fresh_for_in_grammar_parses() {
486 for (value, secs) in [
487 (&b"0"[..], 0),
488 (b"1", 1),
489 (b"0000005", 5),
490 (b"2592000", 2_592_000),
491 ] {
492 assert_eq!(fresh_for(&[value]), Some(Duration::from_secs(secs)));
493 }
494 }
495
496 #[test]
499 fn fresh_for_outside_grammar_is_zero() {
500 let invalid: [&[u8]; 11] = [
501 b"",
502 b"-1",
503 b"+5",
504 b" 5",
505 b"5s",
506 b"1.5",
507 "\u{0663}".as_bytes(), b"12345678",
509 b"2592001",
510 b"9999999",
511 b"4297559296",
512 ];
513 for value in invalid {
514 assert_eq!(
515 fresh_for(&[value]),
516 Some(Duration::ZERO),
517 "{:?} must count as 0",
518 String::from_utf8_lossy(value)
519 );
520 }
521 assert_eq!(
522 fresh_for(&[b"10", b"10"]),
523 Some(Duration::ZERO),
524 "a repeated bound counts as 0"
525 );
526 }
527}
528
529#[cfg(test)]
532#[allow(clippy::expect_used)] mod metrics_wiring_tests {
534 use std::sync::Arc;
535
536 use super::CachekitIO;
537 use crate::backend::Backend;
538 use crate::metrics::{metrics_headers, L1Stats, MetricsProvider};
539
540 fn provider(l1_hits: u64) -> MetricsProvider {
541 Arc::new(move || {
542 Some(L1Stats {
543 l1_hits,
544 l2_hits: 0,
545 misses: 0,
546 l1_enabled: true,
547 })
548 })
549 }
550
551 fn l1_hits_header(backend: &CachekitIO) -> Option<String> {
552 metrics_headers(backend.metrics_provider.get())
553 .into_iter()
554 .find(|h| h.0 == "X-CacheKit-L1-Hits")
555 .map(|h| h.1)
556 }
557
558 #[test]
559 fn attach_fills_an_unset_provider() {
560 let backend = CachekitIO::builder()
561 .api_key("ck_test_key")
562 .build()
563 .expect("builder succeeds");
564 assert_eq!(l1_hits_header(&backend), None, "nothing attached yet");
565
566 backend.attach_metrics(provider(7));
567 assert_eq!(l1_hits_header(&backend).as_deref(), Some("7"));
568 }
569
570 #[test]
571 fn builder_provider_is_retained_over_attach() {
572 let backend = CachekitIO::builder()
573 .api_key("ck_test_key")
574 .metrics_provider(provider(1))
575 .build()
576 .expect("builder succeeds");
577
578 backend.attach_metrics(provider(99));
579 assert_eq!(
580 l1_hits_header(&backend).as_deref(),
581 Some("1"),
582 "user-supplied provider wins"
583 );
584 }
585}
586
587#[cfg(test)]
590#[allow(clippy::expect_used)] mod path_encoding_tests {
592 use super::CachekitIO;
593 use crate::backend::path_encoding_vectors::{reject_keys, transmittable, RS_NEAR_MISSES};
594 use url::Url;
595
596 const API: &str = "https://api.cachekit.io";
597
598 fn backend() -> CachekitIO {
599 CachekitIO::builder()
600 .api_url(API)
601 .api_key("ck_test_key")
602 .build()
603 .expect("builder should succeed for the canonical host")
604 }
605
606 #[test]
612 fn repro_raw_dot_key_escapes_the_cache_prefix() {
613 let cases = [
614 ("..", "", "/v1/"),
615 ("..", "/ttl", "/v1/ttl"),
616 ("..", "/lock", "/v1/lock"),
617 (".", "", "/v1/cache/"),
618 ];
619 for (key, suffix, escaped) in cases {
620 let raw = format!("{API}/v1/cache/{}{suffix}", urlencoding::encode(key));
621 let parsed = Url::parse(&raw).expect("parses");
622 assert_eq!(
623 parsed.path(),
624 escaped,
625 "raw {key:?}{suffix} should collapse to {escaped}"
626 );
627 let pct = key.replace('.', "%2E");
629 let enc = format!("{API}/v1/cache/{pct}{suffix}");
630 assert_eq!(
631 Url::parse(&enc).expect("parses").path(),
632 escaped,
633 "%2E-encoded {key:?}{suffix} also collapses — encoding cannot fix this in rust-url"
634 );
635 }
636 }
637
638 #[test]
642 fn reserved_segments_rejected_by_every_builder() {
643 let b = backend();
644 let keys = reject_keys();
645 assert!(!keys.is_empty(), "fixture has no reject rows");
646 for key in &keys {
647 assert!(b.url(key).is_err(), "url({key:?}) must be rejected");
648 assert!(b.ttl_url(key).is_err(), "ttl_url({key:?}) must be rejected");
649 assert!(
650 b.lock_url(key).is_err(),
651 "lock_url({key:?}) must be rejected"
652 );
653 }
654 }
655
656 #[test]
662 fn safe_keys_never_escape_the_cache_prefix() {
663 let b = backend();
664 let fixture_keys: Vec<String> = transmittable().into_iter().map(|v| v.key).collect();
665 let vectors = fixture_keys
666 .iter()
667 .map(String::as_str)
668 .chain(RS_NEAR_MISSES.iter().copied());
669 for key in vectors {
670 let base = Url::parse(&b.url(key).expect("url")).expect("parse base");
671 let ttl = Url::parse(&b.ttl_url(key).expect("ttl_url")).expect("parse ttl");
672 let lock = Url::parse(&b.lock_url(key).expect("lock_url")).expect("parse lock");
673
674 assert!(
675 base.path().starts_with("/v1/cache/") && base.path().len() > "/v1/cache/".len(),
676 "base path {} escaped prefix for {key:?}",
677 base.path()
678 );
679 assert_eq!(
680 base.path(),
681 format!("/v1/cache/{}", urlencoding::encode(key)),
682 "base wire path mismatch for {key:?}"
683 );
684 assert!(
685 ttl.path().starts_with("/v1/cache/") && ttl.path().ends_with("/ttl"),
686 "ttl path {} escaped prefix for {key:?}",
687 ttl.path()
688 );
689 assert!(
690 lock.path().starts_with("/v1/cache/") && lock.path().ends_with("/lock"),
691 "lock path {} escaped prefix for {key:?}",
692 lock.path()
693 );
694 }
695 }
696}