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"), ¶ms).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 ¶ms,
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}