Skip to main content

cachekit/backend/
cachekitio_ttl.rs

1//! [`TtlInspectable`] implementation for the cachekit.io HTTP backend.
2
3use std::time::Duration;
4
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7
8use super::cachekitio::{reqwest_err_sanitized, CachekitIO};
9use super::{ttl_wire_secs, TtlInspectable};
10use crate::error::BackendError;
11
12#[derive(Deserialize)]
13#[serde(deny_unknown_fields)]
14struct TtlResponse {
15    ttl: Option<u64>,
16}
17
18#[derive(Serialize)]
19struct RefreshTtlRequest {
20    ttl: u64,
21}
22
23#[cfg(not(target_arch = "wasm32"))]
24#[cfg_attr(not(feature = "unsync"), async_trait)]
25#[cfg_attr(feature = "unsync", async_trait(?Send))]
26impl TtlInspectable for CachekitIO {
27    async fn ttl(&self, key: &str) -> Result<Option<Duration>, BackendError> {
28        let url = self.ttl_url(key)?;
29
30        let req = self.with_standard_headers(self.client().get(&url));
31
32        let resp = req
33            .send()
34            .await
35            .map_err(|e| reqwest_err_sanitized(e, self.api_key_str()))?;
36
37        match resp.status().as_u16() {
38            200 => {
39                let body: TtlResponse = resp.json().await.map_err(|e| {
40                    BackendError::transient(format!("failed to parse TTL response: {e}"))
41                })?;
42                Ok(body.ttl.map(Duration::from_secs))
43            }
44            404 => Ok(None),
45            _ => Err(self.error_from_response(resp).await),
46        }
47    }
48
49    async fn refresh_ttl(&self, key: &str, ttl: Duration) -> Result<bool, BackendError> {
50        // PATCH /ttl takes the X-CacheKit-TTL rules (spec/saas-api.md).
51        let secs = ttl_wire_secs(ttl);
52        if secs == 0 {
53            return Err(BackendError::permanent(
54                "refresh_ttl requires a non-zero TTL".to_string(),
55            ));
56        }
57
58        let url = self.ttl_url(key)?;
59
60        let body = serde_json::to_vec(&RefreshTtlRequest { ttl: secs }).map_err(|e| {
61            BackendError::permanent(format!("failed to serialize refresh_ttl request: {e}"))
62        })?;
63
64        let req = self.with_standard_headers(
65            self.client()
66                .patch(&url)
67                .header("Content-Type", "application/json")
68                .body(body),
69        );
70
71        let resp = req
72            .send()
73            .await
74            .map_err(|e| reqwest_err_sanitized(e, self.api_key_str()))?;
75
76        match resp.status().as_u16() {
77            200 | 204 => Ok(true),
78            404 => Ok(false),
79            _ => Err(self.error_from_response(resp).await),
80        }
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    /// Compile-time proof that CachekitIO implements TtlInspectable.
89    fn _assert_ttl_inspectable(_b: &dyn TtlInspectable) {}
90
91    #[test]
92    fn cachekitio_is_ttl_inspectable() {
93        fn _check(backend: &CachekitIO) {
94            _assert_ttl_inspectable(backend);
95        }
96    }
97
98    /// The server answers `{"ttl": null}` for a live key with no expiry.
99    /// That must decode to `None`, never a parse error.
100    #[test]
101    fn ttl_response_decodes_null_ttl_as_none() {
102        let decode = |json: &str| serde_json::from_str::<TtlResponse>(json).map(|r| r.ttl);
103
104        assert!(matches!(decode(r#"{"ttl":null}"#), Ok(None)));
105        assert!(matches!(decode(r#"{"ttl":42}"#), Ok(Some(42))));
106        assert!(decode(r#"{"ttl":null,"extra":1}"#).is_err());
107    }
108}