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    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 as JSON (the value is serialized and content type set to JSON).
205    pub async fn set_record_json<T: Serialize>(
206        &self,
207        key: &str,
208        value: &T,
209    ) -> ApifyClientResult<()> {
210        let bytes = serde_json::to_vec(value)?;
211        self.set_record_raw(key, bytes, "application/json; charset=utf-8")
212            .await
213    }
214
215    /// Builds a public URL for reading the record with the given key.
216    ///
217    /// Mirrors the reference client's `getRecordPublicUrl`: it fetches the store, and if the
218    /// store exposes a URL-signing secret key (private store), appends an HMAC-SHA256
219    /// `signature` over the record key so the URL works without an API token. The URL is
220    /// built from the configured public base URL.
221    pub async fn get_record_public_url(&self, key: &str) -> ApifyClientResult<String> {
222        let mut params = QueryParams::new();
223        if let Some(store) = self.get().await? {
224            if let Some(secret) = store
225                .extra
226                .get("urlSigningSecretKey")
227                .and_then(|v| v.as_str())
228            {
229                params.add_str("signature", Some(create_hmac_signature(secret, key)));
230            }
231        }
232        Ok(params.apply_to_url(
233            &self
234                .ctx
235                .public_url(Some(&format!("records/{}", encode_path_segment(key)))),
236        ))
237    }
238
239    /// Builds a public URL for listing this store's keys.
240    ///
241    /// Like [`get_record_public_url`](Self::get_record_public_url), signs the URL with an
242    /// HMAC-SHA256 `signature` for private stores. `expires_in_secs` optionally bounds a
243    /// signed URL's validity.
244    pub async fn create_keys_public_url(
245        &self,
246        expires_in_secs: Option<i64>,
247    ) -> ApifyClientResult<String> {
248        let mut params = QueryParams::new();
249        if let Some(store) = self.get().await? {
250            if let Some(secret) = store
251                .extra
252                .get("urlSigningSecretKey")
253                .and_then(|v| v.as_str())
254            {
255                let signature = sign_storage_content(secret, &store.id, expires_in_secs);
256                params.add_str("signature", Some(signature));
257            }
258        }
259        Ok(params.apply_to_url(&self.ctx.public_url(Some("keys"))))
260    }
261
262    /// Deletes the record with the given key.
263    pub async fn delete_record(&self, key: &str) -> ApifyClientResult<()> {
264        let url = self
265            .ctx
266            .url(Some(&format!("records/{}", encode_path_segment(key))));
267        self.ctx
268            .http
269            .call(HttpRequest {
270                method: HttpMethod::Delete,
271                url,
272                headers: Default::default(),
273                body: None,
274                timeout: crate::clients::base::DEFAULT_REQUEST_TIMEOUT,
275            })
276            .await?;
277        Ok(())
278    }
279}
280
281/// The maximum number of keys the `GET /v2/key-value-stores/{storeId}/keys` endpoint accepts in
282/// its `limit` query parameter (per the OpenAPI spec: `minimum: 1, maximum: 1000`). Each page the
283/// key iterator requests is bounded to this value so a large total cap still paginates correctly
284/// instead of asking the API for an out-of-range `limit`.
285pub const KEY_LIST_MAX_LIMIT: i64 = 1000;
286
287/// A lazy, page-fetching async iterator over the keys in a key-value store.
288///
289/// Created by [`KeyValueStoreClient::iterate_keys`]. Each call to [`next`](Self::next) returns
290/// the next key, fetching another page from the API when the local buffer is exhausted, until
291/// every key has been yielded (or the caller's total-key cap is reached).
292///
293/// Unlike the offset/limit-paginated [`ListIterator`](crate::ListIterator), key-value stores use
294/// cursor-based pagination: each page is anchored by the previous page's
295/// `nextExclusiveStartKey`. The walk stops once the page reports `isTruncated == false` (the
296/// authoritative end-of-data signal), a page comes back empty, the API stops returning a next
297/// cursor, or the caller's `limit` is exhausted. This yields the same result as the reference
298/// client's `listKeys()` async-iterable (which loops on the cursor); leading with `isTruncated`
299/// additionally avoids a wasted empty fetch when a final page still carries a cursor.
300pub struct KeyValueStoreKeysIterator {
301    client: KeyValueStoreClient,
302    /// Base listing options. The `prefix`/`collection`/`signature` filters are carried into every
303    /// page unchanged; `limit` and `exclusive_start_key` are overridden per page after the first.
304    options: ListKeysOptions,
305    /// Keys still allowed under the caller's total cap (`options.limit`); `None` = uncapped.
306    /// Decremented by each page's key count. Each request asks for `min(remaining,
307    /// KEY_LIST_MAX_LIMIT)` so the cap is honoured across pages without exceeding the endpoint's
308    /// maximum `limit`.
309    remaining: Option<i64>,
310    /// Cursor for the next page: the previous page's `next_exclusive_start_key`.
311    next_exclusive_start_key: Option<String>,
312    buffer: VecDeque<KeyValueStoreKey>,
313    /// `true` until the first page has been fetched. Only the first page honours the caller's
314    /// `exclusive_start_key`; later pages are driven by the cursor. The request `limit` is derived
315    /// from `remaining` on every page (never sent verbatim), so a `limit` of `0`/unset is treated
316    /// as "no limit" rather than sending an out-of-range `limit=0`.
317    first_page: bool,
318    exhausted: bool,
319}
320
321impl KeyValueStoreKeysIterator {
322    /// Returns the next key, or `None` when the store is exhausted (or the caller's `limit` is
323    /// reached). Fetches another page from the API when the local buffer is empty.
324    pub async fn next(&mut self) -> ApifyClientResult<Option<KeyValueStoreKey>> {
325        if let Some(item) = self.buffer.pop_front() {
326            return Ok(Some(item));
327        }
328        if self.exhausted {
329            return Ok(None);
330        }
331
332        // Build this page's options. The request `limit` is always derived from the remaining
333        // budget (never the caller's raw `limit`), clamped to the endpoint maximum: this normalizes
334        // an unset/`0` cap to "no limit" and keeps a large finite cap within the accepted range so
335        // it paginates instead of 400-ing. Only the first page uses the caller's
336        // `exclusive_start_key`; later pages advance the cursor.
337        let mut page_options = self.options.clone();
338        page_options.limit = self.remaining.map(|rem| rem.min(KEY_LIST_MAX_LIMIT));
339        if !self.first_page {
340            page_options.exclusive_start_key = self.next_exclusive_start_key.clone();
341        }
342        self.first_page = false;
343
344        let page = self.client.list_keys(page_options).await?;
345        let is_truncated = page.is_truncated;
346        let mut items = page.items;
347        let received = items.len() as i64;
348
349        // Enforce the caller's total cap exactly, even if the API returns more than requested
350        // (defensive parity with `ListIterator`).
351        if let Some(rem) = self.remaining {
352            if received > rem {
353                items.truncate(rem as usize);
354            }
355        }
356        if let Some(rem) = self.remaining.as_mut() {
357            *rem -= received;
358        }
359        self.next_exclusive_start_key = page.next_exclusive_start_key;
360
361        // Stop when the page is empty; the model reports no more keys (`is_truncated == false`,
362        // the authoritative "more data?" signal); the API returns no next cursor to advance on
363        // (a robustness fallback in case the flag and cursor disagree — e.g. `isTruncated:true`
364        // with a null cursor — which also prevents re-fetching the same first page); or the
365        // caller's cap is reached. Leading with `is_truncated` avoids one wasted empty fetch when
366        // a final page still carries a cursor, matching the model semantics.
367        if received == 0
368            || !is_truncated
369            || self.next_exclusive_start_key.is_none()
370            || matches!(self.remaining, Some(r) if r <= 0)
371        {
372            self.exhausted = true;
373        }
374
375        self.buffer.extend(items);
376        Ok(self.buffer.pop_front())
377    }
378}