Skip to main content

apify_client/
client.rs

1//! The top-level [`ApifyClient`] and its [`ApifyClientBuilder`].
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use crate::clients::actor::ActorClient;
7use crate::clients::actor_collection::ActorCollectionClient;
8use crate::clients::build::BuildClient;
9use crate::clients::build_collection::BuildCollectionClient;
10use crate::clients::dataset::DatasetClient;
11use crate::clients::dataset_collection::DatasetCollectionClient;
12use crate::clients::key_value_store::KeyValueStoreClient;
13use crate::clients::key_value_store_collection::KeyValueStoreCollectionClient;
14use crate::clients::log::LogClient;
15use crate::clients::request_queue::RequestQueueClient;
16use crate::clients::request_queue_collection::RequestQueueCollectionClient;
17use crate::clients::run::RunClient;
18use crate::clients::run_collection::RunCollectionClient;
19use crate::clients::schedule::ScheduleClient;
20use crate::clients::schedule_collection::ScheduleCollectionClient;
21use crate::clients::store_collection::StoreCollectionClient;
22use crate::clients::task::TaskClient;
23use crate::clients::task_collection::TaskCollectionClient;
24use crate::clients::user::UserClient;
25use crate::clients::webhook::WebhookClient;
26use crate::clients::webhook_collection::WebhookCollectionClient;
27use crate::clients::webhook_dispatch::WebhookDispatchClient;
28use crate::clients::webhook_dispatch_collection::WebhookDispatchCollectionClient;
29use crate::common::build_user_agent;
30use crate::http_client::{
31    HttpBackend, HttpClient, RequestCompression, ReqwestBackend, RetryConfig,
32};
33
34/// Default base URL of the Apify API (without the `/v2` suffix).
35const DEFAULT_BASE_URL: &str = "https://api.apify.com";
36/// Apify's API version path segment, appended to a configured base URL unless it is already
37/// present there (see [`to_api_base_url`]).
38const API_VERSION_PATH: &str = "/v2";
39/// Default maximum number of retries, matching the reference clients.
40const DEFAULT_MAX_RETRIES: u32 = 8;
41/// Default minimum delay between retries.
42const DEFAULT_MIN_DELAY_BETWEEN_RETRIES: Duration = Duration::from_millis(500);
43/// Default overall per-request timeout (6 minutes), matching the reference clients.
44const DEFAULT_TIMEOUT: Duration = Duration::from_secs(360);
45/// Placeholder used to address the current user (`/users/me`).
46pub(crate) const ME_USER_PLACEHOLDER: &str = "me";
47
48/// Builder for [`ApifyClient`].
49///
50/// # Example
51/// ```no_run
52/// use apify_client::ApifyClient;
53///
54/// let client = ApifyClient::builder()
55///     .token("my-api-token")
56///     .max_retries(5)
57///     .build();
58/// ```
59pub struct ApifyClientBuilder {
60    token: Option<String>,
61    base_url: String,
62    public_base_url: Option<String>,
63    max_retries: u32,
64    min_delay_between_retries: Duration,
65    timeout: Duration,
66    user_agent_suffix: Option<String>,
67    http_backend: Option<Arc<dyn HttpBackend>>,
68    request_compression: RequestCompression,
69}
70
71impl Default for ApifyClientBuilder {
72    fn default() -> Self {
73        Self {
74            token: None,
75            base_url: DEFAULT_BASE_URL.to_string(),
76            public_base_url: None,
77            max_retries: DEFAULT_MAX_RETRIES,
78            min_delay_between_retries: DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
79            timeout: DEFAULT_TIMEOUT,
80            user_agent_suffix: None,
81            http_backend: None,
82            request_compression: RequestCompression::default(),
83        }
84    }
85}
86
87impl ApifyClientBuilder {
88    /// Sets the API token used for authentication (sent as a `Bearer` token).
89    pub fn token(mut self, token: impl Into<String>) -> Self {
90        self.token = Some(token.into());
91        self
92    }
93
94    /// Overrides the base URL of the API, with or without the `/v2` version path — it is
95    /// appended automatically when not already the URL's final path segment, so passing a URL
96    /// that already ends in `/v2` (e.g. one read back from [`ApifyClient::api_base_url`]) does
97    /// not double it up.
98    ///
99    /// Defaults to `https://api.apify.com`.
100    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
101        self.base_url = base_url.into();
102        self
103    }
104
105    /// Overrides the base URL used when building public, shareable resource URLs (e.g. a
106    /// signed dataset-items URL). Defaults to the API base URL. Like [`base_url`](Self::base_url),
107    /// the `/v2` version path is appended automatically only when not already present.
108    pub fn public_base_url(mut self, public_base_url: impl Into<String>) -> Self {
109        self.public_base_url = Some(public_base_url.into());
110        self
111    }
112
113    /// Sets the maximum number of retries for failed requests (default `8`).
114    pub fn max_retries(mut self, max_retries: u32) -> Self {
115        self.max_retries = max_retries;
116        self
117    }
118
119    /// Sets the minimum delay between retries (default `500ms`).
120    pub fn min_delay_between_retries(mut self, delay: Duration) -> Self {
121        self.min_delay_between_retries = delay;
122        self
123    }
124
125    /// Sets the overall per-request timeout (default `360s`).
126    pub fn timeout(mut self, timeout: Duration) -> Self {
127        self.timeout = timeout;
128        self
129    }
130
131    /// Appends a custom suffix to the `User-Agent` header.
132    pub fn user_agent_suffix(mut self, suffix: impl Into<String>) -> Self {
133        self.user_agent_suffix = Some(suffix.into());
134        self
135    }
136
137    /// Selects the algorithm used to compress large request bodies (default
138    /// [`RequestCompression::Brotli`]).
139    ///
140    /// The client compresses request bodies of at least 1024 bytes; this chooses whether that
141    /// uses brotli (`Content-Encoding: br`) or gzip (`Content-Encoding: gzip`). Brotli is
142    /// preferred (matching the reference client); select [`RequestCompression::Gzip`] for
143    /// environments or intermediaries that do not handle brotli.
144    pub fn request_compression(mut self, compression: RequestCompression) -> Self {
145        self.request_compression = compression;
146        self
147    }
148
149    /// Replaces the default [`reqwest`]-based HTTP backend with a custom implementation.
150    ///
151    /// This is the seam that makes the transport a replaceable component.
152    pub fn http_backend(mut self, backend: Arc<dyn HttpBackend>) -> Self {
153        self.http_backend = Some(backend);
154        self
155    }
156
157    /// Builds the configured [`ApifyClient`].
158    pub fn build(self) -> ApifyClient {
159        let backend = self
160            .http_backend
161            .unwrap_or_else(|| Arc::new(ReqwestBackend::new()));
162
163        let user_agent = build_user_agent(self.user_agent_suffix.as_deref());
164
165        let retry = RetryConfig {
166            max_retries: self.max_retries,
167            min_delay_between_retries: self.min_delay_between_retries,
168            timeout: self.timeout,
169        };
170
171        let http = HttpClient::new(
172            backend,
173            self.token,
174            user_agent,
175            retry,
176            self.request_compression,
177        );
178
179        let base_url = to_api_base_url(&self.base_url);
180        let public_base_url =
181            to_api_base_url(self.public_base_url.as_deref().unwrap_or(&self.base_url));
182
183        ApifyClient {
184            http,
185            base_url,
186            public_base_url,
187        }
188    }
189}
190
191/// The entry point for interacting with the Apify API.
192///
193/// Construct it with [`ApifyClient::builder`] (or [`ApifyClient::new`] for a quick
194/// token-only setup), then obtain resource clients via the accessor methods, e.g.
195/// [`ApifyClient::actor`], [`ApifyClient::dataset`], [`ApifyClient::run`].
196///
197/// The client is cheap to clone — all internal state is reference-counted.
198#[derive(Debug, Clone)]
199pub struct ApifyClient {
200    http: HttpClient,
201    base_url: String,
202    public_base_url: String,
203}
204
205impl ApifyClient {
206    /// Returns a [`ApifyClientBuilder`] for configuring a client.
207    pub fn builder() -> ApifyClientBuilder {
208        ApifyClientBuilder::default()
209    }
210
211    /// Creates a client authenticated with the given API token and default settings.
212    pub fn new(token: impl Into<String>) -> Self {
213        ApifyClientBuilder::default().token(token).build()
214    }
215
216    pub(crate) fn http(&self) -> HttpClient {
217        self.http.clone()
218    }
219
220    /// The `User-Agent` header value this client sends.
221    pub fn user_agent(&self) -> &str {
222        self.http.user_agent()
223    }
224
225    /// The fully-qualified API base URL this client targets (including the `/v2` suffix),
226    /// e.g. `https://api.apify.com/v2`. Reflects any `base_url` override.
227    pub fn api_base_url(&self) -> &str {
228        &self.base_url
229    }
230
231    // ----- Actor accessors -------------------------------------------------
232
233    /// Returns a client for the Actor collection (list & create Actors).
234    pub fn actors(&self) -> ActorCollectionClient {
235        ActorCollectionClient::new(self.http(), &self.base_url)
236    }
237
238    /// Returns a client for a specific Actor, addressed by ID or `username~name`.
239    pub fn actor(&self, id: impl Into<String>) -> ActorClient {
240        ActorClient::new(self.clone(), self.http(), &self.base_url, &id.into())
241    }
242
243    // ----- Build accessors -------------------------------------------------
244
245    /// Returns a client for the Actor build collection (list builds).
246    pub fn builds(&self) -> BuildCollectionClient {
247        BuildCollectionClient::new(self.http(), &self.base_url)
248    }
249
250    /// Returns a client for a specific Actor build.
251    pub fn build(&self, id: impl Into<String>) -> BuildClient {
252        BuildClient::new(self.http(), &self.base_url, &id.into())
253    }
254
255    // ----- Run accessors ---------------------------------------------------
256
257    /// Returns a client for the Actor run collection (list runs).
258    pub fn runs(&self) -> RunCollectionClient {
259        RunCollectionClient::new(self.http(), &self.base_url, "actor-runs")
260    }
261
262    /// Returns a client for a specific Actor run.
263    pub fn run(&self, id: impl Into<String>) -> RunClient {
264        RunClient::new(
265            self.clone(),
266            self.http(),
267            &self.base_url,
268            "actor-runs",
269            &id.into(),
270        )
271    }
272
273    // ----- Dataset accessors ----------------------------------------------
274
275    /// Returns a client for the dataset collection (list & get-or-create datasets).
276    pub fn datasets(&self) -> DatasetCollectionClient {
277        DatasetCollectionClient::new(self.http(), &self.base_url)
278    }
279
280    /// Returns a client for a specific dataset, addressed by ID or name.
281    pub fn dataset(&self, id: impl Into<String>) -> DatasetClient {
282        DatasetClient::new(self.http(), &self.base_url, "datasets", &id.into())
283            .with_public_base(&self.public_base_url)
284    }
285
286    // ----- Key-value store accessors --------------------------------------
287
288    /// Returns a client for the key-value store collection.
289    pub fn key_value_stores(&self) -> KeyValueStoreCollectionClient {
290        KeyValueStoreCollectionClient::new(self.http(), &self.base_url)
291    }
292
293    /// Returns a client for a specific key-value store, addressed by ID or name.
294    pub fn key_value_store(&self, id: impl Into<String>) -> KeyValueStoreClient {
295        KeyValueStoreClient::new(self.http(), &self.base_url, "key-value-stores", &id.into())
296            .with_public_base(&self.public_base_url)
297    }
298
299    // ----- Request queue accessors ----------------------------------------
300
301    /// Returns a client for the request queue collection.
302    pub fn request_queues(&self) -> RequestQueueCollectionClient {
303        RequestQueueCollectionClient::new(self.http(), &self.base_url)
304    }
305
306    /// Returns a client for a specific request queue, addressed by ID or name.
307    pub fn request_queue(&self, id: impl Into<String>) -> RequestQueueClient {
308        RequestQueueClient::new(self.http(), &self.base_url, "request-queues", &id.into())
309    }
310
311    // ----- Task accessors --------------------------------------------------
312
313    /// Returns a client for the Actor task collection (list & create tasks).
314    pub fn tasks(&self) -> TaskCollectionClient {
315        TaskCollectionClient::new(self.http(), &self.base_url)
316    }
317
318    /// Returns a client for a specific Actor task.
319    pub fn task(&self, id: impl Into<String>) -> TaskClient {
320        TaskClient::new(self.clone(), self.http(), &self.base_url, &id.into())
321    }
322
323    // ----- Schedule accessors ---------------------------------------------
324
325    /// Returns a client for the schedule collection (list & create schedules).
326    pub fn schedules(&self) -> ScheduleCollectionClient {
327        ScheduleCollectionClient::new(self.http(), &self.base_url)
328    }
329
330    /// Returns a client for a specific schedule.
331    pub fn schedule(&self, id: impl Into<String>) -> ScheduleClient {
332        ScheduleClient::new(self.http(), &self.base_url, &id.into())
333    }
334
335    // ----- Webhook accessors ----------------------------------------------
336
337    /// Returns a client for the webhook collection (list & create webhooks).
338    pub fn webhooks(&self) -> WebhookCollectionClient {
339        WebhookCollectionClient::new(self.http(), &self.base_url)
340    }
341
342    /// Returns a client for a specific webhook.
343    pub fn webhook(&self, id: impl Into<String>) -> WebhookClient {
344        WebhookClient::new(self.http(), &self.base_url, &id.into())
345    }
346
347    /// Returns a client for the webhook dispatch collection.
348    pub fn webhook_dispatches(&self) -> WebhookDispatchCollectionClient {
349        WebhookDispatchCollectionClient::new(self.http(), &self.base_url)
350    }
351
352    /// Returns a client for a specific webhook dispatch.
353    pub fn webhook_dispatch(&self, id: impl Into<String>) -> WebhookDispatchClient {
354        WebhookDispatchClient::new(self.http(), &self.base_url, &id.into())
355    }
356
357    // ----- Misc accessors --------------------------------------------------
358
359    /// Returns a client for browsing the Apify Store.
360    pub fn store(&self) -> StoreCollectionClient {
361        StoreCollectionClient::new(self.http(), &self.base_url)
362    }
363
364    /// Returns a client for accessing a build's or run's log.
365    pub fn log(&self, build_or_run_id: impl Into<String>) -> LogClient {
366        LogClient::new(self.http(), &self.base_url, "logs", &build_or_run_id.into())
367    }
368
369    /// Returns a client for the current user (`/users/me`).
370    pub fn me(&self) -> UserClient {
371        UserClient::new(self.http(), &self.base_url, ME_USER_PLACEHOLDER)
372    }
373
374    /// Returns a client for a specific user by ID or username.
375    pub fn user(&self, id: impl Into<String>) -> UserClient {
376        UserClient::new(self.http(), &self.base_url, &id.into())
377    }
378
379    /// Sets the status message of the current Actor run.
380    ///
381    /// This convenience method updates the run identified by the `ACTOR_RUN_ID` environment
382    /// variable, so it only works when called from inside an Actor run. If
383    /// `is_terminal` is `true`, the message becomes final and won't be overwritten.
384    ///
385    /// Returns [`ApifyClientError::InvalidArgument`](crate::ApifyClientError::InvalidArgument)
386    /// if `ACTOR_RUN_ID` is not set.
387    pub async fn set_status_message(
388        &self,
389        message: &str,
390        is_terminal: bool,
391    ) -> crate::error::ApifyClientResult<crate::models::ActorRun> {
392        let run_id = std::env::var("ACTOR_RUN_ID").map_err(|_| {
393            crate::error::ApifyClientError::InvalidArgument(
394                "ACTOR_RUN_ID environment variable is not set".to_string(),
395            )
396        })?;
397        let body = serde_json::json!({
398            "statusMessage": message,
399            "isStatusMessageTerminal": is_terminal,
400        });
401        self.run(run_id).update(&body).await
402    }
403}
404
405/// Appends [`API_VERSION_PATH`] to `url` unless it is already the final path segment, mirroring
406/// the reference client's `toApiBaseUrl`.
407///
408/// A trailing slash (or several) is trimmed first, so `https://host/v2/` and `https://host/v2//`
409/// both normalize to `https://host/v2` rather than growing a double slash. The check looks only
410/// at the URL's *path*, not at the string as a whole: `https://v2` has no path (`v2` is the
411/// host), so it still becomes `https://v2/v2` even though the raw string ends in `v2`.
412fn to_api_base_url(url: &str) -> String {
413    let (authority, path) = split_authority(url);
414    let path = path.trim_end_matches('/');
415    if path.ends_with(API_VERSION_PATH) {
416        format!("{authority}{path}")
417    } else {
418        format!("{authority}{path}{API_VERSION_PATH}")
419    }
420}
421
422/// Splits a URL into its authority (`scheme://host[:port]`, or the whole string if there is no
423/// path) and its path (starting with `/`, or empty if there is none).
424fn split_authority(url: &str) -> (&str, &str) {
425    let search_from = url.find("://").map(|i| i + 3).unwrap_or(0);
426    match url[search_from..].find('/') {
427        Some(rel_idx) => url.split_at(search_from + rel_idx),
428        None => (url, ""),
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::to_api_base_url;
435
436    /// Mirrors the reference client's `toApiBaseUrl` test matrix exactly, so the two clients
437    /// agree on every case, including the `https://v2` host-vs-path edge case.
438    #[test]
439    fn to_api_base_url_matches_reference_matrix() {
440        let cases = [
441            ("https://example.com", "https://example.com/v2"),
442            ("https://example.com/", "https://example.com/v2"),
443            ("https://example.com/v2", "https://example.com/v2"),
444            ("https://example.com/v2/", "https://example.com/v2"),
445            ("https://example.com/v2//", "https://example.com/v2"),
446            (
447                "https://example.com/proxy/v2",
448                "https://example.com/proxy/v2",
449            ),
450            ("https://example.com/apiv2", "https://example.com/apiv2/v2"),
451            ("https://v2", "https://v2/v2"),
452        ];
453        for (input, expected) in cases {
454            assert_eq!(to_api_base_url(input), expected, "input: {input}");
455        }
456    }
457}