apify_client/common.rs
1//! Shared building blocks used across resource clients: the response envelope,
2//! query-parameter helpers, pagination types and the `User-Agent` builder.
3
4use serde::de::DeserializeOwned;
5use serde::{Deserialize, Serialize};
6
7use crate::error::ApifyClientResult;
8use crate::version::CLIENT_VERSION;
9
10/// Status code returned when a resource is not found.
11const NOT_FOUND_STATUS_CODE: u16 = 404;
12const RECORD_NOT_FOUND_TYPE: &str = "record-not-found";
13const RECORD_OR_TOKEN_NOT_FOUND_TYPE: &str = "record-or-token-not-found";
14
15/// Most Apify endpoints wrap their payload in a top-level `data` property.
16/// This envelope unwraps `{ "data": ... }` into the inner type.
17#[derive(Debug, Deserialize)]
18pub(crate) struct DataEnvelope<T> {
19 pub data: T,
20}
21
22/// Parses a JSON response body that is wrapped in a `data` envelope.
23pub(crate) fn parse_data_envelope<T: DeserializeOwned>(body: &[u8]) -> ApifyClientResult<T> {
24 let envelope: DataEnvelope<T> = serde_json::from_slice(body)?;
25 Ok(envelope.data)
26}
27
28/// Translates a "not found" API error into `Ok(None)`, re-raising any other error.
29///
30/// This mirrors `catchNotFoundOrThrow` in the reference clients: a `get`/`delete` on a
31/// missing resource resolves to `None` rather than raising.
32pub(crate) fn catch_not_found<T>(result: ApifyClientResult<T>) -> ApifyClientResult<Option<T>> {
33 match result {
34 Ok(value) => Ok(Some(value)),
35 Err(err) => {
36 if let Some(api_error) = err.as_api_error() {
37 let is_not_found_status = api_error.status_code == NOT_FOUND_STATUS_CODE;
38 let is_not_found_type = matches!(
39 api_error.error_type.as_deref(),
40 Some(RECORD_NOT_FOUND_TYPE) | Some(RECORD_OR_TOKEN_NOT_FOUND_TYPE)
41 ) || api_error.http_method.as_deref() == Some("HEAD");
42 if is_not_found_status && is_not_found_type {
43 return Ok(None);
44 }
45 }
46 Err(err)
47 }
48 }
49}
50
51/// A mutable collection of query parameters that serializes booleans as `0`/`1` and
52/// omits `None` values, matching the Apify API conventions.
53#[derive(Debug, Default, Clone)]
54pub struct QueryParams {
55 pairs: Vec<(String, String)>,
56}
57
58impl QueryParams {
59 /// Creates an empty set of query parameters.
60 pub fn new() -> Self {
61 Self::default()
62 }
63
64 /// Adds a string parameter if `value` is `Some`.
65 pub fn add_str(&mut self, key: &str, value: Option<impl Into<String>>) -> &mut Self {
66 if let Some(value) = value {
67 self.pairs.push((key.to_string(), value.into()));
68 }
69 self
70 }
71
72 /// Adds an integer parameter if `value` is `Some`.
73 pub fn add_int(&mut self, key: &str, value: Option<i64>) -> &mut Self {
74 if let Some(value) = value {
75 self.pairs.push((key.to_string(), value.to_string()));
76 }
77 self
78 }
79
80 /// Adds a floating-point parameter if `value` is `Some`.
81 pub fn add_float(&mut self, key: &str, value: Option<f64>) -> &mut Self {
82 if let Some(value) = value {
83 self.pairs.push((key.to_string(), value.to_string()));
84 }
85 self
86 }
87
88 /// Adds a boolean parameter, encoded as `1`/`0`, if `value` is `Some`.
89 pub fn add_bool(&mut self, key: &str, value: Option<bool>) -> &mut Self {
90 if let Some(value) = value {
91 self.pairs
92 .push((key.to_string(), if value { "1" } else { "0" }.to_string()));
93 }
94 self
95 }
96
97 /// Adds a comma-joined list parameter if `value` is `Some` and non-empty.
98 pub fn add_csv(&mut self, key: &str, value: Option<&[String]>) -> &mut Self {
99 if let Some(values) = value {
100 if !values.is_empty() {
101 self.pairs.push((key.to_string(), values.join(",")));
102 }
103 }
104 self
105 }
106
107 /// Returns `true` if no parameters were added.
108 pub fn is_empty(&self) -> bool {
109 self.pairs.is_empty()
110 }
111
112 /// Internal: read access to the raw pairs (used when merging parent params).
113 pub(crate) fn pairs_ref(&self) -> &[(String, String)] {
114 &self.pairs
115 }
116
117 /// Internal: push an already-stringified key/value pair.
118 pub(crate) fn push_raw(&mut self, key: String, value: String) {
119 self.pairs.push((key, value));
120 }
121
122 /// Appends these parameters as a query string to `url`.
123 pub fn apply_to_url(&self, url: &str) -> String {
124 if self.pairs.is_empty() {
125 return url.to_string();
126 }
127 let encoded: Vec<String> = self
128 .pairs
129 .iter()
130 .map(|(k, v)| format!("{}={}", url_encode(k), url_encode(v)))
131 .collect();
132 let separator = if url.contains('?') { '&' } else { '?' };
133 format!("{url}{separator}{}", encoded.join("&"))
134 }
135}
136
137/// Percent-encodes a query-string component.
138fn url_encode(input: &str) -> String {
139 let mut out = String::with_capacity(input.len());
140 for byte in input.bytes() {
141 match byte {
142 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
143 out.push(byte as char);
144 }
145 _ => out.push_str(&format!("%{byte:02X}")),
146 }
147 }
148 out
149}
150
151/// Percent-encodes a single URL *path segment*, so that values interpolated into the path
152/// (such as key-value-store record keys or request IDs) cannot break out of the segment or
153/// inject a query string.
154///
155/// Encodes everything except the RFC 3986 "unreserved" characters. Notably `/`, `?`, `#`,
156/// space and any non-ASCII bytes are escaped — unlike a raw `format!(".../{key}")`, which
157/// would leave them intact and produce a malformed or wrong-endpoint URL.
158pub fn encode_path_segment(input: &str) -> String {
159 // Path segments and query components share the same unreserved set here, so reuse the
160 // query encoder. (`url_encode` already escapes `/`, `?`, `#`, space and non-ASCII.)
161 url_encode(input)
162}
163
164/// Standard offset/limit pagination options shared by most list endpoints.
165#[derive(Debug, Default, Clone)]
166pub struct ListOptions {
167 /// Number of items to skip from the beginning of the list.
168 pub offset: Option<i64>,
169 /// Maximum number of items to return.
170 pub limit: Option<i64>,
171 /// If `true`, items are returned newest-first.
172 pub desc: Option<bool>,
173}
174
175/// Options shared by the storage collection list endpoints (`GET /v2/datasets`,
176/// `/v2/key-value-stores`, `/v2/request-queues`), which add `unnamed` and `ownership`
177/// filters on top of the standard offset/limit pagination.
178#[derive(Debug, Default, Clone)]
179pub struct StorageListOptions {
180 /// Number of items to skip from the beginning of the list.
181 pub offset: Option<i64>,
182 /// Maximum number of items to return.
183 pub limit: Option<i64>,
184 /// If `true`, items are returned newest-first.
185 pub desc: Option<bool>,
186 /// If `true`, include unnamed storages in the result.
187 pub unnamed: Option<bool>,
188 /// Filter by ownership (e.g. `OWNED` / `ACCESSIBLE`).
189 pub ownership: Option<String>,
190}
191
192impl StorageListOptions {
193 /// Serializes these options into query parameters.
194 pub(crate) fn apply(&self, params: &mut QueryParams) {
195 params
196 .add_int("offset", self.offset)
197 .add_int("limit", self.limit)
198 .add_bool("desc", self.desc)
199 .add_bool("unnamed", self.unnamed)
200 .add_str("ownership", self.ownership.clone());
201 }
202}
203
204/// A single page of an offset/limit-paginated list.
205#[derive(Debug, Clone, Deserialize, Serialize)]
206pub struct PaginationList<T> {
207 /// Total number of items available across all pages.
208 #[serde(default)]
209 pub total: i64,
210 /// Number of items skipped at the start.
211 #[serde(default)]
212 pub offset: i64,
213 /// Maximum number of items the API would return for this request.
214 #[serde(default)]
215 pub limit: i64,
216 /// Number of items actually returned in this page.
217 #[serde(default)]
218 pub count: i64,
219 /// Whether the items are in descending order.
220 #[serde(default)]
221 pub desc: bool,
222 /// The items of this page.
223 #[serde(default = "Vec::new")]
224 pub items: Vec<T>,
225 /// Number of underlying rows the endpoint scanned to produce this page, when that can
226 /// differ from `items.len()`. Currently only [`DatasetClient::list_items`]
227 /// (`x-apify-pagination-count` header) sets this: a `clean`/`skip_empty`/`skip_hidden`
228 /// filter can scan more rows than it returns, and `unwind` can return more items than rows
229 /// scanned. [`ListIterator`] uses it (capped at the requested page size) instead of
230 /// `items.len()` to advance the offset and decide when a listing is exhausted, so paging
231 /// neither re-scans nor skips rows. `None` for every other endpoint, where the two always
232 /// agree.
233 ///
234 /// [`DatasetClient::list_items`]: crate::clients::dataset::DatasetClient::list_items
235 /// [`ListIterator`]: crate::clients::pagination::ListIterator
236 #[serde(skip)]
237 pub(crate) scanned: Option<i64>,
238}
239
240/// Reports whether the environment variable `name` is set to a non-empty value.
241fn env_var_set(name: &str) -> bool {
242 matches!(std::env::var(name), Ok(value) if !value.is_empty())
243}
244
245/// Builds the `User-Agent` header value mandated by the client requirements:
246/// `ApifyClient/{version} ({os}; {language version}); isAtHome/{isAtHome}`.
247pub fn build_user_agent(suffix: Option<&str>) -> String {
248 // The OS token must be byte-for-byte identical across all Apify clients, which means it must
249 // equal the reference JS client's `os.platform()` value. `std::env::consts::OS` uses Rust's
250 // own spellings (`macos`, `windows`, `solaris`, `illumos`), so map those to the Node
251 // `os.platform()` tokens (`darwin`, `win32`, `sunos`); the rest already agree.
252 let os = map_os_to_node_platform(std::env::consts::OS);
253 // The `isAtHome` flag signals whether the client runs on the Apify platform. Per the
254 // requirements it is `true`/`false` based solely on the `APIFY_IS_AT_HOME` environment
255 // variable (`false` when the variable is missing), matching the JS reference, which reads
256 // only `APIFY_IS_AT_HOME` and renders a lowercase boolean.
257 let is_at_home = if env_var_set("APIFY_IS_AT_HOME") {
258 "true"
259 } else {
260 "false"
261 };
262 // Rust has no stable runtime-version API, so report the compiler version captured at build
263 // time by `build.rs` (the closest analogue to a "language/runtime version"). Falls back to
264 // "unknown" only if the build script could not invoke rustc.
265 let rust_version = option_env!("BUILD_RUSTC_VERSION").unwrap_or("unknown");
266 let mut ua =
267 format!("ApifyClient/{CLIENT_VERSION} ({os}; Rust/{rust_version}); isAtHome/{is_at_home}");
268 if let Some(suffix) = suffix {
269 if !suffix.is_empty() {
270 ua.push_str("; ");
271 ua.push_str(suffix);
272 }
273 }
274 ua
275}
276
277/// Maps a Rust `std::env::consts::OS` value to the corresponding Node.js `os.platform()` token.
278///
279/// The User-Agent OS field must be identical across every Apify client, and the reference client
280/// derives it from Node's `os.platform()`. Rust and Node agree on most tokens (`linux`, `android`,
281/// `freebsd`, `openbsd`, `netbsd`, `aix`, ...), which pass through unchanged; the four Rust
282/// spellings that differ (`macos`, `windows`, `solaris`, `illumos`) are translated to Node's
283/// spelling. Note both `solaris` and `illumos` map to Node's single `sunos` token. Any unknown
284/// value passes through as-is (best effort).
285fn map_os_to_node_platform(os: &str) -> &str {
286 match os {
287 "macos" => "darwin",
288 "windows" => "win32",
289 "solaris" => "sunos",
290 "illumos" => "sunos",
291 other => other,
292 }
293}
294
295/// Encodes a resource id so it is safe to embed in a URL path. Apify uses the
296/// `username~resourcename` form, so the first `/` of an id is replaced with `~`.
297pub fn to_safe_id(id: &str) -> String {
298 id.replacen('/', "~", 1)
299}
300
301/// Version tag embedded in storage-content signatures. Matches the upstream library default
302/// (`0`), which the reference clients rely on by not passing an explicit version.
303const STORAGE_CONTENT_SIGNATURE_VERSION: &str = "0";
304/// Number of leading hex characters of the HMAC digest used by `create_hmac_signature`.
305const HMAC_SIGNATURE_HEX_LEN: usize = 30;
306/// base62 alphabet (`0-9a-zA-Z`, lowercase first) used to encode the truncated HMAC. This
307/// ordering matches upstream `@apify/utilities`.
308const BASE62_ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
309
310/// Computes an Apify URL-signing signature, byte-for-byte compatible with the platform's
311/// `@apify/utilities` `createHmacSignature`.
312///
313/// The algorithm is: `HMAC-SHA256(secret_key, message)` as lowercase hex, take the first 30
314/// hex characters, interpret them as a big integer, then base62-encode (alphabet `0-9a-zA-Z`).
315/// Used to sign key-value-store record keys for public access.
316pub fn create_hmac_signature(secret_key: &str, message: &str) -> String {
317 use hmac::{Hmac, Mac};
318 use sha2::Sha256;
319
320 let mut mac = Hmac::<Sha256>::new_from_slice(secret_key.as_bytes())
321 .expect("HMAC accepts a key of any length");
322 mac.update(message.as_bytes());
323 let digest = mac.finalize().into_bytes();
324
325 // Hex-encode and keep the leading 30 hex chars (120 bits → fits in u128).
326 let mut hex = String::with_capacity(digest.len() * 2);
327 for byte in digest.iter() {
328 hex.push_str(&format!("{byte:02x}"));
329 }
330 let truncated = &hex[..HMAC_SIGNATURE_HEX_LEN];
331 let value = u128::from_str_radix(truncated, 16).expect("30 hex chars always parse into a u128");
332 to_base62(value)
333}
334
335/// Encodes a non-negative integer in base62 using the `0-9a-zA-Z` alphabet.
336fn to_base62(mut value: u128) -> String {
337 if value == 0 {
338 return "0".to_string();
339 }
340 let base = BASE62_ALPHABET.len() as u128;
341 let mut digits = Vec::new();
342 while value > 0 {
343 let rem = (value % base) as usize;
344 digits.push(BASE62_ALPHABET[rem]);
345 value /= base;
346 }
347 digits.reverse();
348 String::from_utf8(digits).expect("base62 alphabet is valid ASCII")
349}
350
351/// Builds a storage-content signature for a resource's public URL, byte-for-byte compatible
352/// with the platform's `@apify/utilities` `createStorageContentSignature`.
353///
354/// It signs the message `"{version}.{expiresAtMillis}.{resourceId}"` (where `expiresAtMillis`
355/// is the absolute expiry in milliseconds, or `0` for a non-expiring URL) with
356/// [`create_hmac_signature`], then returns the base64url (no padding) encoding of
357/// `"{version}.{expiresAtMillis}.{hmac}"`. Used for dataset-items and key-list public URLs.
358pub fn sign_storage_content(
359 secret_key: &str,
360 resource_id: &str,
361 expires_in_secs: Option<i64>,
362) -> String {
363 use base64::Engine;
364
365 let expires_at_millis = match expires_in_secs {
366 Some(secs) => chrono::Utc::now().timestamp_millis() + secs * 1000,
367 None => 0,
368 };
369 let version = STORAGE_CONTENT_SIGNATURE_VERSION;
370 let message = format!("{version}.{expires_at_millis}.{resource_id}");
371 let hmac = create_hmac_signature(secret_key, &message);
372 let envelope = format!("{version}.{expires_at_millis}.{hmac}");
373 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(envelope.as_bytes())
374}
375
376#[cfg(test)]
377mod user_agent_tests {
378 use super::{build_user_agent, map_os_to_node_platform};
379
380 // `build_user_agent` keys the flag solely on the `APIFY_IS_AT_HOME` env var (per the
381 // requirements and the JS reference). A bare `isAtHome` env var must NOT affect it. The value
382 // is the lowercase `true`/`false` the JS reference emits. Env vars are process-global, so this
383 // test owns both names for its duration and restores them afterwards.
384 #[test]
385 fn is_at_home_reads_only_apify_is_at_home() {
386 let prev_apify = std::env::var("APIFY_IS_AT_HOME").ok();
387 let prev_literal = std::env::var("isAtHome").ok();
388
389 // Neither set -> false.
390 std::env::remove_var("APIFY_IS_AT_HOME");
391 std::env::remove_var("isAtHome");
392 assert!(
393 build_user_agent(None).contains("isAtHome/false"),
394 "no env var set must render isAtHome/false"
395 );
396
397 // A bare `isAtHome` env var must NOT flip the flag (it is not the mandated variable).
398 std::env::set_var("isAtHome", "1");
399 assert!(
400 build_user_agent(None).contains("isAtHome/false"),
401 "bare `isAtHome` env var must not affect the flag"
402 );
403
404 // Only `APIFY_IS_AT_HOME` drives the flag.
405 std::env::remove_var("isAtHome");
406 std::env::set_var("APIFY_IS_AT_HOME", "1");
407 assert!(
408 build_user_agent(None).contains("isAtHome/true"),
409 "APIFY_IS_AT_HOME must drive the flag"
410 );
411
412 // Restore prior environment.
413 match prev_apify {
414 Some(v) => std::env::set_var("APIFY_IS_AT_HOME", v),
415 None => std::env::remove_var("APIFY_IS_AT_HOME"),
416 }
417 match prev_literal {
418 Some(v) => std::env::set_var("isAtHome", v),
419 None => std::env::remove_var("isAtHome"),
420 }
421 }
422
423 // The OS token in the emitted User-Agent must match the reference JS client's `os.platform()`
424 // value (so every Apify client reports the same token). This test checks the token that
425 // `build_user_agent` actually produces against an independent oracle — the set of documented
426 // Node `os.platform()` values — and asserts it is lowercase and never a Rust-native spelling.
427 // The exact per-platform translation is covered by `os_token_maps_rust_spellings_to_node_platform`.
428 #[test]
429 fn user_agent_os_token_matches_node_platform() {
430 // The values Node's `os.platform()` can return (independent of our mapping code).
431 const NODE_PLATFORMS: &[&str] = &[
432 "aix", "android", "darwin", "freebsd", "haiku", "linux", "netbsd", "openbsd", "sunos",
433 "win32", "cygwin",
434 ];
435
436 let ua = build_user_agent(None);
437 // Extract the OS token exactly as it appears in the produced header — the first
438 // component inside the parentheses: `(<os>; Rust/...)`.
439 let os = ua
440 .split_once('(')
441 .and_then(|(_, rest)| rest.split_once(';'))
442 .map(|(token, _)| token.trim())
443 .expect("user agent must contain an `(<os>; Rust/...` section");
444 assert!(
445 NODE_PLATFORMS.contains(&os),
446 "OS token must be a Node `os.platform()` value, got `{os}` in `{ua}`"
447 );
448 assert_eq!(
449 os,
450 os.to_ascii_lowercase(),
451 "OS token must be lowercase, got `{os}`"
452 );
453 // Must never emit the Rust-native spellings or uname-style names.
454 for forbidden in [
455 "macos",
456 "windows",
457 "solaris",
458 "Linux",
459 "Darwin",
460 "Windows_NT",
461 ] {
462 assert_ne!(
463 os, forbidden,
464 "OS token must be the Node `os.platform()` token, not `{forbidden}`"
465 );
466 }
467 }
468
469 // The Rust->Node platform mapping must translate the four tokens that differ (both `solaris`
470 // and `illumos` collapse to Node's `sunos`) and leave every shared token untouched, so the
471 // emitted value is byte-for-byte identical to the reference.
472 #[test]
473 fn os_token_maps_rust_spellings_to_node_platform() {
474 assert_eq!(map_os_to_node_platform("macos"), "darwin");
475 assert_eq!(map_os_to_node_platform("windows"), "win32");
476 assert_eq!(map_os_to_node_platform("solaris"), "sunos");
477 assert_eq!(map_os_to_node_platform("illumos"), "sunos");
478 // Tokens Rust and Node already share must pass through unchanged.
479 for shared in ["linux", "android", "freebsd", "openbsd", "netbsd", "aix"] {
480 assert_eq!(map_os_to_node_platform(shared), shared);
481 }
482 }
483
484 // The `{language version}` segment must carry a real compiler version captured by build.rs,
485 // not the literal "unknown" the MSRV-based source used to produce. We assert the `Rust/`
486 // token is followed by a digit (e.g. `Rust/1.94.1`).
487 #[test]
488 fn user_agent_reports_real_rust_version() {
489 let ua = build_user_agent(None);
490 let after = ua
491 .split("Rust/")
492 .nth(1)
493 .expect("user agent must contain a Rust/ token");
494 let version = after.split([')', ';']).next().unwrap_or("");
495 assert!(
496 version.chars().next().is_some_and(|c| c.is_ascii_digit()),
497 "Rust version must be a real compiler version, got `Rust/{version}`"
498 );
499 }
500}
501
502#[cfg(test)]
503mod signature_tests {
504 use super::{create_hmac_signature, sign_storage_content, to_base62};
505
506 // Known-answer test pinning the `createHmacSignature` algorithm against upstream
507 // `@apify/utilities`: HMAC-SHA256 → hex → first 30 hex chars → big integer → base62 with
508 // the lowercase-first alphabet `0-9a-zA-Z`. For key="secret", msg="message" the upstream
509 // output is `11GYWmGxviysIBMtnQHBk`.
510 #[test]
511 fn hmac_signature_matches_upstream_scheme() {
512 let sig = create_hmac_signature("secret", "message");
513 assert_eq!(sig, "11GYWmGxviysIBMtnQHBk");
514 // base62 output uses only `0-9a-zA-Z`.
515 assert!(sig.chars().all(|c| c.is_ascii_alphanumeric()));
516 }
517
518 #[test]
519 fn base62_encoding() {
520 assert_eq!(to_base62(0), "0");
521 // With the lowercase-first alphabet, 61 maps to the last char `Z`.
522 assert_eq!(to_base62(61), "Z");
523 assert_eq!(to_base62(62), "10");
524 }
525
526 // A non-expiring storage-content signature uses version `0` and `expiresAt = 0`, and is
527 // the base64url (no-pad) encoding of `"0.0.<hmac>"` where the hmac is over `"0.0.RESID"`.
528 #[test]
529 fn storage_content_signature_non_expiring_envelope() {
530 use base64::Engine;
531 let sig = sign_storage_content("secret", "RESID", None);
532 let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
533 .decode(sig)
534 .expect("valid base64url");
535 let decoded = String::from_utf8(decoded).expect("utf8");
536 let expected_hmac = create_hmac_signature("secret", "0.0.RESID");
537 assert_eq!(decoded, format!("0.0.{expected_hmac}"));
538 }
539}