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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 0.13.0

- Synced with Apify OpenAPI spec `v2-2026-11-06T140128Z`: `ActorVersion::getActorPath()` and `Actor::getStandbyUrl()`.
- Added `ActorClient::server()` for Server Actors (formerly Actor Standby): `request()`, `getUrl()` and `getOpenApiSpec()`.
- Added `NoRedirectHttpClientInterface`, implemented by `GuzzleHttpClient` and needed by `request()`.
- **Breaking:** `PaginationList::getCount()` of a dataset items page is the scanned rows, or the items returned when larger.
- Offset iterators, dataset items included, step by the page's `count`, so Store and dataset filters neither repeat nor skip entries.
- Documented the page size of iteration without `$chunkSize`.

## 0.12.0

- Synced with Apify OpenAPI spec `v2-2026-11-01T214220Z`. Response models return the new fields as
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ $psr18 = new \GuzzleHttp\Client(['timeout' => 120]); // any Psr\Http\Message Cli
$client = new ApifyClient(token: 'my-api-token', httpClient: new Psr18HttpClient($psr18));
```

`ActorClient::server()->request()` needs a transport that can return a redirect as it is, so it throws with `Psr18HttpClient`
or any custom transport that does not also implement `NoRedirectHttpClientInterface`. See [Server Actors](docs/actors.md).

## Error handling

Methods that fetch a single resource by ID return `null` when the resource does not exist (rather
Expand Down
12 changes: 11 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Every class is under the `Apify\Client\` PSR-4 root. Use these when writing `use
| `Apify\Client\` | The entry point and version constants. | `ApifyClient`, `Version` |
| `Apify\Client\Model\` | Response models returned by the API. | `RequestQueueRequest`, `ActorEnvVar`, `Dataset`, `ActorRun`, `PaginationList` |
| `Apify\Client\Options\` | Option objects (all the `*Options` classes) **and** enums. | e.g. `ActorListOptions`, `ActorStartOptions`, `TaskStartOptions`, `RunListOptions`, `RunIterateDatasetItemsOptions`, `RunResurrectOptions`, `StorageListOptions`, `StoreListOptions`, `DatasetListItemsOptions`, `ListKeysOptions`, `GetRecordOptions`, `ListRequestsOptions`, `BatchAddRequestsOptions`, `PaginateRequestsOptions`, `LogOptions`, `DownloadItemsFormat` — see [options reference](options.md) for the full list |
| `Apify\Client\Http\` | The replaceable transport, its adapters, and compressors. | `HttpClientInterface`, `GuzzleHttpClient`, `Psr18HttpClient`, `HttpCompressorInterface`, `BrotliHttpCompressor`, `GzipHttpCompressor` |
| `Apify\Client\Http\` | The replaceable transport, its adapters, and compressors. | `HttpClientInterface`, `NoRedirectHttpClientInterface`, `GuzzleHttpClient`, `Psr18HttpClient`, `HttpCompressorInterface`, `BrotliHttpCompressor`, `GzipHttpCompressor` |
| `Apify\Client\Exception\` | Exceptions thrown by the client. | `ApifyApiException` and its status-specific subclasses (`NotFoundException`, `RateLimitException`, ...), `TransportException` |

For example, to add requests to a queue you would import the model and (optionally) the batch options:
Expand Down Expand Up @@ -133,6 +133,16 @@ $builds = $client->builds()->list(new ListOptions(limit: 50, desc: true));
`getOffset()`, `getLimit()`, `getCount()`, `isDesc()` and `getItems()`. Within-storage listers
(`listKeys`, `listHead`) return their own page/head containers instead.

On a page of `DatasetClient::listItems()`, `getCount()` is the number of dataset rows the API scanned for the
page, or the number of items returned when that is larger. Filters such as `skipEmpty` or `clean` can leave it
above `count($page->getItems())`. The next page starts at `offset + min(count, limit)`; the cap matters only with
`unwind`, which can return more items than the rows scanned.

The `iterate*()` helpers take a `$chunkSize`, the number of entries requested per API call. Without it, each call
gets the API's default page size, capped by the remaining `limit`. Dataset items have no default limit, so
iterating `iterateItems()` without `$chunkSize` loads the whole dataset, or all `limit` items, in one response.
Pass `$chunkSize` to iterate a large dataset.

```php
$page = $client->actors()->list(new ActorListOptions(limit: 5));
foreach ($page as $actor) {
Expand Down
62 changes: 62 additions & 0 deletions docs/actors.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,54 @@ throws the last error. In `call()`, time spent retrying does not count toward `$
- `builds(): BuildCollectionClient`, `runs(): RunCollectionClient`
- `version(string $versionNumber): ActorVersionClient`, `versions(): ActorVersionCollectionClient`
- `webhooks(): NestedWebhookCollectionClient` — read-only.
- `server(): ActorServerClient` — the [server interface](#server-actors--client-actorid-server) of a Server Actor. Every call returns the same client.

```php
$run = $client->actor('apify/hello-world')->call(['name' => 'world'], new ActorStartOptions(memoryMbytes: 512), 120);
$isValid = $client->actor('apify/hello-world')->validateInput(['firstNumber' => 1]);
$lastSucceeded = $client->actor('apify/hello-world')->lastRun(new LastRunOptions(status: 'SUCCEEDED'))->get();
```

## Server Actors — `$client->actor($id)->server()`

A Server Actor (formerly Actor Standby) runs a web server that is called over HTTP without starting a run for
each call. `ActorServerClient` sends requests to that server and reads its URL and OpenAPI specification.

- `request(string $path = '/', ?ActorServerRequestOptions $options = null, int|float|string|null $timeoutSecs = null): ActorServerResponse` — sends an HTTP request to the Actor's server. The timeout defaults to the `long` tier, which covers the cold start of an idle Actor.
- `getUrl(int|float|string|null $timeoutSecs = null): string` — the server URL (the Actor's `standbyUrl`). It is fetched once per client, by `getUrl()` or `getOpenApiSpec()`.
- `getOpenApiSpec(int|float|string|null $timeoutSecs = null): ?array` — the web server schema from the build that serves the server (`actorStandby.build`, or `latest`), or `null` if the Actor's definition has none.

`request()` resolves `$path` against the server URL, so neither the request nor the token can reach another host. The
token goes in the `X-Apify-Authorization` header, which the platform removes before the request reaches the Actor, so
the headers you pass, `Authorization` included, reach the Actor as they are. Pass the query in the `params` option.

It returns any response of the server, error statuses included. Only a rate limit (429) and Service Unavailable (503)
are retried, with the client's retry settings, and the last response is returned once the retries run out. A transport
error, a timeout included, throws `TransportException` without a retry, since the Actor may already have handled the
request. A redirect is returned as it is, since following it would send the token to a host the Actor's code picked. A
request body is sent uncompressed unless the `compression` option is set.

The default `GuzzleHttpClient` can return a redirect as it is. A custom [`HttpClientInterface`](../README.md) must also
implement `NoRedirectHttpClientInterface` to be used for `request()`, which otherwise throws `ApifyClientException`
before it sends anything to the Actor's server. `Psr18HttpClient` does not implement it, since a PSR-18 client decides on its own whether to
follow redirects.

`ActorServerResponse` offers `getStatusCode()`, `isSuccessful()`, `getHeaders()`, `getHeaderLine($name)`, `getBody()`
(the raw bytes), `getBodyStream()` (a PSR-7 stream, live with the `stream` option), `getData()` (the body parsed by its
content type: JSON is decoded, anything else is returned as a string) and `getResponse()` (the PSR-7 response).

```php
$server = $client->actor('username/actor-name')->server();

$response = $server->request('/search', new ActorServerRequestOptions(params: ['q' => 'apify']));
if ($response->isSuccessful()) {
print_r($response->getData());
}

$url = $server->getUrl();
$spec = $server->getOpenApiSpec();
```

## Actor versions — `$client->actor($id)->versions()` / `->version($n)`

- Collection: `list(): PaginationList`, `iterate(): iterable`, `create(mixed $version): ActorVersion`.
Expand All @@ -75,6 +116,27 @@ $version = $client->actor('me~my-actor')->versions()->create([
]);
```

A `SOURCE_FILES` or `TARBALL` version can set `actorPath`, the Actor's directory inside the uploaded source, to push
an Actor from an upload of a whole monorepo. It is a path relative to the upload root, with `/` separators and no
leading, trailing or repeated `/`, and no `.` or `..` segment (1 to 255 characters). The API rejects it for a
`GIT_REPO` or `GITHUB_GIST` version. An `update()` that sends `sourceType`, `sourceFiles` or `tarballUrl` without
`actorPath` removes the stored path, so send it again with every upload; `'actorPath' => null` removes it too, and an
update of other fields keeps it. `ActorVersion::getActorPath()` reads it back.

```php
$version = $client->actor('me~my-actor')->versions()->create([
'versionNumber' => '0.2',
'sourceType' => 'SOURCE_FILES',
'sourceFiles' => [],
'actorPath' => 'actors/typescript-actor',
]);
$client->actor('me~my-actor')->version('0.2')->update([
'sourceType' => 'SOURCE_FILES',
'sourceFiles' => [],
'actorPath' => 'actors/typescript-actor',
]);
```

## Environment variables — `->version($n)->envVars()` / `->envVar($name)`

- Collection: `list(): PaginationList`, `iterate(): iterable`, `create(ActorEnvVar $envVar): ActorEnvVar`.
Expand Down
2 changes: 2 additions & 0 deletions docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ model is also used as an input object and therefore additionally exposes setters
| `getTitle(): ?string` | Human-readable title. |
| `getDescription(): ?string` | Free-text description. |
| `isPublic(): ?bool` | Whether the Actor is public. |
| `getStandbyUrl(): ?string` | URL for sending requests to the Actor in Standby mode (a Server Actor), or `null` if Standby mode is not enabled. |
| `getCreatedAt(): ?string` | ISO-8601 creation timestamp. |
| `getModifiedAt(): ?string` | ISO-8601 last-modification timestamp. |

Expand Down Expand Up @@ -72,6 +73,7 @@ model is also used as an input object and therefore additionally exposes setters
|---|---|
| `getVersionNumber(): ?string` | The version number (e.g. `0.0`). |
| `getSourceType(): ?string` | Source type (e.g. `SOURCE_FILES`, `GIT_REPO`). |
| `getActorPath(): ?string` | The Actor's directory inside the uploaded source of a `SOURCE_FILES` or `TARBALL` version, or `null` when the upload root is the Actor's directory. |

### `Task`
| Getter | Description |
Expand Down
13 changes: 13 additions & 0 deletions docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,19 @@ For `actor()->lastRun()` / `task()->lastRun()`.
| `status` | `?string` | Restrict to the last run with this status (e.g. `SUCCEEDED`). |
| `origin` | `?string` | Restrict to the last run started via this origin. |

## Server Actors

### `ActorServerRequestOptions`
For `actor($id)->server()->request()`.
| Field | Type | Description |
|---|---|---|
| `method` | `?string` | HTTP method (default `GET`). |
| `params` | `array<string,mixed>\|null` | Query parameters, converted as for API calls: `null` values are left out, booleans are sent as `1`/`0` and a list is joined with commas. |
| `headers` | `array<string,string>\|null` | Additional headers, `Authorization` included. They reach the Actor's server as they are. |
| `body` | `mixed` | The request body: an array (or any other JSON-serializable value) is sent as JSON, a string as it is. |
| `compression` | `string\|HttpCompressorInterface\|null` | `'brotli'`, `'gzip'` or a custom compressor for the request body. Unset sends the body uncompressed, since the Actor's server may not decode a compressed body. |
| `stream` | `bool` | Leave the response body unread, so it can be consumed incrementally with `ActorServerResponse::getBodyStream()`. The connection stays open until the body is read to the end or closed. |

## Running Actors and tasks

### `ActorStartOptions`
Expand Down
4 changes: 2 additions & 2 deletions docs/storages.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Single — `$client->dataset($id)`:

- `get(): ?Dataset`, `update(mixed $newFields): Dataset`, `delete(): void`
- `listItems(?DatasetListItemsOptions $options = null): PaginationList` — one page of items decoded to PHP values.
- `iterateItems(?DatasetListItemsOptions $options = null, ?int $chunkSize = null): iterable` — lazily iterate all items, paging on demand. The options' `limit` caps the total number of items yielded across all pages (unset = all); `$chunkSize` is the per-page size. The iterator advances and terminates by the number of rows the API scanned (the `X-Apify-Pagination-Count` response header), not by the number of items a page returns, matching the reference JS client exactly — see the method's doc comment for why that matters with `skipEmpty`/`clean`/`unwind`.
- `iterateItems(?DatasetListItemsOptions $options = null, ?int $chunkSize = null): iterable` — lazily iterate all items, paging on demand. The options' `limit` caps the total number of items yielded across all pages (unset = all); `$chunkSize` is the per-page size. Dataset items have no default limit, so without `$chunkSize` the iteration loads the whole dataset, or all `limit` items, in one response. The iterator advances and terminates by the page's `getCount()` (the rows the API scanned, or the items returned when that is larger), not by the number of items a page returns, matching the reference JS client exactly — see the method's doc comment for why that matters with `skipEmpty`/`clean`/`unwind`.
- `downloadItems(DownloadItemsFormat $format, ?DatasetDownloadOptions $options = null): string` — raw export bytes.
- `streamItems(DownloadItemsFormat $format, ?DatasetDownloadOptions $options = null): StreamInterface` — the same export as `downloadItems()`, as a PSR-7 stream instead of a fully-buffered string, so a large export can be read incrementally (e.g. piped to a file) without holding it all in memory. Bypasses the retrying transport, so a failed request is not retried.
- `pushItems(mixed $items): void`
Expand Down Expand Up @@ -59,7 +59,7 @@ Single — `$client->keyValueStore($id)`:

- `get(): ?KeyValueStore`, `update(mixed $newFields): KeyValueStore`, `delete(): void`
- `listKeys(?ListKeysOptions $options = null): KeyValueStoreKeysPage`
- `iterateKeys(?ListKeysOptions $options = null, ?int $chunkSize = null): iterable` — lazily iterate all keys, following cursor pagination (`exclusiveStartKey`/`nextExclusiveStartKey`). The options' `limit` caps the total number of keys yielded across all pages (unset = all); `$chunkSize` is the per-page size (unset = the remaining total cap, or the server default when unbounded).
- `iterateKeys(?ListKeysOptions $options = null, ?int $chunkSize = null): iterable` — lazily iterate all keys, following cursor pagination (`exclusiveStartKey`/`nextExclusiveStartKey`). The options' `limit` caps the total number of keys yielded across all pages (unset = all); `$chunkSize` is the per-page size (unset = the remaining total cap, or the API's default page size when unbounded).
- `recordExists(string $key): bool` — reports whether a record with the given key exists, without downloading its value (a `HEAD` request).
- `getRecord(string $key, ?GetRecordOptions $options = null): ?KeyValueStoreRecord` — the record value is never parsed: `getValue()` returns the exact bytes the API sent, whatever the content type, so a JSON record is decoded by the caller.
- `setRecord(string $key, string $value, string $contentType, ?SetRecordOptions $options = null, ?string $contentEncoding = null): void` — `$value` is sent exactly as given, also under `application/json`, so it has to be valid JSON already (use `setRecordJson()` to have an array encoded). `$contentEncoding`, if given, is sent as the `Content-Encoding` header, for a `$value` that is already compressed (gzip, br, ...); the client's own best-effort request compression always backs off when `Content-Encoding` is already set, so the bytes reach the API exactly as given.
Expand Down
11 changes: 2 additions & 9 deletions src/ApifyClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Apify\Client\Http\GzipHttpCompressor;
use Apify\Client\Http\HttpClientInterface;
use Apify\Client\Http\HttpCompressorInterface;
use Apify\Client\Internal\Compression;
use Apify\Client\Internal\HttpClientCore;
use Apify\Client\Internal\Platform;
use Apify\Client\Internal\RetryConfig;
Expand Down Expand Up @@ -376,15 +377,7 @@ private static function trimTrailingSlash(string $value): string
*/
private static function resolveCompressor(string|HttpCompressorInterface|null $compression): ?HttpCompressorInterface
{
return match (true) {
$compression === null => null,
$compression instanceof HttpCompressorInterface => $compression,
$compression === 'brotli' => new BrotliHttpCompressor(),
$compression === 'gzip' => new GzipHttpCompressor(),
default => throw new RuntimeException(
sprintf('ApifyClient: unknown compression "%s"; expected "brotli", "gzip", or a HttpCompressorInterface', $compression)
),
};
return Compression::resolveCompressor($compression, 'ApifyClient');
}

