Skip to main content

apify_client/clients/
key_value_store.rs

1//! Client for a single key-value store (`/v2/key-value-stores/{storeId}` and variants).
2
3use serde::Serialize;
4
5use crate::clients::base::{
6    delete_resource, get_raw, get_resource, get_resource_required, head_exists, put_raw,
7    update_resource, ResourceContext,
8};
9use crate::common::{
10    create_hmac_signature, encode_path_segment, sign_storage_content, QueryParams,
11};
12use crate::error::ApifyClientResult;
13use crate::http_client::{HttpClient, HttpMethod, HttpRequest};
14use crate::models::{KeyValueStore, KeyValueStoreKeysPage, KeyValueStoreRecord};
15
16/// Options for listing keys in a key-value store.
17#[derive(Debug, Default, Clone)]
18pub struct ListKeysOptions {
19    /// Maximum number of keys to return.
20    pub limit: Option<i64>,
21    /// Start listing after this key (exclusive), for pagination.
22    pub exclusive_start_key: Option<String>,
23    /// Only return keys with this prefix.
24    pub prefix: Option<String>,
25    /// Only return keys belonging to this collection.
26    pub collection: Option<String>,
27    /// URL-signing signature granting access to a private store's key listing.
28    pub signature: Option<String>,
29}
30
31/// Options for downloading all records as a ZIP archive via
32/// [`KeyValueStoreClient::get_records`].
33///
34/// Covers the spec query parameters of `GET /v2/key-value-stores/{storeId}/records`.
35#[derive(Debug, Default, Clone)]
36pub struct GetRecordsOptions {
37    /// Only include records belonging to this collection from the store schema.
38    pub collection: Option<String>,
39    /// Only include records whose key starts with this prefix.
40    pub prefix: Option<String>,
41    /// URL-signing signature granting access to a private store's records.
42    pub signature: Option<String>,
43}
44
45/// Options for reading a single record via [`KeyValueStoreClient::get_record_with_options`].
46///
47/// Covers the spec query parameters of
48/// `GET /v2/key-value-stores/{storeId}/records/{recordKey}`.
49#[derive(Debug, Default, Clone)]
50pub struct GetRecordOptions {
51    /// Request the record with a `Content-Disposition: attachment` response header. When unset,
52    /// the client sends `attachment=true`, matching the reference client's unconditional default.
53    pub attachment: Option<bool>,
54    /// URL-signing signature granting access to a record in a private store.
55    pub signature: Option<String>,
56}
57
58/// Client for a specific key-value store.
59#[derive(Debug, Clone)]
60pub struct KeyValueStoreClient {
61    ctx: ResourceContext,
62}
63
64impl KeyValueStoreClient {
65    pub(crate) fn new(http: HttpClient, base_url: &str, resource_path: &str, id: &str) -> Self {
66        Self {
67            ctx: ResourceContext::single(http, base_url, resource_path, id),
68        }
69    }
70
71    /// Creates a KVS client for a run's default store (nested path, no ID).
72    pub(crate) fn nested(http: HttpClient, base_url: &str, sub_path: &str) -> Self {
73        Self {
74            ctx: ResourceContext::collection(http, base_url, sub_path),
75        }
76    }
77
78    /// Sets the public origin used when building shareable URLs.
79    pub(crate) fn with_public_base(mut self, public_base_url: &str) -> Self {
80        self.ctx = self.ctx.with_public_origin(public_base_url);
81        self
82    }
83
84    /// Fetches the store metadata, or `None` if it does not exist.
85    pub async fn get(&self) -> ApifyClientResult<Option<KeyValueStore>> {
86        get_resource(&self.ctx, None, &QueryParams::new()).await
87    }
88
89    /// Updates the store metadata (e.g. `name`, `title`).
90    pub async fn update<T: Serialize>(&self, new_fields: &T) -> ApifyClientResult<KeyValueStore> {
91        update_resource(&self.ctx, None, new_fields).await
92    }
93
94    /// Deletes the store.
95    pub async fn delete(&self) -> ApifyClientResult<()> {
96        delete_resource(&self.ctx, None).await
97    }
98
99    /// Lists the keys in the store (key-based pagination).
100    pub async fn list_keys(
101        &self,
102        options: ListKeysOptions,
103    ) -> ApifyClientResult<KeyValueStoreKeysPage> {
104        let mut params = QueryParams::new();
105        params
106            .add_int("limit", options.limit)
107            .add_str("exclusiveStartKey", options.exclusive_start_key)
108            .add_str("prefix", options.prefix)
109            .add_str("collection", options.collection)
110            .add_str("signature", options.signature);
111        get_resource_required(&self.ctx, Some("keys"), &params).await
112    }
113
114    /// Downloads all records from the store as a ZIP archive (raw bytes).
115    ///
116    /// Each record is stored as a separate file in the archive, with the filename equal to the
117    /// record key. Use [`GetRecordsOptions`] to filter by `collection` or `prefix`, or to pass a
118    /// URL-signing `signature` for a private store. Wraps
119    /// `GET /v2/key-value-stores/{storeId}/records`.
120    pub async fn get_records(&self, options: GetRecordsOptions) -> ApifyClientResult<Vec<u8>> {
121        let mut params = QueryParams::new();
122        params
123            .add_str("collection", options.collection)
124            .add_str("prefix", options.prefix)
125            .add_str("signature", options.signature);
126        let url = self
127            .ctx
128            .merged_params(&params)
129            .apply_to_url(&self.ctx.url(Some("records")));
130        let response = self
131            .ctx
132            .http
133            .call(HttpRequest {
134                method: HttpMethod::Get,
135                url,
136                headers: Default::default(),
137                body: None,
138                timeout: crate::clients::base::DEFAULT_REQUEST_TIMEOUT,
139            })
140            .await?;
141        Ok(response.body)
142    }
143
144    /// Returns `true` if a record with the given key exists.
145    pub async fn record_exists(&self, key: &str) -> ApifyClientResult<bool> {
146        head_exists(
147            &self.ctx,
148            Some(&format!("records/{}", encode_path_segment(key))),
149            &QueryParams::new(),
150        )
151        .await
152    }
153
154    /// Gets a record's raw value (and content type), or `None` if it does not exist.
155    ///
156    /// Like the reference client's `getRecord`, this sends `attachment=true`. Use
157    /// [`get_record_with_options`](Self::get_record_with_options) to override the attachment
158    /// behaviour or to pass a URL-signing `signature` for a private store.
159    pub async fn get_record(&self, key: &str) -> ApifyClientResult<Option<KeyValueStoreRecord>> {
160        self.get_record_with_options(key, GetRecordOptions::default())
161            .await
162    }
163
164    /// Gets a record with explicit options.
165    ///
166    /// The `attachment` option controls the `Content-Disposition: attachment` response header;
167    /// when unset it defaults to `true`, matching the reference client's unconditional behaviour.
168    /// `signature` supplies a URL-signing signature for accessing a record in a private store.
169    pub async fn get_record_with_options(
170        &self,
171        key: &str,
172        options: GetRecordOptions,
173    ) -> ApifyClientResult<Option<KeyValueStoreRecord>> {
174        let mut params = QueryParams::new();
175        // Default to `attachment=true` (reference parity); honour an explicit override.
176        params
177            .add_bool("attachment", Some(options.attachment.unwrap_or(true)))
178            .add_str("signature", options.signature);
179        let response = get_raw(
180            &self.ctx,
181            Some(&format!("records/{}", encode_path_segment(key))),
182            &params,
183        )
184        .await?;
185        Ok(response.map(|r| {
186            let content_type = r.header("content-type").map(|s| s.to_string());
187            KeyValueStoreRecord {
188                key: key.to_string(),
189                value: r.body,
190                content_type,
191            }
192        }))
193    }
194
195    /// Stores a record with raw bytes and an explicit content type.
196    pub async fn set_record_raw(
197        &self,
198        key: &str,
199        value: Vec<u8>,
200        content_type: &str,
201    ) -> ApifyClientResult<()> {
202        put_raw(
203            &self.ctx,
204            Some(&format!("records/{}", encode_path_segment(key))),
205            &QueryParams::new(),
206            value,
207            content_type,
208        )
209        .await
210    }
211
212    /// Stores a record as JSON (the value is serialized and content type set to JSON).
213    pub async fn set_record_json<T: Serialize>(
214        &self,
215        key: &str,
216        value: &T,
217    ) -> ApifyClientResult<()> {
218        let bytes = serde_json::to_vec(value)?;
219        self.set_record_raw(key, bytes, "application/json; charset=utf-8")
220            .await
221    }
222
223    /// Builds a public URL for reading the record with the given key.
224    ///
225    /// Mirrors the reference client's `getRecordPublicUrl`: it fetches the store, and if the
226    /// store exposes a URL-signing secret key (private store), appends an HMAC-SHA256
227    /// `signature` over the record key so the URL works without an API token. The URL is
228    /// built from the configured public base URL.
229    pub async fn get_record_public_url(&self, key: &str) -> ApifyClientResult<String> {
230        let mut params = QueryParams::new();
231        if let Some(store) = self.get().await? {
232            if let Some(secret) = store
233                .extra
234                .get("urlSigningSecretKey")
235                .and_then(|v| v.as_str())
236            {
237                params.add_str("signature", Some(create_hmac_signature(secret, key)));
238            }
239        }
240        Ok(params.apply_to_url(
241            &self
242                .ctx
243                .public_url(Some(&format!("records/{}", encode_path_segment(key)))),
244        ))
245    }
246
247    /// Builds a public URL for listing this store's keys.
248    ///
249    /// Like [`get_record_public_url`](Self::get_record_public_url), signs the URL with an
250    /// HMAC-SHA256 `signature` for private stores. `expires_in_secs` optionally bounds a
251    /// signed URL's validity.
252    pub async fn create_keys_public_url(
253        &self,
254        expires_in_secs: Option<i64>,
255    ) -> ApifyClientResult<String> {
256        let mut params = QueryParams::new();
257        if let Some(store) = self.get().await? {
258            if let Some(secret) = store
259                .extra
260                .get("urlSigningSecretKey")
261                .and_then(|v| v.as_str())
262            {
263                let signature = sign_storage_content(secret, &store.id, expires_in_secs);
264                params.add_str("signature", Some(signature));
265            }
266        }
267        Ok(params.apply_to_url(&self.ctx.public_url(Some("keys"))))
268    }
269
270    /// Deletes the record with the given key.
271    pub async fn delete_record(&self, key: &str) -> ApifyClientResult<()> {
272        let url = self
273            .ctx
274            .url(Some(&format!("records/{}", encode_path_segment(key))));
275        self.ctx
276            .http
277            .call(HttpRequest {
278                method: HttpMethod::Delete,
279                url,
280                headers: Default::default(),
281                body: None,
282                timeout: crate::clients::base::DEFAULT_REQUEST_TIMEOUT,
283            })
284            .await?;
285        Ok(())
286    }
287}