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 std::collections::VecDeque;
4
5use serde::Serialize;
6
7use crate::clients::base::{
8    delete_resource, get_raw, get_resource, get_resource_required, head_exists, put_raw,
9    put_raw_with_content_encoding, update_resource, ResourceContext,
10};
11use crate::common::{
12    create_hmac_signature, encode_path_segment, sign_storage_content, QueryParams,
13};
14use crate::error::ApifyClientResult;
15use crate::http_client::{HttpClient, HttpMethod, HttpRequest};
16use crate::models::{KeyValueStore, KeyValueStoreKey, KeyValueStoreKeysPage, KeyValueStoreRecord};
17
18/// Options for listing keys in a key-value store.
19#[derive(Debug, Default, Clone)]
20pub struct ListKeysOptions {
21    /// Key limit. Its meaning depends on the method: for [`KeyValueStoreClient::list_keys`] it is a
22    /// single page's size (max keys one call returns, capped at 1000 by the API); for
23    /// [`KeyValueStoreClient::iterate_keys`] it is a cap on the *total* number of keys yielded
24    /// across all pages (unset/`0` iterates the whole store).
25    pub limit: Option<i64>,
26    /// Start listing after this key (exclusive), for pagination.
27    pub exclusive_start_key: Option<String>,
28    /// Only return keys with this prefix.
29    pub prefix: Option<String>,
30    /// Only return keys belonging to this collection.
31    pub collection: Option<String>,
32    /// URL-signing signature granting access to a private store's key listing.
33    pub signature: Option<String>,
34}
35
36/// Options for reading a single record via [`KeyValueStoreClient::get_record_with_options`].
37///
38/// Covers the spec query parameters of
39/// `GET /v2/key-value-stores/{storeId}/records/{recordKey}`.
40#[derive(Debug, Default, Clone)]
41pub struct GetRecordOptions {
42    /// Request the record with a `Content-Disposition: attachment` response header. When unset,
43    /// the client sends `attachment=true`, matching the reference client's unconditional default.
44    pub attachment: Option<bool>,
45    /// URL-signing signature granting access to a record in a private store.
46    pub signature: Option<String>,
47}
48
49/// Client for a specific key-value store.
50#[derive(Debug, Clone)]
51pub struct KeyValueStoreClient {
52    ctx: ResourceContext,
53}
54
55impl KeyValueStoreClient {
56    pub(crate) fn new(http: HttpClient, base_url: &str, resource_path: &str, id: &str) -> Self {
57        Self {
58            ctx: ResourceContext::single(http, base_url, resource_path, id),
59        }
60    }
61
62    /// Creates a KVS client for a run's default store (nested path, no ID).
63    pub(crate) fn nested(http: HttpClient, base_url: &str, sub_path: &str) -> Self {
64        Self {
65            ctx: ResourceContext::collection(http, base_url, sub_path),
66        }
67    }
68
69    /// Sets the public origin used when building shareable URLs.
70    pub(crate) fn with_public_base(mut self, public_base_url: &str) -> Self {
71        self.ctx = self.ctx.with_public_origin(public_base_url);
72        self
73    }
74
75    /// Fetches the store metadata, or `None` if it does not exist.
76    pub async fn get(&self) -> ApifyClientResult<Option<KeyValueStore>> {
77        get_resource(&self.ctx, None, &QueryParams::new()).await
78    }
79
80    /// Updates the store metadata (e.g. `name`, `title`).
81    pub async fn update<T: Serialize>(&self, new_fields: &T) -> ApifyClientResult<KeyValueStore> {
82        update_resource(&self.ctx, None, new_fields).await
83    }
84
85    /// Deletes the store.
86    pub async fn delete(&self) -> ApifyClientResult<()> {
87        delete_resource(&self.ctx, None).await
88    }
89
90    /// Lists the keys in the store (key-based pagination).
91    pub async fn list_keys(
92        &self,
93        options: ListKeysOptions,
94    ) -> ApifyClientResult<KeyValueStoreKeysPage> {
95        let mut params = QueryParams::new();
96        params
97            .add_int("limit", options.limit)
98            .add_str("exclusiveStartKey", options.exclusive_start_key)
99            .add_str("prefix", options.prefix)
100            .add_str("collection", options.collection)
101            .add_str("signature", options.signature);
102        get_resource_required(&self.ctx, Some("keys"), &params).await
103    }
104
105    /// Lazily iterates over every key in the store, fetching pages on demand.
106    ///
107    /// Returns a [`KeyValueStoreKeysIterator`]; call its `next()` to get one key at a time,
108    /// transparently fetching the following page once the local buffer drains, until the store
109    /// is exhausted. This is the auto-paginating counterpart to the single-page
110    /// [`list_keys`](Self::list_keys), matching the reference client's `listKeys()`
111    /// `AsyncIterable`.
112    ///
113    /// Key-value stores use cursor-based (not offset) pagination: each page is anchored by the
114    /// previous page's `nextExclusiveStartKey`, so the iterator threads that cursor through
115    /// automatically. The `prefix`, `collection` and `signature` filters from `options` are
116    /// carried into every page.
117    ///
118    /// `options.limit` caps the *total* number of keys yielded across all pages; leaving it unset
119    /// (or `0`) iterates the entire store, matching the reference client. It is honoured across as
120    /// many pages as needed — each individual request is bounded to the endpoint's maximum page
121    /// size ([`KEY_LIST_MAX_LIMIT`]), so a cap larger than one page still works.
122    /// `options.exclusive_start_key`, when set, resumes iteration after that key.
123    pub fn iterate_keys(&self, options: ListKeysOptions) -> KeyValueStoreKeysIterator {
124        let remaining = options.limit.filter(|&l| l > 0);
125        KeyValueStoreKeysIterator {
126            client: self.clone(),
127            options,
128            remaining,
129            next_exclusive_start_key: None,
130            buffer: VecDeque::new(),
131            first_page: true,
132            exhausted: false,
133        }
134    }
135
136    /// Returns `true` if a record with the given key exists.
137    pub async fn record_exists(&self, key: &str) -> ApifyClientResult<bool> {
138        head_exists(
139            &self.ctx,
140            Some(&format!("records/{}", encode_path_segment(key))),
141            &QueryParams::new(),
142        )
143        .await
144    }
145
146    /// Gets a record's raw value (and content type), or `None` if it does not exist.
147    ///
148    /// Like the reference client's `getRecord`, this sends `attachment=true`. Use
149    /// [`get_record_with_options`](Self::get_record_with_options) to override the attachment
150    /// behaviour or to pass a URL-signing `signature` for a private store.
151    pub async fn get_record(&self, key: &str) -> ApifyClientResult<Option<KeyValueStoreRecord>> {
152        self.get_record_with_options(key, GetRecordOptions::default())
153            .await
154    }
155
156    /// Gets a record with explicit options.
157    ///
158    /// The `attachment` option controls the `Content-Disposition: attachment` response header;
159    /// when unset it defaults to `true`, matching the reference client's unconditional behaviour.
160    /// `signature` supplies a URL-signing signature for accessing a record in a private store.
161    pub async fn get_record_with_options(
162        &self,
163        key: &str,
164        options: GetRecordOptions,
165    ) -> ApifyClientResult<Option<KeyValueStoreRecord>> {
166        let mut params = QueryParams::new();
167        // Default to `attachment=true` (reference parity); honour an explicit override.
168        params
169            .add_bool("attachment", Some(options.attachment.unwrap_or(true)))
170            .add_str("signature", options.signature);
171        let response = get_raw(
172            &self.ctx,
173            Some(&format!("records/{}", encode_path_segment(key))),
174            &params,
175        )
176        .await?;
177        Ok(response.map(|r| {
178            let content_type = r.header("content-type").map(|s| s.to_string());
179            KeyValueStoreRecord {
180                key: key.to_string(),
181                value: r.body,
182                content_type,
183            }
184        }))
185    }
186
187    /// Stores a record with raw bytes and an explicit content type.
188    pub async fn set_record_raw(
189        &self,
190        key: &str,
191        value: Vec<u8>,
192        content_type: &str,
193    ) -> ApifyClientResult<()> {
194        put_raw(
195            &self.ctx,
196            Some(&format!("records/{}", encode_path_segment(key))),
197            &QueryParams::new(),
198            value,
199            content_type,
200        )
201        .await
202    }
203
204    /// Stores a record with raw, already-compressed bytes, sending `content_encoding` as the
205    /// `Content-Encoding` header so it is uploaded (and later served) exactly as given, without
206    /// the client compressing it again.
207    ///
208    /// The API accepts `gzip`, `br`, `deflate`, and `identity`. Unlike the reference client's
209    /// `setRecord({ contentEncoding })`, there is no separate runtime check rejecting a
210    /// non-identity encoding on a string or JSON value: this method, like
211    /// [`set_record_raw`](Self::set_record_raw), only ever accepts bytes that are already the
212    /// wire payload, so that case cannot arise here — pass pre-serialized/pre-compressed bytes,
213    /// not a value for the client to serialize. For plain (uncompressed) raw bytes, use
214    /// [`set_record_raw`](Self::set_record_raw); for a JSON value, use
215    /// [`set_record_json`](Self::set_record_json).
216    pub async fn set_record_raw_with_content_encoding(
217        &self,
218        key: &str,
219        value: Vec<u8>,
220        content_type: &str,
221        content_encoding: &str,
222    ) -> ApifyClientResult<()> {
223        put_raw_with_content_encoding(
224            &self.ctx,
225            Some(&format!("records/{}", encode_path_segment(key))),
226            &QueryParams::new(),
227            value,
228            content_type,
229            Some(content_encoding),
230        )
231        .await
232    }
233
234    /// Stores a record as JSON (the value is serialized and content type set to JSON).
235    pub async fn set_record_json<T: Serialize>(
236        &self,
237        key: &str,
238        value: &T,
239    ) -> ApifyClientResult<()> {
240        let bytes = serde_json::to_vec(value)?;
241        self.set_record_raw(key, bytes, "application/json; charset=utf-8")
242            .await
243    }
244
245    /// Builds a public URL for reading the record with the given key.
246    ///
247    /// Mirrors the reference client's `getRecordPublicUrl`: it fetches the store, and if the
248    /// store exposes a URL-signing secret key (private store), appends an HMAC-SHA256
249    /// `signature` over the record key so the URL works without an API token. The URL is
250    /// built from the configured public base URL.
251    pub async fn get_record_public_url(&self, key: &str) -> ApifyClientResult<String> {
252        let mut params = QueryParams::new();
253        if let Some(store) = self.get().await? {
254            if let Some(secret) = store
255                .extra
256                .get("urlSigningSecretKey")
257                .and_then(|v| v.as_str())
258            {
259                params.add_str("signature", Some(create_hmac_signature(secret, key)));
260            }
261        }
262        Ok(params.apply_to_url(
263            &self
264                .ctx
265                .public_url(Some(&format!("records/{}", encode_path_segment(key)))),
266        ))
267    }
268
269    /// Builds a public URL for listing this store's keys.
270    ///
271    /// Like [`get_record_public_url`](Self::get_record_public_url), signs the URL with an
272    /// HMAC-SHA256 `signature` for private stores. `expires_in_secs` optionally bounds a
273    /// signed URL's validity.
274    pub async fn create_keys_public_url(
275        &self,
276        expires_in_secs: Option<i64>,
277    ) -> ApifyClientResult<String> {
278        let mut params = QueryParams::new();
279        if let Some(store) = self.get().await? {
280            if let Some(secret) = store
281                .extra
282                .get("urlSigningSecretKey")
283                .and_then(|v| v.as_str())
284            {
285                let signature = sign_storage_content(secret, &store.id, expires_in_secs);
286                params.add_str("signature", Some(signature));
287            }
288        }
289        Ok(params.apply_to_url(&self.ctx.public_url(Some("keys"))))
290    }
291
292    /// Deletes the record with the given key.
293    pub async fn delete_record(&self, key: &str) -> ApifyClientResult<()> {
294        let url = self
295            .ctx
296            .url(Some(&format!("records/{}", encode_path_segment(key))));
297        self.ctx
298            .http
299            .call(HttpRequest {
300                method: HttpMethod::Delete,
301                url,
302                headers: Default::default(),
303                body: None,
304                timeout: crate::clients::base::DEFAULT_REQUEST_TIMEOUT,
305            })
306            .await?;
307        Ok(())
308    }
309}
310
311/// The maximum number of keys the `GET /v2/key-value-stores/{storeId}/keys` endpoint accepts in
312/// its `limit` query parameter (per the OpenAPI spec: `minimum: 1, maximum: 1000`). Each page the
313/// key iterator requests is bounded to this value so a large total cap still paginates correctly
314/// instead of asking the API for an out-of-range `limit`.
315pub const KEY_LIST_MAX_LIMIT: i64 = 1000;
316
317/// A lazy, page-fetching async iterator over the keys in a key-value store.
318///
319/// Created by [`KeyValueStoreClient::iterate_keys`]. Each call to [`next`](Self::next) returns
320/// the next key, fetching another page from the API when the local buffer is exhausted, until
321/// every key has been yielded (or the caller's total-key cap is reached).
322///
323/// Unlike the offset/limit-paginated [`ListIterator`](crate::ListIterator), key-value stores use
324/// cursor-based pagination: each page is anchored by the previous page's
325/// `nextExclusiveStartKey`. The walk stops once the page reports `isTruncated == false` (the
326/// authoritative end-of-data signal), a page comes back empty, the API stops returning a next
327/// cursor, or the caller's `limit` is exhausted. This yields the same result as the reference
328/// client's `listKeys()` async-iterable (which loops on the cursor); leading with `isTruncated`
329/// additionally avoids a wasted empty fetch when a final page still carries a cursor.
330pub struct KeyValueStoreKeysIterator {
331    client: KeyValueStoreClient,
332    /// Base listing options. The `prefix`/`collection`/`signature` filters are carried into every
333    /// page unchanged; `limit` and `exclusive_start_key` are overridden per page after the first.
334    options: ListKeysOptions,
335    /// Keys still allowed under the caller's total cap (`options.limit`); `None` = uncapped.
336    /// Decremented by each page's key count. Each request asks for `min(remaining,
337    /// KEY_LIST_MAX_LIMIT)` so the cap is honoured across pages without exceeding the endpoint's
338    /// maximum `limit`.
339    remaining: Option<i64>,
340    /// Cursor for the next page: the previous page's `next_exclusive_start_key`.
341    next_exclusive_start_key: Option<String>,
342    buffer: VecDeque<KeyValueStoreKey>,
343    /// `true` until the first page has been fetched. Only the first page honours the caller's
344    /// `exclusive_start_key`; later pages are driven by the cursor. The request `limit` is derived
345    /// from `remaining` on every page (never sent verbatim), so a `limit` of `0`/unset is treated
346    /// as "no limit" rather than sending an out-of-range `limit=0`.
347    first_page: bool,
348    exhausted: bool,
349}
350
351impl KeyValueStoreKeysIterator {
352    /// Returns the next key, or `None` when the store is exhausted (or the caller's `limit` is
353    /// reached). Fetches another page from the API when the local buffer is empty.
354    pub async fn next(&mut self) -> ApifyClientResult<Option<KeyValueStoreKey>> {
355        if let Some(item) = self.buffer.pop_front() {
356            return Ok(Some(item));
357        }
358        if self.exhausted {
359            return Ok(None);
360        }
361
362        // Build this page's options. The request `limit` is always derived from the remaining
363        // budget (never the caller's raw `limit`), clamped to the endpoint maximum: this normalizes
364        // an unset/`0` cap to "no limit" and keeps a large finite cap within the accepted range so
365        // it paginates instead of 400-ing. Only the first page uses the caller's
366        // `exclusive_start_key`; later pages advance the cursor.
367        let mut page_options = self.options.clone();
368        page_options.limit = self.remaining.map(|rem| rem.min(KEY_LIST_MAX_LIMIT));
369        if !self.first_page {
370            page_options.exclusive_start_key = self.next_exclusive_start_key.clone();
371        }
372        self.first_page = false;
373
374        let page = self.client.list_keys(page_options).await?;
375        let is_truncated = page.is_truncated;
376        let mut items = page.items;
377        let received = items.len() as i64;
378
379        // Enforce the caller's total cap exactly, even if the API returns more than requested
380        // (defensive parity with `ListIterator`).
381        if let Some(rem) = self.remaining {
382            if received > rem {
383                items.truncate(rem as usize);
384            }
385        }
386        if let Some(rem) = self.remaining.as_mut() {
387            *rem -= received;
388        }
389        self.next_exclusive_start_key = page.next_exclusive_start_key;
390
391        // Stop when the page is empty; the model reports no more keys (`is_truncated == false`,
392        // the authoritative "more data?" signal); the API returns no next cursor to advance on
393        // (a robustness fallback in case the flag and cursor disagree — e.g. `isTruncated:true`
394        // with a null cursor — which also prevents re-fetching the same first page); or the
395        // caller's cap is reached. Leading with `is_truncated` avoids one wasted empty fetch when
396        // a final page still carries a cursor, matching the model semantics.
397        if received == 0
398            || !is_truncated
399            || self.next_exclusive_start_key.is_none()
400            || matches!(self.remaining, Some(r) if r <= 0)
401        {
402            self.exhausted = true;
403        }
404
405        self.buffer.extend(items);
406        Ok(self.buffer.pop_front())
407    }
408}