use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::error::ApifyClientResult;
use crate::version::CLIENT_VERSION;
const NOT_FOUND_STATUS_CODE: u16 = 404;
const RECORD_NOT_FOUND_TYPE: &str = "record-not-found";
const RECORD_OR_TOKEN_NOT_FOUND_TYPE: &str = "record-or-token-not-found";
#[derive(Debug, Deserialize)]
pub(crate) struct DataEnvelope<T> {
pub data: T,
}
pub(crate) fn parse_data_envelope<T: DeserializeOwned>(body: &[u8]) -> ApifyClientResult<T> {
let envelope: DataEnvelope<T> = serde_json::from_slice(body)?;
Ok(envelope.data)
}
pub(crate) fn catch_not_found<T>(result: ApifyClientResult<T>) -> ApifyClientResult<Option<T>> {
match result {
Ok(value) => Ok(Some(value)),
Err(err) => {
if let Some(api_error) = err.as_api_error() {
let is_not_found_status = api_error.status_code == NOT_FOUND_STATUS_CODE;
let is_not_found_type = matches!(
api_error.error_type.as_deref(),
Some(RECORD_NOT_FOUND_TYPE) | Some(RECORD_OR_TOKEN_NOT_FOUND_TYPE)
) || api_error.http_method.as_deref() == Some("HEAD");
if is_not_found_status && is_not_found_type {
return Ok(None);
}
}
Err(err)
}
}
}
#[derive(Debug, Default, Clone)]
pub struct QueryParams {
pairs: Vec<(String, String)>,
}
impl QueryParams {
pub fn new() -> Self {
Self::default()
}
pub fn add_str(&mut self, key: &str, value: Option<impl Into<String>>) -> &mut Self {
if let Some(value) = value {
self.pairs.push((key.to_string(), value.into()));
}
self
}
pub fn add_int(&mut self, key: &str, value: Option<i64>) -> &mut Self {
if let Some(value) = value {
self.pairs.push((key.to_string(), value.to_string()));
}
self
}
pub fn add_float(&mut self, key: &str, value: Option<f64>) -> &mut Self {
if let Some(value) = value {
self.pairs.push((key.to_string(), value.to_string()));
}
self
}
pub fn add_bool(&mut self, key: &str, value: Option<bool>) -> &mut Self {
if let Some(value) = value {
self.pairs
.push((key.to_string(), if value { "1" } else { "0" }.to_string()));
}
self
}
pub fn add_csv(&mut self, key: &str, value: Option<&[String]>) -> &mut Self {
if let Some(values) = value {
if !values.is_empty() {
self.pairs.push((key.to_string(), values.join(",")));
}
}
self
}
pub fn is_empty(&self) -> bool {
self.pairs.is_empty()
}
pub(crate) fn pairs_ref(&self) -> &[(String, String)] {
&self.pairs
}
pub(crate) fn push_raw(&mut self, key: String, value: String) {
self.pairs.push((key, value));
}
pub fn apply_to_url(&self, url: &str) -> String {
if self.pairs.is_empty() {
return url.to_string();
}
let encoded: Vec<String> = self
.pairs
.iter()
.map(|(k, v)| format!("{}={}", url_encode(k), url_encode(v)))
.collect();
let separator = if url.contains('?') { '&' } else { '?' };
format!("{url}{separator}{}", encoded.join("&"))
}
}
fn url_encode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
pub fn encode_path_segment(input: &str) -> String {
url_encode(input)
}
#[derive(Debug, Default, Clone)]
pub struct ListOptions {
pub offset: Option<i64>,
pub limit: Option<i64>,
pub desc: Option<bool>,
}
#[derive(Debug, Default, Clone)]
pub struct StorageListOptions {
pub offset: Option<i64>,
pub limit: Option<i64>,
pub desc: Option<bool>,
pub unnamed: Option<bool>,
pub ownership: Option<String>,
}
impl StorageListOptions {
pub(crate) fn apply(&self, params: &mut QueryParams) {
params
.add_int("offset", self.offset)
.add_int("limit", self.limit)
.add_bool("desc", self.desc)
.add_bool("unnamed", self.unnamed)
.add_str("ownership", self.ownership.clone());
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PaginationList<T> {
#[serde(default)]
pub total: i64,
#[serde(default)]
pub offset: i64,
#[serde(default)]
pub limit: i64,
#[serde(default)]
pub count: i64,
#[serde(default)]
pub desc: bool,
#[serde(default = "Vec::new")]
pub items: Vec<T>,
}
fn env_var_set(name: &str) -> bool {
matches!(std::env::var(name), Ok(value) if !value.is_empty())
}
pub fn build_user_agent(suffix: Option<&str>) -> String {
let os = std::env::consts::OS;
let is_at_home = if env_var_set("APIFY_IS_AT_HOME") {
"true"
} else {
"false"
};
let rust_version = option_env!("BUILD_RUSTC_VERSION").unwrap_or("unknown");
let mut ua =
format!("ApifyClient/{CLIENT_VERSION} ({os}; Rust/{rust_version}); isAtHome/{is_at_home}");
if let Some(suffix) = suffix {
if !suffix.is_empty() {
ua.push_str("; ");
ua.push_str(suffix);
}
}
ua
}
pub fn to_safe_id(id: &str) -> String {
id.replacen('/', "~", 1)
}
const STORAGE_CONTENT_SIGNATURE_VERSION: &str = "0";
const HMAC_SIGNATURE_HEX_LEN: usize = 30;
const BASE62_ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
pub fn create_hmac_signature(secret_key: &str, message: &str) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac = Hmac::<Sha256>::new_from_slice(secret_key.as_bytes())
.expect("HMAC accepts a key of any length");
mac.update(message.as_bytes());
let digest = mac.finalize().into_bytes();
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest.iter() {
hex.push_str(&format!("{byte:02x}"));
}
let truncated = &hex[..HMAC_SIGNATURE_HEX_LEN];
let value = u128::from_str_radix(truncated, 16).expect("30 hex chars always parse into a u128");
to_base62(value)
}
fn to_base62(mut value: u128) -> String {
if value == 0 {
return "0".to_string();
}
let base = BASE62_ALPHABET.len() as u128;
let mut digits = Vec::new();
while value > 0 {
let rem = (value % base) as usize;
digits.push(BASE62_ALPHABET[rem]);
value /= base;
}
digits.reverse();
String::from_utf8(digits).expect("base62 alphabet is valid ASCII")
}
pub fn sign_storage_content(
secret_key: &str,
resource_id: &str,
expires_in_secs: Option<i64>,
) -> String {
use base64::Engine;
let expires_at_millis = match expires_in_secs {
Some(secs) => chrono::Utc::now().timestamp_millis() + secs * 1000,
None => 0,
};
let version = STORAGE_CONTENT_SIGNATURE_VERSION;
let message = format!("{version}.{expires_at_millis}.{resource_id}");
let hmac = create_hmac_signature(secret_key, &message);
let envelope = format!("{version}.{expires_at_millis}.{hmac}");
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(envelope.as_bytes())
}
#[cfg(test)]
mod user_agent_tests {
use super::build_user_agent;
#[test]
fn is_at_home_reads_only_apify_is_at_home() {
let prev_apify = std::env::var("APIFY_IS_AT_HOME").ok();
let prev_literal = std::env::var("isAtHome").ok();
std::env::remove_var("APIFY_IS_AT_HOME");
std::env::remove_var("isAtHome");
assert!(
build_user_agent(None).contains("isAtHome/false"),
"no env var set must render isAtHome/false"
);
std::env::set_var("isAtHome", "1");
assert!(
build_user_agent(None).contains("isAtHome/false"),
"bare `isAtHome` env var must not affect the flag"
);
std::env::remove_var("isAtHome");
std::env::set_var("APIFY_IS_AT_HOME", "1");
assert!(
build_user_agent(None).contains("isAtHome/true"),
"APIFY_IS_AT_HOME must drive the flag"
);
match prev_apify {
Some(v) => std::env::set_var("APIFY_IS_AT_HOME", v),
None => std::env::remove_var("APIFY_IS_AT_HOME"),
}
match prev_literal {
Some(v) => std::env::set_var("isAtHome", v),
None => std::env::remove_var("isAtHome"),
}
}
#[test]
fn user_agent_reports_real_rust_version() {
let ua = build_user_agent(None);
let after = ua
.split("Rust/")
.nth(1)
.expect("user agent must contain a Rust/ token");
let version = after.split([')', ';']).next().unwrap_or("");
assert!(
version.chars().next().is_some_and(|c| c.is_ascii_digit()),
"Rust version must be a real compiler version, got `Rust/{version}`"
);
}
}
#[cfg(test)]
mod signature_tests {
use super::{create_hmac_signature, sign_storage_content, to_base62};
#[test]
fn hmac_signature_matches_upstream_scheme() {
let sig = create_hmac_signature("secret", "message");
assert_eq!(sig, "11GYWmGxviysIBMtnQHBk");
assert!(sig.chars().all(|c| c.is_ascii_alphanumeric()));
}
#[test]
fn base62_encoding() {
assert_eq!(to_base62(0), "0");
assert_eq!(to_base62(61), "Z");
assert_eq!(to_base62(62), "10");
}
#[test]
fn storage_content_signature_non_expiring_envelope() {
use base64::Engine;
let sig = sign_storage_content("secret", "RESID", None);
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(sig)
.expect("valid base64url");
let decoded = String::from_utf8(decoded).expect("utf8");
let expected_hmac = create_hmac_signature("secret", "0.0.RESID");
assert_eq!(decoded, format!("0.0.{expected_hmac}"));
}
}