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