cachekit/backend/
cachekitio_lock.rs1use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5
6use super::cachekitio::{reqwest_err_sanitized, CachekitIO};
7use super::LockableBackend;
8use crate::error::BackendError;
9
10const LOCK_ID_HEADER: &str = "X-CacheKit-Lock-Id";
15
16impl CachekitIO {
17 fn release_request(&self, key: &str, lock_id: &str) -> reqwest::RequestBuilder {
20 let url = format!(
21 "{}/v1/cache/{}/lock",
22 self.api_url(),
23 urlencoding::encode(key)
24 );
25 self.with_standard_headers(
26 self.client()
27 .delete(&url)
28 .bearer_auth(self.api_key_str())
29 .header(LOCK_ID_HEADER, lock_id),
30 )
31 }
32}
33
34#[derive(Serialize)]
37struct LockAcquireRequest {
38 timeout_ms: u64,
39}
40
41#[derive(Deserialize)]
42#[serde(deny_unknown_fields)]
43struct LockAcquireResponse {
44 lock_id: Option<String>,
45}
46
47#[cfg(not(target_arch = "wasm32"))]
48#[cfg_attr(not(feature = "unsync"), async_trait)]
49#[cfg_attr(feature = "unsync", async_trait(?Send))]
50impl LockableBackend for CachekitIO {
51 async fn acquire_lock(
52 &self,
53 key: &str,
54 timeout_ms: u64,
55 ) -> Result<Option<String>, BackendError> {
56 let url = format!(
57 "{}/v1/cache/{}/lock",
58 self.api_url(),
59 urlencoding::encode(key)
60 );
61
62 let body = serde_json::to_vec(&LockAcquireRequest { timeout_ms }).map_err(|e| {
63 BackendError::permanent(format!("failed to serialize lock request: {e}"))
64 })?;
65
66 let req = self.with_standard_headers(
67 self.client()
68 .post(&url)
69 .bearer_auth(self.api_key_str())
70 .header("Content-Type", "application/json")
71 .body(body),
72 );
73
74 let resp = req
75 .send()
76 .await
77 .map_err(|e| reqwest_err_sanitized(e, self.api_key_str()))?;
78
79 if !resp.status().is_success() {
80 return Err(self.error_from_response(resp).await);
81 }
82
83 let response: LockAcquireResponse = resp
84 .json()
85 .await
86 .map_err(|e| BackendError::transient(format!("failed to parse lock response: {e}")))?;
87
88 Ok(response.lock_id)
89 }
90
91 async fn release_lock(&self, key: &str, lock_id: &str) -> Result<bool, BackendError> {
92 let resp = self
95 .release_request(key, lock_id)
96 .send()
97 .await
98 .map_err(|e| reqwest_err_sanitized(e, self.api_key_str()))?;
99
100 match resp.status().as_u16() {
101 200 | 204 => Ok(true),
102 404 => Ok(false),
103 _ => Err(self.error_from_response(resp).await),
104 }
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 fn _assert_lockable(_b: &dyn LockableBackend) {}
114
115 #[test]
116 fn cachekitio_is_lockable() {
117 fn _check(backend: &CachekitIO) {
118 _assert_lockable(backend);
119 }
120 }
121
122 #[test]
123 #[allow(clippy::expect_used)] fn release_lock_sends_token_in_header_not_url() {
125 let backend = CachekitIO::builder()
126 .api_url("https://api.cachekit.io")
127 .api_key("ck_test_key")
128 .build()
129 .expect("builder should succeed for the canonical host");
130
131 let req = backend
132 .release_request("my-key", "lock-secret-123")
133 .build()
134 .expect("request should build");
135
136 let url = req.url();
138 assert!(url.query().is_none(), "unexpected query string: {url}");
139 assert!(
140 !url.as_str().contains("lock_id"),
141 "lock_id leaked into URL: {url}"
142 );
143 assert!(
144 !url.as_str().contains("lock-secret-123"),
145 "token leaked into URL: {url}"
146 );
147
148 let header = req
150 .headers()
151 .get("X-CacheKit-Lock-Id")
152 .expect("X-CacheKit-Lock-Id header must be set");
153 assert_eq!(header, "lock-secret-123");
154 }
155
156 #[test]
161 #[allow(clippy::expect_used)]
162 fn acquire_request_serializes_snake_case() {
163 let body = serde_json::to_string(&LockAcquireRequest { timeout_ms: 5000 })
164 .expect("request must serialize");
165 assert_eq!(body, r#"{"timeout_ms":5000}"#);
166 }
167
168 #[test]
169 #[allow(clippy::expect_used)]
170 fn acquire_response_parses_acquired_lock() {
171 let resp: LockAcquireResponse =
172 serde_json::from_str(r#"{"lock_id":"a1b2c3d4-0000-0000-0000-000000000000"}"#)
173 .expect("acquired response must parse");
174 assert_eq!(
175 resp.lock_id.as_deref(),
176 Some("a1b2c3d4-0000-0000-0000-000000000000")
177 );
178 }
179
180 #[test]
181 #[allow(clippy::expect_used)]
182 fn acquire_response_parses_contested_lock() {
183 let resp: LockAcquireResponse =
185 serde_json::from_str(r#"{"lock_id":null}"#).expect("contested response must parse");
186 assert_eq!(resp.lock_id, None);
187 }
188}