1use serde::de::DeserializeOwned;
5use serde::{Deserialize, Serialize};
6
7use crate::error::ApifyClientResult;
8use crate::version::CLIENT_VERSION;
9
10const 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#[derive(Debug, Deserialize)]
18pub(crate) struct DataEnvelope<T> {
19 pub data: T,
20}
21
22pub(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
28pub(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#[derive(Debug, Default, Clone)]
54pub struct QueryParams {
55 pairs: Vec<(String, String)>,
56}
57
58impl QueryParams {
59 pub fn new() -> Self {
61 Self::default()
62 }
63
64 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 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 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 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 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 pub fn is_empty(&self) -> bool {
109 self.pairs.is_empty()
110 }
111
112 pub(crate) fn pairs_ref(&self) -> &[(String, String)] {
114 &self.pairs
115 }
116
117 pub(crate) fn push_raw(&mut self, key: String, value: String) {
119 self.pairs.push((key, value));
120 }
121
122 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
137fn 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
151pub fn encode_path_segment(input: &str) -> String {
159 url_encode(input)
162}
163
164#[derive(Debug, Default, Clone)]
166pub struct ListOptions {
167 pub offset: Option<i64>,
169 pub limit: Option<i64>,
171 pub desc: Option<bool>,
173}
174
175#[derive(Debug, Default, Clone)]
179pub struct StorageListOptions {
180 pub offset: Option<i64>,
182 pub limit: Option<i64>,
184 pub desc: Option<bool>,
186 pub unnamed: Option<bool>,
188 pub ownership: Option<String>,
190}
191
192impl StorageListOptions {
193 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#[derive(Debug, Clone, Deserialize, Serialize)]
206pub struct PaginationList<T> {
207 #[serde(default)]
209 pub total: i64,
210 #[serde(default)]
212 pub offset: i64,
213 #[serde(default)]
215 pub limit: i64,
216 #[serde(default)]
218 pub count: i64,
219 #[serde(default)]
221 pub desc: bool,
222 #[serde(default = "Vec::new")]
224 pub items: Vec<T>,
225}
226
227fn env_var_set(name: &str) -> bool {
229 matches!(std::env::var(name), Ok(value) if !value.is_empty())
230}
231
232pub fn build_user_agent(suffix: Option<&str>) -> String {
235 let os = std::env::consts::OS;
236 let is_at_home = if env_var_set("APIFY_IS_AT_HOME") {
241 "true"
242 } else {
243 "false"
244 };
245 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
260pub fn to_safe_id(id: &str) -> String {
263 id.replacen('/', "~", 1)
264}
265
266const STORAGE_CONTENT_SIGNATURE_VERSION: &str = "0";
269const HMAC_SIGNATURE_HEX_LEN: usize = 30;
271const BASE62_ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
274
275pub 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 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
300fn 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
316pub 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 #[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 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 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 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 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 #[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 #[test]
415 fn hmac_signature_matches_upstream_scheme() {
416 let sig = create_hmac_signature("secret", "message");
417 assert_eq!(sig, "11GYWmGxviysIBMtnQHBk");
418 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 assert_eq!(to_base62(61), "Z");
427 assert_eq!(to_base62(62), "10");
428 }
429
430 #[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}