Skip to main content

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}
226
227/// Reports whether the environment variable `name` is set to a non-empty value.
228fn env_var_set(name: &str) -> bool {
229    matches!(std::env::var(name), Ok(value) if !value.is_empty())
230}
231
232/// Builds the `User-Agent` header value mandated by the client requirements:
233/// `ApifyClient/{version} ({os}; {language version}); isAtHome/{isAtHome}`.
234pub fn build_user_agent(suffix: Option<&str>) -> String {
235    let os = std::env::consts::OS;
236    // The `isAtHome` flag signals whether the client runs on the Apify platform. Per the
237    // requirements it is `true`/`false` based solely on the `APIFY_IS_AT_HOME` environment
238    // variable (`false` when the variable is missing), matching the JS reference, which reads
239    // only `APIFY_IS_AT_HOME` and renders a lowercase boolean.
240    let is_at_home = if env_var_set("APIFY_IS_AT_HOME") {
241        "true"
242    } else {
243        "false"
244    };
245    // Rust has no stable runtime-version API, so report the compiler version captured at build
246    // time by `build.rs` (the closest analogue to a "language/runtime version"). Falls back to
247    // "unknown" only if the build script could not invoke rustc.
248    let rust_version = option_env!("BUILD_RUSTC_VERSION").unwrap_or("unknown");
249    let mut ua =
250        format!("ApifyClient/{CLIENT_VERSION} ({os}; Rust/{rust_version}); isAtHome/{is_at_home}");
251    if let Some(suffix) = suffix {
252        if !suffix.is_empty() {
253            ua.push_str("; ");
254            ua.push_str(suffix);
255        }
256    }
257    ua
258}
259
260/// Encodes a resource id so it is safe to embed in a URL path. Apify uses the
261/// `username~resourcename` form, so the first `/` of an id is replaced with `~`.
262pub fn to_safe_id(id: &str) -> String {
263    id.replacen('/', "~", 1)
264}
265
266/// Version tag embedded in storage-content signatures. Matches the upstream library default
267/// (`0`), which the reference clients rely on by not passing an explicit version.
268const STORAGE_CONTENT_SIGNATURE_VERSION: &str = "0";
269/// Number of leading hex characters of the HMAC digest used by `create_hmac_signature`.
270const HMAC_SIGNATURE_HEX_LEN: usize = 30;
271/// base62 alphabet (`0-9a-zA-Z`, lowercase first) used to encode the truncated HMAC. This
272/// ordering matches upstream `@apify/utilities`.
273const BASE62_ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
274
275/// Computes an Apify URL-signing signature, byte-for-byte compatible with the platform's
276/// `@apify/utilities` `createHmacSignature`.
277///
278/// The algorithm is: `HMAC-SHA256(secret_key, message)` as lowercase hex, take the first 30
279/// hex characters, interpret them as a big integer, then base62-encode (alphabet `0-9a-zA-Z`).
280/// Used to sign key-value-store record keys for public access.
281pub fn create_hmac_signature(secret_key: &str, message: &str) -> String {
282    use hmac::{Hmac, Mac};
283    use sha2::Sha256;
284
285    let mut mac = Hmac::<Sha256>::new_from_slice(secret_key.as_bytes())
286        .expect("HMAC accepts a key of any length");
287    mac.update(message.as_bytes());
288    let digest = mac.finalize().into_bytes();
289
290    // Hex-encode and keep the leading 30 hex chars (120 bits → fits in u128).
291    let mut hex = String::with_capacity(digest.len() * 2);
292    for byte in digest.iter() {
293        hex.push_str(&format!("{byte:02x}"));
294    }
295    let truncated = &hex[..HMAC_SIGNATURE_HEX_LEN];
296    let value = u128::from_str_radix(truncated, 16).expect("30 hex chars always parse into a u128");
297    to_base62(value)
298}
299
300/// Encodes a non-negative integer in base62 using the `0-9a-zA-Z` alphabet.
301fn to_base62(mut value: u128) -> String {
302    if value == 0 {
303        return "0".to_string();
304    }
305    let base = BASE62_ALPHABET.len() as u128;
306    let mut digits = Vec::new();
307    while value > 0 {
308        let rem = (value % base) as usize;
309        digits.push(BASE62_ALPHABET[rem]);
310        value /= base;
311    }
312    digits.reverse();
313    String::from_utf8(digits).expect("base62 alphabet is valid ASCII")
314}
315
316/// Builds a storage-content signature for a resource's public URL, byte-for-byte compatible
317/// with the platform's `@apify/utilities` `createStorageContentSignature`.
318///
319/// It signs the message `"{version}.{expiresAtMillis}.{resourceId}"` (where `expiresAtMillis`
320/// is the absolute expiry in milliseconds, or `0` for a non-expiring URL) with
321/// [`create_hmac_signature`], then returns the base64url (no padding) encoding of
322/// `"{version}.{expiresAtMillis}.{hmac}"`. Used for dataset-items and key-list public URLs.
323pub fn sign_storage_content(
324    secret_key: &str,
325    resource_id: &str,
326    expires_in_secs: Option<i64>,
327) -> String {
328    use base64::Engine;
329
330    let expires_at_millis = match expires_in_secs {
331        Some(secs) => chrono::Utc::now().timestamp_millis() + secs * 1000,
332        None => 0,
333    };
334    let version = STORAGE_CONTENT_SIGNATURE_VERSION;
335    let message = format!("{version}.{expires_at_millis}.{resource_id}");
336    let hmac = create_hmac_signature(secret_key, &message);
337    let envelope = format!("{version}.{expires_at_millis}.{hmac}");
338    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(envelope.as_bytes())
339}
340
341#[cfg(test)]
342mod user_agent_tests {
343    use super::build_user_agent;
344
345    // `build_user_agent` keys the flag solely on the `APIFY_IS_AT_HOME` env var (per the
346    // requirements and the JS reference). A bare `isAtHome` env var must NOT affect it. The value
347    // is the lowercase `true`/`false` the JS reference emits. Env vars are process-global, so this
348    // test owns both names for its duration and restores them afterwards.
349    #[test]
350    fn is_at_home_reads_only_apify_is_at_home() {
351        let prev_apify = std::env::var("APIFY_IS_AT_HOME").ok();
352        let prev_literal = std::env::var("isAtHome").ok();
353
354        // Neither set -> false.
355        std::env::remove_var("APIFY_IS_AT_HOME");
356        std::env::remove_var("isAtHome");
357        assert!(
358            build_user_agent(None).contains("isAtHome/false"),
359            "no env var set must render isAtHome/false"
360        );
361
362        // A bare `isAtHome` env var must NOT flip the flag (it is not the mandated variable).
363        std::env::set_var("isAtHome", "1");
364        assert!(
365            build_user_agent(None).contains("isAtHome/false"),
366            "bare `isAtHome` env var must not affect the flag"
367        );
368
369        // Only `APIFY_IS_AT_HOME` drives the flag.
370        std::env::remove_var("isAtHome");
371        std::env::set_var("APIFY_IS_AT_HOME", "1");
372        assert!(
373            build_user_agent(None).contains("isAtHome/true"),
374            "APIFY_IS_AT_HOME must drive the flag"
375        );
376
377        // Restore prior environment.
378        match prev_apify {
379            Some(v) => std::env::set_var("APIFY_IS_AT_HOME", v),
380            None => std::env::remove_var("APIFY_IS_AT_HOME"),
381        }
382        match prev_literal {
383            Some(v) => std::env::set_var("isAtHome", v),
384            None => std::env::remove_var("isAtHome"),
385        }
386    }
387
388    // The `{language version}` segment must carry a real compiler version captured by build.rs,
389    // not the literal "unknown" the MSRV-based source used to produce. We assert the `Rust/`
390    // token is followed by a digit (e.g. `Rust/1.94.1`).
391    #[test]
392    fn user_agent_reports_real_rust_version() {
393        let ua = build_user_agent(None);
394        let after = ua
395            .split("Rust/")
396            .nth(1)
397            .expect("user agent must contain a Rust/ token");
398        let version = after.split([')', ';']).next().unwrap_or("");
399        assert!(
400            version.chars().next().is_some_and(|c| c.is_ascii_digit()),
401            "Rust version must be a real compiler version, got `Rust/{version}`"
402        );
403    }
404}
405
406#[cfg(test)]
407mod signature_tests {
408    use super::{create_hmac_signature, sign_storage_content, to_base62};
409
410    // Known-answer test pinning the `createHmacSignature` algorithm against upstream
411    // `@apify/utilities`: HMAC-SHA256 → hex → first 30 hex chars → big integer → base62 with
412    // the lowercase-first alphabet `0-9a-zA-Z`. For key="secret", msg="message" the upstream
413    // output is `11GYWmGxviysIBMtnQHBk`.
414    #[test]
415    fn hmac_signature_matches_upstream_scheme() {
416        let sig = create_hmac_signature("secret", "message");
417        assert_eq!(sig, "11GYWmGxviysIBMtnQHBk");
418        // base62 output uses only `0-9a-zA-Z`.
419        assert!(sig.chars().all(|c| c.is_ascii_alphanumeric()));
420    }
421
422    #[test]
423    fn base62_encoding() {
424        assert_eq!(to_base62(0), "0");
425        // With the lowercase-first alphabet, 61 maps to the last char `Z`.
426        assert_eq!(to_base62(61), "Z");
427        assert_eq!(to_base62(62), "10");
428    }
429
430    // A non-expiring storage-content signature uses version `0` and `expiresAt = 0`, and is
431    // the base64url (no-pad) encoding of `"0.0.<hmac>"` where the hmac is over `"0.0.RESID"`.
432    #[test]
433    fn storage_content_signature_non_expiring_envelope() {
434        use base64::Engine;
435        let sig = sign_storage_content("secret", "RESID", None);
436        let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
437            .decode(sig)
438            .expect("valid base64url");
439        let decoded = String::from_utf8(decoded).expect("utf8");
440        let expected_hmac = create_hmac_signature("secret", "0.0.RESID");
441        assert_eq!(decoded, format!("0.0.{expected_hmac}"));
442    }
443}