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