/**
Expand Down
15 changes: 10 additions & 5 deletions src/Http/GuzzleHttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
* are returned as normal responses ({@code http_errors} disabled); only connection/timeout failures
* are thrown as {@see TransportException}.
*/
final class GuzzleHttpClient implements HttpClientInterface
final class GuzzleHttpClient implements NoRedirectHttpClientInterface
{
/** Connection-establishment timeout (distinct from the per-request timeout the client applies). */
private const CONNECT_TIMEOUT_SECS = 30.0;
Expand All @@ -32,20 +32,25 @@ public function __construct(?Guzzle $client = null)

public function send(RequestInterface $request, float $timeoutSecs): ResponseInterface
{
return $this->doSend($request, $timeoutSecs, false);
return $this->doSend($request, $timeoutSecs, false, true);
}

public function sendStreaming(RequestInterface $request, float $timeoutSecs): ResponseInterface
{
return $this->doSend($request, $timeoutSecs, true);
return $this->doSend($request, $timeoutSecs, true, true);
}

private function doSend(RequestInterface $request, float $timeoutSecs, bool $stream): ResponseInterface
public function sendWithoutRedirects(RequestInterface $request, float $timeoutSecs, bool $stream = false): ResponseInterface
{
return $this->doSend($request, $timeoutSecs, $stream, false);
}

private function doSend(RequestInterface $request, float $timeoutSecs, bool $stream, bool $followRedirects): ResponseInterface
{
try {
return $this->client->send($request, [
'http_errors' => false,
'allow_redirects' => true,
'allow_redirects' => $followRedirects,
'connect_timeout' => self::CONNECT_TIMEOUT_SECS,
'timeout' => $timeoutSecs,
'stream' => $stream,
Expand Down
28 changes: 28 additions & 0 deletions src/Http/NoRedirectHttpClientInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Apify\Client\Http;

use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

/**
* An optional extension of {@see HttpClientInterface} for a transport that can return a redirect
* response as it is instead of following it.
*
* {@see \Apify\Client\Resource\ActorServerClient::request()} needs it: that request carries the API token,
* which must never follow a redirect to a host the Actor's code picked. A transport that does not implement
* this interface can still be used for everything else, but {@code request()} fails before it sends anything to the Actor's server.
* {@see GuzzleHttpClient} implements it. {@see Psr18HttpClient} does not, since a PSR-18 client decides on
* its own whether to follow redirects.
*/
interface NoRedirectHttpClientInterface extends HttpClientInterface
{
/**
* Sends a single request like {@see HttpClientInterface::send()}, or like
* {@see HttpClientInterface::sendStreaming()} when {@code $stream} is {@code true}, but returns a
* redirect response (3xx) as it is instead of following it.
*/
public function sendWithoutRedirects(RequestInterface $request, float $timeoutSecs, bool $stream = false): ResponseInterface;
}
Loading
Loading