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)]
35#[serde(rename_all = "camelCase")]
36struct LockAcquireRequest {
37 timeout_ms: u64,
38}
39
40#[derive(Deserialize)]
41#[serde(rename_all = "camelCase", deny_unknown_fields)]
42struct LockAcquireResponse {
43 lock_id: Option<String>,
44}
45
46#[cfg(not(target_arch = "wasm32"))]
47#[cfg_attr(not(feature = "unsync"), async_trait)]
48#[cfg_attr(feature = "unsync", async_trait(?Send))]
49impl LockableBackend for CachekitIO {
50 async fn acquire_lock(
51 &self,
52 key: &str,
53 timeout_ms: u64,
54 ) -> Result<Option<String>, BackendError> {
55 let url = format!(
56 "{}/v1/cache/{}/lock",
57 self.api_url(),
58 urlencoding::encode(key)
59 );
60
61 let body = serde_json::to_vec(&LockAcquireRequest { timeout_ms }).map_err(|e| {
62 BackendError::permanent(format!("failed to serialize lock request: {e}"))
63 })?;
64
65 let req = self.with_standard_headers(
66 self.client()
67 .post(&url)
68 .bearer_auth(self.api_key_str())
69 .header("Content-Type", "application/json")
70 .body(body),
71 );
72
73 let resp = req
74 .send()
75 .await
76 .map_err(|e| reqwest_err_sanitized(e, self.api_key_str()))?;
77
78 if !resp.status().is_success() {
79 return Err(self.error_from_response(resp).await);
80 }
81
82 let response: LockAcquireResponse = resp
83 .json()
84 .await
85 .map_err(|e| BackendError::transient(format!("failed to parse lock response: {e}")))?;
86
87 Ok(response.lock_id)
88 }
89
90 async fn release_lock(&self, key: &str, lock_id: &str) -> Result<bool, BackendError> {
91 let resp = self
94 .release_request(key, lock_id)
95 .send()
96 .await
97 .map_err(|e| reqwest_err_sanitized(e, self.api_key_str()))?;
98
99 match resp.status().as_u16() {
100 200 | 204 => Ok(true),
101 404 => Ok(false),
102 _ => Err(self.error_from_response(resp).await),
103 }
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 fn _assert_lockable(_b: &dyn LockableBackend) {}
113
114 #[test]
115 fn cachekitio_is_lockable() {
116 fn _check(backend: &CachekitIO) {
117 _assert_lockable(backend);
118 }
119 }
120
121 #[test]
122 #[allow(clippy::expect_used)] fn release_lock_sends_token_in_header_not_url() {
124 let backend = CachekitIO::builder()
125 .api_url("https://api.cachekit.io")
126 .api_key("ck_test_key")
127 .build()
128 .expect("builder should succeed for the canonical host");
129
130 let req = backend
131 .release_request("my-key", "lock-secret-123")
132 .build()
133 .expect("request should build");
134
135 let url = req.url();
137 assert!(url.query().is_none(), "unexpected query string: {url}");
138 assert!(
139 !url.as_str().contains("lock_id"),
140 "lock_id leaked into URL: {url}"
141 );
142 assert!(
143 !url.as_str().contains("lock-secret-123"),
144 "token leaked into URL: {url}"
145 );
146
147 let header = req
149 .headers()
150 .get("X-CacheKit-Lock-Id")
151 .expect("X-CacheKit-Lock-Id header must be set");
152 assert_eq!(header, "lock-secret-123");
153 }
154}