Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,41 @@ All notable changes to the Rust Apify API client are documented here. The format
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres
to [Semantic Versioning](https://semver.org/).

## [0.11.0] - 2026-10-02

### Added
- `Build::image_digest`, matching the spec's newly-documented build field (also present on the
reference JS client's `Build`). Previously only accessible untyped via `Build::extra`.
- `ActorClient::start_raw`/`call_raw` and `RunClient::metamorph_raw`: send a raw-bytes input
(e.g. a ZIP archive, paired with `options.content_type`) instead of a JSON-serializable value,
matching the reference client's `ActorInput` now accepting raw bytes in addition to an object
or array.
- `DatasetClient::create_items_public_url_with_format`, matching the reference client's new
`format` option on `createItemsPublicUrl`: builds the shareable items URL for an export format
other than the default `json`.
- `ApiError::is_invalid_request`/`is_unauthorized`/`is_forbidden`/`is_not_found`/`is_conflict`/
`is_rate_limited`/`is_server_error`: status-classification predicates, the idiomatic equivalent
of the reference client's new `InvalidRequestError`/`UnauthorizedError`/.../`ServerError`
subclasses (Rust has no exception hierarchy to mirror them with).

### Fixed
- `ApifyClientBuilder::base_url`/`public_base_url` no longer double up the `/v2` version path
when the given URL already ends in it (e.g. one read back from `ApifyClient::api_base_url`);
matches the reference client's `toApiBaseUrl` fix.
- A failed `LogClient::stream`/`stream_with_options` request (e.g. a 404 on a nonexistent run)
now surfaces the same structured `ApiError` (status/type/message) as every other endpoint,
instead of a generic transport error — this request bypasses the buffered backend path to keep
the connection open, so it previously lost the error body.
- Request bodies are no longer compressed when their content type already carries its own
compression (images, audio, video, common archive formats): compressing them again burned CPU
for a result usually no smaller, sometimes larger. Mainly relevant to a raw-bytes Actor input
sent via the new `start_raw`/`call_raw`/`metamorph_raw`. Matches the reference client's
`isCompressibleContentType`.

### Changed
- Bumped `API_SPEC_VERSION` to `v2-2026-10-01T153946Z`.
- Bumped crate version to `0.11.0`.

## [0.10.2] - 2026-09-25

### Changed
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "apify-client"
version = "0.10.2"
version = "0.11.0"
authors = ["Apify Technologies <support@apify.com>"]
description = "An official, but experimental, AI-generated and AI-maintained Rust client for the Apify API (https://apify.com)."
license = "Apache-2.0"
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ use apify_client::{ApifyClient, RequestCompression};

let client = ApifyClient::builder()
.token("my-api-token")
.base_url("https://api.apify.com") // `/v2` is appended automatically
.base_url("https://api.apify.com") // `/v2` is appended automatically unless already present
.public_base_url("https://api.apify.com") // origin for public/shareable URLs; defaults to `base_url`
.max_retries(8) // default: 8
.min_delay_between_retries(Duration::from_millis(500)) // default: 500ms
Expand Down Expand Up @@ -275,6 +275,18 @@ match client.actor("nonexistent~actor").get().await {
`get`/`delete` operations resolve a missing resource (`404 record-not-found`) to
`Ok(None)` / a successful no-op, matching the reference clients.

`ApiError` also exposes `is_not_found()`, `is_rate_limited()`, `is_server_error()` and similar
`is_*` predicates classifying it by HTTP status — the idiomatic equivalent of the reference
clients' `NotFoundError`/`RateLimitError`/... error subclasses, since Rust has no exception
hierarchy to mirror them with.

### Cancellation

The reference JavaScript client accepts an `AbortSignal` option on most methods. Rust futures do
nothing until polled, so dropping one — e.g. via `tokio::select!`, or letting a
`tokio::time::timeout` elapse — stops the underlying request with no separate API needed; that is
the idiomatic equivalent here.

## Custom HTTP transport

The transport is a replaceable component: any type implementing `http_client::HttpBackend` can be
Expand Down
5 changes: 4 additions & 1 deletion docs/actors.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ be an Actor ID or a `username~name` (or `username/name`) reference.
| `update(fields)` | `&impl Serialize` | `Actor` | Updates the Actor. |
| `delete()` | — | `()` | Deletes the Actor. |
| `start(input, options)` | `Option<&impl Serialize>`, `ActorStartOptions` | `ActorRun` | Starts a run, returns immediately. |
| `start_raw(input, options)` | `&[u8]`, `ActorStartOptions` | `ActorRun` | Like `start`, but sends `input` as a raw request body (no JSON serialization) — for a non-JSON input such as a ZIP archive, paired with `options.content_type`. |
| `call(input, options, wait_secs)` | `Option<&impl Serialize>`, `ActorStartOptions`, `Option<i64>` | `ActorRun` | Starts a run and waits for it to finish. |
| `call_raw(input, options, wait_secs)` | `&[u8]`, `ActorStartOptions`, `Option<i64>` | `ActorRun` | `start_raw` followed by `wait_for_finish`. |
| `build(version, options)` | `&str`, `ActorBuildOptions` | `Build` | Builds a version of the Actor. |
| `default_build(wait_for_finish)` | `Option<i64>` | `BuildClient` | Resolves the Actor's default build, optionally waiting up to `wait_for_finish` seconds. |
| `validate_input(input)` | `&impl Serialize` | `serde_json::Value` | Validates input against the default build's schema. |
Expand All @@ -45,7 +47,7 @@ All fields are optional. Used by both `start` and `call` here, and by the identi
| `wait_for_finish` | `Option<i64>` | Maximum seconds to wait server-side for the run to finish (max 60). |
| `max_items` | `Option<i64>` | Maximum number of dataset items to charge (pay-per-result Actors). |
| `max_total_charge_usd` | `Option<f64>` | Maximum total charge in USD (pay-per-event Actors). |
| `content_type` | `Option<String>` | Content type of the input body. Defaults to `application/json`. |
| `content_type` | `Option<String>` | Content type of the input body. Defaults to `application/json` for `start`/`call`, or `application/octet-stream` for `start_raw`/`call_raw`, when unset. |
| `restart_on_error` | `Option<bool>` | Whether to restart the run if it fails. |
| `force_permission_level` | `Option<String>` | Override the Actor's permission level for this run. |
| `webhooks` | `Option<Vec<serde_json::Value>>` | Ad-hoc webhooks to attach to this run. Encoded as base64 JSON in the `webhooks` query parameter, matching the reference clients. |
Expand Down Expand Up @@ -153,6 +155,7 @@ if let Some(actor) = client.actor("apify~hello-world").get().await? {
| `started_at` | `Option<DateTime<Utc>>` | When the build started. |
| `finished_at` | `Option<DateTime<Utc>>` | When the build finished. |
| `build_number` | `Option<String>` | Build number, e.g. `0.1.2`. |
| `image_digest` | `Option<String>` | Digest of the built Docker image manifest (without the `sha256:` prefix); compare two builds' digests to see if their image contents differ. `None` if not yet available. |
| `extra` | `Extra` | Any other fields returned by the API. |

`Build::is_terminal()` reports whether `status` is a terminal value, mirroring `ActorRun`.
Expand Down
1 change: 1 addition & 0 deletions docs/runs.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ collections are available via `actor.runs()` and `task.runs()`.
| `delete()` | — | `()` | Deletes the run. |
| `abort(gracefully)` | `Option<bool>` | `ActorRun` | Aborts the run. `None` omits the param (server default, immediate); `Some(true)`/`Some(false)` abort gracefully/immediately. |
| `metamorph(target, input, options)` | `&str`, `Option<&impl Serialize>`, `RunMetamorphOptions` | `ActorRun` | Transforms the run into another Actor. |
| `metamorph_raw(target, input, options)` | `&str`, `&[u8]`, `RunMetamorphOptions` | `ActorRun` | Like `metamorph`, but sends `input` as a raw request body (no JSON serialization), paired with `options.content_type`. |
| `reboot()` | — | `ActorRun` | Reboots the run's container. |
| `resurrect(options)` | `RunResurrectOptions` | `ActorRun` | Resurrects a finished run. |
| `charge(options)` | `RunChargeOptions` | `()` | Charges a pay-per-event run (always sends an idempotency key). |
Expand Down
3 changes: 2 additions & 1 deletion docs/storages.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ let scratch = client.datasets().get_or_create(None).await?;
| `push_items(items)` | `&impl Serialize` | `()` | Appends items (object or array). |
| `get_statistics()` | — | `Option<Value>` | Field statistics. |
| `download_items(format, options)` | `DownloadItemsFormat`, `DatasetDownloadOptions` | `Vec<u8>` | Export items as JSON/JSONL/CSV/XLSX/XML/RSS/HTML. |
| `create_items_public_url(options, expires)` | `DatasetListItemsOptions`, `Option<i64>` | `String` | Shareable (HMAC-signed for private) items URL. |
| `create_items_public_url(options, expires)` | `DatasetListItemsOptions`, `Option<i64>` | `String` | Shareable (HMAC-signed for private) items URL, served as `json`. |
| `create_items_public_url_with_format(options, expires, format)` | `DatasetListItemsOptions`, `Option<i64>`, `Option<DownloadItemsFormat>` | `String` | Like `create_items_public_url`, but lets the URL serve another export format (CSV, XLSX, ...). `None` defaults to `json`. |

`DatasetListItemsOptions` (all optional):

Expand Down
78 changes: 66 additions & 12 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ use crate::http_client::{

/// Default base URL of the Apify API (without the `/v2` suffix).
const DEFAULT_BASE_URL: &str = "https://api.apify.com";
/// Apify's API version path segment, appended to a configured base URL unless it is already
/// present there (see [`to_api_base_url`]).
const API_VERSION_PATH: &str = "/v2";
/// Default maximum number of retries, matching the reference clients.
const DEFAULT_MAX_RETRIES: u32 = 8;
/// Default minimum delay between retries.
Expand Down Expand Up @@ -88,7 +91,10 @@ impl ApifyClientBuilder {
self
}

/// Overrides the base URL of the API. The `/v2` suffix is appended automatically.
/// Overrides the base URL of the API, with or without the `/v2` version path — it is
/// appended automatically when not already the URL's final path segment, so passing a URL
/// that already ends in `/v2` (e.g. one read back from [`ApifyClient::api_base_url`]) does
/// not double it up.
///
/// Defaults to `https://api.apify.com`.
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
Expand All @@ -97,8 +103,8 @@ impl ApifyClientBuilder {
}

/// Overrides the base URL used when building public, shareable resource URLs (e.g. a
/// signed dataset-items URL). Defaults to the API base URL. The `/v2` suffix is appended
/// automatically.
/// signed dataset-items URL). Defaults to the API base URL. Like [`base_url`](Self::base_url),
/// the `/v2` version path is appended automatically only when not already present.
pub fn public_base_url(mut self, public_base_url: impl Into<String>) -> Self {
self.public_base_url = Some(public_base_url.into());
self
Expand Down Expand Up @@ -170,15 +176,9 @@ impl ApifyClientBuilder {
self.request_compression,
);

let trimmed = self.base_url.trim_end_matches('/');
let base_url = format!("{trimmed}/v2");

let public_trimmed = self
.public_base_url
.as_deref()
.unwrap_or(&self.base_url)
.trim_end_matches('/');
let public_base_url = format!("{public_trimmed}/v2");
let base_url = to_api_base_url(&self.base_url);
let public_base_url =
to_api_base_url(self.public_base_url.as_deref().unwrap_or(&self.base_url));

ApifyClient {
http,
Expand Down Expand Up @@ -401,3 +401,57 @@ impl ApifyClient {
self.run(run_id).update(&body).await
}
}

/// Appends [`API_VERSION_PATH`] to `url` unless it is already the final path segment, mirroring
/// the reference client's `toApiBaseUrl`.
///
/// A trailing slash (or several) is trimmed first, so `https://host/v2/` and `https://host/v2//`
/// both normalize to `https://host/v2` rather than growing a double slash. The check looks only
/// at the URL's *path*, not at the string as a whole: `https://v2` has no path (`v2` is the
/// host), so it still becomes `https://v2/v2` even though the raw string ends in `v2`.
fn to_api_base_url(url: &str) -> String {
let (authority, path) = split_authority(url);
let path = path.trim_end_matches('/');
if path.ends_with(API_VERSION_PATH) {
format!("{authority}{path}")
} else {
format!("{authority}{path}{API_VERSION_PATH}")
}
}

/// Splits a URL into its authority (`scheme://host[:port]`, or the whole string if there is no
/// path) and its path (starting with `/`, or empty if there is none).
fn split_authority(url: &str) -> (&str, &str) {
let search_from = url.find("://").map(|i| i + 3).unwrap_or(0);
match url[search_from..].find('/') {
Some(rel_idx) => url.split_at(search_from + rel_idx),
None => (url, ""),
}
}

#[cfg(test)]
mod tests {
use super::to_api_base_url;

/// Mirrors the reference client's `toApiBaseUrl` test matrix exactly, so the two clients
/// agree on every case, including the `https://v2` host-vs-path edge case.
#[test]
fn to_api_base_url_matches_reference_matrix() {
let cases = [
("https://example.com", "https://example.com/v2"),
("https://example.com/", "https://example.com/v2"),
("https://example.com/v2", "https://example.com/v2"),
("https://example.com/v2/", "https://example.com/v2"),
("https://example.com/v2//", "https://example.com/v2"),
(
"https://example.com/proxy/v2",
"https://example.com/proxy/v2",
),
("https://example.com/apiv2", "https://example.com/apiv2/v2"),
("https://v2", "https://v2/v2"),
];
for (input, expected) in cases {
assert_eq!(to_api_base_url(input), expected, "input: {input}");
}
}
}
54 changes: 48 additions & 6 deletions src/clients/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,22 +123,52 @@ impl ActorClient {

/// Starts the Actor and returns immediately with the created run.
///
/// `input` is any JSON-serializable value (or `None` for no input).
/// `input` is any JSON-serializable value (or `None` for no input). To send a non-JSON
/// input (e.g. a ZIP archive) as raw bytes instead, use [`start_raw`](Self::start_raw).
pub async fn start<T: Serialize>(
&self,
input: Option<&T>,
options: ActorStartOptions,
) -> ApifyClientResult<ActorRun> {
let body = match input {
Some(value) => Some(serde_json::to_vec(value)?),
None => None,
};
self.start_with_body(body, "application/json", options)
.await
}

/// Starts the Actor with a raw request body instead of a JSON-serializable value.
///
/// Use this for a non-JSON input, e.g. a ZIP archive paired with
/// `options.content_type = Some("application/zip".into())`; the bytes are sent exactly as
/// given, with no JSON serialization. For an object or array input, use
/// [`start`](Self::start) instead, which handles the serialization. Mirrors the reference
/// client, whose `ActorInput` accepts a plain object, an array of them, or raw bytes.
pub async fn start_raw(
&self,
input: &[u8],
options: ActorStartOptions,
) -> ApifyClientResult<ActorRun> {
self.start_with_body(Some(input.to_vec()), "application/octet-stream", options)
.await
}

/// Shared implementation of [`start`](Self::start) and [`start_raw`](Self::start_raw):
/// applies the run-start options as query parameters and posts the given body, falling back
/// to `default_content_type` only when the caller did not set `options.content_type`.
async fn start_with_body(
&self,
body: Option<Vec<u8>>,
default_content_type: &str,
options: ActorStartOptions,
) -> ApifyClientResult<ActorRun> {
let mut params = QueryParams::new();
options.apply(&mut params);
let content_type = options
.content_type
.clone()
.unwrap_or_else(|| "application/json".to_string());
let body = match input {
Some(value) => Some(serde_json::to_vec(value)?),
None => None,
};
.unwrap_or_else(|| default_content_type.to_string());
post_with_body(&self.ctx, Some("runs"), &params, body, &content_type).await
}

Expand All @@ -160,6 +190,18 @@ impl ActorClient {
self.root.run(run.id).wait_for_finish(wait_secs).await
}

/// Starts the Actor with a raw request body ([`start_raw`](Self::start_raw)) and waits for
/// it to finish, exactly like [`call`](Self::call) but for a non-JSON input.
pub async fn call_raw(
&self,
input: &[u8],
options: ActorStartOptions,
wait_secs: Option<i64>,
) -> ApifyClientResult<ActorRun> {
let run = self.start_raw(input, options).await?;
self.root.run(run.id).wait_for_finish(wait_secs).await
}

/// Builds the given version of the Actor and returns the created build.
pub async fn build(
&self,
Expand Down
18 changes: 17 additions & 1 deletion src/clients/dataset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,14 +338,30 @@ impl DatasetClient {
/// the dataset exposes a URL-signing secret key (i.e. it is private), appends an
/// HMAC-SHA256 `signature` so the URL grants access without an API token. `expires_in_secs`
/// optionally bounds the validity of a signed URL. The URL is built from the configured
/// public base URL.
/// public base URL. The served items are JSON; to request another export format, use
/// [`create_items_public_url_with_format`](Self::create_items_public_url_with_format).
pub async fn create_items_public_url(
&self,
options: DatasetListItemsOptions,
expires_in_secs: Option<i64>,
) -> ApifyClientResult<String> {
self.create_items_public_url_with_format(options, expires_in_secs, None)
.await
}

/// Like [`create_items_public_url`](Self::create_items_public_url), but lets the caller
/// choose the `format` the items are served in (JSON, CSV, XLSX, ...) via a `format` query
/// parameter on the generated URL. `None` defaults to `json`, matching the endpoint's own
/// default.
pub async fn create_items_public_url_with_format(
&self,
options: DatasetListItemsOptions,
expires_in_secs: Option<i64>,
format: Option<DownloadItemsFormat>,
) -> ApifyClientResult<String> {
let mut params = QueryParams::new();
options.apply(&mut params);
params.add_str("format", format.map(|f| f.as_str().to_string()));

if let Some(dataset) = self.get().await? {
if let Some(secret) = dataset
Expand Down
Loading
Loading