diff --git a/CHANGELOG.md b/CHANGELOG.md index d6ef020..685eee4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.toml b/Cargo.toml index d1a66da..6b1da0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "apify-client" -version = "0.10.2" +version = "0.11.0" authors = ["Apify Technologies "] description = "An official, but experimental, AI-generated and AI-maintained Rust client for the Apify API (https://apify.com)." license = "Apache-2.0" diff --git a/README.md b/README.md index 1c38650..36ac5c3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/docs/actors.md b/docs/actors.md index 5998f92..1926457 100644 --- a/docs/actors.md +++ b/docs/actors.md @@ -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` | `ActorRun` | Starts a run and waits for it to finish. | +| `call_raw(input, options, wait_secs)` | `&[u8]`, `ActorStartOptions`, `Option` | `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` | `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. | @@ -45,7 +47,7 @@ All fields are optional. Used by both `start` and `call` here, and by the identi | `wait_for_finish` | `Option` | Maximum seconds to wait server-side for the run to finish (max 60). | | `max_items` | `Option` | Maximum number of dataset items to charge (pay-per-result Actors). | | `max_total_charge_usd` | `Option` | Maximum total charge in USD (pay-per-event Actors). | -| `content_type` | `Option` | Content type of the input body. Defaults to `application/json`. | +| `content_type` | `Option` | 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` | Whether to restart the run if it fails. | | `force_permission_level` | `Option` | Override the Actor's permission level for this run. | | `webhooks` | `Option>` | Ad-hoc webhooks to attach to this run. Encoded as base64 JSON in the `webhooks` query parameter, matching the reference clients. | @@ -153,6 +155,7 @@ if let Some(actor) = client.actor("apify~hello-world").get().await? { | `started_at` | `Option>` | When the build started. | | `finished_at` | `Option>` | When the build finished. | | `build_number` | `Option` | Build number, e.g. `0.1.2`. | +| `image_digest` | `Option` | 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`. diff --git a/docs/runs.md b/docs/runs.md index d8028ad..09ade49 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -19,6 +19,7 @@ collections are available via `actor.runs()` and `task.runs()`. | `delete()` | — | `()` | Deletes the run. | | `abort(gracefully)` | `Option` | `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). | diff --git a/docs/storages.md b/docs/storages.md index 2d48446..2baae76 100644 --- a/docs/storages.md +++ b/docs/storages.md @@ -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` | Field statistics. | | `download_items(format, options)` | `DownloadItemsFormat`, `DatasetDownloadOptions` | `Vec` | Export items as JSON/JSONL/CSV/XLSX/XML/RSS/HTML. | -| `create_items_public_url(options, expires)` | `DatasetListItemsOptions`, `Option` | `String` | Shareable (HMAC-signed for private) items URL. | +| `create_items_public_url(options, expires)` | `DatasetListItemsOptions`, `Option` | `String` | Shareable (HMAC-signed for private) items URL, served as `json`. | +| `create_items_public_url_with_format(options, expires, format)` | `DatasetListItemsOptions`, `Option`, `Option` | `String` | Like `create_items_public_url`, but lets the URL serve another export format (CSV, XLSX, ...). `None` defaults to `json`. | `DatasetListItemsOptions` (all optional): diff --git a/src/client.rs b/src/client.rs index 78742b6..87fe490 100644 --- a/src/client.rs +++ b/src/client.rs @@ -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. @@ -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) -> Self { @@ -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) -> Self { self.public_base_url = Some(public_base_url.into()); self @@ -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, @@ -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}"); + } + } +} diff --git a/src/clients/actor.rs b/src/clients/actor.rs index c1318f3..4539ca5 100644 --- a/src/clients/actor.rs +++ b/src/clients/actor.rs @@ -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( &self, input: Option<&T>, options: ActorStartOptions, + ) -> ApifyClientResult { + 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 { + 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>, + default_content_type: &str, + options: ActorStartOptions, ) -> ApifyClientResult { 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"), ¶ms, body, &content_type).await } @@ -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, + ) -> ApifyClientResult { + 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, diff --git a/src/clients/dataset.rs b/src/clients/dataset.rs index d59a815..70b7a37 100644 --- a/src/clients/dataset.rs +++ b/src/clients/dataset.rs @@ -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, + ) -> ApifyClientResult { + 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, + format: Option, ) -> ApifyClientResult { 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 diff --git a/src/clients/log.rs b/src/clients/log.rs index 933672a..ca78aea 100644 --- a/src/clients/log.rs +++ b/src/clients/log.rs @@ -116,10 +116,29 @@ impl LogClient { let response = builder.send().await.map_err(ApifyClientError::from)?; if !response.status().is_success() { - return Err(ApifyClientError::InvalidResponse(format!( - "log stream returned status {}", - response.status().as_u16() - ))); + let status = response.status().as_u16(); + // The error body arrives as a stream here too (this request never goes through the + // buffered backend path). Read it so the failure carries the same structured + // `ApiError` (type/message/data) as every other endpoint, rather than losing the body + // to a generic transport error. A body that fails to read (e.g. the connection drops + // mid-response) still surfaces the status code, just without a parsed message. + let body = response + .bytes() + .await + .map(|b| b.to_vec()) + .unwrap_or_default(); + let http_response = crate::http_client::HttpResponse { + status, + headers: Default::default(), + body, + }; + let api_error = crate::http_client::build_api_error( + &http_response, + 1, + "GET", + &crate::http_client::extract_path(&url), + ); + return Err(ApifyClientError::from(api_error)); } let byte_stream = response.bytes_stream(); diff --git a/src/clients/run.rs b/src/clients/run.rs index d9559ff..b365fc1 100644 --- a/src/clients/run.rs +++ b/src/clients/run.rs @@ -140,25 +140,61 @@ impl RunClient { /// /// `options.content_type` sets the content type of the input body (defaulting to /// `application/json`), matching the reference client's `metamorph(..., { contentType })`. + /// To send a non-JSON input as raw bytes instead, use [`metamorph_raw`](Self::metamorph_raw). pub async fn metamorph( &self, target_actor_id: &str, input: Option<&T>, options: RunMetamorphOptions, ) -> ApifyClientResult { - let mut params = QueryParams::new(); - params - .add_str("targetActorId", Some(to_safe_id(target_actor_id))) - .add_str("build", options.build); let body = match input { Some(value) => Some(serde_json::to_vec(value)?), None => None, }; + self.metamorph_with_body(target_actor_id, body, "application/json", options) + .await + } + + /// Transforms the run into a run of another Actor (metamorph) with a raw request body + /// instead of a JSON-serializable value. + /// + /// The bytes are sent exactly as given, with no JSON serialization — pair this with + /// `options.content_type` for a non-JSON input. For an object or array input, use + /// [`metamorph`](Self::metamorph) instead. + pub async fn metamorph_raw( + &self, + target_actor_id: &str, + input: &[u8], + options: RunMetamorphOptions, + ) -> ApifyClientResult { + self.metamorph_with_body( + target_actor_id, + Some(input.to_vec()), + "application/octet-stream", + options, + ) + .await + } + + /// Shared implementation of [`metamorph`](Self::metamorph) and + /// [`metamorph_raw`](Self::metamorph_raw). + async fn metamorph_with_body( + &self, + target_actor_id: &str, + body: Option>, + default_content_type: &str, + options: RunMetamorphOptions, + ) -> ApifyClientResult { + let mut params = QueryParams::new(); + params + .add_str("targetActorId", Some(to_safe_id(target_actor_id))) + .add_str("build", options.build); let content_type = options .content_type .as_deref() - .unwrap_or("application/json"); - post_with_body(&self.ctx, Some("metamorph"), ¶ms, body, content_type).await + .unwrap_or(default_content_type) + .to_string(); + post_with_body(&self.ctx, Some("metamorph"), ¶ms, body, &content_type).await } /// Reboots the run (restarts its container, preserving the run ID and storages). diff --git a/src/error.rs b/src/error.rs index 098c6a2..6958f33 100644 --- a/src/error.rs +++ b/src/error.rs @@ -59,6 +59,56 @@ impl std::fmt::Display for ApiError { impl std::error::Error for ApiError {} +impl ApiError { + /// `true` for HTTP 400 Bad Request, typically because the request failed validation. + /// + /// The reference clients throw a distinct `InvalidRequestError` subclass for this status; + /// since Rust has no exception hierarchy to mirror, these `is_*` predicates are the + /// idiomatic equivalent classification on the one [`ApiError`] type. + pub fn is_invalid_request(&self) -> bool { + self.status_code == 400 + } + + /// `true` for HTTP 401 Unauthorized: the token is missing or invalid. + pub fn is_unauthorized(&self) -> bool { + self.status_code == 401 + } + + /// `true` for HTTP 403 Forbidden: the token lacks permission for the operation. + pub fn is_forbidden(&self) -> bool { + self.status_code == 403 + } + + /// `true` for HTTP 404 Not Found. + /// + /// Most `get`-style methods already map a `404` to `None` rather than an error (see e.g. + /// [`crate::clients::actor::ActorClient::get`]), so this is mainly useful for the methods + /// that return other errors directly, e.g. [`crate::clients::actor::ActorClient::start`] + /// with a nonexistent Actor ID. + pub fn is_not_found(&self) -> bool { + self.status_code == 404 + } + + /// `true` for HTTP 409 Conflict. + pub fn is_conflict(&self) -> bool { + self.status_code == 409 + } + + /// `true` for HTTP 429 Too Many Requests. The client already retries these internally (see + /// [`crate::ApifyClientBuilder::max_retries`]), so this surfaces only once retries are + /// exhausted. + pub fn is_rate_limited(&self) -> bool { + self.status_code == 429 + } + + /// `true` for an HTTP 5xx status. Like [`is_rate_limited`](Self::is_rate_limited), the + /// client already retries these internally, so this surfaces only once retries are + /// exhausted. + pub fn is_server_error(&self) -> bool { + self.status_code >= 500 + } +} + /// The top-level error type for all client operations. #[derive(Debug, thiserror::Error)] pub enum ApifyClientError { diff --git a/src/http_client.rs b/src/http_client.rs index 62f5628..433b767 100644 --- a/src/http_client.rs +++ b/src/http_client.rs @@ -48,6 +48,63 @@ const BROTLI_BUFFER_SIZE: usize = 4096; /// default level (6). const GZIP_COMPRESSION_LEVEL: u32 = 6; +/// Media-type prefixes whose payloads already carry their own compression, so running them +/// through brotli/gzip burns CPU and memory for a result that is usually no smaller — and the +/// request also keeps the intended `Content-Type`, rather than becoming an unreadable +/// `Content-Encoding: br` blob the destination may not expect for these formats. +/// Matches the reference client's `ALREADY_COMPRESSED_MEDIA_TYPE_PREFIXES`. +const ALREADY_COMPRESSED_MEDIA_TYPE_PREFIXES: [&str; 3] = ["audio/", "image/", "video/"]; + +/// Exact media types (outside the prefixes above) whose payloads already carry their own +/// compression. Matches the reference client's `ALREADY_COMPRESSED_MEDIA_TYPES`. +const ALREADY_COMPRESSED_MEDIA_TYPES: [&str; 19] = [ + "application/epub+zip", + "application/gzip", + "application/java-archive", + "application/vnd.android.package-archive", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.rar", + "application/x-7z-compressed", + "application/x-bzip", + "application/x-bzip2", + "application/x-gzip", + "application/x-rar-compressed", + "application/x-xz", + "application/x-zip-compressed", + "application/zip", + "application/zstd", + "font/woff", + "font/woff2", +]; + +/// Uncompressed media types that sit under an already-compressed prefix above, so compressing +/// them still pays off. Matches the reference client's `COMPRESSIBLE_MEDIA_TYPES`. +const COMPRESSIBLE_MEDIA_TYPES: [&str; 16] = [ + "audio/aiff", + "audio/basic", + "audio/l16", + "audio/l24", + "audio/midi", + "audio/vnd.wave", + "audio/wav", + "audio/wave", + "audio/x-aiff", + "audio/x-wav", + "image/bmp", + "image/tiff", + "image/vnd.adobe.photoshop", + "image/vnd.microsoft.icon", + "image/x-icon", + "image/x-ms-bmp", +]; + +/// Structured-syntax suffixes that mark a media type as text even under an already-compressed +/// prefix (e.g. `image/svg+xml`). Matches the reference client's +/// `COMPRESSIBLE_MEDIA_TYPE_SUFFIXES`. +const COMPRESSIBLE_MEDIA_TYPE_SUFFIXES: [&str; 2] = ["+json", "+xml"]; + /// Algorithm used to compress large request bodies before they are sent. /// /// The Apify API accepts both brotli (`br`) and gzip (`gzip`) request bodies. The reference JS @@ -370,6 +427,18 @@ fn maybe_compress_request(request: &mut HttpRequest, compression: RequestCompres if already_encoded { return; } + // Skip media types that already carry their own compression (images, audio, video, + // archives): compressing them again burns CPU for little to no size reduction. This is + // mainly relevant to a raw-bytes Actor input (e.g. `ActorClient::start_raw` with + // `content_type: Some("application/zip")`); JSON request bodies always pass this check. + let content_type = request + .headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("Content-Type")) + .map(|(_, v)| v.as_str()); + if !is_compressible_content_type(content_type) { + return; + } let (encoding, compressed) = match compression { RequestCompression::Brotli => (CONTENT_ENCODING_BROTLI, brotli_compress(body)), @@ -381,6 +450,42 @@ fn maybe_compress_request(request: &mut HttpRequest, compression: RequestCompres request.body = Some(compressed); } +/// Decides whether a request body with the given content type is worth compressing. +/// +/// Images, audio, video and archives already carry their own compression: running them through +/// brotli or gzip burns CPU, holds a second full copy of the body in memory, and usually produces +/// output no smaller than the input (sometimes larger). Formats that are raw despite such a media +/// type, e.g. `image/bmp` or `audio/wav`, are still compressed. A body with no content type is +/// assumed to be compressible. Matches the reference client's `isCompressibleContentType`. +fn is_compressible_content_type(content_type: Option<&str>) -> bool { + let Some(content_type) = content_type else { + return true; + }; + // `Content-Type` is case-insensitive and may carry parameters, e.g. `text/plain; charset=utf-8`. + let media_type = content_type + .split(';') + .next() + .unwrap_or(content_type) + .trim() + .to_ascii_lowercase(); + + if COMPRESSIBLE_MEDIA_TYPES.contains(&media_type.as_str()) { + return true; + } + if COMPRESSIBLE_MEDIA_TYPE_SUFFIXES + .iter() + .any(|suffix| media_type.ends_with(suffix)) + { + return true; + } + if ALREADY_COMPRESSED_MEDIA_TYPES.contains(&media_type.as_str()) { + return false; + } + !ALREADY_COMPRESSED_MEDIA_TYPE_PREFIXES + .iter() + .any(|prefix| media_type.starts_with(prefix)) +} + /// Brotli-compresses `data`. Writing to an in-memory `Vec` is infallible, so this cannot fail. fn brotli_compress(data: &[u8]) -> Vec { use std::io::Write; @@ -413,7 +518,7 @@ fn gzip_compress(data: &[u8]) -> Vec { } /// Returns the path + query portion of a URL, for error reporting. -fn extract_path(url: &str) -> Option { +pub(crate) fn extract_path(url: &str) -> Option { // Find the start of the path after the scheme+host. let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url); after_scheme @@ -435,7 +540,7 @@ fn is_error_retryable(err: &ApifyClientError) -> bool { } /// Parses the API error body (if present) into an [`ApiError`]. -fn build_api_error( +pub(crate) fn build_api_error( response: &HttpResponse, attempt: u32, method: &str, diff --git a/src/lib.rs b/src/lib.rs index 453972a..561be02 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,6 +41,15 @@ //! [`ApifyClientBuilder::http_backend`]. //! - **Cross-cutting behaviour** (auth, `User-Agent`, retries with exponential backoff, //! timeouts) lives in [`http_client::HttpClient`] and is applied to every request. +//! +//! ## Cancellation +//! +//! The reference JavaScript client accepts an `AbortSignal` option on most methods so a caller +//! can cancel an in-flight request. Rust futures are cancel-safe by construction: they do +//! nothing until polled, so dropping one (e.g. via `tokio::select!`, or letting a +//! `tokio::time::timeout` elapse) stops the underlying request without any API for it. This +//! client therefore exposes no separate cancellation parameter — dropping the future returned +//! by any method is the idiomatic equivalent. #![warn(missing_docs)] diff --git a/src/models.rs b/src/models.rs index 519638a..7bc9e50 100644 --- a/src/models.rs +++ b/src/models.rs @@ -132,6 +132,12 @@ pub struct Build { /// Build number, e.g. `0.1.2`. #[serde(default)] pub build_number: Option, + /// Digest of the built Docker image manifest, without the `sha256:` prefix. Compare the + /// digests of two builds to find out whether their image contents differ. `None` if the + /// digest is not available (e.g. the build has not finished, or is older than when the API + /// started recording it). + #[serde(default)] + pub image_digest: Option, /// Any other fields returned by the API. #[serde(flatten)] pub extra: Extra, diff --git a/src/version.rs b/src/version.rs index 8d7e630..6efcd23 100644 --- a/src/version.rs +++ b/src/version.rs @@ -10,4 +10,4 @@ pub const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION"); /// and verified against. /// /// This corresponds to the `info.version` field of the Apify OpenAPI document. -pub const API_SPEC_VERSION: &str = "v2-2026-09-24T114302Z"; +pub const API_SPEC_VERSION: &str = "v2-2026-10-01T153946Z"; diff --git a/tests/actor_run.rs b/tests/actor_run.rs index 03271a7..d7715bb 100644 --- a/tests/actor_run.rs +++ b/tests/actor_run.rs @@ -137,3 +137,54 @@ async fn last_run_access() { .expect("get last run"); assert!(last.is_some(), "there should be a last succeeded run"); } + +/// `ActorClient::call_raw` sends a raw-bytes input (here, pre-serialized JSON bytes) instead of a +/// JSON-serializable value, and the run still completes normally — exercising the raw-input path +/// end to end against the real API. +#[tokio::test(flavor = "multi_thread")] +async fn call_raw_sends_raw_bytes_input() { + let client = require_client!(); + + let run = client + .actor("apify/hello-world") + .call_raw( + b"{}", + apify_client::ActorStartOptions { + content_type: Some("application/json".to_string()), + ..Default::default() + }, + Some(120), + ) + .await + .expect("call_raw hello-world actor"); + + assert_eq!( + run.status.as_deref(), + Some("SUCCEEDED"), + "hello-world run started with a raw-bytes input should still succeed" + ); +} + +/// A failed `log().stream()` carries the API's structured error (status/type/message), not a +/// generic transport error: this endpoint bypasses the buffered backend path to keep the +/// connection open, so it has to parse the error body itself on a non-2xx response. +#[tokio::test(flavor = "multi_thread")] +async fn log_stream_of_missing_run_carries_structured_api_error() { + let client = require_client!(); + + // The stream type doesn't implement `Debug`, so discard it on success via `map` before + // `expect_err` (which requires the `Ok` variant to be `Debug` for its panic message). + let err = client + .run("nonexistent-run-id-xyz") + .log() + .stream() + .await + .map(|_| ()) + .expect_err("streaming a nonexistent run's log should fail"); + + let api_error = err + .as_api_error() + .expect("error should be a structured ApiError, not a generic transport error"); + assert_eq!(api_error.status_code, 404); + assert!(api_error.is_not_found()); +} diff --git a/tests/build.rs b/tests/build.rs index 1464b40..aca3e8b 100644 --- a/tests/build.rs +++ b/tests/build.rs @@ -140,7 +140,11 @@ async fn build_actor_flow() { // Get the build back. let fetched = build_client.get().await.expect("get build"); - assert!(fetched.is_some()); + let fetched = fetched.expect("build should exist"); + // `image_digest` is populated once the image manifest is available; a finished build on a + // real registry should have one, but we only assert the field deserializes (not `Some`), + // since availability timing is outside this test's control. + let _ = &fetched.image_digest; // Fetch the build log. let log = build_client.log().get().await.expect("get build log"); diff --git a/tests/client_meta.rs b/tests/client_meta.rs index 53a01d0..8a7f076 100644 --- a/tests/client_meta.rs +++ b/tests/client_meta.rs @@ -23,3 +23,27 @@ fn user_agent_format() { assert!(ua.contains("Rust/")); assert!(ua.contains("isAtHome/")); } + +/// `base_url` appends `/v2` when missing, but does not double it when the caller already +/// included it (e.g. a URL read back from [`ApifyClient::api_base_url`] and passed to a new +/// builder) — exercised through the public builder API, end to end. +#[test] +fn base_url_appends_v2_idempotently() { + let client = ApifyClient::builder() + .token("dummy-token") + .base_url("https://example.com") + .build(); + assert_eq!(client.api_base_url(), "https://example.com/v2"); + + let client = ApifyClient::builder() + .token("dummy-token") + .base_url("https://example.com/v2") + .build(); + assert_eq!(client.api_base_url(), "https://example.com/v2"); + + let client = ApifyClient::builder() + .token("dummy-token") + .base_url("https://example.com/v2/") + .build(); + assert_eq!(client.api_base_url(), "https://example.com/v2"); +} diff --git a/tests/unit_http.rs b/tests/unit_http.rs index a0a1419..7529d4f 100644 --- a/tests/unit_http.rs +++ b/tests/unit_http.rs @@ -229,6 +229,110 @@ async fn error_body_is_parsed() { assert!(api.path.as_deref().unwrap().contains("/users/me")); } +/// `ApiError`'s `is_*` status-classification predicates (the idiomatic equivalent of the +/// reference client's `NotFoundError`/`RateLimitError`/... subclasses) match the response status, +/// and only that status's predicate is `true`. +#[tokio::test] +async fn api_error_status_predicates_classify_by_status_code() { + async fn error_for(status: u16) -> apify_client::ApiError { + let backend = MockBackend::new(vec![MockOutcome::Status(status, b"{}".to_vec())]); + let client = client_with(backend, 0); + client + .me() + .get() + .await + .expect_err("should fail") + .as_api_error() + .expect("api error") + .clone() + } + + let not_found = error_for(404).await; + assert!(not_found.is_not_found()); + assert!(!not_found.is_invalid_request()); + assert!(!not_found.is_unauthorized()); + assert!(!not_found.is_forbidden()); + assert!(!not_found.is_conflict()); + assert!(!not_found.is_rate_limited()); + assert!(!not_found.is_server_error()); + + assert!(error_for(400).await.is_invalid_request()); + assert!(error_for(401).await.is_unauthorized()); + assert!(error_for(403).await.is_forbidden()); + assert!(error_for(409).await.is_conflict()); + assert!(error_for(429).await.is_rate_limited()); + assert!(error_for(500).await.is_server_error()); + assert!(error_for(503).await.is_server_error()); +} + +/// `RunClient::metamorph_raw` sends the given bytes verbatim (no JSON serialization), defaulting +/// to `application/octet-stream` when the caller does not set `options.content_type`, and still +/// forwards `targetActorId`/`build` as query parameters like `metamorph`. +#[tokio::test] +async fn metamorph_raw_sends_bytes_verbatim_with_default_content_type() { + let backend = MockBackend::new(vec![MockOutcome::Status( + 200, + br#"{"data":{"id":"run1"}}"#.to_vec(), + )]); + let client = client_with(backend.clone(), 0); + + let input = b"raw metamorph input".to_vec(); + client + .run("run1") + .metamorph_raw( + "other/actor", + &input, + apify_client::RunMetamorphOptions { + build: Some("1.0".to_string()), + ..Default::default() + }, + ) + .await + .expect("ok"); + + assert_eq!( + backend.last_header("Content-Type").as_deref(), + Some("application/octet-stream") + ); + assert_eq!(backend.last_body().expect("a body was sent"), input); + let url = backend.last_url().expect("a url was sent"); + assert!(url.contains("targetActorId=other~actor")); + assert!(url.contains("build=1.0")); +} + +/// `DatasetClient::create_items_public_url_with_format` adds a `format` query parameter to the +/// generated URL, and `create_items_public_url` (unchanged) omits it, defaulting to the +/// endpoint's own `json` default. +#[tokio::test] +async fn create_items_public_url_with_format_adds_format_param() { + let backend = MockBackend::new(vec![MockOutcome::Status( + 200, + br#"{"data":{"id":"ds1"}}"#.to_vec(), + )]); + let client = client_with(backend.clone(), 0); + + let url = client + .dataset("ds1") + .create_items_public_url_with_format( + Default::default(), + None, + Some(apify_client::DownloadItemsFormat::Csv), + ) + .await + .expect("ok"); + assert!(url.contains("format=csv"), "url was: {url}"); + + let url = client + .dataset("ds1") + .create_items_public_url(Default::default(), None) + .await + .expect("ok"); + assert!( + !url.contains("format="), + "create_items_public_url must not set a format param, url was: {url}" + ); +} + /// `max_retries = 0` means exactly one attempt. #[tokio::test] async fn zero_retries_single_attempt() { @@ -450,6 +554,96 @@ async fn small_request_body_is_not_compressed() { ); } +/// A large body whose content type already carries its own compression (e.g. a ZIP archive) is +/// sent uncompressed, with no `Content-Encoding`: compressing it again would burn CPU for little +/// to no size reduction. Exercises the raw-bytes path via `ActorClient::start_raw`, which is the +/// main way a caller sets a non-JSON content type on a large body. +#[tokio::test] +async fn already_compressed_content_type_is_not_compressed() { + let backend = MockBackend::new(vec![MockOutcome::Status( + 200, + br#"{"data":{"id":"run1"}}"#.to_vec(), + )]); + let client = client_with(backend.clone(), 0); + + let original = vec![b'a'; 4096]; + client + .actor("me/actor") + .start_raw( + &original, + apify_client::ActorStartOptions { + content_type: Some("application/zip".to_string()), + ..Default::default() + }, + ) + .await + .expect("ok"); + + assert!( + backend.last_header("Content-Encoding").is_none(), + "an already-compressed content type must not be compressed" + ); + assert_eq!( + backend.last_body().expect("a body was sent"), + original, + "an already-compressed body must be sent verbatim, not JSON-re-encoded" + ); +} + +/// `image/bmp` sits under the already-compressed `image/` prefix but is itself raw, uncompressed +/// data, so it is still compressed. Exercises the exact-media-type exception list. +#[tokio::test] +async fn compressible_exception_under_already_compressed_prefix_is_compressed() { + let backend = MockBackend::new(vec![MockOutcome::Status( + 200, + br#"{"data":{"id":"run1"}}"#.to_vec(), + )]); + let client = client_with(backend.clone(), 0); + + let original = vec![b'a'; 4096]; + client + .actor("me/actor") + .start_raw( + &original, + apify_client::ActorStartOptions { + content_type: Some("image/bmp".to_string()), + ..Default::default() + }, + ) + .await + .expect("ok"); + + assert_eq!( + backend.last_header("Content-Encoding").as_deref(), + Some("br"), + "image/bmp is raw despite the image/ prefix and should still be compressed" + ); +} + +/// `ActorClient::start_raw` defaults to `application/octet-stream` when the caller does not set +/// `options.content_type`, and sends the bytes verbatim (not as a JSON-encoded byte array). +#[tokio::test] +async fn start_raw_defaults_content_type_and_sends_bytes_verbatim() { + let backend = MockBackend::new(vec![MockOutcome::Status( + 200, + br#"{"data":{"id":"run1"}}"#.to_vec(), + )]); + let client = client_with(backend.clone(), 0); + + let input = b"not json, just bytes: \x00\x01\x02".to_vec(); + client + .actor("me/actor") + .start_raw(&input, Default::default()) + .await + .expect("ok"); + + assert_eq!( + backend.last_header("Content-Type").as_deref(), + Some("application/octet-stream") + ); + assert_eq!(backend.last_body().expect("a body was sent"), input); +} + /// The status-only `last_run(status)` and the options-based `last_run_with_options` thread the /// optional `status` and `origin` filters into the last-run request as query parameters, matching /// the JS reference's `lastRun({ status, origin })`. Leaving a filter as `None` omits it.