use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use super::cachekitio::{reqwest_err_sanitized, CachekitIO};
use super::LockableBackend;
use crate::error::BackendError;
const LOCK_ID_HEADER: &str = "X-CacheKit-Lock-Id";
impl CachekitIO {
fn release_request(&self, key: &str, lock_id: &str) -> reqwest::RequestBuilder {
let url = format!(
"{}/v1/cache/{}/lock",
self.api_url(),
urlencoding::encode(key)
);
self.with_standard_headers(
self.client()
.delete(&url)
.bearer_auth(self.api_key_str())
.header(LOCK_ID_HEADER, lock_id),
)
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct LockAcquireRequest {
timeout_ms: u64,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct LockAcquireResponse {
lock_id: Option<String>,
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg_attr(not(feature = "unsync"), async_trait)]
#[cfg_attr(feature = "unsync", async_trait(?Send))]
impl LockableBackend for CachekitIO {
async fn acquire_lock(
&self,
key: &str,
timeout_ms: u64,
) -> Result<Option<String>, BackendError> {
let url = format!(
"{}/v1/cache/{}/lock",
self.api_url(),
urlencoding::encode(key)
);
let body = serde_json::to_vec(&LockAcquireRequest { timeout_ms }).map_err(|e| {
BackendError::permanent(format!("failed to serialize lock request: {e}"))
})?;
let req = self.with_standard_headers(
self.client()
.post(&url)
.bearer_auth(self.api_key_str())
.header("Content-Type", "application/json")
.body(body),
);
let resp = req
.send()
.await
.map_err(|e| reqwest_err_sanitized(e, self.api_key_str()))?;
if !resp.status().is_success() {
return Err(self.error_from_response(resp).await);
}
let response: LockAcquireResponse = resp
.json()
.await
.map_err(|e| BackendError::transient(format!("failed to parse lock response: {e}")))?;
Ok(response.lock_id)
}
async fn release_lock(&self, key: &str, lock_id: &str) -> Result<bool, BackendError> {
let resp = self
.release_request(key, lock_id)
.send()
.await
.map_err(|e| reqwest_err_sanitized(e, self.api_key_str()))?;
match resp.status().as_u16() {
200 | 204 => Ok(true),
404 => Ok(false),
_ => Err(self.error_from_response(resp).await),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn _assert_lockable(_b: &dyn LockableBackend) {}
#[test]
fn cachekitio_is_lockable() {
fn _check(backend: &CachekitIO) {
_assert_lockable(backend);
}
}
#[test]
#[allow(clippy::expect_used)] fn release_lock_sends_token_in_header_not_url() {
let backend = CachekitIO::builder()
.api_url("https://api.cachekit.io")
.api_key("ck_test_key")
.build()
.expect("builder should succeed for the canonical host");
let req = backend
.release_request("my-key", "lock-secret-123")
.build()
.expect("request should build");
let url = req.url();
assert!(url.query().is_none(), "unexpected query string: {url}");
assert!(
!url.as_str().contains("lock_id"),
"lock_id leaked into URL: {url}"
);
assert!(
!url.as_str().contains("lock-secret-123"),
"token leaked into URL: {url}"
);
let header = req
.headers()
.get("X-CacheKit-Lock-Id")
.expect("X-CacheKit-Lock-Id header must be set");
assert_eq!(header, "lock-secret-123");
}
}