diff --git a/.github/workflows/php-integration-tests.yml b/.github/workflows/php-integration-tests.yml index e2b7876..b58c1fc 100644 --- a/.github/workflows/php-integration-tests.yml +++ b/.github/workflows/php-integration-tests.yml @@ -1,7 +1,7 @@ name: PHP integration tests -# Language-specific workflow: only runs for the PHP client. Triggers on PRs to master that touch -# PHP client, test, example, or documentation code, and can be dispatched manually from any branch. +# Runs the PHP client tests for PRs to master that touch PHP code, tests, examples or documentation, and on +# manual dispatch from any branch. on: pull_request: branches: [master] @@ -12,50 +12,84 @@ on: - '.php-cs-fixer.dist.php' - 'phpstan.neon.dist' - 'phpunit.xml.dist' - # The "Test examples" step validates the in-documentation snippets, so doc changes must - # re-run the workflow even though Markdown is not PHP code. + # The "Test examples" step checks the documentation snippets. - 'docs/**' - 'README.md' - '.github/workflows/php-integration-tests.yml' workflow_dispatch: -# Avoid concurrent runs of the same ref racing on the shared test account. +# One run per ref at a time, since the runs share the test account. concurrency: group: php-integration-${{ github.ref }} cancel-in-progress: true jobs: - # Offline unit tests on a build WITH the PECL brotli extension, so the real brotli codec round-trip - # (testRealBrotliRoundTripsWhenExtensionPresent) actually runs instead of self-skipping. The main - # `test` job deliberately omits brotli, so between the two jobs both request-compression codecs - # (brotli and the gzip fallback) get genuine real-codec coverage in CI. This job needs no API token. - unit-brotli: + # Unit tests with the PECL brotli and zstd extensions, so their real codec tests run instead of skipping. + # The `test` job has neither, which covers the gzip fallback. Needs no API token. + unit-codecs: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up PHP (with brotli) + - name: Set up PHP (with brotli and zstd) uses: shivammathur/setup-php@v2 with: php-version: '8.1' - extensions: mbstring, json, curl, zlib, brotli + extensions: mbstring, json, curl, zlib, brotli, zstd coverage: none tools: composer:v2 - name: Install dependencies run: composer install --no-interaction --prefer-dist --no-progress - - name: Fail if brotli extension missing + - name: Fail if a codec extension is missing run: | - if ! php -r "exit(extension_loaded('brotli') ? 0 : 1);"; then - echo "::error::brotli extension failed to load; the real brotli codec test would silently skip." + if ! php -r "exit(extension_loaded('brotli') && extension_loaded('zstd') ? 0 : 1);"; then + echo "::error::The brotli or zstd extension failed to load; the real codec tests would skip." exit 1 fi - - name: Unit tests (brotli path) + - name: Unit tests (brotli and zstd) run: vendor/bin/phpunit --testsuite unit + # The zstd integration tests, with the PECL zstd extension that the `test` job lacks. They skip themselves + # without the extension or the token, so both are checked first. + integration-zstd: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up PHP (with zstd) + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + extensions: mbstring, json, curl, zlib, zstd + coverage: none + tools: composer:v2 + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Require the zstd extension and APIFY_TOKEN + env: + APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }} + run: | + if ! php -r "exit(extension_loaded('zstd') ? 0 : 1);"; then + echo "::error::The zstd extension failed to load; the zstd integration tests would skip." + exit 1 + fi + if [ -z "${APIFY_TOKEN}" ]; then + echo "::error::APIFY_TOKEN secret is empty or missing; the zstd integration tests would skip." + exit 1 + fi + + - name: Integration tests (zstd) + env: + APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }} + run: vendor/bin/phpunit --testsuite integration --filter ZstdCompressionIntegrationTest + test: runs-on: ubuntu-latest steps: @@ -66,7 +100,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.1' - # intl is optional for users but loaded here so the punycode host normalization test runs. + # intl is optional, and loaded so that the punycode test runs. extensions: mbstring, json, curl, zlib, intl coverage: none tools: composer:v2 @@ -74,21 +108,19 @@ jobs: - name: Install dependencies run: composer install --no-interaction --prefer-dist --no-progress - # The formatter/lint gate mandated by the coding rules (PSR-12 via php-cs-fixer). + # Formatting (PSR-12, php-cs-fixer). - name: Check formatting (php-cs-fixer) run: composer lint - # Static-analysis gate mandated by the coding rules. + # Static analysis. - name: Static analysis (PHPStan) run: composer stan - # Offline unit tests (mock transport): prove the retry/error/signature logic without the API. + # Offline unit tests with a mock transport. - name: Unit tests run: vendor/bin/phpunit --testsuite unit - # Fail fast if the integration-test secret is missing or empty. Without this guard the - # integration tests silently "pass" (they self-skip when APIFY_TOKEN is unset), so a green run - # would not prove the API logic actually executed. + # The integration tests skip themselves without APIFY_TOKEN, so fail here instead of passing silently. - name: Require APIFY_TOKEN secret env: APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }} @@ -100,13 +132,11 @@ jobs: - name: Integration tests env: - # The integration-test token is stored as a repository secret. APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }} run: vendor/bin/phpunit --testsuite integration - # Standalone CI step that verifies the documentation examples work end-to-end against the live - # API (ExamplesTest runs each example) and that every in-documentation PHP snippet is valid, - # runnable code (DocSnippetsTest lints each fenced block). + # Runs the documentation examples against the live API (ExamplesTest) and lints every PHP snippet + # of the documentation (DocSnippetsTest). - name: Test examples env: APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }} diff --git a/.github/workflows/php-publish.yml b/.github/workflows/php-publish.yml index d6f16f9..d53d9ea 100644 --- a/.github/workflows/php-publish.yml +++ b/.github/workflows/php-publish.yml @@ -1,13 +1,9 @@ name: Publish PHP client -# Language-specific publish workflow for the PHP client. Triggered manually only (workflow_dispatch) -# so a maintainer deliberately decides when a release is cut. The release version is the single -# source of truth in src/Version.php (CLIENT_VERSION). The package is distributed via Packagist, -# which builds the release from the pushed Git tag; this workflow tags the release, creates the -# GitHub release, and notifies Packagist to update. +# Manual release. The version is `Version::CLIENT_VERSION` in src/Version.php. Packagist builds the +# release from the Git tag, so this workflow tags it, creates the GitHub release and notifies Packagist. # -# Packagist does not offer OIDC "Trusted Publishing"; its update API requires a username + API token, -# which are read from repository secrets (nothing is stored in the repo). +# Packagist has no Trusted Publishing. Its update API needs a username and token, read from repository secrets. on: workflow_dispatch: inputs: @@ -16,7 +12,7 @@ on: type: boolean default: false -# Never allow two publish runs to race; publishing two releases concurrently is hard to undo. +# Never run two releases at once. concurrency: group: php-publish cancel-in-progress: false @@ -33,7 +29,7 @@ jobs: with: fetch-depth: 0 - # A release must only ever be cut from master. + # Releases come from master only. - name: Require master branch run: | if [ "${GITHUB_REF}" != "refs/heads/master" ]; then @@ -52,7 +48,7 @@ jobs: - name: Install dependencies run: composer install --no-interaction --prefer-dist --no-progress - # Gate the release on the same quality bar as CI so a broken build can never be published. + # The release needs the CI checks to pass. - name: Check formatting (php-cs-fixer) run: composer lint @@ -102,13 +98,13 @@ jobs: --title "${TAG}" \ --notes "Apify PHP client ${TAG}. See CHANGELOG.md for details." - # Notify Packagist to pull the new tag. Credentials come from repository secrets. + # Packagist pulls the new tag. - name: Notify Packagist if: ${{ github.event.inputs.dry_run != 'true' }} env: PACKAGIST_USERNAME: ${{ secrets.PACKAGIST_USERNAME }} PACKAGIST_TOKEN: ${{ secrets.PACKAGIST_TOKEN }} run: | - curl -sS -XPOST -H'content-type:application/json' \ + curl --fail-with-body -sS -XPOST -H'content-type:application/json' \ "https://packagist.org/api/update-package?username=${PACKAGIST_USERNAME}&apiToken=${PACKAGIST_TOKEN}" \ -d'{"repository":{"url":"https://packagist.org/packages/apify/apify-client"}}' diff --git a/CHANGELOG.md b/CHANGELOG.md index 007f850..6e277cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.14.0 + +- Synced with Apify OpenAPI spec `v2-2026-11-09T143926Z`: `zstd` is a documented `Content-Encoding`. +- Added `ZstdHttpCompressor` and `compression: 'zstd'`. Needs the PECL `zstd` extension. +- **Breaking:** `compression: 'brotli'`, `'gzip'`, or `'zstd'`, and the three compressor classes, throw `RuntimeException` without their extension (PECL, or `zlib` for gzip), instead of sending bodies uncompressed. +- Added an optional `SetRecordOptions` parameter to `KeyValueStoreClient::setRecordJson()`. +- Added `ActorServerRequestOptions::$parentRequestId`, sent as the `X-Actor-Request-ID` header. +- Shortened the documentation and code comments, and removed outdated ones. + ## 0.13.0 - Synced with Apify OpenAPI spec `v2-2026-11-06T140128Z`: `ActorVersion::getActorPath()` and `Actor::getStandbyUrl()`. diff --git a/README.md b/README.md index 95acc5d..31bbaad 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,15 @@ # Apify API client for PHP -> **Official, but experimental — AI-generated and AI-maintained.** This is an official Apify client, -> but it is experimental: it is generated and maintained by AI. Review the code before relying on it -> in production and report issues on the repository. +> **Official, but experimental.** This client is generated and maintained by AI. Review the code before +> relying on it in production, and report issues on the repository. -A resource-oriented PHP client for the [Apify API](https://docs.apify.com/api/v2), mirroring the -official [JavaScript](https://github.com/apify/apify-client-js) reference client: start from an -`ApifyClient`, then drill down into resources (Actors, runs, datasets, key-value stores, request -queues, tasks, schedules, webhooks, the store, users and logs). +A PHP client for the [Apify API](https://docs.apify.com/api/v2), modeled on the +[JavaScript client](https://github.com/apify/apify-client-js). Start from an `ApifyClient` and go on to +Actors, runs, datasets, key-value stores, request queues, tasks, schedules, webhooks, the Store, users, and logs. ## Requirements -- PHP 8.1 or newer, with the `json`, `mbstring` and `hash` extensions. +- PHP 8.1 or newer with the `json`, `mbstring`, and `hash` extensions. ## Installation @@ -21,32 +19,26 @@ composer require apify/apify-client ## Quick start -All snippets assume the client types are imported from their namespaces, e.g. -`use Apify\Client\ApifyClient;`. +The snippets assume that the client types are imported, for example `use Apify\Client\ApifyClient;`. ```php $client = new ApifyClient('my-api-token'); -// Start an Actor and wait for it to finish. The last argument is the wait budget in seconds; -// pass a value (e.g. 120) to bound the wait, or null to wait indefinitely (as here). +// Start an Actor and wait for it to finish. The last argument bounds the wait in seconds, +// null waits indefinitely. $run = $client->actor('apify/hello-world')->call(null, null, null); -// Read items from the run's default dataset. getDefaultDatasetId() is ?string, so cast it -// to satisfy dataset(string $id). +// getDefaultDatasetId() is ?string, so cast it for dataset(string $id). $items = $client->dataset((string) $run->getDefaultDatasetId())->listItems(); echo 'Item count: ' . $items->getCount() . PHP_EOL; ``` -`new ApifyClient('my-api-token')` takes the token as an explicit argument — it does **not** read -`APIFY_TOKEN` (or any other environment variable) automatically. Read it yourself if you want that, -e.g. `new ApifyClient(getenv('APIFY_TOKEN'))`. - -Get your API token from the -[Apify Console → Settings → API & Integrations](https://console.apify.com/settings/integrations). +The client does not read `APIFY_TOKEN`. Pass it yourself, for example `new ApifyClient(getenv('APIFY_TOKEN'))`. +You can find your token in [Apify Console → Settings → API & Integrations](https://console.apify.com/settings/integrations). ## Configuration -The constructor accepts named arguments for non-default settings: +The constructor takes named arguments: ```php $configured = new ApifyClient( @@ -62,185 +54,165 @@ $configured = new ApifyClient( | Argument | Default | Meaning | |---|---|---| | `token` | `null` | API token, sent as a Bearer token. | -| `baseUrl` | `https://api.apify.com` | API base URL, with or without the trailing `/v2` version path — appended automatically when not already present. | -| `publicBaseUrl` | `baseUrl` | Base URL used when building public, shareable resource URLs. Like `baseUrl`, `/v2` is appended when absent. | -| `maxRetries` | `8` | Maximum retries for failed requests. | -| `minDelayBetweenRetriesMillis` | `500` | Minimum delay between retries (exponential backoff). | -| `maxDelayBetweenRetriesMillis` | `timeoutSecs × 1000` (360000) | Upper bound (milliseconds) on the growing inter-retry delay; defaults to the request timeout expressed in milliseconds. | -| `timeoutSecs` | `360` | Overall per-request timeout: the default duration of the `long` tier and of `timeoutMaxSecs`. | -| `timeoutShortSecs` | `5` | Duration (seconds) of the `short` timeout tier — see [Timeout tiers](#timeout-tiers). | -| `timeoutMediumSecs` | `30` | Duration (seconds) of the `medium` timeout tier. | -| `timeoutLongSecs` | `timeoutSecs` | Duration (seconds) of the `long` timeout tier. | -| `timeoutMaxSecs` | `timeoutSecs` | Upper bound on any single request attempt's timeout, including a per-call override and the growth across retries. | -| `compression` | `null` (brotli, falling back to gzip) | Request-body compression: `'brotli'`, `'gzip'`, or a custom `HttpCompressorInterface` — see [HTTP compression](#http-compression). | -| `userAgentSuffix` | `null` | Custom suffix appended to the `User-Agent` header. | -| `httpClient` | Guzzle | The replaceable transport (`Apify\Client\Http\HttpClientInterface`). | - -Requests are retried on network errors, HTTP 429 (rate limit) and 5xx responses, with exponential -backoff and jitter. Other 4xx responses are thrown immediately as an `ApifyApiException` subclass -matching the status code, with one exception: a resource-not-found 404 on a single-resource fetch is -not thrown — `get()` returns `null` and `delete()` is treated as a successful no-op (see -[Error handling](#error-handling)). +| `baseUrl` | `https://api.apify.com` | API base URL, with or without `/v2`. | +| `publicBaseUrl` | `baseUrl` | Base URL of public resource URLs, with or without `/v2`. | +| `maxRetries` | `8` | Maximum retries of a failed request. | +| `minDelayBetweenRetriesMillis` | `500` | Minimum delay between retries, which grows exponentially. | +| `maxDelayBetweenRetriesMillis` | `timeoutSecs × 1000` | Maximum delay between retries. | +| `timeoutSecs` | `360` | Default timeout of the `long` tier and of `timeoutMaxSecs`. | +| `timeoutShortSecs` | `5` | Timeout of the `short` tier, see [Timeout tiers](#timeout-tiers). | +| `timeoutMediumSecs` | `30` | Timeout of the `medium` tier. | +| `timeoutLongSecs` | `timeoutSecs` | Timeout of the `long` tier. | +| `timeoutMaxSecs` | `timeoutSecs` | Cap on the timeout of any attempt, per-call overrides, and growth across retries included. | +| `compression` | `null` (brotli, else gzip) | Request body compression: `'brotli'`, `'gzip'`, `'zstd'`, or a custom `HttpCompressorInterface`, see [HTTP compression](#http-compression). | +| `userAgentSuffix` | `null` | Suffix appended to the `User-Agent` header. | +| `httpClient` | Guzzle | The transport, an `Apify\Client\Http\HttpClientInterface`. | + +Requests are retried with exponential backoff and jitter on network errors, HTTP 429, and 5xx. Other 4xx +responses throw an `ApifyApiException` subclass for the status. A 404 on a single resource is not thrown: +`get()` returns `null` and `delete()` does nothing, see [Error handling](#error-handling). ### Timeout tiers -Every method that sends a request is assigned a timeout tier: `short` (metadata reads/writes, e.g. -`get()`/`update()`/`delete()`), `medium` (listing/batch/trigger calls), or `long` -(downloads/uploads/streaming). The tiers last 5 seconds (`short`), 30 seconds (`medium`) and the client's -`timeoutSecs`, 360 seconds by default (`long`); set `timeoutShortSecs`/`timeoutMediumSecs`/ -`timeoutLongSecs` to give a tier another duration. A call that asks the API to hold its response with a -`waitForFinish` value (`RunClient::get()`, `BuildClient::get()`, `ActorClient::defaultBuild()`, and the -`waitForFinish` option of `ActorClient::start()`/`build()` and `TaskClient::start()`) gets that hold, -at most 60 seconds, on top of its tier, so the client never aborts a request the API is still holding. +Each method has a timeout tier: `short` for metadata reads and writes such as `get()`, `update()` and +`delete()`, `medium` for listing, batch, and trigger calls, and `long` for downloads, uploads, and streaming. The +tiers last 5 seconds, 30 seconds, and `timeoutSecs` (360 by default). Set `timeoutShortSecs`, +`timeoutMediumSecs` or `timeoutLongSecs` to change one. + +A call that asks the API to hold its response with `waitForFinish` (`RunClient::get()`, `BuildClient::get()`, +`ActorClient::defaultBuild()`, and the `waitForFinish` option of `ActorClient::start()`, `build()` and +`TaskClient::start()`) gets that hold on top of its tier, for at most 60 seconds. -Every method that sends a request additionally takes an optional trailing `$timeoutSecs`, overriding -the tier for that one call — a number of seconds, a tier name (`'short'`/`'medium'`/`'long'`), or -`'noTimeout'` for no request timeout at all: +Every request method also takes a trailing `$timeoutSecs` that overrides the tier for that call: seconds, a +tier name (`'short'`, `'medium'`, `'long'`), or `'noTimeout'`: ```php -$client->actor('my-actor')->get(timeoutSecs: 10); // this call only, 10 seconds -$client->dataset('my-dataset')->listItems(timeoutSecs: 'long'); // this call only, the 'long' tier -$client->requestQueue('my-queue')->unlockRequests(timeoutSecs: 'noTimeout'); // no request timeout at all +$client->actor('my-actor')->get(timeoutSecs: 10); // 10 seconds +$client->dataset('my-dataset')->listItems(timeoutSecs: 'long'); // the 'long' tier +$client->requestQueue('my-queue')->unlockRequests(timeoutSecs: 'noTimeout'); // no timeout ``` -`timeoutMaxSecs` caps every tier and every per-call override alike (defaulting to `timeoutSecs`), so -raise it whenever a call legitimately needs longer than the default 360 seconds. A failed attempt is -retried with a timeout doubled up to that cap. A request-queue -client's own `timeoutSecs` (`requestQueue(id, options)`) caps every tier and per-call override of -calls made through that client specifically, on top of `timeoutMaxSecs`. +`timeoutMaxSecs` caps every tier and override, so raise it for calls that need more than 360 seconds. A retry +doubles the timeout up to that cap. The `timeoutSecs` of a request queue client, set in +`requestQueue(id, options)`, caps the calls of that client too. ### HTTP compression -Request bodies above 1 KiB are compressed before being sent, unless their `Content-Type` already -carries its own compression (`image/*`, `audio/*`, `video/*`, common archive/office/font formats) or -the caller already set a `Content-Encoding` header. By default the client picks brotli when the -optional PECL `brotli` extension is loaded, falling back to gzip (PHP's standard `zlib` extension) -otherwise — compression is always best-effort, so a body is sent uncompressed rather than the request -failing when neither codec is available. +A request body of at least 1 KiB is compressed, unless its `Content-Type` is already compressed (`image/*`, +`audio/*`, `video/*`, common archive, office, and font formats) or the request has a `Content-Encoding` header. +By default the client uses brotli when the PECL `brotli` extension is loaded and gzip (the `zlib` extension) +otherwise. Compression is best-effort: without a codec, the body is sent as it is. -Pass `compression` to pick an algorithm explicitly, or an instance for a custom quality: +Pass `compression` to choose an algorithm, or a compressor to also set the quality: ```php use Apify\Client\Http\BrotliHttpCompressor; use Apify\Client\Http\GzipHttpCompressor; +use Apify\Client\Http\ZstdHttpCompressor; $client = new ApifyClient(token: 'my-api-token', compression: 'gzip'); -// A custom quality (brotli: 0-11, default 6; gzip: 1-9, default 6). +// Brotli: 0-11, default 6. Gzip: 1-9, default 6. Zstd: 1-19, default 3. $client = new ApifyClient(token: 'my-api-token', compression: new BrotliHttpCompressor(quality: 11)); $client = new ApifyClient(token: 'my-api-token', compression: new GzipHttpCompressor(quality: 1)); +$client = new ApifyClient(token: 'my-api-token', compression: new ZstdHttpCompressor(quality: 19)); ``` -A custom algorithm is any `Apify\Client\Http\HttpCompressorInterface` implementation (a -`contentEncoding(): string` plus a `compress(string $body): ?string` that returns `null` to leave a -body uncompressed). +Brotli and zstd need their PECL extensions, and gzip needs `zlib`. Choosing a codec without its extension throws +`RuntimeException` when the client is created. A custom algorithm is an +`Apify\Client\Http\HttpCompressorInterface` implementation: `contentEncoding(): string` and +`compress(string $body): ?string`, which returns `null` to send the body as it is. ### Replaceable HTTP transport -The transport is the `Apify\Client\Http\HttpClientInterface`. The default is `GuzzleHttpClient`; you -can wrap any [PSR-18](https://www.php-fig.org/psr/psr-18/) client with `Psr18HttpClient`, or provide -your own implementation: +The transport is an `Apify\Client\Http\HttpClientInterface`. The default is `GuzzleHttpClient`. Wrap any +[PSR-18](https://www.php-fig.org/psr/psr-18/) client with `Psr18HttpClient`, or write your own: ```php -// Use the default Guzzle transport explicitly. $client = new ApifyClient(token: 'my-api-token', httpClient: new GuzzleHttpClient()); -// Or wrap any PSR-18 client (configure its proxy/TLS/timeout on the wrapped client, since -// PSR-18 has no per-request timeout and Psr18HttpClient ignores the client's timeoutSecs). -$psr18 = new \GuzzleHttp\Client(['timeout' => 120]); // any Psr\Http\Message ClientInterface +// PSR-18 has no per-request timeout, so configure it on the wrapped client. +$psr18 = new \GuzzleHttp\Client(['timeout' => 120]); $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). +`ActorClient::server()->request()` needs a transport that returns redirects as they are. It throws with +`Psr18HttpClient` and any transport that does not 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 -than throwing), and `delete()` is a no-op for a resource that is already gone. Other API failures are -thrown as `Apify\Client\Exception\ApifyApiException`, or one of its subclasses matching the response's -HTTP status — `InvalidRequestException` (400), `UnauthorizedException` (401), `ForbiddenException` -(403), `NotFoundException` (404), `ConflictException` (409), `RateLimitException` (429), or -`ServerException` (any 5xx) — so a `catch` block can branch on `instanceof` instead of comparing -status codes or `getType()` strings. Any other status still throws a plain `ApifyApiException`, and -every subclass extends it, so an existing `instanceof ApifyApiException` check keeps matching all of -them: +A method that fetches one resource by ID returns `null` if it does not exist, and `delete()` does nothing for a +missing resource. Other API failures throw `Apify\Client\Exception\ApifyApiException` or the subclass for the +status: `InvalidRequestException` (400), `UnauthorizedException` (401), `ForbiddenException` (403), +`NotFoundException` (404), `ConflictException` (409), `RateLimitException` (429), and `ServerException` (5xx). +All extend `ApifyApiException`. ```php try { $client->actor('does/not-exist')->update(['title' => 'x']); } catch (NotFoundException $e) { - // The Actor doesn't exist, or the token can't see it. + // The Actor does not exist, or the token cannot see it. } catch (ApifyApiException $e) { echo $e->getStatusCode() . ' ' . $e->getType() . ': ' . $e->getApiMessage() . PHP_EOL; } ``` -A 404 is swallowed (resolving to `null`/no-op) only when it can be pinned to the resource the call -addresses. A resource client obtained *without* an ID — `RunClient::dataset()`/`keyValueStore()`/ -`requestQueue()`/`log()`, `BuildClient::log()` — throws instead, since the 404 there could mean either -the parent run/build or the sub-resource is gone: +A 404 resolves to `null` only if it is certain which resource is missing. A client obtained without an ID, +such as `RunClient::dataset()`, `keyValueStore()`, `requestQueue()`, `log()`, and `BuildClient::log()`, throws +instead, since the run or build may be the missing one: ```php try { $client->run('missing-run-id')->dataset()->get(); } catch (NotFoundException $e) { - // Either the run or its default dataset does not exist — the response cannot tell which. + // The run or its default dataset does not exist. } ``` -The same rule applies to a handful of fixed sub-paths whose only way to 404 is their parent being -gone: `DatasetClient::getStatistics()`, `TaskClient::getInput()`, `ScheduleClient::getLog()`, and -`BuildClient::getOpenApiDefinition()` always throw rather than returning `null`. Lookups by key, such -as `KeyValueStoreClient::getRecord()`/`RequestQueueClient::getRequest()`, are unaffected and still -resolve to `null` for a missing record/request. +`DatasetClient::getStatistics()`, `TaskClient::getInput()`, `ScheduleClient::getLog()` and +`BuildClient::getOpenApiDefinition()` also always throw on a 404, since only a missing parent can cause it. +Lookups by key, such as `KeyValueStoreClient::getRecord()` and `RequestQueueClient::getRequest()`, still +return `null`. -`ApifyApiException` extends `RuntimeException` and exposes: +`ApifyApiException` extends `RuntimeException` and has these accessors: | Accessor | Returns | |---|---| -| `getStatusCode(): int` | HTTP status code of the error response. | -| `getType(): ?string` | Machine-readable API error type (e.g. `"record-not-found"`). | -| `getApiMessage(): string` | Raw API error message, without the status/type prefix. | -| `getMessage(): string` | Formatted message (`apify API error (status …, type …): …`), from `Throwable`. | -| `getAttempt(): int` | 1-based number of the request attempt that produced the error. | -| `getHttpMethod(): string` | HTTP method of the failed call (e.g. `"GET"`). | -| `getPath(): string` | Path of the API endpoint (URL excluding origin). | -| `getData(): ?array` | Additional structured error data provided by the API, if any. | - -Transport-level failures (network errors, timeouts) are retried internally; only if every retry is -exhausted does the underlying error surface, as an `Apify\Client\Exception\TransportException` -(a `RuntimeException`; `isTimeout()` reports whether a request timed out). In short: -`ApifyApiException` means the server returned an error response (a 4xx/5xx with a body), whereas -`TransportException` means the request never produced a usable response (network failure or timeout) -after all retries. Requests are retried on network errors, HTTP 429 and 5xx. - -A successful (2xx) response whose body does not parse as JSON, for an endpoint whose body this client -decodes as JSON, is not retried (it arrived in full, so a retry would get the same body back) and throws -`Apify\Client\Exception\InvalidResponseBodyException` on the first attempt. An error response whose body -does not parse (e.g. a gateway's HTML 502 page) throws the `ApifyApiException` subclass for its status, -with the raw body in the message, and is retried by status like any other error response. - -Every error the client throws for a failed API interaction — `ApifyApiException` and its status -subclasses above, plus `InvalidResponseBodyException` — extends the common -`Apify\Client\Exception\ApifyClientException`, so one `catch` can handle both: +| `getStatusCode(): int` | HTTP status code. | +| `getType(): ?string` | API error type, for example `"record-not-found"`. | +| `getApiMessage(): string` | API error message, without the status and type prefix. | +| `getMessage(): string` | Formatted message: `apify API error (status …, type …): …`. | +| `getAttempt(): int` | Number of the attempt that failed, starting at 1. | +| `getHttpMethod(): string` | HTTP method of the call. | +| `getPath(): string` | Endpoint path, without the origin. | +| `getData(): ?array` | Structured error data from the API, if any. | + +A network failure or timeout is retried. When the retries run out, it throws +`Apify\Client\Exception\TransportException` (a `RuntimeException`, with `isTimeout()`). So `ApifyApiException` +means the server answered with an error, and `TransportException` means no usable response arrived. + +A success response whose body is not valid JSON, for an endpoint that returns JSON, is not retried and throws +`Apify\Client\Exception\InvalidResponseBodyException`. An error response with an unparsable body, such as an +HTML 502 page, throws the `ApifyApiException` subclass for its status with the raw body in the message. + +`ApifyApiException` and `InvalidResponseBodyException` extend `Apify\Client\Exception\ApifyClientException`, +so one `catch` handles both: ```php try { $client->actor('my-actor')->update(['title' => 'x']); } catch (ApifyClientException $e) { - // Either the API returned an error response, or its body could not be parsed. + // An API error response, or a body that could not be parsed. } ``` -`ArgumentValidationError`-style invalid-argument errors (built-in `\InvalidArgumentException`s) and -`TransportException` stay outside this hierarchy, matching the reference client. +`\InvalidArgumentException` and `TransportException` do not extend it. ## Versioning -- `Apify\Client\Version::CLIENT_VERSION` — the semantic version of this library. -- `Apify\Client\Version::API_SPEC_VERSION` — the Apify OpenAPI spec version this client was built - against. +- `Apify\Client\Version::CLIENT_VERSION` is the semantic version of this library. +- `Apify\Client\Version::API_SPEC_VERSION` is the version of the Apify OpenAPI specification it is based on. ```php echo Version::CLIENT_VERSION . ' / ' . Version::API_SPEC_VERSION . PHP_EOL; @@ -248,8 +220,7 @@ echo Version::CLIENT_VERSION . ' / ' . Version::API_SPEC_VERSION . PHP_EOL; ## Documentation -Full documentation lives in [`docs/`](docs/README.md), organized by resource, with runnable -[examples](docs/examples.md). +See [`docs/`](docs/README.md), organized by resource, with runnable [examples](docs/examples.md). ## License diff --git a/composer.json b/composer.json index 47d3213..21e2893 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,9 @@ "phpstan/phpstan": "^1.11" }, "suggest": { - "ext-intl": "Converts an internationalized host of a URL field in a response to punycode, as the reference client does." + "ext-intl": "Converts an internationalized host in a URL field of a response to punycode.", + "ext-brotli": "Compresses request bodies with brotli, which is the default when it is loaded.", + "ext-zstd": "Compresses request bodies with zstd, with compression: 'zstd'." }, "autoload": { "psr-4": { diff --git a/docs/README.md b/docs/README.md index 719c256..0f5d357 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,11 +1,9 @@ # Apify PHP client documentation -This directory documents the public API of the Apify PHP client, organized by resource. Each page -lists the available methods with their parameters and short, runnable snippets. For an overview, -configuration and error handling, see the [top-level README](../README.md). +The public API of the Apify PHP client by resource, with the parameters of each method and short snippets. +For configuration and error handling, see the [README](../README.md). -All snippets assume a configured client and that the client types are imported from their namespaces -(e.g. `use Apify\Client\ApifyClient;`, `use Apify\Client\Options\ActorListOptions;`): +The snippets assume a client and imported types, for example `use Apify\Client\ApifyClient;`: ```php $client = new ApifyClient('my-api-token'); @@ -13,17 +11,17 @@ $client = new ApifyClient('my-api-token'); ### Namespaces -Every class is under the `Apify\Client\` PSR-4 root. Use these when writing `use` statements: +All classes are under the `Apify\Client\` root: | Namespace | Contains | Examples | |---|---|---| | `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`, `NoRedirectHttpClientInterface`, `GuzzleHttpClient`, `Psr18HttpClient`, `HttpCompressorInterface`, `BrotliHttpCompressor`, `GzipHttpCompressor` | +| `Apify\Client\Options\` | The `*Options` classes and enums. | `ActorListOptions`, `ActorStartOptions`, `RunListOptions`, `StorageListOptions`, `DatasetListItemsOptions`, `DownloadItemsFormat`, and the others in the [options reference](options.md) | +| `Apify\Client\Http\` | The transport, its adapters, and the compressors. | `HttpClientInterface`, `NoRedirectHttpClientInterface`, `GuzzleHttpClient`, `Psr18HttpClient`, `HttpCompressorInterface`, `BrotliHttpCompressor`, `GzipHttpCompressor`, `ZstdHttpCompressor` | | `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: +To add requests to a queue, import the model and the batch options: ```php use Apify\Client\ApifyClient; @@ -31,22 +29,16 @@ use Apify\Client\Model\RequestQueueRequest; use Apify\Client\Options\BatchAddRequestsOptions; ``` -The streaming-log accessors (`LogClient::stream()` and `RunClient::getStreamedLog()`) return the -PSR-7 `Psr\Http\Message\StreamInterface` (from the `psr/http-message` package), not an -`Apify\Client\` type — import it as `use Psr\Http\Message\StreamInterface;`. +`LogClient::stream()` and `RunClient::getStreamedLog()` return a PSR-7 `Psr\Http\Message\StreamInterface`. -Methods that fetch a single resource by ID return `null` when the resource does not exist, rather -than throwing. A resource client obtained without an ID (e.g. `RunClient::dataset()`) throws instead, -since a 404 there cannot be pinned to the parent or the sub-resource. API failures are thrown as -`ApifyApiException` or a status-specific subclass (see [error handling](../README.md#error-handling)). +A method that fetches one resource by ID returns `null` if it does not exist. A client obtained without an ID, +such as `RunClient::dataset()`, throws instead. See [error handling](../README.md#error-handling). ## ApifyClient methods -`ApifyClient` is the entry point: construct one, then call an accessor to get a sub-client for a -specific resource or collection. Single-resource accessors take an ID (or, where the API allows it, -a name) and return that resource's client; collection accessors take no arguments and return a -collection client for listing and creating. Method detail lives on the linked [resource -pages](#resource-pages); the signatures below are the entry points. +An accessor returns the client of a resource or collection. A resource accessor takes an ID, or a name where the +API allows it. A collection accessor takes no arguments and lists and creates. The methods of each client are on +the [resource pages](#resource-pages). | Method | Returns | Notes | |---|---|---| @@ -80,11 +72,9 @@ pages](#resource-pages); the signatures below are the entry points. ## Models and unmodeled data (`toArray`) -Response models expose the commonly-used fields as typed getters (e.g. `$actor->getId()`). The -[models reference](models.md) lists every model and its getters. The API returns more fields than are -modelled; every model also exposes `toArray()`, which returns the full raw object, so nothing the API -returns is lost. For example a `Schedule`'s `actions`/`isExclusive`, or the private account details -of `me()`, are available via `toArray()`: +A response model has getters for common fields, for example `$actor->getId()`, listed in the +[models reference](models.md). `toArray()` returns the whole object, including fields without a getter, such as +the `actions` of a `Schedule` or the private account details of `me()`: ```php $schedule = $client->schedule('SCHEDULE_ID')->get(); @@ -93,35 +83,30 @@ $actions = $schedule?->toArray()['actions'] ?? null; ## Raw JSON values -A few methods return data whose shape is not modelled and is instead returned as a decoded -JSON value — typically an associative array, though `getInput()` is typed `mixed` and returns -whatever JSON value was stored (or accept an arbitrary value serialized to JSON): +Some methods return a decoded JSON value instead of a model, usually an associative array. `getInput()` returns +whatever JSON value was stored. - Read: `me()->monthlyUsage(...)`, `me()->limits()`, `schedule($id)->getLog()`, `task($id)->getInput()`, - `build($id)->getOpenApiDefinition()`, `dataset($id)->getStatistics()`. Note that - `deleteRequestLock` returns `void` (it releases a lock and has no meaningful body), so it is not in - this list. The request-queue lock/list/unlock/batch-delete operations - (`listRequests`, `listAndLockHead`, `prolongRequestLock`, `unlockRequests`, `batchDeleteRequests`) - return typed models (see [Models](models.md)), not raw JSON. -- Write: definition/`update`/`create` arguments accept any JSON-serializable value — typically an + `build($id)->getOpenApiDefinition()` and `dataset($id)->getStatistics()`. The request queue operations + `listRequests`, `listAndLockHead`, `prolongRequestLock`, `unlockRequests`, and `batchDeleteRequests` return + [models](models.md). +- Write: the definition arguments of `update` and `create` accept any JSON-serializable value, usually an associative array. ## Options objects -Option objects use named constructor arguments; an unset field means "use the API default". Pass only -the arguments you need: +Option objects take named constructor arguments. An unset field uses the API default: ```php $options = new ActorListOptions(my: true, limit: 10); $page = $client->actors()->list($options); ``` -The [options reference](options.md) lists every option class and all of its fields. +The [options reference](options.md) lists the fields of every option class. ## Common list options — `ListOptions` -Most `list` methods (builds, runs, tasks, schedules, webhooks, Actor versions) take the shared -`ListOptions`, which carries the standard pagination/ordering controls: `offset`, `limit`, `desc`. +Most `list` methods take `ListOptions`: `offset`, `limit`, and `desc`. ```php $builds = $client->builds()->list(new ListOptions(limit: 50, desc: true)); @@ -129,19 +114,16 @@ $builds = $client->builds()->list(new ListOptions(limit: 50, desc: true)); ## Pagination — `PaginationList` -`list` methods return a `PaginationList`, which is iterable and countable and exposes `getTotal()`, -`getOffset()`, `getLimit()`, `getCount()`, `isDesc()` and `getItems()`. Within-storage listers -(`listKeys`, `listHead`) return their own page/head containers instead. +`list` methods return a `PaginationList`, which is iterable and countable and has `getTotal()`, `getOffset()`, +`getLimit()`, `getCount()`, `isDesc()`, and `getItems()`. `listKeys` and `listHead` return their own page types. -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. +For `DatasetClient::listItems()`, `getCount()` is the number of rows the API scanned, or the number of items +when that is larger. Filters such as `skipEmpty` and `clean` can leave it above the number of items. The next +page starts at `offset + min(count, limit)`. -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. +The `iterate*()` helpers take a `$chunkSize`, the number of entries per API call. Without it, a call gets the +API's default page size, capped by the remaining `limit`. Dataset items have no default limit, so +`iterateItems()` without `$chunkSize` loads the whole dataset in one response. ```php $page = $client->actors()->list(new ActorListOptions(limit: 5)); @@ -152,9 +134,8 @@ foreach ($page as $actor) { ## Setting single-resource status -`$client->setStatusMessage(string $message, bool $isTerminal = false)` updates the status message of -the current Actor run (identified by the `ACTOR_RUN_ID` environment variable); it only works from -inside a run and throws otherwise. Returns the updated run. +`$client->setStatusMessage(string $message, bool $isTerminal = false)` sets the status message of the run in +the `ACTOR_RUN_ID` environment variable and returns the run. It throws outside a run. ## Resource pages diff --git a/docs/actors.md b/docs/actors.md index 77c1d27..13df8bc 100644 --- a/docs/actors.md +++ b/docs/actors.md @@ -4,9 +4,9 @@ Snippets assume `$client = new ApifyClient('my-api-token');` and imported types. ## Actor collection — `$client->actors()` -- `list(?ActorListOptions $options = null): PaginationList` — list the account's Actors. -- `iterate(?ActorListOptions $options = null, ?int $chunkSize = null): iterable` — lazily iterate all matching Actors, paging on demand. The options' `limit` caps the total number yielded across all pages (unset = all); `$chunkSize` is the per-page size. -- `create(mixed $actor): Actor` — create a new Actor from a JSON-serializable definition. +- `list(?ActorListOptions $options = null): PaginationList`: list the account's Actors. +- `iterate(?ActorListOptions $options = null, ?int $chunkSize = null): iterable`: lazily yields the Actors. The `limit` option caps the total (unset for all), `$chunkSize` is the page size. +- `create(mixed $actor): Actor`: create an Actor from a JSON-serializable definition. ```php $page = $client->actors()->list(new ActorListOptions(my: true, limit: 10)); @@ -34,25 +34,25 @@ Addressed by ID or `username~name` (a `username/name` is accepted too). - `get(): ?Actor` - `update(mixed $newFields): Actor` - `delete(): void` -- `start(mixed $input = null, ?ActorStartOptions $options = null): ActorRun` — start and return immediately. -- `call(mixed $input = null, ?ActorStartOptions $options = null, ?int $waitSecs = null, int|float|string|null $timeoutSecs = null): ActorRun` — start and wait. `$timeoutSecs` is the timeout of the start request and of every poll (default `'noTimeout'`). +- `start(mixed $input = null, ?ActorStartOptions $options = null): ActorRun`: start and return immediately. +- `call(mixed $input = null, ?ActorStartOptions $options = null, ?int $waitSecs = null, int|float|string|null $timeoutSecs = null): ActorRun`: start and wait. `$timeoutSecs` is the timeout of the start request and of every poll (default `'noTimeout'`). - `validateInput(mixed $input = null, ?ValidateInputOptions $options = null): bool` -`$input` is a JSON-serializable array (the normal case), or a raw `string` of bytes sent exactly as -given — pair a raw string with a non-default `$options->contentType` (e.g. a scraper that takes a -plain-text URL list instead of JSON). +`$input` is a JSON-serializable array, or a `string` sent as it is. Set `$options->contentType` for a string, for +example a plain-text URL list. + +`$options->waitForResources` retries a start that the API rejects for lack of account memory or a free run slot +(`actor-memory-limit-exceeded`, `concurrent-runs-limit-exceeded`) every 10 seconds. `true` retries until the run +starts, an `int` for that many seconds, and then throws the last error. In `call()`, the retries do not count +toward `$waitSecs`. -`$options->waitForResources` retries a start the API rejects for lack of account memory or a free -concurrent-run slot (`actor-memory-limit-exceeded`/`concurrent-runs-limit-exceeded`), every 10 -seconds: `true` retries until the run starts, an `int` stops retrying after that many seconds and -throws the last error. In `call()`, time spent retrying does not count toward `$waitSecs`. - `build(string $versionNumber, ?ActorBuildOptions $options = null): Build` - `defaultBuild(?int $waitForFinish = null): BuildClient` - `lastRun(?LastRunOptions $options = null): RunClient` - `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. +- `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); @@ -62,31 +62,36 @@ $lastSucceeded = $client->actor('apify/hello-world')->lastRun(new LastRunOptions ## 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. +A Server Actor runs a web server that answers HTTP requests without a run for each request. +`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 server. The timeout defaults to the `long` tier, which covers the start of an idle Actor. +- `getUrl(int|float|string|null $timeoutSecs = null): string`: the server URL, the `standbyUrl` of the Actor. It is fetched once per client. +- `getOpenApiSpec(int|float|string|null $timeoutSecs = null): ?array`: the web server schema of the build that serves the server (`actorStandby.build`, else `latest`), or `null` if the Actor has none. -- `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 the request and the token cannot reach another host. Pass +the query in the `params` option. The token goes in the `X-Apify-Authorization` header, which the platform removes, +so the headers you pass, `Authorization` included, reach the Actor as they are. -`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. +Any response of the server is returned, error statuses included. A 429 or 503 is retried with the client's retry +settings, and the last response is returned when they run out. A transport error, a timeout included, throws +`TransportException` without a retry, since the Actor may have handled the request. A redirect is returned as it +is, so the token never follows it to another host. The body is sent uncompressed unless the `compression` option +is set. -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. +A custom [`HttpClientInterface`](../README.md) must also implement `NoRedirectHttpClientInterface` for `request()`, +which otherwise throws `ApifyClientException` before sending anything. `GuzzleHttpClient` implements it, +`Psr18HttpClient` does not. -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` has `getStatusCode()`, `isSuccessful()`, `getHeaders()`, `getHeaderLine($name)`, `getBody()` +(the raw bytes), `getBodyStream()` (a PSR-7 stream, live with the `stream` option), `getData()` (JSON decoded, any +other body a string) and `getResponse()` (the PSR-7 response). -`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). +A multi-tenant Server Actor shares the runs of its developer among its users and tells their requests apart by the +`X-Actor-Request-ID` header that the platform adds to each request. When such an Actor calls another Server Actor +while it handles a request, pass that header in `parentRequestId`. The client sends it as the same header, so the +called Actor charges at the pricing tier of the incoming request. Use a client created with the `APIFY_TOKEN` of the +run. ```php $server = $client->actor('username/actor-name')->server(); @@ -98,6 +103,13 @@ if ($response->isSuccessful()) { $url = $server->getUrl(); $spec = $server->getOpenApiSpec(); + +// Inside a multi-tenant Server Actor that handles a request with the header $incomingRequestId. +$incomingRequestId = 'REQUEST_ID_HEADER_VALUE'; +$response = $server->request('/search', new ActorServerRequestOptions( + params: ['q' => 'apify'], + parentRequestId: $incomingRequestId, +)); ``` ## Actor versions — `$client->actor($id)->versions()` / `->version($n)` @@ -105,8 +117,8 @@ $spec = $server->getOpenApiSpec(); - Collection: `list(): PaginationList`, `iterate(): iterable`, `create(mixed $version): ActorVersion`. - Single: `get(): ?ActorVersion`, `update(mixed $newFields): ActorVersion`, `delete(): void`. -The versions endpoint takes no filtering or pagination parameters — it always returns every version -in one response — so `list()`/`iterate()` take no options and `iterate()` makes a single request. +The endpoint returns all versions in one response, so `list()` and `iterate()` take no options and `iterate()` +makes one request. ```php $version = $client->actor('me~my-actor')->versions()->create([ @@ -116,12 +128,12 @@ $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. +A `SOURCE_FILES` or `TARBALL` version can set `actorPath`, the directory of the Actor inside the uploaded source, +to push an Actor from an upload of a monorepo. It is relative to the upload root, 1 to 255 characters, with `/` +separators, no leading, trailing, or repeated `/`, and no `.` or `..` segment. 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 with every upload. `'actorPath' => null` removes it too, and an update of +other fields keeps it. `ActorVersion::getActorPath()` reads it. ```php $version = $client->actor('me~my-actor')->versions()->create([ @@ -142,8 +154,7 @@ $client->actor('me~my-actor')->version('0.2')->update([ - Collection: `list(): PaginationList`, `iterate(): iterable`, `create(ActorEnvVar $envVar): ActorEnvVar`. - Single: `get(): ?ActorEnvVar`, `update(ActorEnvVar $envVar): ActorEnvVar`, `delete(): void`. -This endpoint likewise takes no filtering or pagination parameters, so `iterate()` makes a single -request, mirroring the reference client's parameterless iterator. +The endpoint takes no filter or pagination parameters, so `iterate()` makes one request. ```php $client->actor('me~my-actor')->version('0.0')->envVars()->create(new ActorEnvVar('API_KEY', 'secret', isSecret: true)); diff --git a/docs/builds.md b/docs/builds.md index e085221..271af94 100644 --- a/docs/builds.md +++ b/docs/builds.md @@ -4,8 +4,8 @@ Snippets assume `$client = new ApifyClient('my-api-token');` and imported types. ## Build collection — `$client->builds()` -- `list(?ListOptions $options = null): PaginationList` — list the account's builds. -- `iterate(?ListOptions $options = null, ?int $chunkSize = null): iterable` — lazily iterate all builds, paging on demand. The options' `limit` caps the total number yielded across all pages (unset = all); `$chunkSize` is the per-page size. +- `list(?ListOptions $options = null): PaginationList`: list the account's builds. +- `iterate(?ListOptions $options = null, ?int $chunkSize = null): iterable`: lazily yields the builds. The `limit` option caps the total (unset for all), `$chunkSize` is the page size. ```php $page = $client->builds()->list(new ListOptions(limit: 20, desc: true)); @@ -19,14 +19,12 @@ An Actor's builds are available at `$client->actor($id)->builds()`. ## A single build — `$client->build($id)` -- `get(?int $waitForFinishSecs = null): ?Build` — fetch, optionally waiting server-side (max 60s). +- `get(?int $waitForFinishSecs = null): ?Build`: fetches the build. The API can hold the response until the build finishes, for at most 60 seconds. - `abort(): Build` - `delete(): void` -- `waitForFinish(?int $waitSecs = null, int|float|string|null $timeoutSecs = null): Build` — poll until terminal (`null` waits indefinitely); `$timeoutSecs` is the timeout of each poll request (default `'noTimeout'`). -- `getOpenApiDefinition(): ?array` — the build's generated OpenAPI document. Throws if the build - itself does not exist (a 404 here cannot mean anything else). -- `log(): LogClient` — `get()` throws instead of resolving to `null` on a 404, since there is no ID - to pin the 404 to the build or its log. +- `waitForFinish(?int $waitSecs = null, int|float|string|null $timeoutSecs = null): Build`: polls until the build is terminal (`null` waits indefinitely). `$timeoutSecs` is the timeout of each poll (default `'noTimeout'`). +- `getOpenApiDefinition(): ?array`: the generated OpenAPI document of the build. Throws if the build does not exist. +- `log(): LogClient`: a 404 from `get()` is thrown, since the client cannot tell a missing build from a missing log. ```php $build = $client->actor('me~my-actor')->build('0.0', new ActorBuildOptions(tag: 'latest')); diff --git a/docs/examples.md b/docs/examples.md index d984364..8cb1405 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,20 +1,16 @@ # Runnable examples -Each snippet below assumes a configured `$client` and that the types it uses are imported with the -appropriate `use` statements (see [Namespaces](README.md#namespaces)); the first -[complete program](#a-complete-standalone-program) shows the full scaffolding the shorter snippets -omit for brevity. The complete programs on this page live under -[`tests/Examples/`](../tests/Examples) and are executed end-to-end against the live API by the -`Test examples` CI step (see `ExamplesTest`), so those programs are guaranteed to stay runnable. -Inline snippets on the other documentation pages are not executed: they are only syntax-checked with -`php -l` by `DocSnippetsTest`, which catches parse errors but does not resolve classes or check types. +The snippets assume a `$client` and imported types (see [Namespaces](README.md#namespaces)). The first +[complete program](#a-complete-standalone-program) shows the scaffolding that the shorter snippets omit. + +The programs on this page are in [`tests/Examples/`](../tests/Examples), and the `Test examples` CI step runs them +against the live API (`ExamplesTest`). Other snippets in the documentation are only checked with `php -l` by +`DocSnippetsTest`, which finds parse errors but not unknown classes or wrong types. ## A complete, standalone program -The snippet below is a full program you can save as `example.php` and run with `php example.php` -after `composer require apify/apify-client`. It shows the required scaffolding — the `actor('apify/hello-world')->call(null, null, 120); - // Read the items the run produced into its default dataset. getDefaultDatasetId() is - // ?string, so cast it to satisfy dataset(string $id). + // getDefaultDatasetId() is ?string, so cast it for dataset(string $id). $items = $client->dataset((string) $run->getDefaultDatasetId())->listItems(); echo 'Item count: ' . $items->getCount() . PHP_EOL; } catch (ApifyApiException $e) { diff --git a/docs/misc.md b/docs/misc.md index 73dfd63..48cd880 100644 --- a/docs/misc.md +++ b/docs/misc.md @@ -4,14 +4,14 @@ Snippets assume `$client = new ApifyClient('my-api-token');` and imported types. ## Apify Store — `$client->store()` -- `list(?StoreListOptions $options = null): PaginationList` — one page of Store Actors. -- `iterate(?StoreListOptions $options = null, ?int $chunkSize = null): iterable` — lazily iterate all matching Actors, paging on demand. The options' `limit` caps the total number yielded across all pages (unset = all); `$chunkSize` is the per-page size. +- `list(?StoreListOptions $options = null): PaginationList`: one page of Store Actors. +- `iterate(?StoreListOptions $options = null, ?int $chunkSize = null): iterable`: lazily yields the matching Actors. The `limit` option caps the total (unset for all), `$chunkSize` is the page size. ```php $page = $client->store()->list(new StoreListOptions(search: 'scraper', limit: 10)); $shown = 0; -// $chunkSize (50) is the per-page size; limit (unset) would cap the total across all pages. +// 50 is the page size. The limit option would cap the total. foreach ($client->store()->iterate(new StoreListOptions(search: 'scraper'), 50) as $item) { echo $item->getName() . PHP_EOL; if (++$shown >= 5) { @@ -22,9 +22,9 @@ foreach ($client->store()->iterate(new StoreListOptions(search: 'scraper'), 50) ## Users — `$client->me()` / `$client->user($id)` -- `get(): ?User` — for `me()`, private account details are available via `toArray()`. -- `monthlyUsage(?string $date = null): array` — the user's monthly usage. `$date` is an ISO date in `YYYY-MM-DD` format; the report covers the monthly usage cycle containing that date. Omit it (or pass `null`) to report the current month. -- `limits(): array`, `updateLimits(mixed $newLimits): void` — the user's account limits. `monthlyUsage()`, `limits()` and `updateLimits()` work for `me()` and for any user ID the token may access. +- `get(): ?User`: for `me()`, the private account details are in `toArray()`. +- `monthlyUsage(?string $date = null): array`: the monthly usage of the cycle that contains `$date` (`YYYY-MM-DD`), by default the current month. +- `limits(): array`, `updateLimits(mixed $newLimits): void`: the account limits. These three methods work for `me()` and for any user ID the token may access. ```php $me = $client->me()->get(); @@ -35,10 +35,8 @@ $publicProfile = $client->user('some-username')->get(); ## Logs — `$client->log($buildOrRunId)` -- `get(?LogOptions $options = null): ?string` — the full log as text. A PHP string is already a byte - sequence and is never re-decoded, so this already returns the log's exact bytes — there is no - separate "raw bytes" mode to opt into. -- `stream(?LogOptions $options = null): StreamInterface` — a live stream of the log. +- `get(?LogOptions $options = null): ?string`: the log as text, with the exact bytes the API sent. +- `stream(?LogOptions $options = null): StreamInterface`: a live stream of the log. ```php $log = $client->log('RUN_OR_BUILD_ID')->get(new LogOptions(raw: true)); diff --git a/docs/models.md b/docs/models.md index 56a3dcf..a5884f3 100644 --- a/docs/models.md +++ b/docs/models.md @@ -1,26 +1,24 @@ # Models reference -Response models live under the `Apify\Client\Model\` namespace and wrap the JSON objects the API -returns. Each model exposes the commonly-used fields as typed getters; every model also extends -`ApifyResource`, so two extra methods are always available: - -- `toArray(): array` — the full raw object as an associative array (nothing the API returns is lost, - even fields without a dedicated getter). -- `get(string $key): mixed` — a single raw field by its API name. - -Getters return `null` when the field is absent from the API response. - -URL fields the OpenAPI specification declares as `format: uri` — a run's `consoleUrl`, `containerUrl` and -`output` links, the `consoleUrl` and public URLs of storages, a key's `recordPublicUrl`, a task's -`standbyUrl`, a webhook's `requestUrl`, a Store Actor's `url` and `userPictureUrl`, and a user profile's -`pictureUrl` and `websiteUrl` — are returned in the WHATWG URL serialization, as the reference client -does, by the getters, `get()` and `toArray()` alike: an empty path gets a trailing slash -(`https://abc.runs.apify.net` becomes `https://abc.runs.apify.net/`), the scheme and host are lowercased, -a default port is dropped, an internationalized host becomes punycode (needs the optional `intl` -extension) and unsafe characters are percent-encoded. A value that is not an absolute http(s)/ws(s)/ftp -URL is kept as received, so a value compared against a stored URL has to be in that form too. The `RequestQueueRequest` -model is also used as an input object and therefore additionally exposes setters and a constructor -(see [Input models](#input-models)). +Response models are in the `Apify\Client\Model\` namespace and wrap the JSON objects of the API. They have typed +getters for common fields, and all extend `ApifyResource`, which adds: + +- `toArray(): array`: the whole object, including fields without a getter. +- `get(string $key): mixed`: a field by its API name. + +A getter returns `null` when the field is absent from the response. + +The URL fields that the OpenAPI specification declares as `format: uri` are returned in the WHATWG URL +serialization by the getters, `get()` and `toArray()`. They are a run's `consoleUrl`, `containerUrl`, and `output` +links, the `consoleUrl` and public URLs of storages, a key's `recordPublicUrl`, a task's `standbyUrl`, a webhook's +`requestUrl`, a Store Actor's `url` and `userPictureUrl`, and a user's `pictureUrl` and `websiteUrl`. An empty path +gets a trailing slash (`https://abc.runs.apify.net` becomes `https://abc.runs.apify.net/`), the scheme and host +are lowercased, a default port is dropped, an internationalized host becomes punycode (needs the optional `intl` +extension) and unsafe characters are percent-encoded. A value that is not an absolute http(s), ws(s), or ftp URL is +kept as received. + +`RequestQueueRequest` is also an input object, so it has setters and a constructor (see +[Input models](#input-models)). ## Resource models @@ -34,7 +32,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. | +| `getStandbyUrl(): ?string` | URL for sending requests to the Server Actor, or `null` if it has no server interface. | | `getCreatedAt(): ?string` | ISO-8601 creation timestamp. | | `getModifiedAt(): ?string` | ISO-8601 last-modification timestamp. | diff --git a/docs/options.md b/docs/options.md index 91bffa6..2e0ceb2 100644 --- a/docs/options.md +++ b/docs/options.md @@ -56,7 +56,7 @@ Supports [`withPagination($offset, $limit)`](#manual-offset-paging-with-withpagi Supports [`withPagination($offset, $limit)`](#manual-offset-paging-with-withpagination). ### `StorageListOptions` -Used when listing datasets, key-value stores and request queues. +Used when listing datasets, key-value stores, and request queues. | Field | Type | Description | |---|---|---| | `offset` | `?int` | Number of items to skip. | @@ -122,10 +122,11 @@ For `actor($id)->server()->request()`. | Field | Type | Description | |---|---|---| | `method` | `?string` | HTTP method (default `GET`). | -| `params` | `array\|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. | +| `params` | `array\|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\|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. | +| `compression` | `string\|HttpCompressorInterface\|null` | `'brotli'`, `'gzip'`, `'zstd'`, or a custom compressor for the request body. Unset sends the body uncompressed, since the server may not decode it. A codec without its extension throws `RuntimeException`. | +| `parentRequestId` | `?string` | The `X-Actor-Request-ID` header of the request your Server Actor is handling. It is sent as the same header, replacing one in `headers`, so the called Actor charges at the pricing tier of that request. | | `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 @@ -183,7 +184,7 @@ For `run()->charge()` (pay-per-event Actors). `eventName` is **required**. | `eventName` | `string` | Name of the event to charge for. | | `count` | `?int` | Number of times to charge the event (defaults to 1). | | `idempotencyKey` | `?string` | Deduplicates the charge across retries; auto-generated if unset. Expires 3 minutes after the charge — reusing it later creates a new charge rather than deduplicating. | -| `requestId` | `?string` | ID of the Server Actor request being charged, from the `X-Actor-Request-ID` header of the incoming request. Required for multi-tenant Server Actor runs. | +| `requestId` | `?string` | ID of the Server Actor request being charged, from the `X-Actor-Request-ID` header of the incoming request. Required for runs of multi-tenant Server Actors. Runs of single-tenant Server Actors ignore it. | ## Builds @@ -267,10 +268,9 @@ For `keyValueStore()->setRecord()`. | `timeoutSecs` | `?int` | Per-request timeout for the upload (capped at the client's overall request timeout). | | `doNotRetryTimeouts` | `bool` | If `true`, do not retry the upload on request timeout (default `false`). | -`setRecord()` also takes a trailing `?string $contentEncoding = null` parameter (not a field of -`SetRecordOptions`, which only holds the write-retry options above): set it to the encoding already -applied to `$value` (e.g. `'gzip'`) so the client's own request compression backs off and the bytes -are uploaded exactly as given. +`setRecord()` also takes a trailing `?string $contentEncoding = null` parameter, which is not a field of +`SetRecordOptions`. Set it to the encoding already applied to `$value`, for example `'gzip'` or `'zstd'`, and the +bytes are uploaded as they are, without further compression. ## Request queues diff --git a/docs/runs.md b/docs/runs.md index 7302922..8f02e91 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -4,8 +4,8 @@ Snippets assume `$client = new ApifyClient('my-api-token');` and imported types. ## Run collection — `$client->runs()` -- `list(?ListOptions $options = null, ?RunListOptions $filter = null): PaginationList` — list runs. -- `iterate(?ListOptions $options = null, ?RunListOptions $filter = null, ?int $chunkSize = null): iterable` — lazily iterate all runs, applying the filters to every page. The options' `limit` caps the total number yielded across all pages (unset = all); `$chunkSize` is the per-page size. +- `list(?ListOptions $options = null, ?RunListOptions $filter = null): PaginationList`: list runs. +- `iterate(?ListOptions $options = null, ?RunListOptions $filter = null, ?int $chunkSize = null): iterable`: lazily yields the runs, with the filters applied to every page. The `limit` option caps the total (unset for all), `$chunkSize` is the page size. ```php $page = $client->runs()->list(new ListOptions(limit: 10), new RunListOptions(status: ['SUCCEEDED'])); @@ -19,20 +19,18 @@ An Actor's or task's runs are available at `$client->actor($id)->runs()` / `$cli ## A single run — `$client->run($id)` -- `get(?int $waitForFinishSecs = null): ?ActorRun` — fetch, optionally waiting server-side (max 60s). +- `get(?int $waitForFinishSecs = null): ?ActorRun`: fetches the run. The API can hold the response until the run finishes, for at most 60 seconds. - `update(mixed $newFields): ActorRun` - `delete(): void` -- `abort(?bool $gracefully = null): ActorRun` — aborts the run; with `$gracefully` `true` the run is signalled so it can finish its current request before terminating, `false` aborts immediately, and `null` (the default) lets the server apply its default (immediate abort). -- `metamorph(string $targetActorId, mixed $input = null, ?MetamorphOptions $options = null): ActorRun` — `$input` accepts the same shapes as `ActorClient::start()`'s (a JSON-serializable array, or a raw string of bytes with a matching `$options->contentType`). +- `abort(?bool $gracefully = null): ActorRun`: with `$gracefully` `true`, the run can finish its current request first. `false` and `null` (the default) abort at once. +- `metamorph(string $targetActorId, mixed $input = null, ?MetamorphOptions $options = null): ActorRun`: `$input` is as for `ActorClient::start()`. - `reboot(): ActorRun` - `resurrect(?RunResurrectOptions $options = null): ActorRun` -- `charge(RunChargeOptions $options): void` — for pay-per-event Actors. -- `waitForFinish(?int $waitSecs = null, int|float|string|null $timeoutSecs = null): ActorRun` — `$timeoutSecs` is the timeout of each poll request (default `'noTimeout'`). -- `dataset(): DatasetClient`, `keyValueStore(): KeyValueStoreClient`, `requestQueue(): RequestQueueClient` - — `get()`/`delete()` on these throw instead of resolving to `null`/no-op on a 404, since there is no - ID to pin the 404 to the run or the sub-resource. -- `iterateDatasetItems(?RunIterateDatasetItemsOptions $options = null, int|float|string|null $timeoutSecs = null): Generator` — lazily iterates the items of the run's default dataset *while the run is still producing them*, and returns once the run has finished and the dataset is drained. `$timeoutSecs` is the timeout of each request (default `'long'` tier), also used for the polls between pages. See [below](#reading-a-dataset-while-the-run-is-running). -- `log(): LogClient`, `getStreamedLog(): StreamInterface` — `log()->get()` throws on a 404 for the same reason. +- `charge(RunChargeOptions $options): void`: for pay-per-event Actors. +- `waitForFinish(?int $waitSecs = null, int|float|string|null $timeoutSecs = null): ActorRun`: `$timeoutSecs` is the timeout of each poll request (default `'noTimeout'`). +- `dataset(): DatasetClient`, `keyValueStore(): KeyValueStoreClient`, `requestQueue(): RequestQueueClient`: a 404 from `get()` or `delete()` is thrown, since the client cannot tell a missing run from a missing storage. +- `iterateDatasetItems(?RunIterateDatasetItemsOptions $options = null, int|float|string|null $timeoutSecs = null): Generator`: lazily yields the items of the default dataset while the run is producing them, and returns when the run has finished and the dataset is drained. `$timeoutSecs` is the timeout of each request, polls included (default: the `long` tier). See [below](#reading-a-dataset-while-the-run-is-running). +- `log(): LogClient`, `getStreamedLog(): StreamInterface`: `log()->get()` throws on a 404 for the same reason. ```php $run = $client->run('RUN_ID')->waitForFinish(120); @@ -42,13 +40,11 @@ $items = $client->run('RUN_ID')->dataset()->listItems(); ### Reading a dataset while the run is running -`iterateDatasetItems()` yields the run's dataset items as the run pushes them, polling the dataset's -`itemCount` and waiting up to `pollIntervalSecs` (default 5) for the run to finish between polls. It -returns after the run has finished and the remaining rows are read, which also covers the few seconds -the dataset's `itemCount` lags behind the pushed items. Each page is requested with a `limit` that ends at -`itemCount`, so offsets stay exact even when `clean`, `skipEmpty` or `unwind` change how many items a page -returns. On a last-run client (`actor($id)->lastRun()`), the iteration sticks to the run its first request -resolved to. +`iterateDatasetItems()` yields the items as the run pushes them. It polls the `itemCount` of the dataset and waits up +to `pollIntervalSecs` (default 5) for the run to finish between polls. It returns when the run has finished and the +remaining rows are read, which covers the few seconds that `itemCount` lags behind. A page ends at `itemCount`, so +offsets stay exact even when `clean`, `skipEmpty`, or `unwind` change the number of items. On a last-run client +(`actor($id)->lastRun()`), the iteration stays on the run its first request resolved to. ```php $run = $client->actor('my-actor')->start(); @@ -60,15 +56,13 @@ foreach ($client->run($run->getId())->iterateDatasetItems(new RunIterateDatasetI ### `waitForFinish` — two distinct meanings -`waitForFinish` appears in two different roles; do not confuse them: - -- **`waitForFinish(?int $waitSecs = null)`** — the client-side helper method (on runs and builds). It - polls until the run/build reaches a terminal state, transparently issuing repeated server-side - waits. `$waitSecs` is the total budget in seconds and is **not** capped; `null` waits indefinitely. - For instance, `waitForFinish(300)` waits up to five minutes. A 404 (replica lag right after the run - or build is created) is tolerated for up to 3 seconds; if it keeps 404ing past that, the wait gives - up and throws a `NotFoundException` instead of polling for the rest of `$waitSecs`. -- **The server-side `waitForFinish` parameter** — `get(?int $waitForFinishSecs = null)` (and - `defaultBuild()`) and the `waitForFinish` field on `*Options` (e.g. `ActorStartOptions`). This is a - single API-side wait and the server caps it at 60 seconds, so the client clamps larger values. The - request's timeout is that wait, at most 60 seconds, on top of the method's [timeout tier](../README.md#timeout-tiers). +The name has two meanings: + +- **`waitForFinish(?int $waitSecs = null)`** is the client-side method of runs and builds. It polls until the run or + build is terminal, with repeated server-side waits. `$waitSecs` is the total budget in seconds, not capped, and + `null` waits indefinitely. A 404 right after creation, which is replica lag, is tolerated for up to 3 seconds. + If it lasts longer, the wait throws `NotFoundException`. +- **The server-side `waitForFinish` parameter** is in `get(?int $waitForFinishSecs = null)`, `defaultBuild()` and + the `waitForFinish` field of the `*Options`, such as `ActorStartOptions`. It is one wait of the API, capped at + 60 seconds, and the client clamps larger values. The timeout of the request is that wait on top of the + [timeout tier](../README.md#timeout-tiers) of the method. diff --git a/docs/schedules.md b/docs/schedules.md index edd262d..db0fc8c 100644 --- a/docs/schedules.md +++ b/docs/schedules.md @@ -1,12 +1,12 @@ # Schedules -Schedules automatically start Actor or task runs at specified times. Snippets assume +A schedule starts Actor or task runs at set times. Snippets assume `$client = new ApifyClient('my-api-token');` and imported types. ## Schedule collection — `$client->schedules()` - `list(?ListOptions $options = null): PaginationList` -- `iterate(?ListOptions $options = null, ?int $chunkSize = null): iterable` — lazily iterate all schedules, paging on demand. The options' `limit` caps the total number yielded across all pages (unset = all); `$chunkSize` is the per-page size. +- `iterate(?ListOptions $options = null, ?int $chunkSize = null): iterable`: lazily yields the schedules. The `limit` option caps the total (unset for all), `$chunkSize` is the page size. - `create(mixed $schedule): Schedule` ```php @@ -25,9 +25,8 @@ foreach ($client->schedules()->iterate(new ListOptions(), 50) as $s) { ## A single schedule — `$client->schedule($id)` - `get(): ?Schedule`, `update(mixed $newFields): Schedule`, `delete(): void` -- `getLog(): array` — the schedule's invocation log: a list with one entry per invocation, each an - array with `message`, `level` and `createdAt` (an empty list if the schedule has not run yet). Throws - if the schedule itself does not exist (a 404 here cannot mean anything else). +- `getLog(): array`: the invocation log: a list with one array per invocation, with `message`, `level` and + `createdAt`. It is empty if the schedule has not run. Throws if the schedule does not exist. ```php $updated = $client->schedule('SCHEDULE_ID')->update(['cronExpression' => '0 12 * * *']); diff --git a/docs/storages.md b/docs/storages.md index cfea2ed..02fc95c 100644 --- a/docs/storages.md +++ b/docs/storages.md @@ -1,31 +1,29 @@ # Storages: datasets, key-value stores, request queues Snippets assume `$client = new ApifyClient('my-api-token');` and imported types (see -[Namespaces](README.md#namespaces)). The storage collections all support -`list(?StorageListOptions $options = null)` and `getOrCreate(?string $name = null)`; the dataset and -key-value-store collections additionally accept an optional `?array $schema` on `getOrCreate` -(request queues take only a name). The same storage can be reached from a run -(`$client->run($id)->dataset()`, etc.). +[Namespaces](README.md#namespaces)). Every storage collection has `list(?StorageListOptions $options = null)` and +`getOrCreate(?string $name = null)`. The dataset and key-value store collections also take an optional +`?array $schema` in `getOrCreate`. The storages of a run are reached from the run, for example +`$client->run($id)->dataset()`. ## Datasets Collection — `$client->datasets()`: - `list(?StorageListOptions $options = null): PaginationList` -- `iterate(?StorageListOptions $options = null, ?int $chunkSize = null): iterable` — lazily iterate all datasets, paging on demand. The options' `limit` caps the total number yielded across all pages (unset = all); `$chunkSize` is the per-page size. +- `iterate(?StorageListOptions $options = null, ?int $chunkSize = null): iterable`: lazily yields the datasets. The `limit` option caps the total (unset for all), `$chunkSize` is the page size. - `getOrCreate(?string $name = null, ?array $schema = null): Dataset` 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. 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. +- `listItems(?DatasetListItemsOptions $options = null): PaginationList`: one page of items decoded to PHP values. +- `iterateItems(?DatasetListItemsOptions $options = null, ?int $chunkSize = null): iterable`: lazily yields the items. The `limit` option caps the total (unset for all), `$chunkSize` is the page size. Dataset items have no default limit, so without `$chunkSize` the first request loads the whole dataset. The iterator advances by the `getCount()` of a page (the rows the API scanned, or the items when that is larger), not by its number of items, which matters with `skipEmpty`, `clean` and `unwind`. +- `downloadItems(DownloadItemsFormat $format, ?DatasetDownloadOptions $options = null): string`: the export as bytes. +- `streamItems(DownloadItemsFormat $format, ?DatasetDownloadOptions $options = null): StreamInterface`: the export of `downloadItems()` as a PSR-7 stream, so a large export can be read in chunks, for example piped to a file. It is not retried. - `pushItems(mixed $items): void` -- `getStatistics(): ?array` — throws if the dataset itself does not exist (a 404 here cannot mean - anything else). -- `createItemsPublicUrl(?DatasetListItemsOptions $options = null, ?int $expiresInSecs = null, ?DownloadItemsFormat $format = null, int|float|string|null $timeoutSecs = null): string` — builds a shareable URL for downloading this dataset's items (forwarding the given item filters); for a private dataset it appends an access signature, optionally bounded to `$expiresInSecs`, which must be positive (an `InvalidArgumentException` is thrown otherwise, before any request; omit it for a URL that never expires). `$timeoutSecs` applies to the request that fetches the dataset (default: the `long` tier). `$format` selects the served output format (defaults to `json`). +- `getStatistics(): ?array`: throws if the dataset does not exist. +- `createItemsPublicUrl(?DatasetListItemsOptions $options = null, ?int $expiresInSecs = null, ?DownloadItemsFormat $format = null, int|float|string|null $timeoutSecs = null): string`: builds a public URL for downloading the items, with the filters of the options. The URL of a private dataset is signed, and valid for `$expiresInSecs` if given. It must be positive, otherwise `InvalidArgumentException` is thrown before any request. Omit it for a URL that never expires. `$format` is the output format (default `json`). `$timeoutSecs` is for the request that fetches the dataset (default: the `long` tier). ```php $dataset = $client->datasets()->getOrCreate('my-dataset'); @@ -52,21 +50,21 @@ fclose($out); Collection — `$client->keyValueStores()`: - `list(?StorageListOptions $options = null): PaginationList` -- `iterate(?StorageListOptions $options = null, ?int $chunkSize = null): iterable` — lazily iterate all stores, paging on demand. The options' `limit` caps the total number yielded across all pages (unset = all); `$chunkSize` is the per-page size. +- `iterate(?StorageListOptions $options = null, ?int $chunkSize = null): iterable`: lazily yields the stores. The `limit` option caps the total (unset for all), `$chunkSize` is the page size. - `getOrCreate(?string $name = null, ?array $schema = null): KeyValueStore` 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 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. -- `setRecordJson(string $key, mixed $value): void` — convenience over `setRecord()` that JSON-encodes `$value` and stores it with a JSON content type. -- `deleteRecord(string $key): void` — permanently removes the record with the given key. -- `getRecordPublicUrl(string $key, int|float|string|null $timeoutSecs = null): string` — builds a shareable URL for downloading a single record; for a private store it appends an access signature so the URL works without an API token. -- `createKeysPublicUrl(?ListKeysOptions $options = null, ?int $expiresInSecs = null, int|float|string|null $timeoutSecs = null): string` — builds a shareable URL for listing this store's keys (forwarding the given key filters); for a private store it appends an access signature, optionally bounded to `$expiresInSecs`, which must be positive (an `InvalidArgumentException` is thrown otherwise, before any request; omit it for a URL that never expires). `$timeoutSecs` applies to the request that fetches the store (default: the `long` tier). +- `iterateKeys(?ListKeysOptions $options = null, ?int $chunkSize = null): iterable`: lazily yields the keys, following the cursor pages. The `limit` option caps the total (unset for all), `$chunkSize` is the page size (unset for the remaining total, or the API's default without a limit). +- `recordExists(string $key): bool`: returns whether the record exists, without downloading it (a `HEAD` request). +- `getRecord(string $key, ?GetRecordOptions $options = null): ?KeyValueStoreRecord`: `getValue()` returns the bytes the API sent, whatever the content type, so decode a JSON record yourself. +- `setRecord(string $key, string $value, string $contentType, ?SetRecordOptions $options = null, ?string $contentEncoding = null): void`: `$value` is sent as it is, also under `application/json`, so it must already be valid JSON. Use `setRecordJson()` to have a value encoded. `$contentEncoding` is the `Content-Encoding` of a `$value` that is already compressed, for example `gzip`, `br`, or `zstd`. The bytes are then sent as they are, without further compression. +- `setRecordJson(string $key, mixed $value, ?SetRecordOptions $options = null): void`: JSON-encodes `$value` and stores it as a JSON record. +- `deleteRecord(string $key): void`: deletes the record. +- `getRecordPublicUrl(string $key, int|float|string|null $timeoutSecs = null): string`: builds a public URL of a record. The URL of a private store is signed, so it works without a token. +- `createKeysPublicUrl(?ListKeysOptions $options = null, ?int $expiresInSecs = null, int|float|string|null $timeoutSecs = null): string`: builds a public URL for listing the keys, with the filters of the options. The URL of a private store is signed, and valid for `$expiresInSecs` if given. It must be positive, otherwise `InvalidArgumentException` is thrown before any request. Omit it for a URL that never expires. `$timeoutSecs` is for the request that fetches the store (default: the `long` tier). ```php $store = $client->keyValueStores()->getOrCreate('my-store'); @@ -91,7 +89,7 @@ $client->keyValueStore($store->getId())->setRecord('OUTPUT.json', $gzipped, 'app Collection — `$client->requestQueues()`: - `list(?StorageListOptions $options = null): PaginationList` -- `iterate(?StorageListOptions $options = null, ?int $chunkSize = null): iterable` — lazily iterate all request queues, paging on demand. The options' `limit` caps the total number yielded across all pages (unset = all); `$chunkSize` is the per-page size. +- `iterate(?StorageListOptions $options = null, ?int $chunkSize = null): iterable`: lazily yields the request queues. The `limit` option caps the total (unset for all), `$chunkSize` is the page size. - `getOrCreate(?string $name = null): RequestQueue` A specific queue client is obtained with `$client->requestQueue($id, ?RequestQueueClientOptions $options = null)`. @@ -102,20 +100,20 @@ Single — `$client->requestQueue($id)`: - `get(): ?RequestQueue`, `update(mixed $newFields): RequestQueue`, `delete(): void` - `listHead(?int $limit = null): RequestQueueHead` -- `addRequest(RequestQueueRequest $request, bool $forefront = false): RequestQueueOperationInfo` — adds a request to the queue; when `$forefront` is `true` it is added to the front (handled before the rest) instead of the back. -- `getRequest(string $id): ?RequestQueueRequest`, `updateRequest(RequestQueueRequest $request, bool $forefront = false): RequestQueueOperationInfo` (with `$forefront` `true` the updated request is moved to the front of the queue), `deleteRequest(string $id): void` -- `batchAddRequests(array $requests, bool $forefront = false, ?BatchAddRequestsOptions $options = null): BatchAddResult` — every request must have a non-empty `uniqueKey`; with `$forefront` `true` the requests are added to the front of the queue; input is split into batches of at most 25 requests that also respect the ~9 MiB payload limit. -- `batchDeleteRequests(array $requests): BatchDeleteResult` — `$requests` is a `list` (1-25 entries) where each entry identifies a request to delete via `setId()` or `setUniqueKey()` (other fields, if set, are ignored by the API); throws `InvalidArgumentException` if empty, over 25, or if any entry has neither a non-empty `id` nor `uniqueKey` set (unlike `batchAddRequests()`, oversized input is rejected rather than chunked, since delete is idempotent and can simply be called again). +- `addRequest(RequestQueueRequest $request, bool $forefront = false): RequestQueueOperationInfo`: adds a request, at the front with `$forefront`. +- `getRequest(string $id): ?RequestQueueRequest`, `updateRequest(RequestQueueRequest $request, bool $forefront = false): RequestQueueOperationInfo` (with `$forefront`, the request moves to the front), `deleteRequest(string $id): void` +- `batchAddRequests(array $requests, bool $forefront = false, ?BatchAddRequestsOptions $options = null): BatchAddResult`: every request needs a non-empty `uniqueKey`. With `$forefront`, they are added at the front. The input is split into batches of at most 25 requests and about 9 MiB. +- `batchDeleteRequests(array $requests): BatchDeleteResult`: `$requests` is a `list` of 1 to 25 entries, each identified by `setId()` or `setUniqueKey()`. Throws `InvalidArgumentException` for an empty list, more than 25 entries, or an entry without an id and a uniqueKey. Larger input is rejected instead of split, since a delete can be repeated. - `listRequests(?ListRequestsOptions $options = null): RequestQueueRequestsPage` -- `paginateRequests(?PaginateRequestsOptions $options = null): iterable` — lazily iterate the queue's requests, yielding `RequestQueueRequest` instances and following cursor pagination (see the options note below). -- `listAndLockHead(int $lockSecs, ?int $limit = null): LockedRequestQueueHead` — atomically returns and locks up to `$limit` requests for `$lockSecs` seconds. -- `prolongRequestLock(string $id, int $lockSecs, bool $forefront = false): RequestLockInfo` — extends a request's lock by `$lockSecs`; with `$forefront` `true` the request is placed at the front of the queue once its lock expires. -- `deleteRequestLock(string $id, bool $forefront = false): void` — releases the lock on a single request; with `$forefront` `true` the request is returned to the front of the queue. -- `unlockRequests(): UnlockRequestsResult` — releases all locks the client holds on this queue. +- `paginateRequests(?PaginateRequestsOptions $options = null): iterable`: lazily yields the `RequestQueueRequest`s, following the cursor pages. See the options below. +- `listAndLockHead(int $lockSecs, ?int $limit = null): LockedRequestQueueHead`: returns up to `$limit` requests and locks them for `$lockSecs` seconds. +- `prolongRequestLock(string $id, int $lockSecs, bool $forefront = false): RequestLockInfo`: extends the lock of a request by `$lockSecs`. With `$forefront`, the request moves to the front when the lock expires. +- `deleteRequestLock(string $id, bool $forefront = false): void`: releases the lock of a request. With `$forefront`, it moves to the front of the queue. +- `unlockRequests(): UnlockRequestsResult`: releases all locks that the client holds on the queue. - `withClientKey(string $clientKey): RequestQueueClient` `paginateRequests()` accepts a `PaginateRequestsOptions` with `limit` (total across all pages), -`maxPageLimit` (page size), `exclusiveStartId`/`cursor` (starting point) and `filter` +`maxPageLimit` (page size), `exclusiveStartId`/`cursor` (starting point), and `filter` (`locked`/`pending`). ```php diff --git a/docs/tasks.md b/docs/tasks.md index 3492ab2..cc4d129 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -1,12 +1,12 @@ # Tasks -Tasks are pre-configured Actor runs with stored input. Snippets assume +A task is an Actor run with a stored input. Snippets assume `$client = new ApifyClient('my-api-token');` and imported types. ## Task collection — `$client->tasks()` - `list(?ListOptions $options = null): PaginationList` -- `iterate(?ListOptions $options = null, ?int $chunkSize = null): iterable` — lazily iterate all tasks, paging on demand. The options' `limit` caps the total number yielded across all pages (unset = all); `$chunkSize` is the per-page size. +- `iterate(?ListOptions $options = null, ?int $chunkSize = null): iterable`: lazily yields the tasks. The `limit` option caps the total (unset for all), `$chunkSize` is the page size. - `create(mixed $task): Task` ```php @@ -24,21 +24,17 @@ foreach ($client->tasks()->iterate(new ListOptions(), 50) as $t) { ## A single task — `$client->task($id)` - `get(): ?Task`, `update(mixed $newFields): Task`, `delete(): void` -- `publish(): Task`, `unpublish(): Task` — publish/unpublish the task's public landing page, by - setting `isPublic` through `update()`. Both require write permission to the task's Actor; - publishing additionally requires the Actor to be public and the task's - `publicConfig.inputSchemaFields`/`publicConfig.datasetView` to be set. An Actor can have up to 10 - published tasks and an account up to 100; if the conditions aren't met, the request fails and - nothing is changed. -- `start(mixed $input = null, ?TaskStartOptions $options = null): ActorRun` — `$options->waitForResources` - retries a start the API rejects for lack of account resources; see the Actor docs' `start()` for the - full behavior. +- `publish(): Task`, `unpublish(): Task`: publish or unpublish the public landing page by setting `isPublic`. + Both need write permission to the Actor of the task. Publishing also needs a public Actor and the + `publicConfig.inputSchemaFields` and `publicConfig.datasetView` of the task. An Actor can have 10 published + tasks and an account 100. If a condition fails, nothing changes. +- `start(mixed $input = null, ?TaskStartOptions $options = null): ActorRun`: `$options->waitForResources` is as + for [`ActorClient::start()`](actors.md). - `call(mixed $input = null, ?TaskStartOptions $options = null, ?int $waitSecs = null, int|float|string|null $timeoutSecs = null): ActorRun` -- `getInput(): mixed` (throws if the task itself does not exist — a 404 here cannot mean anything - else), `updateInput(mixed $input): mixed` +- `getInput(): mixed` (throws if the task does not exist), `updateInput(mixed $input): mixed` - `lastRun(?LastRunOptions $options = null): RunClient` - `runs(): RunCollectionClient` -- `webhooks(): NestedWebhookCollectionClient` — read-only. +- `webhooks(): NestedWebhookCollectionClient`: read-only. ```php $run = $client->task('me~my-task')->call(['message' => 'override'], null, 120); diff --git a/docs/webhooks.md b/docs/webhooks.md index 27d4e1d..8868650 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -5,13 +5,12 @@ Snippets assume `$client = new ApifyClient('my-api-token');` and imported types. ## Webhook collection — `$client->webhooks()` - `list(?ListOptions $options = null): PaginationList` -- `iterate(?ListOptions $options = null, ?int $chunkSize = null): iterable` — lazily iterate all webhooks, paging on demand. The options' `limit` caps the total number yielded across all pages (unset = all); `$chunkSize` is the per-page size. +- `iterate(?ListOptions $options = null, ?int $chunkSize = null): iterable`: lazily yields the webhooks. The `limit` option caps the total (unset for all), `$chunkSize` is the page size. - `create(mixed $webhook): Webhook` -Webhooks nested under an Actor or task (`$client->actor($id)->webhooks()`, -`$client->task($id)->webhooks()`) are **read-only** — they support `list(...)` and `iterate(...)` -only. Create webhooks through the account-wide collection, targeting an Actor or task via the -webhook's `condition`. +The webhooks of an Actor or task (`$client->actor($id)->webhooks()`, `$client->task($id)->webhooks()`) are +read-only, with `list()` and `iterate()`. Create a webhook in this collection, and target an Actor or task in its +`condition`. ```php $webhook = $client->webhooks()->create([ @@ -24,7 +23,7 @@ $webhook = $client->webhooks()->create([ ## A single webhook — `$client->webhook($id)` - `get(): ?Webhook`, `update(mixed $newFields): Webhook`, `delete(): void` -- `test(): WebhookDispatch` — dispatch immediately. +- `test(): WebhookDispatch`: dispatch immediately. - `dispatches(): WebhookDispatchCollectionClient` ```php @@ -34,7 +33,7 @@ $client->webhook('WEBHOOK_ID')->dispatches()->list(new ListOptions(limit: 10)); ## Webhook dispatches — `$client->webhookDispatches()` / `$client->webhookDispatch($id)` -- Collection: `list(?ListOptions $options = null): PaginationList`, `iterate(?ListOptions $options = null, ?int $chunkSize = null): iterable` — lazily iterate all dispatches, paging on demand (options' `limit` caps the total; `$chunkSize` is the per-page size). +- Collection: `list(?ListOptions $options = null): PaginationList`, `iterate(?ListOptions $options = null, ?int $chunkSize = null): iterable`: lazily yields the dispatches. The `limit` option caps the total, `$chunkSize` is the page size. - Single: `get(): ?WebhookDispatch`. ```php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 307e3ab..21a6f5b 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,11 +1,7 @@ -# Rationale for level 6: the public surface is fully, explicitly typed (native param/return types -# plus array-shape PHPDoc), which level 6 enforces. Levels 7-9 mainly tighten handling of `mixed`, -# but this client deliberately exposes `mixed` at the API boundary (arbitrary JSON-serializable Actor -# input, raw record values, unmodelled response fields via `toArray()`), so those levels would force -# defensive casts on values that are `mixed` by design rather than surface real bugs. Level 6 is the -# strongest level that fits this JSON-centric API without such noise. +# Level 8 checks types, nullable access, and union types. Level 9 adds strict `mixed` handling. The client exposes +# `mixed` on purpose at the API boundary (JSON input, record values, `toArray()`), so it stays at level 8. parameters: - level: 6 + level: 8 paths: - src - tests diff --git a/src/ApifyClient.php b/src/ApifyClient.php index 21ca6e4..3971345 100644 --- a/src/ApifyClient.php +++ b/src/ApifyClient.php @@ -4,9 +4,7 @@ namespace Apify\Client; -use Apify\Client\Http\BrotliHttpCompressor; use Apify\Client\Http\GuzzleHttpClient; -use Apify\Client\Http\GzipHttpCompressor; use Apify\Client\Http\HttpClientInterface; use Apify\Client\Http\HttpCompressorInterface; use Apify\Client\Internal\Compression; @@ -45,33 +43,28 @@ use RuntimeException; /** - * The entry point for interacting with the Apify API. + * The entry point to the Apify API. Create it with an API token, then get a resource client from one of + * its accessors, for example {@see actor()}, {@see dataset()}, or {@see run()}. * - * Construct it with an API token (and optional settings via named arguments), then obtain resource - * clients via the accessor methods, e.g. {@see actor()}, {@see dataset()}, {@see run()}. - * - * Architecture. The public interface is this class and the resource clients it returns. The - * replaceable transport is the {@see HttpClientInterface} (default {@see GuzzleHttpClient}); pass a - * custom one via the {@code httpClient} argument. Cross-cutting behaviour (auth, User-Agent, retries - * with exponential backoff, timeouts) lives in the internal HTTP client and is applied to every - * request. + * Requests go through an {@see HttpClientInterface}, by default {@see GuzzleHttpClient}. Pass another one as + * {@code httpClient}. */ final class ApifyClient { - /** Default base URL of the Apify API (without the {@code /v2} suffix). */ + /** The default API base URL, without the {@code /v2} suffix. */ public const DEFAULT_BASE_URL = 'https://api.apify.com'; public const DEFAULT_MAX_RETRIES = 8; public const DEFAULT_MIN_DELAY_MILLIS = 500; public const DEFAULT_TIMEOUT_SECS = 360; - /** The API version path this client targets; appended to {@code $baseUrl}/{@code $publicBaseUrl} when absent. */ + /** The API version path, appended to the base URLs when absent. */ private const API_VERSION_PATH = '/v2'; - /** Environment variable that signals the client is running on the Apify platform. */ + /** Set to a non-empty value on the Apify platform. */ private const ENV_IS_AT_HOME = 'APIFY_IS_AT_HOME'; - /** Addresses the current user ({@code /users/me}). */ + /** The ID that addresses the current user. */ private const ME_USER_PLACEHOLDER = 'me'; private HttpClientCore $http; @@ -80,12 +73,9 @@ final class ApifyClient /** * @param string|null $token API token, sent as a Bearer token - * @param string $baseUrl API base URL, with or without the trailing - * {@code /v2} version path — appended automatically - * when not already present - * @param string|null $publicBaseUrl base URL for building public, shareable resource - * URLs (defaults to {@code $baseUrl}); like - * {@code $baseUrl}, {@code /v2} is appended when absent + * @param string $baseUrl API base URL, with or without {@code /v2} + * @param string|null $publicBaseUrl base URL of public resource URLs (defaults to + * {@code $baseUrl}) * @param int $maxRetries maximum retries for failed requests (default 8) * @param int $minDelayBetweenRetriesMillis minimum delay between retries in ms (default 500) * @param int|null $maxDelayBetweenRetriesMillis upper bound for the growing inter-retry delay @@ -93,22 +83,26 @@ final class ApifyClient * @param int $timeoutSecs overall per-request timeout in seconds (default * 360); the default of the {@code long} tier and of * {@code $timeoutMaxSecs} - * @param int|null $timeoutShortSecs duration (seconds) of the {@code short} timeout - * tier (metadata reads/writes); defaults to 5 - * @param int|null $timeoutMediumSecs duration (seconds) of the {@code medium} timeout - * tier (listing/batch/trigger calls); defaults to 30 - * @param int|null $timeoutLongSecs duration (seconds) of the {@code long} timeout - * tier (downloads/uploads/streaming); defaults to - * {@code $timeoutSecs} - * @param int|null $timeoutMaxSecs upper bound on any single request attempt's - * timeout, including a per-call override and the - * growth across retries; defaults to - * {@code $timeoutSecs} + * @param int|null $timeoutShortSecs timeout of the {@code short} tier, for metadata + * reads and writes (default 5) + * @param int|null $timeoutMediumSecs timeout of the {@code medium} tier, for listing, + * batch and trigger calls (default 30) + * @param int|null $timeoutLongSecs timeout of the {@code long} tier, for downloads, + * uploads and streaming (default {@code $timeoutSecs}) + * @param int|null $timeoutMaxSecs cap on the timeout of any attempt, per-call + * overrides and growth across retries included + * (default {@code $timeoutSecs}) * @param string|null $userAgentSuffix custom suffix appended to the User-Agent header * @param HttpClientInterface|null $httpClient replaces the default transport (Guzzle) * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (defaults to Guzzle's) * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (defaults to Guzzle's) - * @param callable():bool|null $isAtHomeFn test seam overriding the isAtHome flag detection + * @param callable():bool|null $isAtHomeFn replaces the detection of the {@code isAtHome} flag + * @param string|HttpCompressorInterface|null $compression request-body compression: {@code 'brotli'}, + * {@code 'gzip'}, {@code 'zstd'}, or a custom compressor, + * which also sets the quality. A codec whose extension + * is not loaded throws {@see RuntimeException}. + * {@code null} picks brotli when its extension is + * loaded, gzip otherwise. */ public function __construct( ?string $token = null, @@ -127,14 +121,6 @@ public function __construct( ?RequestFactoryInterface $requestFactory = null, ?StreamFactoryInterface $streamFactory = null, ?callable $isAtHomeFn = null, - /** - * Overrides request-body compression: {@code 'brotli'}, {@code 'gzip'}, or a custom {@see - * HttpCompressorInterface}. {@code null} (the default) keeps the client's existing best-effort - * choice (brotli when the optional PECL extension is loaded, falling back to gzip), unchanged - * from before this option existed, so a client constructed without it sends the same requests - * as always. Pass an explicit {@see BrotliHttpCompressor}/{@see GzipHttpCompressor} instance to - * also choose the compression quality. - */ string|HttpCompressorInterface|null $compression = null, ) { $transport = $httpClient ?? new GuzzleHttpClient(); @@ -166,21 +152,19 @@ public function __construct( $this->publicBaseUrl = self::toApiBaseUrl($publicBaseUrl ?? $baseUrl); } - /** Returns the {@code User-Agent} header value this client sends. */ + /** The {@code User-Agent} header value of the requests. */ public function getUserAgent(): string { return $this->http->userAgent(); } - /** Returns the fully-qualified API base URL this client targets (including the {@code /v2} suffix). */ + /** The API base URL, including {@code /v2}. */ public function getApiBaseUrl(): string { return $this->baseUrl; } - // ----- Actor accessors ----------------------------------------------------- - - /** A client for the Actor collection (list & create Actors). */ + /** A client for the Actor collection. */ public function actors(): ActorCollectionClient { return new ActorCollectionClient($this->http, $this->baseUrl); @@ -192,64 +176,54 @@ public function actor(string $id): ActorClient return new ActorClient($this, $this->http, $this->baseUrl, $id); } - // ----- Build accessors ----------------------------------------------------- - - /** A client for the Actor build collection (list builds). */ + /** A client for the build collection. */ public function builds(): BuildCollectionClient { return new BuildCollectionClient($this->http, $this->baseUrl, 'actor-builds'); } - /** A client for a specific Actor build. */ + /** A client for a build. */ public function build(string $id): BuildClient { return new BuildClient($this->http, $this->baseUrl, $id); } - // ----- Run accessors ------------------------------------------------------- - - /** A client for the Actor run collection (list runs). */ + /** A client for the run collection. */ public function runs(): RunCollectionClient { return new RunCollectionClient($this->http, $this->baseUrl, 'actor-runs'); } - /** A client for a specific Actor run. */ + /** A client for a run. */ public function run(string $id): RunClient { return new RunClient($this, $this->http, $this->baseUrl, 'actor-runs', $id); } - // ----- Dataset accessors --------------------------------------------------- - - /** A client for the dataset collection (list & get-or-create datasets). */ + /** A client for the dataset collection. */ public function datasets(): DatasetCollectionClient { return new DatasetCollectionClient($this->http, $this->baseUrl); } - /** A client for a specific dataset, addressed by ID or name. */ + /** A client for a dataset, addressed by ID or name. */ public function dataset(string $id): DatasetClient { return DatasetClient::forId($this->http, $this->baseUrl, $id)->withPublicBase($this->publicBaseUrl); } - // ----- Key-value store accessors ------------------------------------------- - /** A client for the key-value store collection. */ public function keyValueStores(): KeyValueStoreCollectionClient { return new KeyValueStoreCollectionClient($this->http, $this->baseUrl); } - /** A client for a specific key-value store, addressed by ID or name. */ + /** A client for a key-value store, addressed by ID or name. */ public function keyValueStore(string $id): KeyValueStoreClient { return KeyValueStoreClient::forId($this->http, $this->baseUrl, $id)->withPublicBase($this->publicBaseUrl); } - // ----- Request queue accessors --------------------------------------------- - /** A client for the request queue collection. */ public function requestQueues(): RequestQueueCollectionClient { @@ -257,52 +231,45 @@ public function requestQueues(): RequestQueueCollectionClient } /** - * A client for a specific request queue, addressed by ID or name. Optionally pass a - * {@see RequestQueueClientOptions} to set a stable {@code clientKey} and/or a per-request - * {@code timeoutSecs} for this queue's calls. + * A client for a request queue, addressed by ID or name. The options set a stable {@code clientKey} and a + * timeout for the calls of this client. */ public function requestQueue(string $id, ?RequestQueueClientOptions $options = null): RequestQueueClient { return RequestQueueClient::forId($this->http, $this->baseUrl, $id, $options); } - // ----- Task accessors ------------------------------------------------------ - - /** A client for the Actor task collection (list & create tasks). */ + /** A client for the task collection. */ public function tasks(): TaskCollectionClient { return new TaskCollectionClient($this->http, $this->baseUrl); } - /** A client for a specific Actor task. */ + /** A client for a task. */ public function task(string $id): TaskClient { return new TaskClient($this, $this->http, $this->baseUrl, $id); } - // ----- Schedule accessors -------------------------------------------------- - - /** A client for the schedule collection (list & create schedules). */ + /** A client for the schedule collection. */ public function schedules(): ScheduleCollectionClient { return new ScheduleCollectionClient($this->http, $this->baseUrl); } - /** A client for a specific schedule. */ + /** A client for a schedule. */ public function schedule(string $id): ScheduleClient { return new ScheduleClient($this->http, $this->baseUrl, $id); } - // ----- Webhook accessors --------------------------------------------------- - - /** A client for the webhook collection (list & create webhooks). */ + /** A client for the webhook collection. */ public function webhooks(): WebhookCollectionClient { return new WebhookCollectionClient($this->http, $this->baseUrl); } - /** A client for a specific webhook. */ + /** A client for a webhook. */ public function webhook(string $id): WebhookClient { return new WebhookClient($this->http, $this->baseUrl, $id); @@ -314,45 +281,41 @@ public function webhookDispatches(): WebhookDispatchCollectionClient return new WebhookDispatchCollectionClient($this->http, $this->baseUrl, 'webhook-dispatches'); } - /** A client for a specific webhook dispatch. */ + /** A client for a webhook dispatch. */ public function webhookDispatch(string $id): WebhookDispatchClient { return new WebhookDispatchClient($this->http, $this->baseUrl, $id); } - // ----- Misc accessors ------------------------------------------------------ - - /** A client for browsing the Apify Store. */ + /** A client for the Apify Store. */ public function store(): StoreCollectionClient { return new StoreCollectionClient($this->http, $this->baseUrl); } - /** A client for accessing a build's or run's log. */ + /** A client for the log of a build or run. */ public function log(string $buildOrRunId): LogClient { return LogClient::forId($this->http, $this->baseUrl, $buildOrRunId); } - /** A client for the current user ({@code /users/me}). */ + /** A client for the user the token belongs to. */ public function me(): UserClient { return new UserClient($this->http, $this->baseUrl, self::ME_USER_PLACEHOLDER); } - /** A client for a specific user by ID or username. */ + /** A client for a user, addressed by ID or username. */ public function user(string $id): UserClient { return new UserClient($this->http, $this->baseUrl, $id); } /** - * Sets the status message of the current Actor run. + * Sets the status message of the run in the {@code ACTOR_RUN_ID} environment variable. A terminal message + * is final and is not overwritten. * - * This convenience method updates the run identified by the {@code ACTOR_RUN_ID} environment - * variable, so it only works when called from inside an Actor run. If {@code $isTerminal} is - * true, the message becomes final and won't be overwritten. Throws {@see RuntimeException} if - * {@code ACTOR_RUN_ID} is not set. + * @throws RuntimeException if {@code ACTOR_RUN_ID} is not set */ public function setStatusMessage(string $message, bool $isTerminal = false): ActorRun { @@ -371,32 +334,20 @@ private static function trimTrailingSlash(string $value): string return rtrim($value, '/'); } - /** - * Resolves the {@code compression} constructor option to a concrete compressor, or {@code null} to - * keep the client's own default best-effort choice. - */ + /** Resolves the {@code compression} option to a compressor, or {@code null} for the default choice. */ private static function resolveCompressor(string|HttpCompressorInterface|null $compression): ?HttpCompressorInterface { return Compression::resolveCompressor($compression, 'ApifyClient'); } - /** - * Appends the {@code /v2} API version path to {@code $url}, unless it already ends with one, so - * passing a URL that already includes it (e.g. {@code https://api.apify.com/v2}) does not produce - * {@code .../v2/v2}. A trailing slash is stripped first, matching the reference client's - * {@code toApiBaseUrl}. Only an exact {@code /v2} suffix counts; another {@code /vN} is left alone, - * since every endpoint this client calls exists only under {@code v2}. - */ + /** Appends {@code /v2} to a URL that does not end with it. Another version suffix is left alone. */ private static function toApiBaseUrl(string $url): string { $path = self::trimTrailingSlash($url); return str_ends_with($path, self::API_VERSION_PATH) ? $path : $path . self::API_VERSION_PATH; } - /** - * Reports whether the client is running on the Apify platform, by reading the - * {@code APIFY_IS_AT_HOME} environment variable (set to a non-empty value on the platform). - */ + /** Whether the client runs on the Apify platform. */ private static function defaultIsAtHome(): bool { $value = getenv(self::ENV_IS_AT_HOME); @@ -404,8 +355,7 @@ private static function defaultIsAtHome(): bool } /** - * Builds the {@code User-Agent} header value mandated by the client requirements: - * {@code ApifyClient/{version} ({os}; PHP/{phpVersion}); isAtHome/{true|false}}. + * Builds the {@code User-Agent} value: {@code ApifyClient/{version} ({os}; PHP/{phpVersion}); isAtHome/{true|false}}. * * @param callable():bool $isAtHomeFn */ diff --git a/src/Exception/ApifyApiException.php b/src/Exception/ApifyApiException.php index 1dbb09a..4db0bc4 100644 --- a/src/Exception/ApifyApiException.php +++ b/src/Exception/ApifyApiException.php @@ -5,13 +5,9 @@ namespace Apify\Client\Exception; /** - * Thrown for HTTP requests that reach the Apify API but receive a non-success status code. - * - * It mirrors the {@code ApifyApiError} of the reference JavaScript client and exposes the parsed - * error {@see getType() type}, the human-readable {@see getMessage() message}, the HTTP - * {@see getStatusCode() status code}, the number of the final {@see getAttempt() attempt}, and the - * request {@see getHttpMethod() method}/{@see getPath() path}. Extends {@see ApifyClientException}, - * the common base every client error shares. + * An API response with an error status. Gives the error {@see getType() type}, {@see getMessage() message}, + * {@see getStatusCode() status code}, the {@see getAttempt() attempt} that failed, and the request + * {@see getHttpMethod() method} and {@see getPath() path}. */ class ApifyApiException extends ApifyClientException { @@ -27,7 +23,7 @@ class ApifyApiException extends ApifyClientException private string $apiMessage; /** - * @param array|null $data additional structured error data provided by the API + * @param array|null $data structured error data from the API */ public function __construct( int $statusCode, @@ -38,8 +34,7 @@ public function __construct( string $path, ?array $data = null ) { - // Exception::getMessage() is final, so the human-readable prefix is baked into the message - // passed to the parent constructor (matching the JS reference's formatted error output). + // getMessage() is final, so the prefix is part of the message given to the parent. $errType = ($type === null || $type === '') ? 'unknown' : $type; parent::__construct(sprintf('apify API error (status %d, type %s): %s', $statusCode, $errType, $message)); $this->apiMessage = $message; @@ -82,7 +77,7 @@ public function getPath(): string } /** - * Additional structured data provided by the API about the error, if any. + * Structured data about the error from the API, if any. * * @return array|null */ diff --git a/src/Exception/ApifyClientException.php b/src/Exception/ApifyClientException.php index ff59f4d..27229b2 100644 --- a/src/Exception/ApifyClientException.php +++ b/src/Exception/ApifyClientException.php @@ -7,15 +7,11 @@ use RuntimeException; /** - * Base class of every error the client throws for a failed API interaction: {@see ApifyApiException} - * and its HTTP-status subclasses, and {@see InvalidResponseBodyException}. A single {@code instanceof - * ApifyClientException} check catches all of them, mirroring the reference JS client's - * {@code ApifyClientError}. + * Base class of the errors for a failed API interaction: {@see ApifyApiException}, its status subclasses and + * {@see InvalidResponseBodyException}. Catch it to handle them all. * - * An invalid argument throws a built-in {@code \InvalidArgumentException} before any request is sent, - * and a network/timeout failure surfaces as {@see TransportException}; neither extends this class, - * matching the reference client, where argument validation errors and transport errors also stay - * outside {@code ApifyClientError}. + * An invalid argument throws {@code \InvalidArgumentException} before any request, and a network failure or + * timeout throws {@see TransportException}. Neither extends this class. */ class ApifyClientException extends RuntimeException { diff --git a/src/Exception/InvalidResponseBodyException.php b/src/Exception/InvalidResponseBodyException.php index ec7d3d7..94488f4 100644 --- a/src/Exception/InvalidResponseBodyException.php +++ b/src/Exception/InvalidResponseBodyException.php @@ -7,10 +7,9 @@ use JsonException; /** - * Thrown when the body of a successful (2xx) response does not parse as valid JSON, for example a - * non-JSON value served as {@code application/json}. The response arrived in full, so the request is not - * retried. An error status with an unparsable body throws {@see ApifyApiException} for its status code - * instead. Mirrors the reference client's {@code InvalidResponseBodyError}. + * Thrown when the body of a success response is not valid JSON, for example non-JSON served as + * {@code application/json}. It is not retried, since the response arrived in full. An error status with an + * unparsable body throws {@see ApifyApiException} instead. */ final class InvalidResponseBodyException extends ApifyClientException { diff --git a/src/Exception/RateLimitException.php b/src/Exception/RateLimitException.php index 75486b8..1e3fcd1 100644 --- a/src/Exception/RateLimitException.php +++ b/src/Exception/RateLimitException.php @@ -4,10 +4,7 @@ namespace Apify\Client\Exception; -/** - * Thrown when the Apify API responds with HTTP 429 Too Many Requests. The client retries such - * requests with exponential backoff, so this surfaces only once the retries are exhausted. - */ +/** The API answered 429 Too Many Requests. Thrown once the retries are exhausted. */ class RateLimitException extends ApifyApiException { } diff --git a/src/Exception/ServerException.php b/src/Exception/ServerException.php index f7c18da..bb77e9b 100644 --- a/src/Exception/ServerException.php +++ b/src/Exception/ServerException.php @@ -4,10 +4,7 @@ namespace Apify\Client\Exception; -/** - * Thrown when the Apify API responds with an HTTP 5xx status. The client retries such requests with - * exponential backoff, so this surfaces only once the retries are exhausted. - */ +/** The API answered with a 5xx status. Thrown once the retries are exhausted. */ class ServerException extends ApifyApiException { } diff --git a/src/Exception/TransportException.php b/src/Exception/TransportException.php index 796e135..254c5f1 100644 --- a/src/Exception/TransportException.php +++ b/src/Exception/TransportException.php @@ -8,7 +8,7 @@ use Throwable; /** - * Marks a transport-level (network/timeout) failure, which is retryable by the client. + * A network failure or timeout, retried by the client. * * @internal */ diff --git a/src/Http/BrotliHttpCompressor.php b/src/Http/BrotliHttpCompressor.php index 97370d9..e16d320 100644 --- a/src/Http/BrotliHttpCompressor.php +++ b/src/Http/BrotliHttpCompressor.php @@ -5,27 +5,47 @@ namespace Apify\Client\Http; use InvalidArgumentException; +use RuntimeException; /** - * Compresses request bodies with brotli at a configurable quality (0-11, default 6 — matching the - * client's own default). Requires the optional PECL {@code brotli} extension; when it is not loaded, - * {@see compress()} returns {@code null} (the body is sent uncompressed) rather than throwing, since - * compression is always best-effort. + * Compresses request bodies with brotli at a quality from 0 to 11 (default 6). + * + * Needs the PECL {@code brotli} extension. The constructor throws without it, see {@see isAvailable()}. */ final class BrotliHttpCompressor implements HttpCompressorInterface { - private const DEFAULT_QUALITY = 6; + public const MIN_QUALITY = 0; + public const MAX_QUALITY = 11; + public const DEFAULT_QUALITY = 6; private int $quality; + /** + * @throws InvalidArgumentException if {@code $quality} is out of range + * @throws RuntimeException if the PECL brotli extension is not loaded + */ public function __construct(int $quality = self::DEFAULT_QUALITY) { - if ($quality < 0 || $quality > 11) { - throw new InvalidArgumentException('BrotliHttpCompressor: $quality must be between 0 and 11, got ' . $quality); + if ($quality < self::MIN_QUALITY || $quality > self::MAX_QUALITY) { + throw new InvalidArgumentException(sprintf( + 'BrotliHttpCompressor: $quality must be between %d and %d, got %d', + self::MIN_QUALITY, + self::MAX_QUALITY, + $quality + )); + } + if (!self::isAvailable()) { + throw new RuntimeException('BrotliHttpCompressor needs the PECL brotli extension.'); } $this->quality = $quality; } + /** Whether the PECL {@code brotli} extension is loaded. */ + public static function isAvailable(): bool + { + return function_exists('brotli_compress'); + } + public function contentEncoding(): string { return 'br'; @@ -33,11 +53,7 @@ public function contentEncoding(): string public function compress(string $body): ?string { - if (!function_exists('brotli_compress')) { - return null; - } - // Called indirectly: brotli_compress only exists when the PECL brotli extension is loaded, so a - // direct call would be an unresolved reference for static analysis on builds without it. + // Called by name, so static analysis does not flag builds without the extension. $brotliCompress = 'brotli_compress'; $compressed = $brotliCompress($body, $this->quality); return is_string($compressed) ? $compressed : null; diff --git a/src/Http/GuzzleHttpClient.php b/src/Http/GuzzleHttpClient.php index 3c92e57..cc60cf7 100644 --- a/src/Http/GuzzleHttpClient.php +++ b/src/Http/GuzzleHttpClient.php @@ -12,11 +12,8 @@ use Psr\Http\Message\ResponseInterface; /** - * The default {@see HttpClientInterface}, backed by Guzzle. - * - * The per-attempt timeout is applied to each request by the orchestrating client. Non-2xx statuses - * are returned as normal responses ({@code http_errors} disabled); only connection/timeout failures - * are thrown as {@see TransportException}. + * The default {@see HttpClientInterface}, backed by Guzzle. Error statuses are returned as responses. Connection + * failures and timeouts throw {@see TransportException}. */ final class GuzzleHttpClient implements NoRedirectHttpClientInterface { diff --git a/src/Http/GzipHttpCompressor.php b/src/Http/GzipHttpCompressor.php index 7dda1e7..faf1530 100644 --- a/src/Http/GzipHttpCompressor.php +++ b/src/Http/GzipHttpCompressor.php @@ -5,26 +5,47 @@ namespace Apify\Client\Http; use InvalidArgumentException; +use RuntimeException; /** - * Compresses request bodies with gzip at a configurable quality (1-9, default 6). Uses - * {@code gzencode()} from PHP's standard {@code zlib} extension, which is present on nearly every - * build; when it is not, {@see compress()} returns {@code null} (the body is sent uncompressed). + * Compresses request bodies with gzip at a quality from 1 to 9 (default 6). + * + * Needs the {@code zlib} extension. The constructor throws without it, see {@see isAvailable()}. */ final class GzipHttpCompressor implements HttpCompressorInterface { - private const DEFAULT_QUALITY = 6; + public const MIN_QUALITY = 1; + public const MAX_QUALITY = 9; + public const DEFAULT_QUALITY = 6; private int $quality; + /** + * @throws InvalidArgumentException if {@code $quality} is out of range + * @throws RuntimeException if the zlib extension is not loaded + */ public function __construct(int $quality = self::DEFAULT_QUALITY) { - if ($quality < 1 || $quality > 9) { - throw new InvalidArgumentException('GzipHttpCompressor: $quality must be between 1 and 9, got ' . $quality); + if ($quality < self::MIN_QUALITY || $quality > self::MAX_QUALITY) { + throw new InvalidArgumentException(sprintf( + 'GzipHttpCompressor: $quality must be between %d and %d, got %d', + self::MIN_QUALITY, + self::MAX_QUALITY, + $quality + )); + } + if (!self::isAvailable()) { + throw new RuntimeException('GzipHttpCompressor needs the zlib extension.'); } $this->quality = $quality; } + /** Whether the {@code zlib} extension is loaded. */ + public static function isAvailable(): bool + { + return function_exists('gzencode'); + } + public function contentEncoding(): string { return 'gzip'; @@ -32,9 +53,6 @@ public function contentEncoding(): string public function compress(string $body): ?string { - if (!function_exists('gzencode')) { - return null; - } $compressed = gzencode($body, $this->quality); return is_string($compressed) ? $compressed : null; } diff --git a/src/Http/HttpClientInterface.php b/src/Http/HttpClientInterface.php index 3fd0ce3..77987e6 100644 --- a/src/Http/HttpClientInterface.php +++ b/src/Http/HttpClientInterface.php @@ -8,21 +8,16 @@ use Psr\Http\Message\ResponseInterface; /** - * The replaceable transport contract of the client. + * The transport of the client. An implementation sends one prepared PSR-7 request and returns the PSR-7 + * response. The client adds the token, the {@code User-Agent}, and the retries. * - * Implementations are responsible only for sending a single, fully-prepared PSR-7 request and - * returning the raw PSR-7 response. Authentication, the {@code User-Agent} header, retries and - * (de)serialization are handled by the client, so a backend only needs to perform one network - * round-trip. - * - * A non-2xx HTTP status is not an error at this layer — return it as a normal response. - * Only transport-level failures (connection refused, DNS, timeout, a connection dropped before the - * response body is complete) should be thrown, as an {@see \Apify\Client\Exception\TransportException}. + * An error status is a normal response here. Throw {@see \Apify\Client\Exception\TransportException} only for + * transport failures: a refused connection, DNS, a timeout, or a connection dropped before the body is complete. * The client takes a returned body as complete and does not retry one that fails to parse. * - * Swap the default implementation ({@see GuzzleHttpClient}) via the {@code httpClient} constructor - * argument of {@see \Apify\Client\ApifyClient} to share a connection pool, customize TLS/proxy - * settings, wrap any PSR-18 client ({@see Psr18HttpClient}), or inject a mock in tests. + * Pass an implementation as the {@code httpClient} argument of {@see \Apify\Client\ApifyClient}, for example to + * share a connection pool, set TLS or proxy options, wrap a PSR-18 client ({@see Psr18HttpClient}), or mock it + * in tests. */ interface HttpClientInterface { @@ -30,8 +25,8 @@ interface HttpClientInterface public function send(RequestInterface $request, float $timeoutSecs): ResponseInterface; /** - * Sends a single request and returns a response whose body is a live stream, for incremental - * consumption (used by log streaming). The caller reads the body stream to completion. + * Sends a request and returns a response with a live body stream, for incremental reading. The caller reads it to + * the end. */ public function sendStreaming(RequestInterface $request, float $timeoutSecs): ResponseInterface; } diff --git a/src/Http/HttpCompressorInterface.php b/src/Http/HttpCompressorInterface.php index e51ec6c..2e4a529 100644 --- a/src/Http/HttpCompressorInterface.php +++ b/src/Http/HttpCompressorInterface.php @@ -5,24 +5,18 @@ namespace Apify\Client\Http; /** - * A pluggable request-body compressor, matching the reference client's {@code HttpCompressor}. Pass - * an implementation (or one of the built-ins, {@see BrotliHttpCompressor}/{@see GzipHttpCompressor}) - * as {@code ApifyClient}'s {@code compression} constructor option to control which algorithm — and at - * what quality — compresses request bodies, instead of the client's default best-effort choice. + * Compresses request bodies. Pass an implementation, or a built-in one ({@see BrotliHttpCompressor}, + * {@see GzipHttpCompressor}, {@see ZstdHttpCompressor}), as the {@code compression} option of + * {@see \Apify\Client\ApifyClient} to choose the algorithm and its quality. * - * Only bodies that already pass the client's other compression gates reach {@see compress()}: at - * least {@see \Apify\Client\Internal\Compression::MIN_COMPRESS_BYTES}, a compressible content type, - * and no caller-supplied {@code Content-Encoding}. + * Only bodies of at least {@see \Apify\Client\Internal\Compression::MIN_COMPRESS_BYTES} with a compressible + * content type and no caller-set {@code Content-Encoding} reach {@see compress()}. */ interface HttpCompressorInterface { - /** The value sent in the {@code Content-Encoding} header for a body this compressor encoded. */ + /** The {@code Content-Encoding} value of a body this compressor encoded. */ public function contentEncoding(): string; - /** - * Compresses {@code $body}, or returns {@code null} to send it uncompressed (e.g. the codec is - * unavailable in this PHP build). A compressor is never required to succeed: compression is - * always a best-effort optimization, never a correctness requirement. - */ + /** Compresses {@code $body}, or returns {@code null} to send it uncompressed. */ public function compress(string $body): ?string; } diff --git a/src/Http/NoRedirectHttpClientInterface.php b/src/Http/NoRedirectHttpClientInterface.php index 5a3dc69..c74d7f0 100644 --- a/src/Http/NoRedirectHttpClientInterface.php +++ b/src/Http/NoRedirectHttpClientInterface.php @@ -8,21 +8,18 @@ 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. + * A transport that can return a redirect 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. + * {@see \Apify\Client\Resource\ActorServerClient::request()} needs it, since its request carries the API token, + * which must not follow a redirect to a host that the Actor picked. Without it, {@code request()} fails before + * sending anything. {@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. + * Sends a request like {@see HttpClientInterface::send()}, or {@see HttpClientInterface::sendStreaming()} with + * {@code $stream}, but returns a 3xx response instead of following it. */ public function sendWithoutRedirects(RequestInterface $request, float $timeoutSecs, bool $stream = false): ResponseInterface; } diff --git a/src/Http/Psr18HttpClient.php b/src/Http/Psr18HttpClient.php index f8d2865..e8fede3 100644 --- a/src/Http/Psr18HttpClient.php +++ b/src/Http/Psr18HttpClient.php @@ -12,17 +12,15 @@ use Psr\Http\Message\ResponseInterface; /** - * Adapts any PSR-18 {@see ClientInterface} to the client's {@see HttpClientInterface}, so an - * application can plug in its own configured HTTP client (proxy, TLS, connection pool). + * Adapts a PSR-18 {@see ClientInterface} to {@see HttpClientInterface}, so the application can plug in its own + * HTTP client. * - * PSR-18 has no per-request timeout, so the {@code timeoutSecs} argument is ignored here — configure - * the timeout on the wrapped PSR-18 client instead. + * PSR-18 has no per-request timeout, so {@code timeoutSecs} is ignored. Configure the timeout on the wrapped + * client. * - * {@see send()} reads the response body in full, since a PSR-18 client may return a body it reads lazily: - * a connection dropped mid-body then surfaces as a {@see TransportException} (which is retried) and not - * as a failure later, when the response is already taken as complete. The response is returned with a - * seekable buffered body. {@see sendStreaming()} returns the body as the wrapped client provides it, so - * it is live only if that client reads it lazily. + * {@see send()} reads the whole body, so a connection dropped mid-body throws {@see TransportException}, which + * is retried, and the response has a seekable body. {@see sendStreaming()} returns the body as the wrapped + * client provides it. */ final class Psr18HttpClient implements HttpClientInterface { diff --git a/src/Http/ZstdHttpCompressor.php b/src/Http/ZstdHttpCompressor.php new file mode 100644 index 0000000..19cf556 --- /dev/null +++ b/src/Http/ZstdHttpCompressor.php @@ -0,0 +1,67 @@ + self::MAX_QUALITY) { + throw new InvalidArgumentException(sprintf( + 'ZstdHttpCompressor: $quality must be between %d and %d, got %d', + self::MIN_QUALITY, + self::MAX_QUALITY, + $quality + )); + } + if (!self::isAvailable()) { + throw new RuntimeException('ZstdHttpCompressor needs the PECL zstd extension.'); + } + $this->quality = $quality; + } + + /** Whether the PECL {@code zstd} extension is loaded. */ + public static function isAvailable(): bool + { + return function_exists('zstd_compress'); + } + + public function contentEncoding(): string + { + return 'zstd'; + } + + public function compress(string $body): ?string + { + // Called by name, so static analysis does not flag builds without the extension. + $zstdCompress = 'zstd_compress'; + $compressed = $zstdCompress($body, $this->quality); + return is_string($compressed) ? $compressed : null; + } +} diff --git a/src/Internal/Compression.php b/src/Internal/Compression.php index 5ac1c48..1661ee3 100644 --- a/src/Internal/Compression.php +++ b/src/Internal/Compression.php @@ -7,41 +7,22 @@ use Apify\Client\Http\BrotliHttpCompressor; use Apify\Client\Http\GzipHttpCompressor; use Apify\Client\Http\HttpCompressorInterface; +use Apify\Client\Http\ZstdHttpCompressor; use RuntimeException; /** - * Optional request-body compression, matching the reference JS client's behaviour. + * Request-body compression: brotli when the PECL {@code brotli} extension is loaded, gzip otherwise. * - * Large request bodies are compressed before being sent, saving bandwidth on uploads (Actor inputs, - * key-value-store records, dataset item batches, ...). Brotli ({@code Content-Encoding: br}) is - * preferred when available and gzip ({@code Content-Encoding: gzip}) is used as a fallback — the same - * codec choice, brotli quality (6), and size threshold (1024 bytes) as the reference client's - * {@code maybeCompressValue}. The API accepts br/gzip/deflate as request {@code Content-Encoding} - * (see apify-docs #2750), so preferring brotli is valid. - * - * In PHP, brotli lives in the optional PECL {@code brotli} extension, which is frequently absent, - * while gzip ({@code gzencode}) ships with the standard {@code zlib} extension. We therefore prefer - * brotli only when the extension is loaded and fall back to gzip otherwise. Compression is - * best-effort: if neither codec is available (or a payload is too small), the body is sent - * unchanged rather than raising an error. + * The default choice is best-effort. A body is sent unchanged when it is small, already compressed, or no codec is + * available. * * @internal */ final class Compression { - /** - * Minimum body size (in bytes) worth compressing. Below this the CPU cost and the few bytes of - * codec framing outweigh the savings, so the body is left uncompressed. Matches the reference - * client's {@code MIN_COMPRESS_BYTES}. - */ + /** Minimum body size in bytes worth compressing. */ public const MIN_COMPRESS_BYTES = 1024; - /** - * Brotli quality level. Level 6 mirrors the reference client and trades a little ratio for much - * faster compression than the brotli default (11). - */ - private const BROTLI_QUALITY = 6; - /** Media type prefixes whose payloads already carry their own compression. */ private const ALREADY_COMPRESSED_PREFIXES = ['audio/', 'image/', 'video/']; @@ -68,7 +49,7 @@ final class Compression 'font/woff2', ]; - /** Uncompressed media types that sit under an already-compressed prefix, so compressing them still pays off. */ + /** Raw media types under an already-compressed prefix, which are still worth compressing. */ private const COMPRESSIBLE_TYPES = [ 'audio/aiff', 'audio/basic', @@ -88,19 +69,12 @@ final class Compression 'image/x-ms-bmp', ]; - /** Structured syntax suffixes marking a media type as text even under an already-compressed prefix. */ + /** Structured syntax suffixes that mark a media type as text, whatever its prefix. */ private const COMPRESSIBLE_SUFFIXES = ['+json', '+xml']; /** - * Decides whether a request body with the given {@code Content-Type} is worth compressing, matching - * the reference client's {@code isCompressibleContentType}. - * - * 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 slightly larger than the input. Formats that are raw despite such a media type (e.g. - * {@code image/bmp}, {@code audio/wav}) are still compressed, as are structured-syntax subtypes - * such as {@code image/svg+xml}. A body with no content type, or {@code application/octet-stream} - * (the catch-all for unknown binary data and {@code setRecord()}'s fallback), is assumed compressible. + * Whether a request body with the given {@code Content-Type} is worth compressing. Media and archives are + * not, since they carry their own compression. A missing content type counts as compressible. */ public static function isCompressibleContentType(?string $contentType): bool { @@ -108,7 +82,7 @@ public static function isCompressibleContentType(?string $contentType): bool return true; } - // Content-Type is case-insensitive and may carry parameters, e.g. "text/plain; charset=utf-8". + // Drop parameters such as "; charset=utf-8". $mediaType = strtolower(trim(explode(';', $contentType, 2)[0])); if (in_array($mediaType, self::COMPRESSIBLE_TYPES, true)) { @@ -132,50 +106,39 @@ public static function isCompressibleContentType(?string $contentType): bool } /** - * Returns {@code [encoding, compressedBody]} when {@code $body} should be sent compressed, or - * {@code null} to send it unchanged. {@code $encoding} is the value for the - * {@code Content-Encoding} header ({@code 'br'} or {@code 'gzip'}). + * Compresses {@code $body} with brotli if the extension is loaded, else with gzip. * - * Only byte payloads at least {@see MIN_COMPRESS_BYTES} long are compressed. PHP strings are - * byte strings, so {@code strlen()} already measures the encoded size. - * - * @return array{0: string, 1: string}|null + * @return array{0: string, 1: string}|null the {@code Content-Encoding} value and the compressed body, or + * {@code null} to send the body unchanged */ public static function maybeCompress(string $body): ?array { - return self::compressWith($body, self::brotliEncoder(), self::gzipEncoder()); + $compressors = []; + if (BrotliHttpCompressor::isAvailable()) { + $compressors[] = new BrotliHttpCompressor(); + } + if (GzipHttpCompressor::isAvailable()) { + $compressors[] = new GzipHttpCompressor(); + } + return self::compressWith($body, $compressors); } /** - * Size gate plus codec selection, split out from {@see maybeCompress} so both the brotli and the - * gzip path can be exercised by tests regardless of which extensions the host PHP build loaded. + * Applies the size gate and compresses with the first compressor that succeeds. * - * Brotli ({@code br}) is preferred over gzip when its encoder is available; each encoder returns - * the compressed bytes as a string, or a non-string on failure, in which case the next codec is - * tried. Returns {@code null} when the body is below {@see MIN_COMPRESS_BYTES} or no codec - * succeeds. A {@code null} encoder means that codec is unavailable and is skipped. - * - * @param (callable(string): mixed)|null $brotli brotli encoder, or {@code null} when the PECL brotli extension is absent - * @param (callable(string): mixed)|null $gzip gzip encoder, or {@code null} when zlib is absent + * @param list $compressors in order of preference * @return array{0: string, 1: string}|null */ - public static function compressWith(string $body, ?callable $brotli, ?callable $gzip): ?array + public static function compressWith(string $body, array $compressors): ?array { if (strlen($body) < self::MIN_COMPRESS_BYTES) { return null; } - if ($brotli !== null) { - $compressed = $brotli($body); - if (is_string($compressed)) { - return ['br', $compressed]; - } - } - - if ($gzip !== null) { - $compressed = $gzip($body); - if (is_string($compressed)) { - return ['gzip', $compressed]; + foreach ($compressors as $compressor) { + $compressed = $compressor->compress($body); + if ($compressed !== null) { + return [$compressor->contentEncoding(), $compressed]; } } @@ -183,45 +146,11 @@ public static function compressWith(string $body, ?callable $brotli, ?callable $ } /** - * The brotli encoder for this build, or {@code null} when the PECL {@code brotli} extension is not - * loaded. Frequently absent, since brotli is not part of PHP's standard distribution. - * - * @return (callable(string): mixed)|null - */ - private static function brotliEncoder(): ?callable - { - if (!function_exists('brotli_compress')) { - return null; - } - - // Called indirectly: brotli_compress only exists when the PECL brotli extension is loaded, so - // a direct call would be an unresolved reference for static analysis on the (common) PHP - // builds without the extension. - $brotliCompress = 'brotli_compress'; - return static fn (string $body) => $brotliCompress($body, self::BROTLI_QUALITY); - } - - /** - * The gzip encoder for this build, or {@code null} when {@code gzencode} (zlib) is unavailable. - * Ships with PHP's standard {@code zlib} extension, so it is the near-universal fallback. - * - * @return (callable(string): mixed)|null - */ - private static function gzipEncoder(): ?callable - { - if (!function_exists('gzencode')) { - return null; - } - - return static fn (string $body) => gzencode($body); - } - - /** - * Resolves a {@code compression} option ({@code 'brotli'}, {@code 'gzip'} or a custom compressor) to a - * concrete compressor, or {@code null} to keep the default best-effort choice. + * Resolves a {@code compression} option to a compressor, or {@code null} for the default choice. * * @param string $option the name of the option's owner, for the error message - * @throws RuntimeException if the option is an unknown algorithm name + * @throws RuntimeException if the option is an unknown algorithm name, or names a codec whose extension is + * not loaded */ public static function resolveCompressor(string|HttpCompressorInterface|null $compression, string $option): ?HttpCompressorInterface { @@ -230,8 +159,9 @@ public static function resolveCompressor(string|HttpCompressorInterface|null $co $compression instanceof HttpCompressorInterface => $compression, $compression === 'brotli' => new BrotliHttpCompressor(), $compression === 'gzip' => new GzipHttpCompressor(), + $compression === 'zstd' => new ZstdHttpCompressor(), default => throw new RuntimeException( - sprintf('%s: unknown compression "%s"; expected "brotli", "gzip", or a HttpCompressorInterface', $option, $compression) + sprintf('%s: unknown compression "%s"; expected "brotli", "gzip", "zstd", or a HttpCompressorInterface', $option, $compression) ), }; } diff --git a/src/Internal/DatasetItemsPage.php b/src/Internal/DatasetItemsPage.php index 65c5397..7b2c3e9 100644 --- a/src/Internal/DatasetItemsPage.php +++ b/src/Internal/DatasetItemsPage.php @@ -8,8 +8,8 @@ use Apify\Client\Options\DatasetListItemsOptions; /** - * Reads one page of dataset items. The page's count is the number of rows the API scanned to produce - * it, or the number of items returned when that is larger. Shared by {@see \Apify\Client\Resource\DatasetClient} and {@see \Apify\Client\Resource\RunClient}. + * Reads a page of dataset items for {@see \Apify\Client\Resource\DatasetClient} and + * {@see \Apify\Client\Resource\RunClient}. * * @internal */ @@ -20,10 +20,9 @@ private function __construct() } /** - * Fetches one page of items and parses the pagination headers. The page's {@code count} is the - * {@code X-Apify-Pagination-Count} "scanned rows" count, or the number of items returned when that is - * larger (or when the response omits the header). The header is derived from the dataset's item count, - * which lags a fresh push, so it can fall below the items returned on an unfiltered page. + * Fetches a page of items and parses the pagination headers. The {@code count} of the page is the + * {@code X-Apify-Pagination-Count} scanned rows, or the number of items when that is larger or the header is + * missing. The header lags a fresh push, so it can be below the items of an unfiltered page. * * @return PaginationList */ @@ -45,9 +44,7 @@ public static function fetch(HttpClientCore $http, ResourceContext $ctx, Dataset $response->getHeaderLine('X-Apify-Pagination-Offset') === '' ? 0 : (int) $response->getHeaderLine('X-Apify-Pagination-Offset'), $response->getHeaderLine('X-Apify-Pagination-Limit') === '' ? $returned : (int) $response->getHeaderLine('X-Apify-Pagination-Limit'), max($scannedHeader === '' ? 0 : (int) $scannedHeader, $returned), - // Prefer the server-reported X-Apify-Pagination-Desc header (matches the reference JS - // client's `_createPaginationList`); fall back to the requested option when the header - // is absent, e.g. against an older API version that predates it. + // The header wins, the requested option applies when it is missing. self::headerBool($response->getHeaderLine('X-Apify-Pagination-Desc'), $options->desc ?? false), ); } diff --git a/src/Internal/HttpClientCore.php b/src/Internal/HttpClientCore.php index bd31d82..95ab027 100644 --- a/src/Internal/HttpClientCore.php +++ b/src/Internal/HttpClientCore.php @@ -24,26 +24,26 @@ use Throwable; /** - * The orchestrating HTTP client shared by every resource client. It owns the transport, the optional - * API token, the {@code User-Agent}, and the retry/timeout policy, and applies them to every request. + * The HTTP client shared by every resource client. Adds the token, {@code User-Agent}, compression, retries and + * timeouts to each request. * * @internal */ final class HttpClientCore { - /** Status returned when the per-resource rate limit is hit. */ + /** Status of a rate-limited request. */ private const RATE_LIMIT_EXCEEDED = 429; - /** Statuses at or above this value are treated as retryable internal server errors. */ + /** Statuses from this value up are server errors, retried by default. */ private const MIN_SERVER_ERROR = 500; - /** Responses with a status below this value are treated as success. */ + /** Statuses below this value are successes. */ public const MAX_SUCCESS_STATUS = 300; - /** Exponential-backoff multiplier applied to the inter-retry delay after each attempt. */ + /** Multiplier of the delay between retries. */ private const BACKOFF_FACTOR = 2; - /** Multiplier applied to the per-attempt timeout on each retry (independent of {@see BACKOFF_FACTOR}). */ + /** Multiplier of the timeout on each retry. */ private const TIMEOUT_BACKOFF_FACTOR = 2; private const NOT_FOUND = 404; @@ -55,11 +55,7 @@ public function __construct( private ?string $token, private string $userAgent, private RetryConfig $retry, - /** - * Overrides the client's default best-effort compression (brotli, falling back to gzip) with a - * single configured algorithm. {@code null} keeps the default. See - * {@see \Apify\Client\Http\HttpCompressorInterface}. - */ + /** Replaces the default compression (brotli, then gzip). */ private ?HttpCompressorInterface $compressor = null, ) { } @@ -69,14 +65,14 @@ public function userAgent(): string return $this->userAgent; } - /** The configured timeout tiers (short/medium/long/max). See {@see TimeoutTiers}. */ + /** The configured timeout tiers. */ public function timeoutTiers(): TimeoutTiers { return $this->retry->timeoutTiers; } /** - * Sends a request with auth, User-Agent and the retry policy applied. + * Sends a request with auth, User-Agent, and the retry policy applied. * * @param array $extraHeaders * @param RequestPolicy|null $policy per-request deviations from the defaults of a call to the API @@ -104,7 +100,7 @@ public function call( )); } - // Compress the body once, up front, so every retry reuses the already-compressed payload. + // Compressed once, so every retry reuses the compressed body. if ($policy->compression !== false) { [$body, $extraHeaders] = $this->maybeCompressBody($body, $contentType, $extraHeaders, $policy->compression ?? $this->compressor); } @@ -139,8 +135,7 @@ public function call( $response->getBody()->close(); } catch (TransportException $e) { $lastError = $e; - // Network/timeout failures are retryable, unless the caller opted out of retrying timeouts or - // of retrying transport errors at all. + // Retried unless the caller opted out of retrying timeouts or transport errors. $retryable = $policy->retryTransportErrors && !($doNotRetryTimeouts && $e->isTimeout()); } @@ -152,24 +147,16 @@ public function call( $delayMillis = min($delayMillis * self::BACKOFF_FACTOR, $this->retry->maxDelayMillis); } - // Unreachable in practice (maxAttempts >= 1); defensive. throw $lastError ?? new TransportException('request failed with no attempts'); } /** - * Compresses the request body when it is large enough to be worth it, returning the possibly - * replaced body together with the (possibly extended) header map. A caller that already set a - * {@code Content-Encoding} header is left untouched, so an explicitly-encoded body is never - * double-compressed. A body whose {@code $contentType} already carries its own compression (media, - * archives, fonts, ...) is left uncompressed too — see {@see Compression::isCompressibleContentType()}. - * - * Uses {@code $compressor} when one is given: the one the caller configured on the constructor (the - * {@code compression} option of {@see \Apify\Client\ApifyClient}) or a per-request one; otherwise falls back to the client's - * default best-effort choice ({@see Compression::maybeCompress()}: brotli, falling back to gzip). + * Compresses the body when it is large enough and compressible. A body with a caller-set + * {@code Content-Encoding} is left as it is, so it is never compressed twice. * * @param array $extraHeaders * @param HttpCompressorInterface|null $compressor the compressor to use, {@code null} for the default choice - * @return array{0: string|null, 1: array} + * @return array{0: string|null, 1: array} the body and the headers to send */ private function maybeCompressBody(?string $body, string $contentType, array $extraHeaders, ?HttpCompressorInterface $compressor): array { @@ -180,20 +167,9 @@ private function maybeCompressBody(?string $body, string $contentType, array $ex return [$body, $extraHeaders]; } - if ($compressor !== null) { - // A configured compressor has no built-in size gate of its own (unlike - // Compression::maybeCompress() below), so it is applied here. - if (strlen($body) < Compression::MIN_COMPRESS_BYTES) { - return [$body, $extraHeaders]; - } - $compressedBody = $compressor->compress($body); - if ($compressedBody === null) { - return [$body, $extraHeaders]; - } - return [$compressedBody, self::withContentEncoding($extraHeaders, $compressor->contentEncoding())]; - } - - $compressed = Compression::maybeCompress($body); + $compressed = $compressor !== null + ? Compression::compressWith($body, [$compressor]) + : Compression::maybeCompress($body); if ($compressed === null) { return [$body, $extraHeaders]; } @@ -203,8 +179,8 @@ private function maybeCompressBody(?string $body, string $contentType, array $ex } /** - * Marks a body as compressed and drops any caller-set {@code Content-Length}, which describes the - * uncompressed body; the transport computes the real one. + * Sets {@code Content-Encoding} and drops a caller-set {@code Content-Length}, which describes the + * uncompressed body. * * @param array $headers * @return array @@ -220,18 +196,14 @@ private static function withContentEncoding(array $headers, string $encoding): a return $headers; } - /** - * Case-insensitive check for a header key, since HTTP header names are case-insensitive. - * - * @param array $headers - */ + /** @param array $headers */ private static function hasHeader(array $headers, string $name): bool { return self::headerValue($headers, $name) !== null; } /** - * Case-insensitive lookup of a header value, {@code null} if the header is not set. + * The value of a header, or {@code null} if it is not set. Header names are compared case-insensitively. * * @param array $headers */ @@ -245,7 +217,7 @@ private static function headerValue(array $headers, string $name): ?string return null; } - /** Opens a live streaming response (single attempt, no retry). Used by log streaming. */ + /** Opens a streaming response. Makes a single attempt. */ public function stream(string $url): ResponseInterface { $request = $this->buildRequest('GET', $url, null, '', [], RequestPolicy::AUTHORIZATION_HEADER); @@ -253,7 +225,7 @@ public function stream(string $url): ResponseInterface } /** - * Builds a fully-prepared PSR-7 request with auth, User-Agent, content type and extra headers. + * Builds a PSR-7 request with the token, User-Agent, content type, and extra headers. * * @param array $extraHeaders * @param string $authorizationHeader the header that carries the token @@ -296,7 +268,6 @@ private function doAttempt( RequestPolicy $policy ): ResponseInterface { $request = $this->buildRequest($method, $url, $body, $contentType, $extraHeaders, $policy->authorizationHeader); - // `call()` has already checked that a transport asked not to follow redirects can do that. if (!$policy->followRedirects && $this->transport instanceof NoRedirectHttpClientInterface) { return $this->transport->sendWithoutRedirects($request, $timeoutSecs, $policy->stream); } @@ -305,14 +276,7 @@ private function doAttempt( : $this->transport->send($request, $timeoutSecs); } - /** - * Returns {@code min(cap, base * TIMEOUT_BACKOFF_FACTOR^(attempt-1))}: the first attempt uses the - * base timeout; each retry scales it up by {@see TIMEOUT_BACKOFF_FACTOR} (a slow-but-progressing - * connection gets more time) while never exceeding {@code $cap} — the configured {@code - * timeoutMaxSecs} ({@see TimeoutTiers::$maxSecs}), which defaults to the legacy overall {@code - * timeoutSecs} when not explicitly raised, so a tier/per-call timeout above the legacy default - * still grows and is honored rather than being silently clipped back down to it. - */ + /** The timeout of an attempt: the base timeout, scaled up on each retry and capped at {@code timeoutMaxSecs}. */ private function attemptTimeout(float $base, int $attempt): float { $cap = $this->retry->timeoutTiers->maxSecs; @@ -340,7 +304,7 @@ private static function isStatusRetryable(int $status, ?array $retryStatusCodes) return $status === self::RATE_LIMIT_EXCEEDED || $status >= self::MIN_SERVER_ERROR; } - /** Returns a delay chosen randomly from {@code [delay, 2*delay)} (exponential backoff + jitter). */ + /** A random delay in {@code [delay, 2*delay)}, so retries do not arrive in lockstep. */ private function randomizedDelayMillis(float $delayMillis): float { if ($delayMillis <= 0) { @@ -358,10 +322,8 @@ private function sleepMillis(float $millis): void } /** - * Maps an HTTP status code to the {@see ApifyApiException} subclass that represents it, mirroring - * the reference client's {@code ApifyApiError.fromResponse()}. Any status with no dedicated - * subclass below (and no 5xx) stays a plain {@see ApifyApiException}, so {@code instanceof - * ApifyApiException} keeps matching every error regardless of status. + * The exception class of each status. Other statuses give {@see ServerException} from 500 up and a plain + * {@see ApifyApiException} below. * * @var array> */ @@ -374,7 +336,7 @@ private function sleepMillis(float $millis): void 429 => RateLimitException::class, ]; - /** Builds an {@see ApifyApiException} (or the subclass matching the status) from an API error response body. */ + /** Builds the exception for an API error response. */ public static function buildApiError(int $status, string $body, int $attempt, string $method, string $path): ApifyApiException { $type = null; @@ -403,7 +365,7 @@ public static function buildApiError(int $status, string $body, int $attempt, st return new $exceptionClass($status, $type, $message, $attempt, $method, $path, $data); } - /** Returns the path+query portion of a URL, for error reporting. */ + /** The path and query of a URL, for error messages. */ public static function extractPath(string $url): string { $rest = $url; @@ -415,15 +377,7 @@ public static function extractPath(string $url): string return $slash !== false ? substr($rest, $slash) : ''; } - /** - * Reports whether an exception represents a "resource not found" API error — any HTTP 404, - * whatever its machine-readable {@code type}. Matches the reference client's {@code - * catchNotFoundOrThrow()}, which checks only {@code instanceof NotFoundError}: earlier versions of - * both clients swallowed only the {@code record-not-found}/{@code record-or-token-not-found} types - * (plus every HEAD request), which meant a 404 with any other type — for example on an endpoint - * added after that type list was written — incorrectly propagated as an error instead of resolving - * to "not found" like every other 404. - */ + /** Whether the exception is an API error with status 404, whatever its type. */ public static function isNotFound(Throwable $e): bool { return $e instanceof ApifyApiException && $e->getStatusCode() === self::NOT_FOUND; diff --git a/src/Internal/Json.php b/src/Internal/Json.php index f6cf88b..558f0df 100644 --- a/src/Internal/Json.php +++ b/src/Internal/Json.php @@ -8,7 +8,7 @@ use JsonException; /** - * Shared JSON (de)serialization for the client. + * JSON encoding and decoding. * * @internal */ @@ -28,12 +28,8 @@ public static function encode(mixed $value): string } /** - * Decodes a JSON string into PHP values (objects become associative arrays). - * - * Every caller of this method (directly or via {@see decodeData()}) decodes a response body the - * client just received over the wire, never a value the caller constructed itself (that path is - * {@see encode()}), so a malformed body is wrapped as an {@see InvalidResponseBodyException} instead - * of leaking the raw {@see JsonException}. A response body that arrived in full is not retried. + * Decodes a JSON response body to PHP values (objects become associative arrays). A malformed body throws + * {@see InvalidResponseBodyException} and is not retried, since it arrived in full. * * @return mixed the decoded value */ @@ -50,8 +46,7 @@ public static function decode(string $body): mixed } /** - * Decodes a JSON response body wrapped in a {@code {"data": ...}} envelope, returning the - * unwrapped {@code data} value (or {@code null} if it is absent/null). + * Decodes a JSON response body and returns its {@code data} envelope content, or {@code null} if absent. * * @return mixed the unwrapped data */ diff --git a/src/Internal/Platform.php b/src/Internal/Platform.php index b9690f1..aa11052 100644 --- a/src/Internal/Platform.php +++ b/src/Internal/Platform.php @@ -5,27 +5,16 @@ namespace Apify\Client\Internal; /** - * Derives the short, lowercase OS platform token used in the {@code User-Agent} header. - * - * The Apify clients agreed to report the operating system using the same short identifiers that - * Node's {@code os.platform()} yields (e.g. {@code linux}, {@code darwin}, {@code win32}), so a - * server-side parser sees one consistent value across every client. PHP's {@code PHP_OS} instead - * reports uname-style names ({@code Linux}, {@code Darwin}, {@code WINNT}, ...), so we translate - * the ones that differ and lowercase the rest. + * The OS token of the {@code User-Agent}: the short lowercase name that Node's {@code os.platform()} gives, such as + * {@code linux}, {@code darwin}, or {@code win32}, so the API sees one value across all clients. * * @internal */ final class Platform { /** - * Translates a {@code PHP_OS}-style value into the short lowercase platform token used in the - * User-Agent header. Pass {@code PHP_OS} in production; the argument exists so the mapping can be - * unit-tested for every platform without depending on the host it runs on. - * - * Only two families need translating; everything else (Linux, Darwin, FreeBSD, OpenBSD, NetBSD, - * SunOS, ...) already matches Node's token once lowercased: - * - Windows: PHP reports {@code WINNT} (or {@code Windows}), Node reports {@code win32}. - * - Cygwin: PHP reports {@code CYGWIN_NT-10.0-...}, Node reports {@code cygwin}. + * Translates a {@code PHP_OS} value to the token. Only Windows ({@code win32}) and Cygwin ({@code cygwin}) + * differ from the lowercased value. The argument lets tests cover every platform. */ public static function osToken(string $phpOs): string { diff --git a/src/Internal/QueryParams.php b/src/Internal/QueryParams.php index 6c0ffc9..589778a 100644 --- a/src/Internal/QueryParams.php +++ b/src/Internal/QueryParams.php @@ -5,8 +5,7 @@ namespace Apify\Client\Internal; /** - * An ordered collection of query parameters that omits absent ({@code null}) values and encodes - * booleans as {@code 1}/{@code 0}, matching the Apify API conventions. + * Ordered query parameters. A {@code null} value is left out, and a boolean is sent as {@code 1} or {@code 0}. * * @internal */ @@ -43,11 +42,7 @@ public function addFloat(string $key, ?float $value): self return $this; } - /** - * Adds a boolean parameter, encoded as {@code 1}/{@code 0}, if the value is non-null. This - * matches the JS reference client, whose axios {@code paramsSerializer} converts booleans via - * {@code Number(value)} (so {@code true}→{@code 1}, {@code false}→{@code 0}). - */ + /** Adds a boolean as {@code 1} or {@code 0}, unless it is {@code null}. */ public function addBool(string $key, ?bool $value): self { if ($value !== null) { diff --git a/src/Internal/RequestPolicy.php b/src/Internal/RequestPolicy.php index 8834c16..60c2183 100644 --- a/src/Internal/RequestPolicy.php +++ b/src/Internal/RequestPolicy.php @@ -7,9 +7,8 @@ use Apify\Client\Http\HttpCompressorInterface; /** - * Per-request deviations from the default behaviour of {@see HttpClientCore::call()}. The defaults describe - * a call to the Apify API itself; {@see \Apify\Client\Resource\ActorServerClient} overrides them to talk to an - * Actor's own web server, whose responses and side effects the client must not second-guess. + * Per-request deviations from the defaults of {@see HttpClientCore::call()}, which describe a call to the Apify + * API. {@see \Apify\Client\Resource\ActorServerClient} overrides them to call the web server of an Actor. * * @internal */ @@ -18,7 +17,9 @@ final class RequestPolicy /** The name of the header the API reads the token from. */ public const AUTHORIZATION_HEADER = 'Authorization'; - /** The header that carries the token to an Actor server. The platform removes it before the Actor sees the request. */ + /** + * The header that carries the token to an Actor server. The platform removes it before the Actor sees the request. + */ public const SERVER_AUTHORIZATION_HEADER = 'X-Apify-Authorization'; /** diff --git a/src/Internal/ResourceContext.php b/src/Internal/ResourceContext.php index 6b68794..8d4c6c3 100644 --- a/src/Internal/ResourceContext.php +++ b/src/Internal/ResourceContext.php @@ -13,8 +13,7 @@ use RuntimeException; /** - * The resolved context for a resource client: its base URL and the shared HTTP client. The methods - * here implement the CRUD primitives once, so each resource client stays small and consistent (DRY). + * The URL and HTTP client of a resource client, with the CRUD calls they share. * * @internal */ @@ -23,29 +22,24 @@ final class ResourceContext public const CONTENT_TYPE_JSON = 'application/json'; public const CONTENT_TYPE_JSON_CHARSET = 'application/json; charset=utf-8'; - /** How long to wait between polls while waiting for a run/build to finish, in seconds. */ + /** Seconds between polls while waiting for a run or build to finish. */ private const WAIT_POLL_INTERVAL_SECS = 0.25; - /** Server-side waitForFinish chunk size (the API caps server waiting at 60 seconds). */ + /** Seconds of one {@code waitForFinish} request. The API holds a response for at most 60. */ private const WAIT_REQUEST_SECS = 60; /** - * Largest safety margin subtracted from the per-request timeout when choosing the server-side - * {@code waitForFinish} value, so the server responds before the client's socket timeout fires. - * The margin never exceeds half of the timeout, so a short timeout still gets a positive hold. + * The most that is subtracted from the request timeout to get the {@code waitForFinish} value, so the API + * answers before the socket times out. Never more than half the timeout. */ private const WAIT_TIMEOUT_MARGIN_SECS = 5; - /** - * Finite upper bound used when the caller asks to wait "indefinitely" ({@code waitSecs === null}). - * The API will not accept "Infinity" and an unbounded loop can spin forever on a transient 404; - * 999999s (~11.5 days) is effectively indefinite while guaranteeing termination. - */ + /** Stands in for waiting indefinitely, which the API cannot express. About 11.5 days. */ private const MAX_WAIT_FOR_FINISH_SECS = 999999; /** - * How long {@see waitForFinish()} keeps polling a job that returns 404, to ride out replica lag, - * before it gives up and throws. A successful poll in between resets this window. + * How long {@see waitForFinish()} keeps polling a job that returns 404, to ride out replica lag. A + * successful poll resets it. */ private const MAX_WAIT_WHEN_JOB_NOT_FOUND_MILLIS = 3000; @@ -54,26 +48,18 @@ final class ResourceContext private string $apiOrigin; private string $publicOrigin; - /** - * Optional overall per-request timeout (seconds) applied to every call made through this context. - * {@code null} means "use the shared client-wide timeout". Set per resource client (e.g. a request - * queue client created with an explicit {@code timeoutSecs}). - */ + /** A timeout for every call of this context, set per resource client. {@code null} uses the client-wide one. */ private ?float $requestTimeoutSecs = null; private function __construct( public HttpClientCore $http, - /** Fully-qualified base URL of the resource, e.g. https://api.apify.com/v2/actors/ID. */ + /** The URL of the resource, for example {@code https://api.apify.com/v2/actors/ID}. */ public string $url, string $baseUrl, /** - * Whether this context addresses a resource by an explicit ID ({@see single()}) rather than a - * fixed, ID-less sub-path ({@see collection()}, used e.g. by {@code run.dataset()}). Gates - * whether {@see getResource()}/{@see getRaw()}/{@see deleteResource()} swallow a 404 on the - * resource itself ({@code $subPath === ''}): an ID-addressed resource that is missing resolves - * to {@code null}/no-op, but a 404 on an ID-less chained resource (e.g. a run's default - * dataset) can mean either the parent or the sub-resource is gone, so it is rethrown instead — - * matching the reference client's {@code catchNotFoundForResourceOrThrow}. + * Whether the resource is addressed by an ID ({@see single()}) or by a fixed sub-path ({@see collection()}). + * A 404 on a missing ID-addressed resource resolves to {@code null}, but a 404 on a sub-path resource + * is rethrown, since the parent may be the one that is gone. */ private bool $hasId = true, ) { @@ -82,40 +68,40 @@ private function __construct( $this->publicOrigin = $this->apiOrigin; } - /** Sets an overall per-request timeout (seconds) for every call made through this context. */ + /** Sets the timeout of every call of this context. */ public function withTimeout(?float $timeoutSecs): self { $this->requestTimeoutSecs = $timeoutSecs; return $this; } - /** Creates a context for a collection endpoint: {@code {base}/{resourcePath}}. */ + /** A context for a collection or sub-path: {@code {base}/{resourcePath}}. */ public static function collection(HttpClientCore $http, string $baseUrl, string $resourcePath): self { return new self($http, $baseUrl . '/' . $resourcePath, $baseUrl, hasId: false); } - /** Creates a context for a single resource: {@code {base}/{resourcePath}/{safeId}}. */ + /** A context for a resource: {@code {base}/{resourcePath}/{id}}. */ public static function single(HttpClientCore $http, string $baseUrl, string $resourcePath, string $id): self { $segment = self::encodePathSegment(self::toSafeId($id)); return new self($http, $baseUrl . '/' . $resourcePath . '/' . $segment, $baseUrl, hasId: true); } - /** Overrides the origin used when building public URLs. */ + /** Sets the origin of public URLs. */ public function withPublicOrigin(string $publicBaseUrl): self { $this->publicOrigin = self::originOf($publicBaseUrl); return $this; } - /** This resource's URL with an optional extra path segment appended. */ + /** The URL of the resource, with an optional sub-path. */ public function subUrl(string $subPath = ''): string { return $subPath === '' ? $this->url : $this->url . '/' . $subPath; } - /** The public (shareable) form of this resource's URL, swapping the API origin for the public one. */ + /** The shareable URL of the resource, with the public origin in place of the API one. */ public function publicUrl(string $subPath): string { $apiUrl = $this->subUrl($subPath); @@ -128,19 +114,13 @@ public function publicUrl(string $subPath): string return $apiUrl; } - /** Merges the inherited base params with per-call params. */ + /** The base params extended with the params of the call. */ public function mergedParams(?QueryParams $params): QueryParams { return $this->baseParams->copy()->extend($params); } - // ---- CRUD primitives ------------------------------------------------------ - - /** - * GET a single resource, returning its decoded {@code data}, or {@code null} on not-found. - * - * @return mixed - */ + /** GETs a resource and returns its decoded {@code data}, or {@code null} if it is not found. */ public function getResource(string $subPath, QueryParams $params, ?float $timeoutSecs = null): mixed { try { @@ -153,11 +133,7 @@ public function getResource(string $subPath, QueryParams $params, ?float $timeou } } - /** - * GET a single resource, returning its decoded {@code data} (propagates errors). - * - * @return mixed - */ + /** GETs a resource and returns its decoded {@code data}. Any error is thrown. */ public function getResourceRequired(string $subPath, QueryParams $params, ?float $timeoutSecs = null): mixed { $url = $this->mergedParams($params)->applyToUrl($this->subUrl($subPath)); @@ -166,7 +142,7 @@ public function getResourceRequired(string $subPath, QueryParams $params, ?float } /** - * PUT to update a resource with a JSON-serializable body, returning the decoded {@code data}. + * PUTs a JSON body and returns the decoded {@code data}. * * @return array */ @@ -177,11 +153,7 @@ public function updateResource(string $subPath, mixed $body, ?float $timeoutSecs return self::asArray(Json::decodeData((string) $response->getBody())); } - /** - * Performs a DELETE; a not-found is treated as a successful no-op when this context addresses its - * resource by ID ({@see $hasId}). A DELETE on an ID-less chained resource (e.g. {@code - * run.dataset()->delete()}) rethrows instead, since the 404 could mean the parent run is gone. - */ + /** DELETEs a resource. A 404 is ignored when the resource is addressed by an ID. */ public function deleteResource(string $subPath, ?float $timeoutSecs = null): void { $url = $this->mergedParams(new QueryParams())->applyToUrl($this->subUrl($subPath)); @@ -194,27 +166,16 @@ public function deleteResource(string $subPath, ?float $timeoutSecs = null): voi } } - /** - * A per-call timeout override (seconds), falling back to this context's own configured timeout - * ({@see withTimeout()}) when {@code null}, which in turn falls back to the client-wide default. - */ + /** The timeout of the call, else the one of this context. */ private function effectiveTimeout(?float $timeoutSecs): ?float { return $timeoutSecs ?? $this->requestTimeoutSecs; } /** - * Resolves a per-call {@code $timeoutSecs} option (a number of seconds, a timeout tier name, {@see - * \Apify\Client\Internal\TimeoutTiers::NO_TIMEOUT}, or {@code null} for {@code $defaultTier}'s - * configured duration) against the client's configured {@see TimeoutTiers}, matching the reference - * client's per-method tier assignment. See {@see TimeoutTiers} for the tiers and their defaults. - * - * When this context carries its own timeout ({@see withTimeout()} — e.g. the per-queue {@code - * timeoutSecs} of {@code requestQueue(id, options)}), the tier-resolved value is additionally - * capped at it, matching the reference client ("caps the default tier of every request that queue - * client sends"). {@see TimeoutTiers::INFINITE_SECS} (from an explicit {@code 'noTimeout'}) is - * treated as unbounded for that comparison, so a context-level cap still applies to it, rather than - * "no timeout" winning outright over a cap the caller configured for a reason. + * Resolves a per-call {@code $timeoutSecs} (seconds, a tier name, {@see TimeoutTiers::NO_TIMEOUT} or + * {@code null} for the default tier) to seconds. A timeout set on this context caps the result, even + * a {@code 'noTimeout'} one. * * @param int|float|string|null $timeoutSecs */ @@ -230,11 +191,10 @@ public function resolveTimeout(int|float|string|null $timeoutSecs, string $defau } /** - * Picks the timeout of a request that asks the API to hold its response with {@code waitForFinish}: - * the hold the caller asked for (the API holds a response for at most {@see WAIT_REQUEST_SECS} - * seconds) plus the round trip the method's tier allows, so the client does not abort a request the - * API is still holding, which for {@code start()} and {@code build()} would retry a call that - * creates a resource. An explicit per-call {@code $timeoutSecs} is used as given. + * The timeout of a request that asks the API to hold its response with {@code waitForFinish}: the hold + * (at most {@see WAIT_REQUEST_SECS}) plus the timeout of the tier. A request that is still being held is + * then not aborted and retried, which would create a second resource for {@code start()} and + * {@code build()}. An explicit {@code $timeoutSecs} is used as given. * * @param int|float|string|null $timeoutSecs */ @@ -252,7 +212,7 @@ public function resolveWaitTimeout(int|float|string|null $timeoutSecs, string $d } /** - * GET a paginated listing and build a {@see PaginationList} with each item hydrated. + * GETs a paginated listing as a {@see PaginationList} of hydrated items. * * @template T * @param callable(array):T $hydrate @@ -265,21 +225,15 @@ public function listResource(string $subPath, QueryParams $params, callable $hyd } /** - * Lazily iterates over every item of an offset/limit-paginated listing, fetching pages on demand. + * Lazily yields the items of an offset-paginated listing, fetching pages on demand. * - * Ports the reference client's paginated iterator ({@code _listPaginatedFromCallback}): - * {@code $limit} caps the TOTAL number of items yielded across all pages ({@code null} = no cap, - * i.e. all items), while {@code $chunkSize} caps how many items are requested per page - * ({@code null} = the API's default page size, capped by the remaining {@code $limit}). The two are - * independent: {@code $limit} is never reused as the page size. {@code $startOffset} is the offset - * of the first page. + * {@code $limit} caps the total number of items ({@code null} for all), {@code $chunkSize} the items per + * page ({@code null} for the API's default), and {@code $startOffset} is the offset of the first page. * - * The next offset and the stop condition follow the page's {@see PaginationList::getCount()} (or - * the items it returned when larger), capped at the limit requested for that page, never the - * listing's reported {@code total}, which can grow while the iteration runs. A page whose - * filtered-out entries were still counted (the Store, dataset filters) therefore neither repeats - * entries nor ends the iteration early. With no {@code $limit} the loop always ends with one - * request that advances by nothing, matching the reference client. + * The offset advances by the page's count, or by its items when there are more, capped at the limit asked + * for. The listing's {@code total} is not used, since it can grow during the iteration. Entries the API + * counted and then filtered out are therefore neither repeated nor cut the iteration short. Without + * {@code $limit}, the last request advances by nothing. * * @template T * @param callable(int,?int):PaginationList $fetchPage receives (offset, pageLimit) and returns that page @@ -288,11 +242,10 @@ public function listResource(string $subPath, QueryParams $params, callable $hyd public static function paginateOffset(int $startOffset, ?int $limit, ?int $chunkSize, callable $fetchPage): Generator { $currentOffset = $startOffset; - // Items still to yield, or null for "unbounded" (no $limit given). + // Items still to yield, or null without a $limit. $remaining = ($limit !== null && $limit > 0) ? $limit : null; do { - // A null/0 on either bound means "unbounded", so the other bound wins (mirrors the - // reference client's minForLimitParam). + // A null or 0 bound is unbounded, so the other one wins. $pageLimit = self::minLimit($remaining, $chunkSize); $page = $fetchPage($currentOffset, $pageLimit); foreach ($page->getItems() as $item) { @@ -303,20 +256,17 @@ public static function paginateOffset(int $startOffset, ?int $limit, ?int $chunk if ($remaining !== null) { $remaining -= $advance; } - // Two distinct ways to stop: a page that advances by nothing (the listing is exhausted), or - // $remaining having reached 0 (the caller's $limit is satisfied). + // Stops when a page advances by nothing (the listing is exhausted) or the $limit is reached. } while ($advance > 0 && ($remaining === null || $remaining > 0)); } /** - * How far a page advances the offset: its count or the items it returned, whichever is larger, - * capped at the limit requested for it ({@code null} = uncapped). + * How far a page advances the offset: its count or its items, whichever is larger, capped at the limit asked + * for ({@code null} for no cap). * - * The count can exceed the returned items because the API drops entries after counting them (Store - * Actors, the dataset {@code skipEmpty} and {@code clean} filters). The items can exceed the count - * with {@code unwind}, which returns one item per unwound element. The server never walks more - * entries than the requested limit, so the cap keeps such an inflated item count from skipping the - * entries between the real and the inflated advance. + * The count exceeds the items when the API drops entries after counting them (Store Actors, the dataset + * {@code skipEmpty} and {@code clean} filters). The items exceed the count with {@code unwind}. The cap + * keeps inflated items from skipping entries. * * @template T * @param PaginationList $page @@ -328,8 +278,8 @@ private static function advancedBy(PaginationList $page, ?int $requestedLimit): } /** - * Returns the smaller of two optional positive bounds, treating {@code null} or {@code 0} as - * "unbounded" (the API treats {@code limit=0} as unset). Mirrors the reference minForLimitParam. + * The smaller of two bounds. {@code null} and {@code 0} are unbounded, as the API treats {@code limit=0} as + * unset. * * @internal */ @@ -351,7 +301,7 @@ public static function minLimit(?int $a, ?int $b): ?int } /** - * POST to create a resource with a JSON-serializable body, returning the decoded {@code data}. + * POSTs a JSON body to create a resource and returns the decoded {@code data}. * * @return array */ @@ -363,9 +313,7 @@ public function createResource(QueryParams $params, mixed $body, ?float $timeout } /** - * POST that gets-or-creates a named resource ({@code POST {collection}?name=...}). An optional - * {@code $schema} is sent in the request body as {@code {"schema": ...}}, matching the reference - * client's {@code getOrCreate(name, { schema })}. + * POSTs to get or create a named resource, with an optional schema in the body. * * @param array|null $schema * @return array @@ -385,7 +333,7 @@ public function getOrCreateNamed(?string $name, ?array $schema = null, ?float $t } /** - * POST with an optional raw body and content type, unwrapping the data envelope. + * POSTs a raw body and returns the decoded {@code data}. * * @return array */ @@ -396,12 +344,7 @@ public function postWithBody(string $subPath, QueryParams $params, ?string $body return self::asArray(Json::decodeData((string) $response->getBody())); } - /** - * POST with a raw body, parsing the response directly without unwrapping a data - * envelope. Used by endpoints (e.g. actor input validation) whose response is a plain object. - * - * @return mixed - */ + /** POSTs a raw body and returns the decoded response, for endpoints that answer without a {@code data} envelope. */ public function postWithBodyNoEnvelope(string $subPath, QueryParams $params, ?string $body, string $contentType, ?float $timeoutSecs = null): mixed { $url = $this->mergedParams($params)->applyToUrl($this->subUrl($subPath)); @@ -410,7 +353,7 @@ public function postWithBodyNoEnvelope(string $subPath, QueryParams $params, ?st } /** - * DELETE with a JSON body (used for batch request deletion), unwrapping the data envelope. + * DELETEs with a JSON body and returns the decoded {@code data}. * * @return array */ @@ -421,11 +364,7 @@ public function deleteWithBody(string $subPath, QueryParams $params, mixed $body return self::asArray(Json::decodeData((string) $response->getBody())); } - /** - * GET returning the raw response (no data envelope). Returns {@code null} on not-found, unless - * this context addresses an ID-less chained resource ({@see $hasId}) and {@code $subPath} is the - * resource itself ({@code ''}), in which case the 404 is rethrown (see {@see deleteResource()}). - */ + /** GETs the raw response, or returns {@code null} if the resource is not found (see {@see deleteResource()}). */ public function getRaw(string $subPath, QueryParams $params, ?float $timeoutSecs = null): ?ResponseInterface { try { @@ -438,10 +377,7 @@ public function getRaw(string $subPath, QueryParams $params, ?float $timeoutSecs } } - /** - * GET returning the raw response (no data envelope). Propagates every error, including a 404. - * The caller decodes the body itself, if it is JSON. - */ + /** GETs the raw response. Any error is thrown, a 404 included. */ public function getRawRequired(string $subPath, QueryParams $params, ?float $timeoutSecs = null): ResponseInterface { $url = $this->mergedParams($params)->applyToUrl($this->subUrl($subPath)); @@ -449,17 +385,15 @@ public function getRawRequired(string $subPath, QueryParams $params, ?float $tim } /** - * Whether a 404 on {@code $subPath} should resolve to "not found" ({@code null}/no-op) rather than - * being rethrown. True for any fixed sub-path of an ID-addressed resource, and for the resource - * itself only when it is addressed by ID; an ID-less chained resource's own 404 is ambiguous - * between "parent missing" and "sub-resource missing" and is always rethrown. + * Whether a 404 on {@code $subPath} resolves to "not found" instead of being rethrown. A 404 on a sub-path + * always does, on the resource itself only when it is addressed by an ID. */ private function swallowsNotFound(string $subPath): bool { return $subPath !== '' || $this->hasId; } - /** HEAD request; returns whether the resource exists. */ + /** Sends a HEAD request and returns whether the resource exists. */ public function headExists(string $subPath, QueryParams $params, ?float $timeoutSecs = null): bool { $url = $this->mergedParams($params)->applyToUrl($this->subUrl($subPath)); @@ -475,15 +409,8 @@ public function headExists(string $subPath, QueryParams $params, ?float $timeout } /** - * Opens a live, streaming connection to {@code $subPath} and returns its body as a stream, for - * incremental consumption instead of buffering the whole response in memory. Bypasses the - * buffered/retrying transport ({@see HttpClientCore::stream()}), so because the response is - * consumed incrementally, it is not retried: a non-2xx status is read eagerly and thrown as the - * matching {@see ApifyApiException} (there is nothing to stream in that case anyway). - * - * Shared by {@see \Apify\Client\Resource\LogClient::stream()} and - * {@see \Apify\Client\Resource\DatasetClient::streamItems()}, the two endpoints that expose a - * streamed read alongside their buffered one. + * Opens a streaming GET and returns the body, which is read incrementally. It is not retried, and an error + * status is thrown as the matching {@see ApifyApiException}. */ public function streamRaw(string $subPath, QueryParams $params): StreamInterface { @@ -503,11 +430,9 @@ public function streamRaw(string $subPath, QueryParams $params): StreamInterface } /** - * PUT with raw bytes and a content type, with an explicit per-request timeout and retry control. + * PUTs raw bytes. * - * @param array $extraHeaders additional request headers, e.g. a caller-supplied - * {@code Content-Encoding} ({@see - * \Apify\Client\Resource\KeyValueStoreClient::setRecord()}) + * @param array $extraHeaders additional headers, for example a caller-set {@code Content-Encoding} */ public function putRaw( string $subPath, @@ -522,13 +447,9 @@ public function putRaw( $this->http->call('PUT', $url, $body, $contentType, $timeoutSecs ?? $this->requestTimeoutSecs, $doNotRetryTimeouts, $extraHeaders); } - // ---- Wait-for-finish ------------------------------------------------------ - /** - * The largest server-side {@code waitForFinish} value that is safe to send for a request whose - * timeout is {@code $requestTimeoutSecs}: below it by a safety margin, and never above the 60 seconds - * the API holds a response for. {@see TimeoutTiers::INFINITE_SECS} (no request timeout) allows the - * full 60 seconds. + * The largest {@code waitForFinish} value that is safe for a request timeout: the timeout less a margin, + * and at most 60 seconds. With no request timeout it is 60. */ private static function serverWaitCapSecs(float $requestTimeoutSecs): int { @@ -539,12 +460,7 @@ private static function serverWaitCapSecs(float $requestTimeoutSecs): int return min(self::WAIT_REQUEST_SECS, max(0, (int) floor($requestTimeoutSecs - $margin))); } - /** - * Clamps a caller-supplied server-side {@code waitForFinish} value (seconds) so a synchronous get - * never asks the API to hold the connection for longer than a request can last: the value is capped - * at 60 seconds, and below the {@code timeoutMaxSecs} cap of a single attempt by a safety margin. - * Returns {@code null} for a {@code null} input. - */ + /** Clamps a {@code waitForFinish} value to what a request can last. {@code null} stays {@code null}. */ public function clampServerWait(?int $waitForFinishSecs): ?int { if ($waitForFinishSecs === null) { @@ -554,17 +470,14 @@ public function clampServerWait(?int $waitForFinishSecs): ?int } /** - * Polls a GET endpoint with {@code waitForFinish} until the resource reaches a terminal state or - * the wait budget elapses. {@code waitSecs === null} means "wait indefinitely", implemented as a - * finite but very large bound so the loop always terminates. + * Polls a resource with {@code waitForFinish} until {@code $isTerminal} accepts it or {@code $waitSecs} + * elapse. A {@code null} {@code $waitSecs} waits indefinitely. * - * A 404 is treated as "not yet available" (replica lag) rather than failing immediately, but only - * for {@see MAX_WAIT_WHEN_JOB_NOT_FOUND_MILLIS}: if the resource keeps 404ing for that long, the - * wait gives up and rethrows that 404 instead of polling for the rest of {@code $waitSecs} (which - * defaults to effectively indefinite). A successful poll in between resets this grace window. + * A 404 counts as replica lag. If it lasts {@see MAX_WAIT_WHEN_JOB_NOT_FOUND_MILLIS}, the 404 is rethrown. + * A successful poll resets that window. * - * Each poll's request timeout is {@code $timeoutSecs} (a number of seconds, a tier name or - * {@code 'noTimeout'}; default {@code 'noTimeout'}), and the server-side hold of a poll stays below it. + * Each poll uses {@code $timeoutSecs} as its request timeout (default {@code 'noTimeout'}) and holds the + * response for less than that. * * @param callable(array):bool $isTerminal * @param int|float|string|null $timeoutSecs @@ -637,9 +550,7 @@ private static function nowMillis(): int } /** - * Coerces a decoded value to an associative array. Endpoints that return a resource object - * always decode to an array; a non-array (e.g. an unexpected {@code null} data field) becomes an - * empty array so model construction stays type-safe. + * The value as an array, or an empty one if it is not. * * @return array */ @@ -648,17 +559,7 @@ private static function asArray(mixed $value): array return is_array($value) ? $value : []; } - /** - * Encodes an Actor run input body for {@code ActorClient::start()/validateInput()} and - * {@code RunClient::metamorph()}: {@code null} stays {@code null}, a {@code string} is sent as - * raw bytes exactly as given (pair it with a non-default {@code contentType}), and anything else - * — the normal case, an associative array — is JSON-encoded. - * - * Mirrors the reference client's {@code ActorInput}, which accepts a plain object/array of them - * (serialized to JSON) or raw bytes such as a {@code Buffer} (sent as they are). A PHP string is - * this client's idiomatic stand-in for the reference client's raw-bytes case, since PHP strings - * are already byte sequences and the language has no separate binary-buffer type. - */ + /** Encodes a run input: {@code null} stays {@code null}, a string is sent as it is, and anything else as JSON. */ public static function encodeInputBody(mixed $input): ?string { if ($input === null) { @@ -667,14 +568,9 @@ public static function encodeInputBody(mixed $input): ?string return is_string($input) ? $input : Json::encode($input); } - // ---- URL / id helpers ----------------------------------------------------- - /** - * Encodes a resource id so it is safe to embed in a URL path. Apify uses the {@code - * username~resourcename} form, so every {@code /} in the id is replaced with {@code ~} — not just - * the first — matching the reference client. Replacing only the first occurrence would leave any - * further {@code /} in the id as literal path separators once interpolated into a URL, letting an - * id such as {@code "a/../.."} introduce extra, attacker-controlled path segments. + * Turns {@code username/name} into the {@code username~name} form of an ID. Every {@code /} is replaced, + * so an ID cannot add path segments to the URL. */ public static function toSafeId(string $id): string { @@ -682,15 +578,10 @@ public static function toSafeId(string $id): string } /** - * Percent-encodes a single URL path segment, so values interpolated into the path (resource ids, - * record keys, request ids) cannot break out of the segment or restructure the request path. - * - * Rejects an empty segment and the dot-segments {@code "."}/{@code ".."} instead of encoding them: - * a URL parser (client-side proxy, CDN, or the server itself) resolves dot segments ({@code - * RFC 3986 §5.2.4}) from the decoded path, so a literal {@code .}/{@code ..} segment can still - * collapse to the parent resource even though the string itself contains no {@code /}. + * Percent-encodes a path segment, so an ID or key cannot restructure the path. An empty segment, {@code "."} + * and {@code ".."} are rejected, since a URL parser would collapse them to the parent resource. * - * @throws InvalidArgumentException if {@code $input} is empty or is {@code "."}/{@code ".."} + * @throws InvalidArgumentException if {@code $input} is empty, {@code "."}, or {@code ".."} */ public static function encodePathSegment(string $input): string { @@ -702,7 +593,7 @@ public static function encodePathSegment(string $input): string return rawurlencode($input); } - /** Extracts the origin ({@code scheme://host[:port]}) from a URL, dropping any path. */ + /** The origin of a URL: {@code scheme://host[:port]}. */ public static function originOf(string $rawUrl): string { $rest = $rawUrl; diff --git a/src/Internal/RetryConfig.php b/src/Internal/RetryConfig.php index 565d653..799b2a8 100644 --- a/src/Internal/RetryConfig.php +++ b/src/Internal/RetryConfig.php @@ -5,7 +5,7 @@ namespace Apify\Client\Internal; /** - * Retry/timeout policy for the orchestrating HTTP client. + * The retry and timeout settings of the HTTP client. * * @internal */ @@ -20,7 +20,10 @@ public function __construct( public readonly float $maxDelayMillis, /** Overall per-request timeout budget, in seconds. Each attempt's timeout grows but is capped here. */ public readonly float $timeoutSecs, - /** The configured timeout tiers (short/medium/long/max), used as the default when a call has no per-call override. */ + /** + * The configured timeout tiers (short/medium/long/max), used as the default when a call has no per-call + * override. + */ public readonly TimeoutTiers $timeoutTiers, ) { } diff --git a/src/Internal/Signatures.php b/src/Internal/Signatures.php index 51b1576..288fa15 100644 --- a/src/Internal/Signatures.php +++ b/src/Internal/Signatures.php @@ -7,20 +7,19 @@ use InvalidArgumentException; /** - * Apify storage-content URL signing, byte-for-byte compatible with the platform's - * {@code @apify/utilities} implementation that the reference clients rely on. + * Apify storage content URL signing, compatible with the platform's {@code @apify/utilities}. * * @internal */ final class Signatures { - /** Version tag embedded in storage-content signatures (upstream default). */ + /** The version tag in storage content signatures. */ private const STORAGE_CONTENT_SIGNATURE_VERSION = '0'; - /** Number of leading hex characters of the HMAC digest used. */ + /** The number of leading hex characters of the HMAC digest that are used. */ private const HMAC_SIGNATURE_HEX_LEN = 30; - /** Base62 alphabet (digits, then lowercase, then uppercase), matching upstream. */ + /** The base62 alphabet: digits, lowercase, uppercase. */ private const BASE62_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; private function __construct() @@ -28,9 +27,8 @@ private function __construct() } /** - * Computes an Apify URL-signing signature, byte-for-byte compatible with upstream - * {@code createHmacSignature}: HMAC-SHA256(secret, message) as lowercase hex, take the first 30 - * hex characters, interpret them as a big integer, then base62-encode (alphabet {@code 0-9a-zA-Z}). + * Computes a signature: the first 30 hex characters of HMAC-SHA256(secret, message) as a big integer in + * base62 ({@code 0-9a-zA-Z}). Same as upstream {@code createHmacSignature}. */ public static function createHmacSignature(string $secretKey, string $message): string { @@ -48,15 +46,12 @@ public static function requirePositiveExpiry(?int $expiresInSecs): void } /** - * Builds a storage-content signature for a resource's public URL, byte-for-byte compatible with - * upstream {@code createStorageContentSignature}. + * Builds the signature of a public storage URL, as upstream {@code createStorageContentSignature} does: + * the base64url encoding, without padding, of {@code "{version}.{expiresAtMillis}.{hmac}"}. The HMAC signs + * {@code "{version}.{expiresAtMillis}.{resourceId}"}, where {@code expiresAtMillis} is the absolute expiry or + * {@code 0} for none. * - * It signs the message {@code "{version}.{expiresAtMillis}.{resourceId}"} ({@code expiresAtMillis} - * is the absolute expiry in ms, or {@code 0} for a non-expiring URL) with - * {@see createHmacSignature}, then returns the base64url (no padding) encoding of - * {@code "{version}.{expiresAtMillis}.{hmac}"}. - * - * @param int|null $expiresInSecs optional expiry in seconds ({@code null} for a non-expiring URL) + * @param int|null $expiresInSecs the expiry in seconds ({@code null} for none) */ public static function signStorageContent(string $secretKey, string $resourceId, ?int $expiresInSecs): string { @@ -70,10 +65,7 @@ public static function signStorageContent(string $secretKey, string $resourceId, return rtrim(strtr(base64_encode($envelope), '+/', '-_'), '='); } - /** - * Interprets a hex string as a big-endian non-negative integer and encodes it in base62. - * Implemented with byte-wise long division (base 256 → base 62) so it needs no bignum extension. - */ + /** Converts a hex string to base62 by long division on bytes, so it needs no bignum extension. */ private static function hexToBase62(string $hex): string { $binary = hex2bin($hex); diff --git a/src/Internal/TimeoutTiers.php b/src/Internal/TimeoutTiers.php index d8f8ccf..1f868f2 100644 --- a/src/Internal/TimeoutTiers.php +++ b/src/Internal/TimeoutTiers.php @@ -7,15 +7,12 @@ use InvalidArgumentException; /** - * The named request-timeout tiers, mirroring the reference client's {@code TimeoutTier}: {@code - * 'short'} for metadata reads/writes, {@code 'medium'} for listing/batch/trigger calls, and {@code - * 'long'} for downloads/uploads/streaming. {@code 'noTimeout'} (accepted per call, not a configured - * duration) opts a single call out of any request timeout. + * The named request timeout tiers: {@code 'short'} for metadata reads and writes, {@code 'medium'} for + * listing, batch, and trigger calls, and {@code 'long'} for downloads, uploads, and streaming. A call can also + * pass {@code 'noTimeout'}. * - * Defaults match the reference client: {@code short} 5s, {@code medium} 30s, {@code long} and the - * per-attempt cap {@code max} the client's overall {@code $timeoutSecs} (360s by default). Every - * tier is configurable on the {@see \Apify\Client\ApifyClient} constructor; a tier or per-call - * duration above the cap is capped at it. + * Defaults: {@code short} 5 s, {@code medium} 30 s, and {@code long} and the cap {@code max} the client's + * {@code $timeoutSecs} (360 s). A duration above the cap is capped. * * @internal */ @@ -27,19 +24,15 @@ final class TimeoutTiers public const NO_TIMEOUT = 'noTimeout'; /** - * The request-timeout value meaning "no timeout", as sent to the transport. Both Guzzle - * ({@code RequestOptions::TIMEOUT}) and the underlying cURL option it sets treat {@code 0.0} as - * "wait indefinitely", so this sentinel reaches {@see \Apify\Client\Http\HttpClientInterface} - * as an ordinary {@code float} with no special-cased {@code null} along the way — {@code 0.0} is - * never confused with "no override" because every {@code ?? } fallback in this client only - * triggers on {@code null}, not on a falsy {@code 0.0}. + * The timeout meaning "none", which Guzzle and cURL read from {@code 0.0}. A {@code null} fallback never triggers + * on it. */ public const INFINITE_SECS = 0.0; /** Default duration of the {@code short} tier, in seconds (metadata reads and writes). */ public const DEFAULT_SHORT_SECS = 5.0; - /** Default duration of the {@code medium} tier, in seconds (listing, batch and trigger calls). */ + /** Default duration of the {@code medium} tier, in seconds (listing, batch, and trigger calls). */ public const DEFAULT_MEDIUM_SECS = 30.0; private function __construct( @@ -51,9 +44,8 @@ private function __construct( } /** - * Builds the tier configuration. A tier argument left {@code null} falls back to its default: - * {@see DEFAULT_SHORT_SECS}, {@see DEFAULT_MEDIUM_SECS}, and, for the {@code long} tier and the - * per-attempt cap, {@code $defaultSecs} (this client's overall {@code $timeoutSecs}). + * Builds the tiers. A {@code null} argument falls back to {@see DEFAULT_SHORT_SECS}, + * {@see DEFAULT_MEDIUM_SECS} or, for the {@code long} tier and the cap, {@code $defaultSecs}. */ public static function create( float $defaultSecs, @@ -83,14 +75,11 @@ public function secondsFor(string $tier): float } /** - * Resolves a per-call timeout override against this tier configuration: {@code null} uses - * {@code $defaultTier}'s configured duration; a tier name ({@see TIER_SHORT}, {@see TIER_MEDIUM}, - * {@see TIER_LONG}) uses that tier's duration instead; {@see NO_TIMEOUT} returns {@see - * INFINITE_SECS} (no request timeout at all — for calls that poll until a job finishes); a number - * of seconds is used as-is, capped at {@see $maxSecs}. + * Resolves the timeout of a call: {@code null} is the duration of {@code $defaultTier}, a tier name that + * tier's duration, {@see NO_TIMEOUT} {@see INFINITE_SECS}, and a number of seconds itself, capped at + * {@see $maxSecs}. * - * @param int|float|string|null $timeoutSecs a number of seconds, a tier name, {@see NO_TIMEOUT}, or - * {@code null} to use {@code $defaultTier} + * @param int|float|string|null $timeoutSecs seconds, a tier name, {@see NO_TIMEOUT}, or {@code null} */ public function resolve(int|float|string|null $timeoutSecs, string $defaultTier): float { diff --git a/src/Internal/UrlNormalizer.php b/src/Internal/UrlNormalizer.php index 9367a91..bc2e190 100644 --- a/src/Internal/UrlNormalizer.php +++ b/src/Internal/UrlNormalizer.php @@ -5,14 +5,12 @@ namespace Apify\Client\Internal; /** - * Normalizes the URL fields of API responses to the WHATWG URL serialization, so this client returns - * the same string for the same field as the reference client (which parses them with {@code new URL()}). + * Normalizes the URL fields of API responses to the WHATWG URL serialization, as {@code new URL()} does. * - * For a special-scheme URL ({@code http}, {@code https}, {@code ws}, {@code wss}, {@code ftp}) it - * lowercases the scheme and host, converts an internationalized host to punycode (when the optional - * {@code intl} extension is available), drops a default port, resolves dot segments, adds the {@code /} - * of an empty path, and percent-encodes characters that are unsafe in the path, query and fragment. A - * value that is not an absolute special-scheme URL is returned unchanged. + * A URL with a special scheme ({@code http}, {@code https}, {@code ws}, {@code wss}, {@code ftp}) gets a + * lowercase scheme and host, a punycode host (with the optional {@code intl} extension), no default port, no + * dot segments, a {@code /} for an empty path, and percent-encoded unsafe characters. Any other value is + * returned unchanged. * * @internal */ @@ -21,25 +19,24 @@ final class UrlNormalizer /** Default ports of the special schemes; a URL spelling its scheme's default port drops it. */ private const DEFAULT_PORTS = ['http' => 80, 'https' => 443, 'ws' => 80, 'wss' => 443, 'ftp' => 21]; - /** Characters (besides controls, space and non-ASCII) percent-encoded in a path. */ + /** Characters (besides controls, space, and non-ASCII) percent-encoded in a path. */ private const PATH_UNSAFE = '"#<>?`{}'; - /** Characters (besides controls, space and non-ASCII) percent-encoded in a query. */ + /** Characters (besides controls, space, and non-ASCII) percent-encoded in a query. */ private const QUERY_UNSAFE = '"#<>\''; - /** Characters (besides controls, space and non-ASCII) percent-encoded in a fragment. */ + /** Characters (besides controls, space, and non-ASCII) percent-encoded in a fragment. */ private const FRAGMENT_UNSAFE = '"<>`'; - /** Characters (besides controls, space and non-ASCII) percent-encoded in the user name and password. */ + /** Characters (besides controls, space, and non-ASCII) percent-encoded in the user name and password. */ private const USERINFO_UNSAFE = '"#<>?`{}/:;=@[\\]^|'; - /** An IPv6 literal in a host: the brackets around hexadecimal digits, colons and dots. */ + /** An IPv6 literal in a host: the brackets around hexadecimal digits, colons, and dots. */ private const IPV6_LITERAL_PATTERN = '/^\[[0-9a-f:.]+\]$/'; /** - * The IDNA error bits that WHATWG URL parsing ignores (it turns {@code CheckHyphens} and - * {@code VerifyDnsLength} off): empty label, label too long, domain too long, leading hyphen, - * trailing hyphen and hyphens in positions 3 and 4. + * The IDNA errors that WHATWG URL parsing ignores, since it turns {@code CheckHyphens} and {@code VerifyDnsLength} + * off. */ private const IGNORED_IDNA_ERRORS = IDNA_ERROR_EMPTY_LABEL | IDNA_ERROR_LABEL_TOO_LONG | IDNA_ERROR_DOMAIN_NAME_TOO_LONG | IDNA_ERROR_LEADING_HYPHEN | IDNA_ERROR_TRAILING_HYPHEN | IDNA_ERROR_HYPHEN_3_4; @@ -60,10 +57,7 @@ private function __construct() { } - /** - * Returns the normalized form of {@code $url}, or {@code $url} itself when it is not an absolute - * special-scheme URL. - */ + /** Returns the normalized {@code $url}, or {@code $url} itself when it is not an absolute special-scheme URL. */ public static function normalize(string $url): string { $cleaned = preg_replace('/[\t\n\r]/', '', trim($url, "\x00..\x20")); @@ -103,9 +97,8 @@ public static function normalize(string $url): string } /** - * Normalizes every URL field named by {@code $paths} in a decoded response object. A path is a - * dot-separated list of keys; the key {@code *} matches every entry of an object (for a map of - * URLs). Only string values are touched. + * Normalizes the URL fields named by {@code $paths} in a decoded response. A path is dot-separated keys, + * where {@code *} matches every entry of an object. Only strings are changed. * * @param array $data * @param list $paths @@ -203,7 +196,10 @@ private static function normalizeAuthority(string $authority, string $scheme): ? return $userinfo . $host; } - /** Lowercases a domain host and converts it to punycode when {@code intl} can; {@code ''} when it is not a valid host. */ + /** + * Lowercases a domain host and converts it to punycode when {@code intl} can; {@code ''} when it is not a valid + * host. + */ private static function normalizeHost(string $host): string { $decoded = rawurldecode($host); @@ -213,9 +209,8 @@ private static function normalizeHost(string $host): string } if (preg_match('/[^\x20-\x7E]/', $lower) === 1 && function_exists('idn_to_ascii')) { $info = []; - // IDNA_CHECK_BIDI is off: Node's `new URL()` accepts hosts that ICU's RFC 5893 check rejects (such as - // `١.com`), and ICU flags both cases with the same error, so a host that Node rejects is punycoded - // instead, the smaller divergence. + // IDNA_CHECK_BIDI is off, since Node accepts hosts that the ICU check rejects, such as `١.com`. + // A host that Node rejects is punycoded instead. $flags = IDNA_NONTRANSITIONAL_TO_ASCII | IDNA_CHECK_CONTEXTJ; $ascii = idn_to_ascii($lower, $flags, INTL_IDNA_VARIANT_UTS46, $info); if ($ascii === false && (($info['errors'] ?? -1) & ~self::IGNORED_IDNA_ERRORS) === 0) { @@ -256,10 +251,7 @@ private static function normalizePath(string $path): string return '/' . implode('/', $resolved); } - /** - * Percent-encodes every byte that is a control, a space, outside printable ASCII, or in - * {@code $unsafe}, leaving existing {@code %XX} escapes as they are. - */ + /** Percent-encodes controls, spaces, non-ASCII bytes, and the {@code $unsafe} characters. Existing escapes stay. */ private static function encode(string $value, string $unsafe): string { $encoded = ''; diff --git a/src/Internal/WaitForResources.php b/src/Internal/WaitForResources.php index 87e86ef..3ccf55e 100644 --- a/src/Internal/WaitForResources.php +++ b/src/Internal/WaitForResources.php @@ -8,27 +8,17 @@ use InvalidArgumentException; /** - * Backs the {@code waitForResources} option of {@see \Apify\Client\Resource\ActorClient::start()}/ - * {@code call()} and {@see \Apify\Client\Resource\TaskClient::start()}/{@code call()}: retries a run - * start that the API rejects for lack of account resources, mirroring the reference client's - * {@code startWaitingForResources}. + * Retries a run start that the API rejects for lack of account resources. Backs the {@code waitForResources} + * option of the Actor and task {@code start()} and {@code call()}. * * @internal */ final class WaitForResources { - /** - * Error types the API rejects a run start with while the account has no free memory or - * concurrent-run slot for it. Both clear as other runs or builds finish. - */ + /** The errors for a start that finds no free memory or concurrent run slot. Both clear as other runs finish. */ private const RESOURCE_LIMIT_ERROR_TYPES = ['actor-memory-limit-exceeded', 'concurrent-runs-limit-exceeded']; - /** - * Cooldown between two attempts to start a run that was rejected for lack of resources. Real - * callers ({@see \Apify\Client\Resource\ActorClient}/{@see \Apify\Client\Resource\TaskClient}) use - * this default; {@see start()}'s {@code $cooldownSecs} parameter exists only so tests can shrink it - * instead of making the suite sleep through real 10-second cooldowns. - */ + /** Seconds between two attempts. {@see start()} takes its own value so tests need not wait. */ public const COOLDOWN_SECS = 10.0; private function __construct() @@ -36,16 +26,15 @@ private function __construct() } /** - * Calls {@code $start}, retrying it every {@code $cooldownSecs} while it fails with an {@see - * ApifyApiException} of one of {@see RESOURCE_LIMIT_ERROR_TYPES}. {@code true} retries until the - * call succeeds; a number of seconds bounds the retrying, after which the last error is thrown; - * {@code null}/{@code false} makes exactly one attempt. Any other error is thrown right away. + * Calls {@code $start}, retrying every {@code $cooldownSecs} while it fails with one of + * {@see RESOURCE_LIMIT_ERROR_TYPES}. {@code true} retries until it succeeds, a number of seconds for that + * long, then the last error is thrown. {@code null} and {@code false} make one attempt. Any other error is + * thrown at once. * * @template T * @param callable():T $start - * @throws InvalidArgumentException if {@code $waitForResources} is a negative number - * @throws ApifyApiException the last rejection, once the retry budget is bounded and exhausted, or - * immediately for any error that is not a resource-limit rejection + * @throws InvalidArgumentException if {@code $waitForResources} is negative + * @throws ApifyApiException the last rejection once a bounded retry is exhausted, or any other error * @return T */ public static function start(callable $start, bool|int|null $waitForResources, float $cooldownSecs = self::COOLDOWN_SECS): mixed diff --git a/src/Model/Actor.php b/src/Model/Actor.php index b6364ed..5c01a99 100644 --- a/src/Model/Actor.php +++ b/src/Model/Actor.php @@ -61,10 +61,7 @@ public function getModifiedAt(): ?string return $this->getString('modifiedAt'); } - /** - * The URL for sending requests to the Actor in Standby mode (a Server Actor), or {@code null} if Standby - * mode is not enabled. - */ + /** The URL for sending requests to the Server Actor, or {@code null} if it has no server interface. */ public function getStandbyUrl(): ?string { return $this->getString('standbyUrl'); diff --git a/src/Model/ActorRun.php b/src/Model/ActorRun.php index 32ad666..39b2523 100644 --- a/src/Model/ActorRun.php +++ b/src/Model/ActorRun.php @@ -36,9 +36,8 @@ public function getUserId(): ?string } /** - * The current run status. One of the eight {@code ActorJobStatus} values: {@code READY}, - * {@code RUNNING}, {@code SUCCEEDED}, {@code FAILED}, {@code TIMING-OUT}, {@code TIMED-OUT}, - * {@code ABORTING}, {@code ABORTED}. + * The run status: {@code READY}, {@code RUNNING}, {@code SUCCEEDED}, {@code FAILED}, {@code TIMING-OUT}, + * {@code TIMED-OUT}, {@code ABORTING}, or {@code ABORTED}. */ public function getStatus(): ?string { diff --git a/src/Model/ActorVersion.php b/src/Model/ActorVersion.php index ca1c8a3..162abdb 100644 --- a/src/Model/ActorVersion.php +++ b/src/Model/ActorVersion.php @@ -20,9 +20,9 @@ public function getSourceType(): ?string } /** - * The Actor's directory inside the uploaded source, as a path relative to the upload root, for a - * {@code SOURCE_FILES} or {@code TARBALL} version, or {@code null} when the upload root is the Actor's - * directory. On a {@code TARBALL} version, only users with write access to the Actor get it. + * The directory of the Actor inside the uploaded source, relative to the upload root, for a + * {@code SOURCE_FILES} or {@code TARBALL} version. {@code null} when the upload root is that directory. A + * {@code TARBALL} version gives it only to users with write access to the Actor. */ public function getActorPath(): ?string { diff --git a/src/Model/ApifyResource.php b/src/Model/ApifyResource.php index 8fd3330..04d89a6 100644 --- a/src/Model/ApifyResource.php +++ b/src/Model/ApifyResource.php @@ -7,30 +7,23 @@ use Apify\Client\Internal\UrlNormalizer; /** - * Base class for API resource models. + * Base class of the response models. A model wraps the decoded JSON object and has getters for common fields. + * {@see toArray()} and {@see get()} return every field, including those the model does not list. * - * Each model wraps the raw decoded JSON object and exposes commonly-used fields as typed getters. - * The full payload — including any field the API adds that is not modelled here — is always - * available via {@see toArray()} and {@see get()}, so additive API changes never lose data. - * - * The fields a model lists in {@see URL_FIELDS} (those the OpenAPI specification declares as - * {@code format: uri}) are normalized to the WHATWG URL serialization when the model is built, the same - * way the reference client does: a trailing slash is added to an empty path, the scheme and host are - * lowercased, a default port is dropped and unsafe characters are percent-encoded. A value that is not - * an absolute http(s)/ws(s)/ftp URL is kept as received. + * The {@see URL_FIELDS} of a model, which the OpenAPI specification declares as {@code format: uri}, are + * normalized to the WHATWG URL serialization when the model is built. Other values are kept as received. */ abstract class ApifyResource { /** - * The URL fields of this resource: dot-separated key paths into the raw object, where {@code *} - * stands for every entry of an object. + * The URL fields: dot-separated key paths into the object, where {@code *} matches every entry of an object. * * @var list */ protected const URL_FIELDS = []; /** - * @param array $data the raw decoded resource object + * @param array $data the decoded resource object */ public function __construct(protected array $data) { @@ -38,7 +31,7 @@ public function __construct(protected array $data) } /** - * The full raw resource object, including fields not mapped to a typed getter. + * The whole resource object, including fields without a getter. * * @return array */ @@ -48,7 +41,7 @@ public function toArray(): array } /** - * A single raw field by key ({@code null} if absent). + * A field by key, or {@code null} if it is absent. * * @return mixed */ diff --git a/src/Model/BatchAddResult.php b/src/Model/BatchAddResult.php index 51a759e..94950b1 100644 --- a/src/Model/BatchAddResult.php +++ b/src/Model/BatchAddResult.php @@ -4,9 +4,7 @@ namespace Apify\Client\Model; -/** - * The result of a batch request-add: the accepted (processed) and the unprocessed requests. - */ +/** The result of adding requests in a batch: the processed and the unprocessed ones. */ final class BatchAddResult { /** @@ -20,7 +18,7 @@ public function __construct( } /** - * The requests the API successfully added. + * The requests that were added. * * @return list */ @@ -30,7 +28,7 @@ public function getProcessedRequests(): array } /** - * The requests the API did not process. + * The requests that were not processed. * * @return list */ @@ -58,7 +56,7 @@ public function setUnprocessedRequests(array $unprocessedRequests): void } /** - * Appends another result's requests into this one (used to merge per-chunk batch results). + * Adds the requests of another result to this one. * * @internal */ diff --git a/src/Model/BatchDeleteResult.php b/src/Model/BatchDeleteResult.php index 93604cd..5a113e8 100644 --- a/src/Model/BatchDeleteResult.php +++ b/src/Model/BatchDeleteResult.php @@ -5,9 +5,8 @@ namespace Apify\Client\Model; /** - * The result of a batch request-delete: the requests that were successfully removed and the ones - * that could not be (and can be retried). Returned by - * {@see \Apify\Client\Resource\RequestQueueClient::batchDeleteRequests()}. + * The result of {@see \Apify\Client\Resource\RequestQueueClient::batchDeleteRequests()}: the deleted requests and the + * ones to retry. */ final class BatchDeleteResult { @@ -46,7 +45,7 @@ private static function hydrateList(mixed $rawList): array } /** - * The requests that were successfully deleted from the queue. + * The requests that were deleted. * * @return list */ @@ -56,7 +55,7 @@ public function getProcessedRequests(): array } /** - * The requests that failed to be deleted and can be retried. + * The requests that were not deleted and can be retried. * * @return list */ diff --git a/src/Model/Build.php b/src/Model/Build.php index cf7f0cd..45dd038 100644 --- a/src/Model/Build.php +++ b/src/Model/Build.php @@ -22,9 +22,8 @@ public function getActId(): ?string } /** - * The current build status. One of the eight {@code ActorJobStatus} values: {@code READY}, - * {@code RUNNING}, {@code SUCCEEDED}, {@code FAILED}, {@code TIMING-OUT}, {@code TIMED-OUT}, - * {@code ABORTING}, {@code ABORTED}. + * The build status: {@code READY}, {@code RUNNING}, {@code SUCCEEDED}, {@code FAILED}, {@code TIMING-OUT}, + * {@code TIMED-OUT}, {@code ABORTING}, or {@code ABORTED}. */ public function getStatus(): ?string { @@ -50,9 +49,8 @@ public function getBuildNumber(): ?string } /** - * Digest of the built Docker image manifest, without the {@code sha256:} prefix, or - * {@code null} if unavailable. Compare the digests of two builds to tell whether their image - * contents differ. + * The digest of the Docker image manifest without the {@code sha256:} prefix, or {@code null}. It differs between + * builds with different image contents. */ public function getImageDigest(): ?string { diff --git a/src/Model/KeyValueStoreRecord.php b/src/Model/KeyValueStoreRecord.php index c398eef..491a23e 100644 --- a/src/Model/KeyValueStoreRecord.php +++ b/src/Model/KeyValueStoreRecord.php @@ -4,10 +4,7 @@ namespace Apify\Client\Model; -/** - * A single record retrieved from a key-value store. Its {@see getValue() value} holds the raw bytes - * (as a string); callers can decode it according to {@see getContentType() content type}. - */ +/** A record of a key-value store. {@see getValue()} holds the raw bytes, to decode by {@see getContentType()}. */ final class KeyValueStoreRecord { public function __construct( diff --git a/src/Model/LockedRequestQueueHead.php b/src/Model/LockedRequestQueueHead.php index 211837f..243f307 100644 --- a/src/Model/LockedRequestQueueHead.php +++ b/src/Model/LockedRequestQueueHead.php @@ -5,7 +5,7 @@ namespace Apify\Client\Model; /** - * A batch of requests from the head of a request queue, locked for exclusive processing. Returned by + * Requests from the head of a queue, locked for exclusive processing. Returned by * {@see \Apify\Client\Resource\RequestQueueClient::listAndLockHead()}. */ final class LockedRequestQueueHead @@ -48,8 +48,7 @@ public static function fromData(mixed $data): self } /** - * The locked requests from the head of the queue. Each item's own - * {@see RequestQueueRequest::getLockExpiresAt()} reports when its individual lock expires. + * The locked requests. {@see RequestQueueRequest::getLockExpiresAt()} gives the lock expiry of each. * * @return list */ diff --git a/src/Model/PaginationList.php b/src/Model/PaginationList.php index ee2dc6d..71887d1 100644 --- a/src/Model/PaginationList.php +++ b/src/Model/PaginationList.php @@ -9,12 +9,8 @@ use Traversable; /** - * A single page of an offset/limit-paginated list. - * - * The pagination metadata ({@see getTotal()}, {@see getOffset()}, {@see getLimit()}, - * {@see getCount()}, {@see isDesc()}) accompanies the {@see getItems()} items. Note: {@code total} - * reflects the API's reported total, which can briefly lag immediately after a write (the count is - * computed asynchronously) — re-read after a short delay if you need an exact post-write total. + * A page of an offset-paginated list. {@see getTotal()} can lag right after a write, since the API counts + * asynchronously. * * @template T * @implements IteratorAggregate @@ -60,8 +56,8 @@ public static function fromData(mixed $data, callable $hydrate): self } /** - * Builds a page directly from items and metadata (used by the dataset-items endpoint, which - * returns a bare array with pagination in response headers). + * Builds a page from items and metadata. Used by the dataset items endpoint, which reports the pagination + * in headers. * * @template TItem * @param list $items @@ -73,7 +69,7 @@ public static function fromItems(array $items, int $total, int $offset, int $lim } /** - * The items of this page (never {@code null}). + * The items of the page. * * @return list */ @@ -101,11 +97,9 @@ public function getLimit(): int } /** - * Number of entries on this page. On a page from {@see \Apify\Client\Resource\DatasetClient::listItems()} - * it is the number of dataset rows the API scanned for it, or the number of items returned when that is - * larger: filters ({@code clean}, {@code skipEmpty}) can leave it above the number of items. The next - * page starts at {@code offset + min(count, limit)}; the cap matters only with {@code unwind}, which can - * return more items than the rows scanned. + * The number of entries on the page. For {@see \Apify\Client\Resource\DatasetClient::listItems()}, it is + * the number of rows the API scanned, or the number of items when that is larger. The next page starts at + * {@code offset + min(count, limit)}. */ public function getCount(): int { diff --git a/src/Model/RequestLockInfo.php b/src/Model/RequestLockInfo.php index b211252..bbbbbba 100644 --- a/src/Model/RequestLockInfo.php +++ b/src/Model/RequestLockInfo.php @@ -4,10 +4,7 @@ namespace Apify\Client\Model; -/** - * The result of prolonging a request lock. Returned by - * {@see \Apify\Client\Resource\RequestQueueClient::prolongRequestLock()}. - */ +/** The result of {@see \Apify\Client\Resource\RequestQueueClient::prolongRequestLock()}. */ final class RequestLockInfo { public function __construct(private ?string $lockExpiresAt) diff --git a/src/Model/RequestQueueOperationInfo.php b/src/Model/RequestQueueOperationInfo.php index 14ff80a..7ffa654 100644 --- a/src/Model/RequestQueueOperationInfo.php +++ b/src/Model/RequestQueueOperationInfo.php @@ -13,10 +13,7 @@ public function getRequestId(): ?string return $this->getString('requestId'); } - /** - * The unique key of the affected request. Populated for batch-add results; may be {@code null} - * for single add/update operations. - */ + /** The unique key of the request. Set for batch-add results, possibly {@code null} for single operations. */ public function getUniqueKey(): ?string { return $this->getString('uniqueKey'); diff --git a/src/Model/RequestQueueRequest.php b/src/Model/RequestQueueRequest.php index 444c116..bce6328 100644 --- a/src/Model/RequestQueueRequest.php +++ b/src/Model/RequestQueueRequest.php @@ -4,14 +4,11 @@ namespace Apify\Client\Model; -/** - * A single request stored in a request queue. Fields left {@code null} are omitted when the request - * is sent to the API. Construct one for adding to a queue, or receive one when reading a queue. - */ +/** A request in a request queue. Fields left {@code null} are not sent to the API. */ final class RequestQueueRequest extends ApifyResource { /** - * @param array $data raw data (used when hydrating from the API) + * @param array $data the raw data, when built from an API response */ public function __construct(?string $url = null, ?string $uniqueKey = null, array $data = []) { @@ -81,7 +78,7 @@ public function setMethod(string $method): self } /** - * Arbitrary user-attached metadata. + * User-attached metadata. * * @return mixed */ @@ -97,11 +94,10 @@ public function setUserData(mixed $userData): self } /** - * How many times processing this request has already been retried. Populated on requests - * returned by {@see \Apify\Client\Resource\RequestQueueClient::listHead()}, - * {@see \Apify\Client\Resource\RequestQueueClient::listAndLockHead()} and - * {@see \Apify\Client\Resource\RequestQueueClient::listRequests()}; absent when constructing a - * request to add. + * How many times the request was retried. Set on requests from + * {@see \Apify\Client\Resource\RequestQueueClient::listHead()}, + * {@see \Apify\Client\Resource\RequestQueueClient::listAndLockHead()}, and + * {@see \Apify\Client\Resource\RequestQueueClient::listRequests()}. */ public function getRetryCount(): ?int { @@ -109,8 +105,8 @@ public function getRetryCount(): ?int } /** - * ISO 8601 timestamp of when this request's processing lock expires. Only present on requests - * returned by {@see \Apify\Client\Resource\RequestQueueClient::listAndLockHead()}. + * ISO 8601 time when the lock of the request expires. Set on requests from + * {@see \Apify\Client\Resource\RequestQueueClient::listAndLockHead()}. */ public function getLockExpiresAt(): ?string { diff --git a/src/Model/RequestQueueRequestsPage.php b/src/Model/RequestQueueRequestsPage.php index bedf75e..d5f9f38 100644 --- a/src/Model/RequestQueueRequestsPage.php +++ b/src/Model/RequestQueueRequestsPage.php @@ -5,8 +5,7 @@ namespace Apify\Client\Model; /** - * A single, cursor-paginated page of a request queue's requests. Returned by - * {@see \Apify\Client\Resource\RequestQueueClient::listRequests()}. + * A cursor-paginated page of requests, returned by {@see \Apify\Client\Resource\RequestQueueClient::listRequests()}. */ final class RequestQueueRequestsPage { @@ -44,7 +43,7 @@ public static function fromData(mixed $data): self } /** - * The requests in this page. + * The requests of the page. * * @return list */ @@ -60,9 +59,9 @@ public function getLimit(): int } /** - * The ID of the last request of the previous page, if pagination was continued by ID. + * The ID of the last request of the previous page, when the pagination continued by ID. * - * @deprecated superseded by {@see getCursor()}/{@see getNextCursor()} + * @deprecated superseded by {@see getCursor()} and {@see getNextCursor()} */ public function getExclusiveStartId(): ?string { diff --git a/src/Model/Task.php b/src/Model/Task.php index 8a4488f..d48f83f 100644 --- a/src/Model/Task.php +++ b/src/Model/Task.php @@ -58,9 +58,8 @@ public function getModifiedAt(): ?string } /** - * Whether the task is published on its public landing page. Derived from - * {@code publicConfig.publishedAt} — use {@see TaskClient::publish()} and - * {@see TaskClient::unpublish()} to change it. + * Whether the task is published, from {@code publicConfig.publishedAt}. Change it with {@see TaskClient::publish()} + * and {@see TaskClient::unpublish()}. */ public function isPublic(): ?bool { @@ -68,9 +67,8 @@ public function isPublic(): ?bool } /** - * The public-facing display configuration of the task's public landing page, or {@code null} - * if the task is not published. Contains fields such as {@code publishedAt}, {@code seoTitle} - * and {@code datasetView}. + * The display configuration of the public landing page, or {@code null} if the task is not published. It has + * fields such as {@code publishedAt}, {@code seoTitle}, and {@code datasetView}. * * @return array|null */ diff --git a/src/Model/UnlockRequestsResult.php b/src/Model/UnlockRequestsResult.php index d7b6f5e..5e8b515 100644 --- a/src/Model/UnlockRequestsResult.php +++ b/src/Model/UnlockRequestsResult.php @@ -4,10 +4,7 @@ namespace Apify\Client\Model; -/** - * The result of releasing all of a client's request locks on a queue. Returned by - * {@see \Apify\Client\Resource\RequestQueueClient::unlockRequests()}. - */ +/** The result of {@see \Apify\Client\Resource\RequestQueueClient::unlockRequests()}: the locks the client released. */ final class UnlockRequestsResult { public function __construct(private int $unlockedCount) diff --git a/src/Model/User.php b/src/Model/User.php index 8472403..682cd61 100644 --- a/src/Model/User.php +++ b/src/Model/User.php @@ -5,8 +5,7 @@ namespace Apify\Client\Model; /** - * An Apify user account. Private account details for {@code me} (email, plan, proxy settings, …) - * are available via {@see toArray()}. + * A user account. The private details of {@code me}, such as email, plan, and proxy settings, are in {@see toArray()}. */ final class User extends ApifyResource { diff --git a/src/Options/ActorListOptions.php b/src/Options/ActorListOptions.php index c41dafa..3d61764 100644 --- a/src/Options/ActorListOptions.php +++ b/src/Options/ActorListOptions.php @@ -23,10 +23,7 @@ public function __construct( ) { } - /** - * Returns a copy of these options with a new {@code offset}/{@code limit}, preserving the other - * filters. Used by lazy iteration to request successive pages. - */ + /** A copy with a new {@code offset} and {@code limit} and the same filters. Used to request successive pages. */ public function withPagination(?int $offset, ?int $limit): self { return new self($offset, $limit, $this->desc, $this->my, $this->sortBy); diff --git a/src/Options/ActorServerRequestOptions.php b/src/Options/ActorServerRequestOptions.php index 82b7f18..62835f8 100644 --- a/src/Options/ActorServerRequestOptions.php +++ b/src/Options/ActorServerRequestOptions.php @@ -24,8 +24,8 @@ final class ActorServerRequestOptions * @param array|null $headers additional headers of the request, {@code Authorization} included; * they reach the Actor's server as they are * @param string|HttpCompressorInterface|null $compression the compression of the request body: {@code 'brotli'}, - * {@code 'gzip'} or a custom compressor. The body is sent - * uncompressed when it is not set, since the Actor's server may + * {@code 'gzip'}, {@code 'zstd'}, or a custom compressor. The body is + * sent uncompressed when it is not set, since the Actor's server may * not decode a compressed body. A body is compressed under the same * conditions as for API calls, so set this only if the server * decodes the resulting {@code Content-Encoding}. @@ -47,6 +47,13 @@ public function __construct( * connection stays open until the caller reads the body to the end or closes it. */ public readonly bool $stream = false, + /** + * The {@code X-Actor-Request-ID} header of the request your Server Actor is handling. It is sent on as the + * same header, replacing one in {@code headers}, so the called Actor charges at that request's pricing tier. + * + * @see https://docs.apify.com/platform/actors/development/programming-interface/server-actors + */ + public readonly ?string $parentRequestId = null, ) { } diff --git a/src/Options/ActorStartOptions.php b/src/Options/ActorStartOptions.php index fa061bd..c681b14 100644 --- a/src/Options/ActorStartOptions.php +++ b/src/Options/ActorStartOptions.php @@ -8,14 +8,12 @@ use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; -/** - * Configures starting an Actor run. All fields are optional. - */ +/** Options for starting an Actor run. */ final class ActorStartOptions { /** - * @param list|null $webhooks ad-hoc webhooks to attach to this run; serialized to - * base64-encoded JSON as the {@code webhooks} query parameter + * @param list|null $webhooks ad-hoc webhooks of the run, sent base64-encoded as the {@code webhooks} + * query parameter */ public function __construct( /** The tag or number of the build to run (e.g. {@code "latest"}, {@code "0.1.2"}). */ @@ -35,25 +33,21 @@ public function __construct( /** If {@code true}, restart the run if it fails. */ public readonly ?bool $restartOnError = null, /** - * Override the Actor's permission level for this run ({@code LIMITED_PERMISSIONS}/ + * Overrides the permission level of the Actor for this run ({@code LIMITED_PERMISSIONS} or * {@code FULL_PERMISSIONS}). */ public readonly ?string $forcePermissionLevel = null, public readonly ?array $webhooks = null, /** - * Retry the start while the account lacks the resources for the run, that is while the API - * rejects it with an {@see \Apify\Client\Exception\ApifyApiException} of type - * {@code actor-memory-limit-exceeded} or {@code concurrent-runs-limit-exceeded}. Both clear as - * other runs or builds finish. The start is retried every 10 seconds, and any other error is - * thrown right away. + * Retries the start while the account lacks the resources for the run, which the API reports as + * {@code actor-memory-limit-exceeded} or {@code concurrent-runs-limit-exceeded}. The start is retried + * every 10 seconds, and any other error is thrown at once. * - * {@code true} retries until the run starts. An {@code int} number of seconds stops retrying - * after that many seconds and throws the last error. {@code null} (the default) makes a single - * attempt. + * {@code true} retries until the run starts, an {@code int} for that many seconds. {@code null} makes + * one attempt. * - * A run that requests more memory than the whole memory limit of the account is rejected with - * {@code actor-memory-limit-exceeded} as well and never starts, so {@code true} retries it - * forever. + * A run that asks for more memory than the account limit also gets {@code actor-memory-limit-exceeded}, + * so {@code true} retries it forever. */ public readonly bool|int|null $waitForResources = null, ) { @@ -82,9 +76,8 @@ public function appendTo(QueryParams $q): void } /** - * Encodes an ad-hoc webhooks list as base64-encoded JSON, as the API's {@code webhooks} query - * parameter requires. Returns {@code null} for a {@code null} list. Shared by Actor and task - * start options. + * Encodes ad-hoc webhooks as base64-encoded JSON for the {@code webhooks} query parameter. {@code null} + * stays {@code null}. * * @param list|null $webhooks * @internal diff --git a/src/Options/BatchAddRequestsOptions.php b/src/Options/BatchAddRequestsOptions.php index f3bb7d9..d161057 100644 --- a/src/Options/BatchAddRequestsOptions.php +++ b/src/Options/BatchAddRequestsOptions.php @@ -5,18 +5,16 @@ namespace Apify\Client\Options; /** - * Tuning options for batch request adding. Requests the API reports as unprocessed (typically due to - * rate limiting) are automatically retried with exponential backoff. - * - * Note: unlike the reference JS client, this synchronous client sends the batch API calls - * sequentially, so it exposes no {@code maxParallel} knob (it would have no effect). + * Options for adding requests in batches. Requests the API reports as unprocessed, typically because of rate + * limiting, are retried with exponential backoff. Batches are sent one after another, so there is no + * {@code maxParallel} option. */ final class BatchAddRequestsOptions { - /** Default number of retry rounds for unprocessed requests (matches the reference client). */ + /** The default number of retry rounds for unprocessed requests. */ public const DEFAULT_MAX_UNPROCESSED_RETRIES = 3; - /** Default minimum delay before retrying unprocessed requests (matches the reference client). */ + /** The default minimum delay before a retry, in milliseconds. */ public const DEFAULT_MIN_DELAY_MILLIS = 500; public readonly int $maxUnprocessedRequestsRetries; diff --git a/src/Options/DatasetDownloadOptions.php b/src/Options/DatasetDownloadOptions.php index e17b93c..f642d74 100644 --- a/src/Options/DatasetDownloadOptions.php +++ b/src/Options/DatasetDownloadOptions.php @@ -6,10 +6,7 @@ use Apify\Client\Internal\QueryParams; -/** - * Adds format-specific options for downloading dataset items on top of the shared item - * filtering/projection options ({@see DatasetListItemsOptions}). - */ +/** Format-specific options for downloading dataset items, on top of the {@see DatasetListItemsOptions}. */ final class DatasetDownloadOptions { public function __construct( diff --git a/src/Options/DatasetListItemsOptions.php b/src/Options/DatasetListItemsOptions.php index d82b849..f991259 100644 --- a/src/Options/DatasetListItemsOptions.php +++ b/src/Options/DatasetListItemsOptions.php @@ -6,18 +6,15 @@ use Apify\Client\Internal\QueryParams; -/** - * Configures listing or downloading dataset items ({@code GET /v2/datasets/{datasetId}/items}). - * All fields are optional. - */ +/** Options for listing or downloading dataset items. */ final class DatasetListItemsOptions { /** - * @param list|null $fields restrict the output to these fields - * @param list|null $outputFields positionally rename the selected {@code fields} (requires {@code fields}) - * @param list|null $omit exclude these fields from the output - * @param list|null $unwind expand these fields (each array element becomes a separate item) - * @param list|null $flatten flatten these nested fields into dot-notation keys + * @param list|null $fields limits the output to these fields + * @param list|null $outputFields renames the selected {@code fields} by position (needs {@code fields}) + * @param list|null $omit leaves these fields out + * @param list|null $unwind turns each element of these array fields into its own item + * @param list|null $flatten flattens these nested fields into dot-notation keys */ public function __construct( /** Number of items to skip. */ @@ -49,8 +46,8 @@ public function __construct( } /** - * Returns a copy of these options with a new {@code offset}/{@code limit}, preserving every other - * field. Used by {@see \Apify\Client\Resource\DatasetClient::iterateItems()} to request pages. + * A copy with a new {@code offset} and {@code limit} and the same other fields. Used by + * {@see \Apify\Client\Resource\DatasetClient::iterateItems()}. */ public function withPagination(?int $offset, ?int $limit): self { diff --git a/src/Options/GetRecordOptions.php b/src/Options/GetRecordOptions.php index 23231b4..0e73c8d 100644 --- a/src/Options/GetRecordOptions.php +++ b/src/Options/GetRecordOptions.php @@ -10,11 +10,7 @@ final class GetRecordOptions { public function __construct( - /** - * Controls the {@code Content-Disposition: attachment} behaviour. Defaults to {@code true}, - * matching the reference client, which always requests the record as an attachment so the API - * returns the raw bytes directly. Pass {@code null} to omit the parameter entirely. - */ + /** Requests the record as an attachment, so the API returns the raw bytes. {@code null} omits the parameter. */ public readonly ?bool $attachment = true, /** A pre-shared URL signature granting access without an API token. */ public readonly ?string $signature = null, diff --git a/src/Options/LastRunOptions.php b/src/Options/LastRunOptions.php index 1e25c35..051946b 100644 --- a/src/Options/LastRunOptions.php +++ b/src/Options/LastRunOptions.php @@ -4,14 +4,7 @@ namespace Apify\Client\Options; -/** - * Filters which "last" run the last-run accessors resolve to. Leave a field {@code null} to leave - * that filter unset. - * - * {@code origin} is a query parameter declared on the {@code runs/last} endpoints in the OpenAPI - * spec (alongside {@code status}); it is threaded to that endpoint, matching the reference client's - * {@code lastRun({ status, origin })}. - */ +/** Filters by status and origin which run the last-run accessors resolve to. A {@code null} field sets no filter. */ final class LastRunOptions { public function __construct( diff --git a/src/Options/ListOptions.php b/src/Options/ListOptions.php index 651f00e..e259f65 100644 --- a/src/Options/ListOptions.php +++ b/src/Options/ListOptions.php @@ -6,11 +6,7 @@ use Apify\Client\Internal\QueryParams; -/** - * The standard offset/limit pagination shared by most {@code list} endpoints (builds, runs, tasks, - * schedules, webhooks, Actor versions). All fields are optional; leave one {@code null} to use the - * API default. - */ +/** Offset and limit pagination, shared by most {@code list} endpoints. A {@code null} field uses the API default. */ final class ListOptions { public function __construct( @@ -23,10 +19,7 @@ public function __construct( ) { } - /** - * Returns a copy of these options with a new {@code offset}/{@code limit}, preserving the other - * filters. Used by lazy iteration to request successive pages. - */ + /** A copy with a new {@code offset} and {@code limit} and the same filters. Used to request successive pages. */ public function withPagination(?int $offset, ?int $limit): self { return new self($offset, $limit, $this->desc); diff --git a/src/Options/ListRequestsOptions.php b/src/Options/ListRequestsOptions.php index c7a81a4..cc7cea4 100644 --- a/src/Options/ListRequestsOptions.php +++ b/src/Options/ListRequestsOptions.php @@ -17,8 +17,7 @@ final class ListRequestsOptions public const FILTER_PENDING = 'pending'; /** - * @param list|null $filter restrict the listing to requests in the given states; each - * value must be {@see FILTER_LOCKED} or {@see FILTER_PENDING} + * @param list|null $filter lists only requests in these states, {@see FILTER_LOCKED} or {@see FILTER_PENDING} */ public function __construct( /** Maximum number of requests to return. */ diff --git a/src/Options/PaginateRequestsOptions.php b/src/Options/PaginateRequestsOptions.php index 6678f07..8f31998 100644 --- a/src/Options/PaginateRequestsOptions.php +++ b/src/Options/PaginateRequestsOptions.php @@ -6,14 +6,10 @@ use InvalidArgumentException; -/** - * Configures lazy iteration over a request queue's requests - * ({@see \Apify\Client\Resource\RequestQueueClient::paginateRequests()}), mirroring the reference - * client's {@code paginateRequests({ limit, maxPageLimit, exclusiveStartId, cursor, filter })}. - */ +/** Options for {@see \Apify\Client\Resource\RequestQueueClient::paginateRequests()}. */ final class PaginateRequestsOptions { - /** Default maximum number of requests fetched per page (matches the reference client). */ + /** The default maximum number of requests per page. */ public const DEFAULT_MAX_PAGE_LIMIT = 1000; /** Filter value: currently locked requests. */ @@ -23,8 +19,8 @@ final class PaginateRequestsOptions public const FILTER_PENDING = 'pending'; /** - * @param list|null $filter restrict the iteration to requests in the given states; each - * value must be {@see FILTER_LOCKED} or {@see FILTER_PENDING} + * @param list|null $filter iterates only requests in these states, {@see FILTER_LOCKED} or + * {@see FILTER_PENDING} */ public function __construct( /** Maximum total number of requests to iterate across all pages ({@code null} for no bound). */ diff --git a/src/Options/RequestQueueClientOptions.php b/src/Options/RequestQueueClientOptions.php index a934645..a8eb56a 100644 --- a/src/Options/RequestQueueClientOptions.php +++ b/src/Options/RequestQueueClientOptions.php @@ -4,22 +4,16 @@ namespace Apify\Client\Options; -/** - * Per-client options for a {@see \Apify\Client\Resource\RequestQueueClient}, mirroring the reference - * client's {@code requestQueue(id, { clientKey, timeoutSecs })}. - */ +/** Options of a {@see \Apify\Client\Resource\RequestQueueClient}. */ final class RequestQueueClientOptions { public function __construct( /** - * A stable client key identifying this client to the queue. Required to operate on locks the - * client itself created, and lets the API detect whether multiple clients access the queue. + * A stable key that identifies the client to the queue. Needed to operate on locks the client created, and lets + * the API detect several clients on one queue. */ public readonly ?string $clientKey = null, - /** - * Overall per-request timeout (seconds) for this queue client's calls. When {@code null} the - * shared client-wide timeout is used. - */ + /** The request timeout in seconds for the calls of the client. {@code null} uses the client-wide timeout. */ public readonly ?float $timeoutSecs = null, ) { } diff --git a/src/Options/RunChargeOptions.php b/src/Options/RunChargeOptions.php index 18d265f..f555e72 100644 --- a/src/Options/RunChargeOptions.php +++ b/src/Options/RunChargeOptions.php @@ -13,16 +13,15 @@ public function __construct( /** The number of times to charge the event (defaults to 1). */ public readonly ?int $count = null, /** - * A key that deduplicates the charge across retries. If unset, one is auto-generated as - * {@code "{runId}-{eventName}-{timestampMillis}-{random}"}, matching the reference client. - * The key expires 3 minutes after the charge; a later request reusing it creates a new - * charge instead of being deduplicated against the original. + * A key that deduplicates the charge across retries. By default + * {@code "{runId}-{eventName}-{timestampMillis}-{random}"}. It expires 3 minutes after the charge, and a later + * request that reuses it creates a new charge. */ public readonly ?string $idempotencyKey = null, /** - * The ID of the Server Actor request being charged, taken from the {@code X-Actor-Request-ID} - * header of the incoming request. Required for multi-tenant Server Actor runs, where it maps the - * charge to the caller's account instead of the account that started the run. Ignored elsewhere. + * The ID of the Server Actor request being charged, from the {@code X-Actor-Request-ID} header of the + * incoming request. Required for runs of multi-tenant Server Actors, where it identifies the request the + * charge belongs to. Runs of single-tenant Server Actors ignore it. */ public readonly ?string $requestId = null, ) { diff --git a/src/Options/RunIterateDatasetItemsOptions.php b/src/Options/RunIterateDatasetItemsOptions.php index e040d38..3c341fa 100644 --- a/src/Options/RunIterateDatasetItemsOptions.php +++ b/src/Options/RunIterateDatasetItemsOptions.php @@ -5,25 +5,23 @@ namespace Apify\Client\Options; /** - * Options for {@see \Apify\Client\Resource\RunClient::iterateDatasetItems()}, which reads a run's - * default dataset while the run is still producing items. - * - * Only the item filters that keep row offsets stable are offered; ordering and format options such as - * {@code desc} or {@code view} would break the offset arithmetic the live iteration relies on. + * Options for {@see \Apify\Client\Resource\RunClient::iterateDatasetItems()}. Only the item filters that keep + * row offsets stable are offered, since options such as {@code desc} or {@code view} would break the live + * iteration. */ final class RunIterateDatasetItemsOptions { /** - * @param list|null $fields field names to include in the items (all others are omitted) - * @param list|null $omit field names to exclude from the items - * @param list|null $unwind field names to unwind: each array value becomes a separate item + * @param list|null $fields fields to include (all others are left out) + * @param list|null $omit fields to leave out + * @param list|null $unwind array fields whose elements become separate items */ public function __construct( /** Number of dataset rows to skip from the beginning (default 0). */ public readonly ?int $offset = null, /** - * Maximum number of dataset rows to scan (default: no limit). Fewer items are yielded when - * filters drop some, more when {@code unwind} splits a row into several. + * The maximum number of dataset rows to scan (default: no limit). Filters can yield fewer items, {@code unwind} + * more. */ public readonly ?int $limit = null, /** Maximum number of dataset rows requested per API call (default 1000). */ diff --git a/src/Options/RunListOptions.php b/src/Options/RunListOptions.php index 35d8dc7..606a91b 100644 --- a/src/Options/RunListOptions.php +++ b/src/Options/RunListOptions.php @@ -6,15 +6,11 @@ use Apify\Client\Internal\QueryParams; -/** - * Run-specific filters for listing runs. The {@code startedAfter}/{@code startedBefore} filters are - * only honoured by the Actor-scoped and task-scoped run collections. - */ +/** Filters for listing runs. {@code startedAfter} and {@code startedBefore} work only for Actor and task runs. */ final class RunListOptions { /** - * @param list|null $status filter by one or more run statuses (e.g. {@code "SUCCEEDED"}, - * {@code "RUNNING"}); sent as a comma-separated list + * @param list|null $status filters by run status, for example {@code "SUCCEEDED"}; sent comma-separated */ public function __construct( public readonly ?array $status = null, @@ -23,12 +19,10 @@ public function __construct( /** Filter to runs started before this ISO-8601 timestamp. */ public readonly ?string $startedBefore = null, /** - * Filter to runs of the given Actor Standby deployments ({@code SINGLE_TENANT}, - * {@code MULTI_TENANT_FREE_USERS}, {@code MULTI_TENANT_PAYING_USERS}, - * {@code MULTI_TENANT_OWNER_PAID}); sent as a comma-separated list. Honoured by the Actor-scoped - * and account-wide run collections. The API requires {@code status} to be set to non-terminal - * statuses only ({@code READY}, {@code RUNNING}, {@code TIMING-OUT}, {@code ABORTING}) when this - * filter is used. + * Filters by the deployment of Server Actor runs ({@code SINGLE_TENANT}, {@code MULTI_TENANT_FREE_USERS}, + * {@code MULTI_TENANT_PAYING_USERS}, {@code MULTI_TENANT_OWNER_PAID}); sent comma-separated. Works for + * Actor runs and all runs. The API then requires {@code status} to hold only non-terminal statuses + * ({@code READY}, {@code RUNNING}, {@code TIMING-OUT}, {@code ABORTING}). * * @var list|null */ diff --git a/src/Options/SetRecordOptions.php b/src/Options/SetRecordOptions.php index 3f74728..500c426 100644 --- a/src/Options/SetRecordOptions.php +++ b/src/Options/SetRecordOptions.php @@ -4,17 +4,14 @@ namespace Apify\Client\Options; -/** - * Write options for storing a key-value-store record, mirroring the reference client's - * {@code timeoutSecs}/{@code doNotRetryTimeouts}. - */ +/** Options for storing a key-value store record. */ final class SetRecordOptions { public function __construct( /** - * Per-request timeout for the upload: a number of seconds, a timeout tier name, or {@code - * 'noTimeout'} (see {@see \Apify\Client\Internal\TimeoutTiers}). Defaults to the {@code long} - * tier. A numeric value is capped at the configured {@code timeoutMaxSecs}. + * The timeout of the upload: seconds, a tier name, or {@code 'noTimeout'} + * (see {@see \Apify\Client\Internal\TimeoutTiers}). Defaults to the {@code long} tier. Seconds are capped at + * {@code timeoutMaxSecs}. */ public readonly int|float|string|null $timeoutSecs = null, /** If {@code true}, do not retry the upload when it fails with a request timeout. */ diff --git a/src/Options/StorageListOptions.php b/src/Options/StorageListOptions.php index 921b9e8..2e2f453 100644 --- a/src/Options/StorageListOptions.php +++ b/src/Options/StorageListOptions.php @@ -7,9 +7,8 @@ use Apify\Client\Internal\QueryParams; /** - * Options for the storage collection list endpoints ({@code GET /v2/datasets}, - * {@code /v2/key-value-stores}, {@code /v2/request-queues}), which add {@code unnamed} and - * {@code ownership} filters on top of the standard pagination. + * Options for listing datasets, key-value stores, and request queues: the {@code unnamed} and {@code ownership} filters + * and the pagination. */ final class StorageListOptions { @@ -27,10 +26,7 @@ public function __construct( ) { } - /** - * Returns a copy of these options with a new {@code offset}/{@code limit}, preserving the other - * filters. Used by lazy iteration to request successive pages. - */ + /** A copy with a new {@code offset} and {@code limit} and the same filters. Used to request successive pages. */ public function withPagination(?int $offset, ?int $limit): self { return new self($offset, $limit, $this->desc, $this->unnamed, $this->ownership); diff --git a/src/Options/StoreListOptions.php b/src/Options/StoreListOptions.php index 7c2eb6e..830c250 100644 --- a/src/Options/StoreListOptions.php +++ b/src/Options/StoreListOptions.php @@ -13,8 +13,8 @@ public function __construct( /** Number of Actors to skip. */ public readonly ?int $offset = null, /** - * Maximum number of Actors to return. When iterating, this caps the total number of Actors - * yielded across all pages (the per-page size is the separate {@code chunkSize} argument). + * The maximum number of Actors. When iterating, it caps the total over all pages, not the page size + * ({@code chunkSize}). */ public readonly ?int $limit = null, /** Full-text search query. */ @@ -25,10 +25,7 @@ public function __construct( public readonly ?string $category = null, /** Filter Actors by owner username. */ public readonly ?string $username = null, - /** - * Filter Actors by pricing model ({@code FREE}, {@code PRICE_PER_DATASET_ITEM}, - * {@code PAY_PER_EVENT}). - */ + /** Filters by pricing model ({@code FREE}, {@code PRICE_PER_DATASET_ITEM}, {@code PAY_PER_EVENT}). */ public readonly ?string $pricingModel = null, /** Include Actors the current user cannot run. */ public readonly ?bool $includeUnrunnableActors = null, @@ -36,18 +33,12 @@ public function __construct( public readonly ?bool $allowsAgenticUsers = null, /** The response format ({@code full}, {@code agent}). */ public readonly ?string $responseFormat = null, - /** - * Include the compact input schema of each Actor's default build in the listing (only - * returned when the default build succeeded and defines input properties). - */ + /** Includes the compact input schema of the default build, when it succeeded and defines input properties. */ public readonly ?bool $includeInputSchema = null, ) { } - /** - * Returns a copy of these options with a new {@code offset}/{@code limit}, preserving the other - * filters. Used by lazy iteration to request successive pages. - */ + /** A copy with a new {@code offset} and {@code limit} and the same filters. Used to request successive pages. */ public function withPagination(?int $offset, ?int $limit): self { return new self( diff --git a/src/Options/TaskStartOptions.php b/src/Options/TaskStartOptions.php index 0888bc9..fe782c2 100644 --- a/src/Options/TaskStartOptions.php +++ b/src/Options/TaskStartOptions.php @@ -7,15 +7,13 @@ use Apify\Client\Internal\QueryParams; /** - * Configures starting a task run. - * - * It mirrors {@see ActorStartOptions} but omits the fields the task run endpoint does not accept - * (the Actor-only {@code contentType} and {@code forcePermissionLevel}), matching the reference client. + * Options for starting a task run. Like {@see ActorStartOptions}, without the Actor-only {@code contentType} and + * {@code forcePermissionLevel}. */ final class TaskStartOptions { /** - * @param list|null $webhooks ad-hoc webhooks to attach (serialized to base64-encoded JSON) + * @param list|null $webhooks ad-hoc webhooks, sent base64-encoded */ public function __construct( /** The tag or number of the build to run (e.g. {@code "latest"}, {@code "0.1.2"}). */ @@ -33,10 +31,7 @@ public function __construct( /** If {@code true}, restart the run if it fails. */ public readonly ?bool $restartOnError = null, public readonly ?array $webhooks = null, - /** - * Retry the start while the account lacks the resources for the run. See {@see - * \Apify\Client\Options\ActorStartOptions::$waitForResources} for the full behavior. - */ + /** Retries the start while the account lacks resources. See {@see ActorStartOptions::$waitForResources}. */ public readonly bool|int|null $waitForResources = null, ) { } diff --git a/src/Resource/AbstractWebhookCollectionClient.php b/src/Resource/AbstractWebhookCollectionClient.php index 7739858..95aa6a2 100644 --- a/src/Resource/AbstractWebhookCollectionClient.php +++ b/src/Resource/AbstractWebhookCollectionClient.php @@ -14,10 +14,8 @@ use Generator; /** - * Shared read-only behavior for webhook collections. Both the account-wide collection - * ({@see WebhookCollectionClient}) and the read-only collections nested under an Actor or task - * ({@see NestedWebhookCollectionClient}) can list webhooks; only the account-wide collection can - * create them. + * The read-only calls of the webhook collections. Both {@see WebhookCollectionClient} and + * {@see NestedWebhookCollectionClient} list webhooks, and only the first creates them. * * @internal */ @@ -44,9 +42,8 @@ public function list(?ListOptions $options = null, int|float|string|null $timeou } /** - * Lazily iterates over webhooks, fetching pages on demand. The options' {@code limit} caps the - * total number of webhooks yielded across all pages ({@code null} = all); {@code $chunkSize} is - * the per-page size ({@code null} = the API's default page size). + * Lazily yields webhooks. The {@code limit} option caps the total ({@code null} for all) and + * {@code $chunkSize} sets the page size ({@code null} for the API's default). * * @return Generator */ diff --git a/src/Resource/ActorClient.php b/src/Resource/ActorClient.php index 49e60e5..ef861ba 100644 --- a/src/Resource/ActorClient.php +++ b/src/Resource/ActorClient.php @@ -19,12 +19,7 @@ use Apify\Client\Options\ValidateInputOptions; use InvalidArgumentException; -/** - * A client for a specific Actor. - * - * It provides CRUD methods plus convenience helpers to start/call the Actor, build it, and access - * its runs, builds, versions and webhooks. - */ +/** A client for an Actor: its CRUD calls, start, call, and build, and its runs, builds, versions, and webhooks. */ final class ActorClient { private ResourceContext $ctx; @@ -55,9 +50,9 @@ public function get(int|float|string|null $timeoutSecs = null): ?Actor } /** - * Updates the Actor with the given fields and returns the updated object. + * Updates the Actor and returns it. * - * @param mixed $newFields any JSON-serializable set of fields to update + * @param mixed $newFields any JSON-serializable fields */ public function update(mixed $newFields, int|float|string|null $timeoutSecs = null): Actor { @@ -71,11 +66,10 @@ public function delete(int|float|string|null $timeoutSecs = null): void } /** - * Starts the Actor and returns immediately with the created run. + * Starts the Actor and returns the created run without waiting. * - * @param mixed $input a JSON-serializable array (or {@code null} for no input), or a raw string - * of bytes sent exactly as given — pair it with a non-default - * {@code $options->contentType} + * @param mixed $input a JSON-serializable value, a string sent as it is (set a matching + * {@code $options->contentType}), or {@code null} for no input */ public function start( mixed $input = null, @@ -99,28 +93,26 @@ public function start( } /** - * Starts the Actor and waits (client-side polling) for it to finish. + * Starts the Actor and polls until it finishes. * - * @param mixed $input a JSON-serializable array (or {@code null} for no input), or a raw - * string of bytes sent exactly as given — see {@see start()} - * @param int|null $waitSecs bounds the wait; {@code null} waits indefinitely. Time spent retrying a - * {@code $options->waitForResources} start does not count toward it. - * @param int|float|string|null $timeoutSecs timeout of the start request and of every poll (a number of - * seconds, a tier name or {@code 'noTimeout'}); default {@code 'noTimeout'} + * @param mixed $input the input, as for {@see start()} + * @param int|null $waitSecs bounds the wait ({@code null} waits indefinitely). Retries of a + * {@code $options->waitForResources} start do not count. + * @param int|float|string|null $timeoutSecs timeout of the start request and of each poll (default + * {@code 'noTimeout'}) */ public function call(mixed $input = null, ?ActorStartOptions $options = null, ?int $waitSecs = null, int|float|string|null $timeoutSecs = null): ActorRun { - // Like the reference client, the start request and every poll have no request timeout by default. + // The start request and every poll have no request timeout by default. $timeoutSecs ??= TimeoutTiers::NO_TIMEOUT; $run = $this->start($input, $options, $timeoutSecs); return $this->root->run((string) $run->getId())->waitForFinish($waitSecs, $timeoutSecs); } /** - * Validates {@code input} against the Actor's input schema and returns whether it is valid. + * Returns whether {@code input} is valid against the input schema of the Actor. * - * @param mixed $input a JSON-serializable array (or {@code null}), or a raw string of bytes sent - * exactly as given — see {@see start()} + * @param mixed $input the input, as for {@see start()} */ public function validateInput( mixed $input = null, @@ -144,7 +136,7 @@ public function validateInput( } /** - * Builds the given version of the Actor and returns the created build. + * Builds a version of the Actor and returns the build. * * @throws InvalidArgumentException if {@code $versionNumber} is empty */ @@ -170,8 +162,8 @@ public function build( } /** - * Resolves the Actor's default build and returns a client for it. {@code $waitForFinish} - * optionally bounds how long (seconds) the API waits for the build to finish before responding. + * Returns a client for the default build. {@code $waitForFinish} is how many seconds the API holds the response + * until the build finishes. */ public function defaultBuild(?int $waitForFinish = null, int|float|string|null $timeoutSecs = null): BuildClient { @@ -222,13 +214,10 @@ public function webhooks(): NestedWebhookCollectionClient } /** - * Returns a client for the server interface of this Actor. + * Returns a client for the server interface of the Actor. Every call returns the same client, so the + * server URL it fetches is reused. * - * A Server Actor (formerly Actor Standby) runs a web server, its server interface, that is called over - * HTTP without starting a run for each call. Every call returns the same client, so the server URL it - * fetches is reused. - * - * @see https://docs.apify.com/platform/actors/running/standby + * @see https://docs.apify.com/platform/actors/running/server-actors */ public function server(): ActorServerClient { diff --git a/src/Resource/ActorCollectionClient.php b/src/Resource/ActorCollectionClient.php index 7c7146f..003c83f 100644 --- a/src/Resource/ActorCollectionClient.php +++ b/src/Resource/ActorCollectionClient.php @@ -37,9 +37,8 @@ public function list(?ActorListOptions $options = null, int|float|string|null $t } /** - * Lazily iterates over the account's Actors, fetching pages on demand. The options' {@code limit} - * caps the total number of Actors yielded across all pages ({@code null} = all); {@code $chunkSize} - * is the per-page size ({@code null} = the API's default page size). + * Lazily yields the account's Actors. The {@code limit} option caps the total ({@code null} for all) and + * {@code $chunkSize} sets the page size ({@code null} for the API's default). * * @return Generator */ diff --git a/src/Resource/ActorEnvVarClient.php b/src/Resource/ActorEnvVarClient.php index dd5e6e9..5fcf815 100644 --- a/src/Resource/ActorEnvVarClient.php +++ b/src/Resource/ActorEnvVarClient.php @@ -10,10 +10,7 @@ use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\ActorEnvVar; -/** - * A client for a single environment variable ({@code GET/PUT/DELETE - * /v2/actors/{actorId}/versions/{versionNumber}/env-vars/{name}}). - */ +/** A client for an environment variable of an Actor version. */ final class ActorEnvVarClient { private ResourceContext $ctx; diff --git a/src/Resource/ActorEnvVarCollectionClient.php b/src/Resource/ActorEnvVarCollectionClient.php index 1ac312e..61fb9b5 100644 --- a/src/Resource/ActorEnvVarCollectionClient.php +++ b/src/Resource/ActorEnvVarCollectionClient.php @@ -12,10 +12,7 @@ use Apify\Client\Model\PaginationList; use Generator; -/** - * A client for an Actor version's environment variable collection ({@code GET/POST - * /v2/actors/{actorId}/versions/{versionNumber}/env-vars}). - */ +/** A client for the environment variables of an Actor version. */ final class ActorEnvVarCollectionClient { private ResourceContext $ctx; @@ -42,12 +39,8 @@ public function list(int|float|string|null $timeoutSecs = null): PaginationList } /** - * Lazily iterates over the version's environment variables. - * - * This endpoint ignores {@code offset}/{@code limit} and always returns every environment - * variable in one response, so iteration makes exactly one request — unlike the offset/limit - * collection iterators, there is no per-page {@code chunkSize} to request fewer at a time, and a - * generic paginating loop would re-request (and re-yield) the same full list forever. + * Lazily yields the environment variables of the version. The endpoint returns all of them in one response, + * so this makes one request. * * @return Generator */ diff --git a/src/Resource/ActorServerClient.php b/src/Resource/ActorServerClient.php index de67370..7f61357 100644 --- a/src/Resource/ActorServerClient.php +++ b/src/Resource/ActorServerClient.php @@ -17,27 +17,26 @@ use Apify\Client\Options\ListOptions; /** - * A client for the server interface of a specific Actor. + * A client for the server interface of an Actor. A Server Actor runs a web server that answers HTTP requests + * without a run per request. Get an instance with {@see ActorClient::server()}. * - * A Server Actor (formerly Actor Standby) runs a web server, its server interface, that is called over HTTP - * without starting a run for each call. This client sends requests to that server and reads its URL and - * OpenAPI specification. Get an instance with {@see ActorClient::server()}. - * - * @see https://docs.apify.com/platform/actors/running/standby + * @see https://docs.apify.com/platform/actors/running/server-actors */ final class ActorServerClient { /** - * The statuses a server request is retried on. The platform answers with them when it cannot pass a request - * to a server run, so a retry usually does not run the Actor's code again. An Actor that returns one itself - * gets the request again. + * The statuses a server request is retried on. The platform answers with them when it cannot pass the + * request to a server run. An Actor that returns one itself gets the request again. */ private const SERVER_RETRY_STATUS_CODES = [429, 503]; + /** The header that carries the ID of a request to a multi-tenant Server Actor. */ + private const REQUEST_ID_HEADER = 'X-Actor-Request-ID'; + /** The tag of the build that serves the server interface when the Actor does not name one. */ private const DEFAULT_BUILD_REF = 'latest'; - /** A build number such as {@code 0.1.23}: version, minor version and sequence number. */ + /** A build number such as {@code 0.1.23}: version, minor version, and sequence number. */ private const BUILD_NUMBER_PATTERN = '/^(\d+)\.(\d+)\.(\d+)$/'; private ResourceContext $ctx; @@ -55,11 +54,8 @@ public function __construct( } /** - * Returns the URL of the Actor's server. - * - * The URL is the Actor's {@code standbyUrl}, fetched from the API by the first call of this method or of - * {@see getOpenApiSpec()} and reused by later calls on this client. Use it to call the server with another - * HTTP client, for example one generated from the server's OpenAPI specification. + * Returns the URL of the Actor's server, its {@code standbyUrl}. It is fetched by the first call of this + * method or of {@see getOpenApiSpec()} and reused. Use it to call the server with another HTTP client. * * @param int|float|string|null $timeoutSecs timeout of the API request (default: the {@code short} tier) * @throws ApifyClientException if the Actor is not a Server Actor @@ -71,17 +67,12 @@ public function getUrl(int|float|string|null $timeoutSecs = null): string } /** - * Returns the OpenAPI specification of the Actor's server. - * - * The specification is the web server schema from the Actor's definition, taken from the build that serves - * the server interface: the build set in {@code actorStandby.build}, or {@code latest} if none is set. Use it - * to generate an HTTP client for the server, and point that client at the URL returned by {@see getUrl()}. + * Returns the OpenAPI specification of the Actor's server: the web server schema of the build that serves it, + * which is {@code actorStandby.build} or {@code latest}. Point a generated HTTP client at {@see getUrl()}. * * @param int|float|string|null $timeoutSecs timeout of each API request (default: the {@code short} tier) - * @return array|null the specification, or {@code null} if the Actor's definition does not - * include one - * @throws ApifyClientException if the Actor is not a Server Actor, or the build that serves its server - * interface does not exist + * @return array|null the specification, or {@code null} if the Actor's definition has none + * @throws ApifyClientException if the Actor is not a Server Actor, or the build does not exist * @throws \Apify\Client\Exception\NotFoundException if the Actor does not exist */ public function getOpenApiSpec(int|float|string|null $timeoutSecs = null): ?array @@ -105,32 +96,28 @@ public function getOpenApiSpec(int|float|string|null $timeoutSecs = null): ?arra /** * Sends an HTTP request to the Actor's server. * - * The request goes to the URL returned by {@see getUrl()} and carries the client's token in the - * {@code X-Apify-Authorization} header, which the platform consumes, so it never reaches the Actor's code. - * Headers you pass, {@code Authorization} included, reach the Actor's server as they are. The first request - * to an idle Actor waits for the platform to start a server run, which is why the timeout defaults to the - * {@code long} tier. + * The token goes in the {@code X-Apify-Authorization} header, which the platform consumes, so it never + * reaches the Actor. The headers you pass, {@code Authorization} included, reach it as they are. The first + * request to an idle Actor waits for a server run to start, so the timeout defaults to the {@code long} tier. * - * The method returns any response of the server, error statuses included. A rate-limit (429) or Service - * Unavailable (503) response is retried with the client's retry settings, and the last one is returned once - * the retries run out. The Actor's code can return these statuses too, and then it gets the request again. - * Other failures are not retried, since the Actor may already have handled the request: a transport error, - * a timeout included, throws {@see \Apify\Client\Exception\TransportException} right away. A redirect is - * returned as it is, since following it would send the token to a host the Actor's code picked. Request - * bodies are sent uncompressed unless the {@code compression} option is set, since the Actor's server may - * not decode a compressed body. + * Any response of the server is returned, error statuses included. A 429 or 503 is retried with the client's + * retry settings, and the last one is returned when they run out. Other failures are not retried, since the + * Actor may have handled the request: a transport error, a timeout included, throws + * {@see \Apify\Client\Exception\TransportException}. A redirect is returned as it is, so the token never + * follows it to another host. The body is sent uncompressed unless the {@code compression} option is set, + * since the server may not decode it. * - * @param string $path the path on the Actor's server, for example {@code /search}. It is always resolved - * against the server URL, so the request, and the token with it, cannot be sent to - * another host. Pass the query in the {@code params} option. A fragment - * ({@code #...}) is dropped, since it is never sent to the server. + * To call a Server Actor from a multi-tenant Server Actor, set {@see ActorServerRequestOptions::$parentRequestId}. + * + * @param string $path the path on the server, for example {@code /search}. It is resolved against the server + * URL, so the request cannot go to another host. Pass the query in the {@code params} + * option. A fragment is dropped. * @param int|float|string|null $timeoutSecs timeout of the HTTP request (default: the {@code long} tier). - * With the {@code stream} option, it bounds the wait for the server to start responding. - * @throws ApifyClientException if the Actor is not a Server Actor, or the HTTP client in use cannot disable + * With the {@code stream} option, it bounds the wait for the response to start. + * @throws ApifyClientException if the Actor is not a Server Actor, or the HTTP client cannot disable * redirects (see {@see \Apify\Client\Http\NoRedirectHttpClientInterface}) * @throws \Apify\Client\Exception\NotFoundException if the Actor does not exist - * @throws \Apify\Client\Exception\TransportException if the request fails or times out before the server - * responds + * @throws \Apify\Client\Exception\TransportException if the request fails or times out * @throws \InvalidArgumentException if a query parameter is not a scalar or a list of scalars */ public function request(string $path = '/', ?ActorServerRequestOptions $options = null, int|float|string|null $timeoutSecs = null): ActorServerResponse @@ -139,7 +126,7 @@ public function request(string $path = '/', ?ActorServerRequestOptions $options $params = new QueryParams(); $options->appendTo($params); [$body, $contentType] = self::encodeBody($options->body); - $extraHeaders = $options->headers ?? []; + $extraHeaders = self::withParentRequestId($options->headers ?? [], $options->parentRequestId); $policy = new RequestPolicy( compression: Compression::resolveCompressor($options->compression, 'ActorServerRequestOptions') ?? false, @@ -175,9 +162,7 @@ private function fetchServerActor(int|float|string|null $timeoutSecs): array return [$actor, $url]; } - /** - * Finds the ID of the build a tag or build number refers to, or {@code null} if there is none. - */ + /** Finds the ID of the build that a tag or build number names, or returns {@code null}. */ private function resolveBuildId(Actor $actor, string $buildRef, int|float|string|null $timeoutSecs): ?string { $taggedBuilds = $actor->get('taggedBuilds'); @@ -191,9 +176,8 @@ private function resolveBuildId(Actor $actor, string $buildRef, int|float|string if ($target === null) { return null; } - // Not a tag, so look `$buildRef` up as a build number, which the API cannot resolve directly. Builds are - // listed newest first and the sequence numbers of a version grow over time, so meeting an older build of - // the same version means the target does not exist. + // The API cannot resolve a build number directly. Builds are listed newest first and sequence numbers + // grow, so meeting an older build of the same version means the target does not exist. $builds = new BuildCollectionClient($this->http, $this->ctx->subUrl(''), 'builds'); foreach ($builds->iterate(new ListOptions(desc: true), timeoutSecs: $timeoutSecs) as $build) { if ($build->getBuildNumber() === $buildRef) { @@ -208,7 +192,7 @@ private function resolveBuildId(Actor $actor, string $buildRef, int|float|string } /** - * Splits a build number like {@code 0.1.23} into its parts, or returns {@code null} for anything else. + * Splits a build number such as {@code 0.1.23} into its parts, or returns {@code null}. * * @return array{0:int,1:int,2:int}|null */ @@ -220,6 +204,26 @@ private static function parseBuildNumber(?string $buildNumber): ?array return [(int) $match[1], (int) $match[2], (int) $match[3]]; } + /** + * Sets the {@code X-Actor-Request-ID} header, replacing one in {@code $headers} whatever its casing. + * + * @param array $headers + * @return array + */ + private static function withParentRequestId(array $headers, ?string $parentRequestId): array + { + if ($parentRequestId === null) { + return $headers; + } + foreach (array_keys($headers) as $name) { + if (strcasecmp($name, self::REQUEST_ID_HEADER) === 0) { + unset($headers[$name]); + } + } + $headers[self::REQUEST_ID_HEADER] = $parentRequestId; + return $headers; + } + /** Resolves {@code $path} against the server URL, so no path can address another host. */ private static function serverRequestUrl(string $serverUrl, string $path): string { diff --git a/src/Resource/ActorServerResponse.php b/src/Resource/ActorServerResponse.php index 20da9d7..3669d0b 100644 --- a/src/Resource/ActorServerResponse.php +++ b/src/Resource/ActorServerResponse.php @@ -11,8 +11,8 @@ use Psr\Http\Message\StreamInterface; /** - * The response of an Actor's web server to {@see ActorServerClient::request()}. Any status the server answers - * with is returned, an error status included: check {@see getStatusCode()} or {@see isSuccessful()}. + * The response of an Actor's server to {@see ActorServerClient::request()}. Error statuses are returned too, so check + * {@see getStatusCode()} or {@see isSuccessful()}. */ final class ActorServerResponse { @@ -45,23 +45,24 @@ public function getHeaders(): array return $this->response->getHeaders(); } - /** The values of a header joined with commas, or an empty string if the response has none. The name is case-insensitive. */ + /** + * The values of a header joined with commas, or an empty string if the response has none. The name is + * case-insensitive. + */ public function getHeaderLine(string $name): string { return $this->response->getHeaderLine($name); } - /** - * The response body as raw bytes. With the {@code stream} request option, this reads the rest of the stream. - */ + /** The body as raw bytes. With the {@code stream} option, this reads the rest of the stream. */ public function getBody(): string { return (string) $this->response->getBody(); } /** - * The response body as a PSR-7 stream. It is live, and holds its connection open until it is read to the end - * or closed, when the request was made with the {@code stream} option. + * The body as a PSR-7 stream. With the {@code stream} option it is live, and its connection stays open until it is + * read to the end or closed. */ public function getBodyStream(): StreamInterface { @@ -69,14 +70,12 @@ public function getBodyStream(): StreamInterface } /** - * The response body parsed by its content type: JSON is decoded to PHP values (objects become associative - * arrays), and any other body is returned as a string. An error response whose body does not parse as its - * content type comes back as the raw string. With the {@code stream} request option, this reads the rest of the - * stream. + * The body parsed by its content type: JSON becomes PHP values, anything else a string. An error response + * that does not parse comes back as the raw string. With the {@code stream} option, this reads the rest of + * the stream. * * @return mixed - * @throws InvalidResponseBodyException if the body of a success response is not valid JSON, although its - * content type says it is + * @throws InvalidResponseBodyException if a success response says it is JSON but is not */ public function getData(): mixed { diff --git a/src/Resource/ActorVersionClient.php b/src/Resource/ActorVersionClient.php index ec42b0a..7d28960 100644 --- a/src/Resource/ActorVersionClient.php +++ b/src/Resource/ActorVersionClient.php @@ -10,10 +10,7 @@ use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\ActorVersion; -/** - * A client for a specific Actor version ({@code GET/PUT/DELETE - * /v2/actors/{actorId}/versions/{versionNumber}}). - */ +/** A client for an Actor version. */ final class ActorVersionClient { private ResourceContext $ctx; @@ -34,9 +31,9 @@ public function get(int|float|string|null $timeoutSecs = null): ?ActorVersion } /** - * Updates the version with the given fields and returns the updated object. + * Updates the version and returns it. * - * @param mixed $newFields any JSON-serializable set of fields to update + * @param mixed $newFields any JSON-serializable fields */ public function update(mixed $newFields, int|float|string|null $timeoutSecs = null): ActorVersion { diff --git a/src/Resource/ActorVersionCollectionClient.php b/src/Resource/ActorVersionCollectionClient.php index 96779d2..3e35c79 100644 --- a/src/Resource/ActorVersionCollectionClient.php +++ b/src/Resource/ActorVersionCollectionClient.php @@ -24,8 +24,8 @@ public function __construct(HttpClientCore $http, string $actorUrl) } /** - * Lists the Actor's versions. The endpoint takes no filtering or pagination parameters: it always - * returns every version in one response. + * Lists the versions of the Actor. The endpoint has no filter or pagination parameters and returns all + * versions at once. * * @return PaginationList */ @@ -35,12 +35,8 @@ public function list(int|float|string|null $timeoutSecs = null): PaginationList } /** - * Lazily iterates over the Actor's versions. - * - * This endpoint ignores {@code offset}/{@code limit} and always returns every version in one - * response, so iteration makes exactly one request — unlike the offset/limit collection iterators, - * there is no per-page {@code chunkSize} to request fewer at a time, and a generic paginating loop - * would re-request (and re-yield) the same full list forever. + * Lazily yields the versions of the Actor. The endpoint returns all versions in one response, so this makes + * one request. * * @return Generator */ @@ -50,7 +46,7 @@ public function iterate(int|float|string|null $timeoutSecs = null): Generator } /** - * Creates a new Actor version. + * Creates a version of the Actor. * * @param mixed $version any JSON-serializable version definition */ diff --git a/src/Resource/BuildClient.php b/src/Resource/BuildClient.php index 798e0c5..b996549 100644 --- a/src/Resource/BuildClient.php +++ b/src/Resource/BuildClient.php @@ -23,11 +23,9 @@ public function __construct(private HttpClientCore $http, string $baseUrl, strin } /** - * Fetches the build, optionally asking the API to wait up to {@code $waitForFinishSecs} seconds - * for the build to finish before responding. The API caps the wait at 60s, and the client - * additionally keeps it below the {@code timeoutMaxSecs} cap of a single attempt. Unless - * {@code $timeoutSecs} is given, the request timeout is that wait on top of the {@code short} tier. - * Returns {@code null} if it does not exist. + * Fetches the build, or returns {@code null} if it does not exist. With {@code $waitForFinishSecs}, the API + * holds the response until the build finishes, for at most 60 seconds and less than the request timeout. The + * request timeout then defaults to that wait plus the {@code short} tier. */ public function get(?int $waitForFinishSecs = null, int|float|string|null $timeoutSecs = null): ?Build { @@ -53,9 +51,8 @@ public function delete(int|float|string|null $timeoutSecs = null): void } /** - * Polls until the build reaches a terminal state or {@code $waitSecs} elapses ({@code null} waits - * indefinitely). Returns the latest build. {@code $timeoutSecs} is the timeout of each poll request (a - * number of seconds, a tier name or {@code 'noTimeout'}, the default). + * Polls until the build finishes or {@code $waitSecs} elapse ({@code null} waits indefinitely), and returns + * the latest build. {@code $timeoutSecs} is the timeout of each poll (default {@code 'noTimeout'}). */ public function waitForFinish(?int $waitSecs = null, int|float|string|null $timeoutSecs = null): Build { @@ -69,14 +66,10 @@ public function waitForFinish(?int $waitSecs = null, int|float|string|null $time } /** - * Returns the OpenAPI definition generated for the build. + * Returns the OpenAPI definition generated for the build. A 404 is always thrown, never turned into + * {@code null}. A {@code null} result means the body is not an object. * - * Like the reference client, a 404 here is not swallowed: it always throws instead, since the only - * way this fixed sub-path 404s is the build itself being gone (same rule as {@see - * \Apify\Client\Resource\DatasetClient::getStatistics()} and friends). A {@code null} return means - * the response body did not decode to an object — not a missing build, which throws. - * - * @return array|null the raw OpenAPI document + * @return array|null the OpenAPI document */ public function getOpenApiDefinition(int|float|string|null $timeoutSecs = null): ?array { @@ -86,12 +79,10 @@ public function getOpenApiDefinition(int|float|string|null $timeoutSecs = null): } /** - * A client for accessing this build's log. + * A client for the log of the build. * - * A 404 from this client's {@code get()} throws an {@see \Apify\Client\Exception\ApifyApiException} - * instead of resolving to {@code null}, since this build client has no ID of its own at this - * sub-path — the 404 could mean either the build or its log is gone, and the response cannot tell - * the two apart (matches the reference client's {@code catchNotFoundForResourceOrThrow}). + * A 404 from its {@code get()} is thrown instead of returning {@code null}, since the client cannot tell a + * missing build from a missing log. */ public function log(): LogClient { diff --git a/src/Resource/BuildCollectionClient.php b/src/Resource/BuildCollectionClient.php index ac87965..ff5c17a 100644 --- a/src/Resource/BuildCollectionClient.php +++ b/src/Resource/BuildCollectionClient.php @@ -13,10 +13,7 @@ use Apify\Client\Options\ListOptions; use Generator; -/** - * A client for a build collection: the account-wide collection ({@code GET /v2/actor-builds}) or an - * Actor's builds ({@code GET /v2/actors/{id}/builds}). - */ +/** A client for a build collection: all builds of the account or the builds of an Actor. */ final class BuildCollectionClient { private ResourceContext $ctx; @@ -40,9 +37,8 @@ public function list(?ListOptions $options = null, int|float|string|null $timeou } /** - * Lazily iterates over builds, fetching pages on demand. The options' {@code limit} caps the total - * number of builds yielded across all pages ({@code null} = all); {@code $chunkSize} is the - * per-page size ({@code null} = the API's default page size). + * Lazily yields builds. The {@code limit} option caps the total ({@code null} for all) and + * {@code $chunkSize} sets the page size ({@code null} for the API's default). * * @return Generator */ diff --git a/src/Resource/DatasetClient.php b/src/Resource/DatasetClient.php index 8666aac..f7b8a9e 100644 --- a/src/Resource/DatasetClient.php +++ b/src/Resource/DatasetClient.php @@ -34,8 +34,9 @@ public static function forId(HttpClientCore $http, string $baseUrl, string $id): } /** - * Creates a dataset client over a ready context, such as the one of a run's default dataset (nested - * path only, no ID). @internal + * A dataset client over a ready context, such as the one of the default dataset of a run, which has no ID. + * + * @internal */ public static function fromContext(HttpClientCore $http, ResourceContext $ctx): self { @@ -50,10 +51,8 @@ public function withPublicBase(string $publicBaseUrl): self } /** - * Fetches the dataset metadata, or {@code null} if it does not exist. - * - * When this client was obtained without an ID (e.g. {@see \Apify\Client\Resource\RunClient::dataset()}), - * a 404 is rethrown instead, since it could mean either the parent resource or the dataset is gone. + * Fetches the dataset, or returns {@code null} if it does not exist. A client without an ID, such as + * {@see \Apify\Client\Resource\RunClient::dataset()}, rethrows the 404. */ public function get(int|float|string|null $timeoutSecs = null): ?Dataset { @@ -62,33 +61,27 @@ public function get(int|float|string|null $timeoutSecs = null): ?Dataset } /** - * Updates the dataset metadata (e.g. name, title) and returns the updated object. + * Updates the dataset, for example its name or title. * - * @param mixed $newFields any JSON-serializable set of fields to update + * @param mixed $newFields any JSON-serializable fields */ public function update(mixed $newFields, int|float|string|null $timeoutSecs = null): Dataset { return new Dataset($this->ctx->updateResource('', $newFields, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT))); } - /** - * Deletes the dataset. A not-found is a no-op, unless this client was obtained without an ID (e.g. - * {@see \Apify\Client\Resource\RunClient::dataset()}), in which case it is rethrown. - */ + /** Deletes the dataset. A missing dataset is ignored, unless the client has no ID. */ public function delete(int|float|string|null $timeoutSecs = null): void { $this->ctx->deleteResource('', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); } /** - * Lists items from the dataset, each decoded to a PHP value (associative array for objects). + * Lists items of the dataset, decoded to PHP values. * - * The dataset items endpoint returns a bare JSON array (not a data envelope) and reports - * pagination via {@code X-Apify-Pagination-*} headers, surfaced in the returned page. The page's - * {@see PaginationList::getCount()} is the number of dataset rows the API scanned for it, or the number - * of items returned when that is larger: filters ({@code clean}, {@code skipEmpty}) can leave it above - * the number of items. The next page starts at {@code offset + min(count, limit)}; the cap matters only - * with {@code unwind}, which can return more items than the rows scanned. + * The endpoint answers with a bare JSON array and reports the pagination in {@code X-Apify-Pagination-*} + * headers. The page's {@see PaginationList::getCount()} is the number of rows the API scanned, or the + * number of items returned when that is larger. The next page starts at {@code offset + min(count, limit)}. * * @return PaginationList */ @@ -99,23 +92,17 @@ public function listItems(?DatasetListItemsOptions $options = null, int|float|st } /** - * Lazily iterates over the dataset's items, fetching pages on demand. Each item is decoded to a - * PHP value (an associative array for objects), like {@see listItems()}. + * Lazily yields the items of the dataset, fetching pages on demand and decoding them like + * {@see listItems()}. * - * The options' {@code limit} caps the total number of items yielded across all pages ({@code null} - * = all), {@code offset} is the starting offset, and {@code $chunkSize} is the per-page size. Dataset - * items have no default limit, so without {@code $chunkSize} (and without a {@code limit}) the first - * request loads the whole dataset in one response: pass {@code $chunkSize} to iterate a large - * dataset. All other {@see DatasetListItemsOptions} fields (field selection, filtering, ordering) are - * applied to every page. + * The {@code limit} option caps the total number of items, {@code offset} is the start and + * {@code $chunkSize} is the page size. Without {@code $chunkSize} and {@code limit}, the first request loads + * the whole dataset, so pass {@code $chunkSize} for a large one. The other {@see DatasetListItemsOptions} + * apply to every page. * - * Item-dropping filters ({@code skipEmpty}, and {@code clean} which implies it) and {@code unwind} - * are applied after {@code offset}/{@code limit}, so a page's item count can land on either side of - * the number of rows the API scanned to produce it. The iterator therefore pages like every other - * collection iterator (see {@see ResourceContext::paginateOffset()}): by the page's - * {@see PaginationList::getCount()}, the scanned rows or the returned items when larger, capped at the - * page's requested limit, never by the returned item count alone. ({@code skipHidden} only strips - * hidden fields from each item, not whole items, so it does not affect paging.) + * The filters {@code skipEmpty} and {@code clean}, and {@code unwind}, change the number of items per page, + * so the iterator advances as {@see ResourceContext::paginateOffset()} does: by the page's + * {@see PaginationList::getCount()}, capped at the limit asked for. * * @return Generator */ @@ -137,11 +124,7 @@ public function iterateItems(?DatasetListItemsOptions $options = null, ?int $chu ); } - /** - * Downloads dataset items serialized in the given format, returning the raw bytes as a string. - * Unlike {@see listItems()} (parsed items), this returns the items already serialized to JSON, - * CSV, XLSX, XML, RSS or HTML — useful for exporting. - */ + /** Downloads the items in a format (JSON, CSV, XLSX, XML, RSS, or HTML) and returns the bytes. */ public function downloadItems( DownloadItemsFormat $format, ?DatasetDownloadOptions $options = null, @@ -156,18 +139,11 @@ public function downloadItems( } /** - * Opens a live, streaming connection to the dataset's items export in the given format and - * returns a stream over the serialized bytes, so a large export can be read (and, e.g., piped to a - * file) in chunks without holding the whole thing in memory. + * Opens a stream of the items in a format, so a large export can be read in chunks. The stream is not + * retried. * - * The reference client exposes this as a {@code stream} option of {@code downloadItems()}; this - * client keeps a separate method instead, since PHP has no equivalent of a single method whose - * return type switches on a boolean option, and a dedicated {@see streamItems()} name matches this - * client's existing {@see \Apify\Client\Resource\LogClient::get()}/{@see - * \Apify\Client\Resource\LogClient::stream()} split for the same buffered/streamed choice. - * - * Like {@see \Apify\Client\Resource\LogClient::stream()}, this bypasses the buffered/retrying - * transport, so because the response is consumed incrementally, it is not retried. + * This is a separate method from {@see downloadItems()}, since a PHP method cannot change its return type + * with an option. */ public function streamItems( DownloadItemsFormat $format, @@ -180,9 +156,9 @@ public function streamItems( } /** - * Pushes one or more items to the dataset. + * Pushes items to the dataset. * - * @param mixed $items must serialize to a JSON object or an array of objects + * @param mixed $items a JSON object or an array of objects */ public function pushItems(mixed $items, int|float|string|null $timeoutSecs = null): void { @@ -199,12 +175,8 @@ public function pushItems(mixed $items, int|float|string|null $timeoutSecs = nul } /** - * Returns statistical information about the dataset. - * - * Unlike {@see get()}, a 404 here is not swallowed: it always throws instead, since the only way - * this fixed sub-path 404s is the dataset itself being gone (matching the reference client). A - * {@code null} return means the response body did not decode to an object — not a missing - * dataset, which throws. + * Returns statistics of the dataset. A 404 is always thrown, never turned into {@code null}. A {@code null} + * result means the body is not an object. * * @return array|null */ @@ -216,15 +188,12 @@ public function getStatistics(int|float|string|null $timeoutSecs = null): ?array } /** - * Builds a public URL for downloading this dataset's items. + * Builds a public URL for downloading the items. * - * It fetches the dataset, and if 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. - * {@code $expiresInSecs} optionally bounds the validity of a signed URL ({@code null} for - * non-expiring); it must be positive, otherwise an {@see InvalidArgumentException} is thrown before - * any request is sent. {@code $format} selects the output format of the items served by the URL - * (defaults to {@code json} when omitted). The URL is built from the configured public base URL. - * {@code $timeoutSecs} applies to the request that fetches the dataset (default: the {@code long} tier). + * Fetches the dataset and, if it has a URL signing secret, signs the URL with HMAC-SHA256, so it works + * without a token. {@code $expiresInSecs} limits the validity of a signed URL ({@code null} for none). The + * {@code $format} defaults to {@code json}. {@code $timeoutSecs} is for the request that fetches the + * dataset (default: the {@code long} tier). * * @throws InvalidArgumentException if {@code $expiresInSecs} is zero or negative */ @@ -241,8 +210,7 @@ public function createItemsPublicUrl( if ($format !== null) { $params->addString('format', $format->value); } - // Only compute a signature when the caller did not already supply one, otherwise the URL - // would carry two conflicting `signature` query params (mirrors KeyValueStoreClient::createKeysPublicUrl). + // A signature the caller passed is kept, so the URL does not carry two. if ($options->signature === null) { $dataset = $this->get($timeoutSecs ?? TimeoutTiers::TIER_LONG); if ($dataset !== null) { diff --git a/src/Resource/DatasetCollectionClient.php b/src/Resource/DatasetCollectionClient.php index a144335..368bdfa 100644 --- a/src/Resource/DatasetCollectionClient.php +++ b/src/Resource/DatasetCollectionClient.php @@ -37,9 +37,8 @@ public function list(?StorageListOptions $options = null, int|float|string|null } /** - * Lazily iterates over datasets, fetching pages on demand. The options' {@code limit} caps the - * total number of datasets yielded across all pages ({@code null} = all); {@code $chunkSize} is - * the per-page size ({@code null} = the API's default page size). + * Lazily yields datasets. The {@code limit} option caps the total ({@code null} for all) and + * {@code $chunkSize} sets the page size ({@code null} for the API's default). * * @return Generator */ @@ -55,9 +54,8 @@ public function iterate(?StorageListOptions $options = null, ?int $chunkSize = n } /** - * Gets the dataset with the given name, creating it if it does not exist. An empty/{@code null} - * name creates a new unnamed dataset. An optional {@code $schema} (an associative array) is sent - * when creating the dataset, mirroring the reference client's {@code getOrCreate(name, { schema })}. + * Returns the dataset with the name, creating it if needed. An empty name creates an unnamed dataset. The + * {@code $schema} is sent when the dataset is created. * * @param array|null $schema */ diff --git a/src/Resource/KeyValueStoreClient.php b/src/Resource/KeyValueStoreClient.php index c6c125a..66c82f2 100644 --- a/src/Resource/KeyValueStoreClient.php +++ b/src/Resource/KeyValueStoreClient.php @@ -34,9 +34,10 @@ public static function forId(HttpClientCore $http, string $baseUrl, string $id): } /** - * Creates a client for a run's default key-value store (nested path only, no ID). Any - * {@code $inheritedParams} (e.g. the {@code status}/{@code origin} filters pinned by a last-run - * accessor) become base params so every request resolves the correct run's store. @internal + * A client for the default key-value store of a run, which has no ID. The {@code $inheritedParams}, such as + * the status filter of a last-run accessor, are sent with every request. + * + * @internal */ public static function nested(HttpClientCore $http, string $base, string $subPath, ?QueryParams $inheritedParams = null): self { @@ -55,10 +56,8 @@ public function withPublicBase(string $publicBaseUrl): self } /** - * Fetches the store metadata, or {@code null} if it does not exist. - * - * When this client was obtained without an ID (e.g. {@see \Apify\Client\Resource\RunClient::keyValueStore()}), - * a 404 is rethrown instead, since it could mean either the parent resource or the store is gone. + * Fetches the store, or returns {@code null} if it does not exist. A client without an ID, such as + * {@see \Apify\Client\Resource\RunClient::keyValueStore()}, rethrows the 404. */ public function get(int|float|string|null $timeoutSecs = null): ?KeyValueStore { @@ -67,19 +66,16 @@ public function get(int|float|string|null $timeoutSecs = null): ?KeyValueStore } /** - * Updates the store metadata (e.g. name) and returns the updated object. + * Updates the store, for example its name. * - * @param mixed $newFields any JSON-serializable set of fields to update + * @param mixed $newFields any JSON-serializable fields */ public function update(mixed $newFields, int|float|string|null $timeoutSecs = null): KeyValueStore { return new KeyValueStore($this->ctx->updateResource('', $newFields, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_LONG))); } - /** - * Deletes the store. A not-found is a no-op, unless this client was obtained without an ID (e.g. - * {@see \Apify\Client\Resource\RunClient::keyValueStore()}), in which case it is rethrown. - */ + /** Deletes the store. A missing store is ignored, unless the client has no ID. */ public function delete(int|float|string|null $timeoutSecs = null): void { $this->ctx->deleteResource('', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); @@ -96,32 +92,25 @@ public function listKeys(?ListKeysOptions $options = null, int|float|string|null } /** - * Lazily iterates over the store's keys, transparently following cursor pagination - * ({@code exclusiveStartKey}/{@code nextExclusiveStartKey}), mirroring the reference client's - * async-iterable {@code listKeys()}. + * Lazily yields the keys of the store, following the cursor pages. * - * The options' {@code limit} caps the total number of keys yielded across all pages ({@code null} - * or {@code 0} = all); {@code exclusiveStartKey} starts the listing after a given key; {@code prefix} and - * {@code collection} restrict which keys are listed. {@code $chunkSize} caps how many keys are - * requested per page ({@code null} = the remaining total cap, or the API's default page size when - * unbounded) — the two are independent, matching the offset/limit collection iterators. + * The {@code limit} option caps the total number of keys ({@code null} or {@code 0} for all), + * {@code exclusiveStartKey} starts the listing after a key, and {@code prefix} and {@code collection} filter + * the keys. {@code $chunkSize} caps the keys per page ({@code null} for the remaining total, or the API's + * default without a limit). * * @return Generator */ public function iterateKeys(?ListKeysOptions $options = null, ?int $chunkSize = null, int|float|string|null $timeoutSecs = null): Generator { $options ??= new ListKeysOptions(); - // Total cap across all pages. null or 0 means "iterate the whole store" (the API treats - // limit=0 as unset). Normalizing 0 -> null here matches the offset paginator's minLimit - // convention and the sibling clients, and stops a per-page limit=0 from short-circuiting - // the iteration after a single page. + // A limit of 0 means no limit, as for the API. $limit = ($options->limit !== null && $options->limit > 0) ? $options->limit : null; $exclusiveStartKey = $options->exclusiveStartKey; $iterated = 0; while (true) { - // Ask for only as many keys as remain under the total cap (null = server default), - // further capped by $chunkSize. + // Only the keys that remain under the total cap, further capped by $chunkSize. $remaining = $limit !== null ? $limit - $iterated : null; $page = $this->listKeys(new ListKeysOptions( limit: ResourceContext::minLimit($remaining, $chunkSize), @@ -159,15 +148,12 @@ public function recordExists(string $key, int|float|string|null $timeoutSecs = n } /** - * Fetches a record by key, or {@code null} if it does not exist. Like the reference client, it - * requests the record as an attachment so the API returns the raw bytes directly. The value is never - * parsed: {@see KeyValueStoreRecord::getValue()} holds the exact bytes the API sent whatever the - * content type, so a JSON record is decoded by the caller. + * Fetches a record, or returns {@code null} if it does not exist. The record is requested as an + * attachment, so the API returns the raw bytes. {@see KeyValueStoreRecord::getValue()} holds them whatever the + * content type, so decode a JSON record yourself. */ public function getRecord(string $key, ?GetRecordOptions $options = null, int|float|string|null $timeoutSecs = null): ?KeyValueStoreRecord { - // GetRecordOptions defaults attachment=true (matching the reference client), so a caller- - // supplied options object requests the record as an attachment unless it opts out explicitly. $options ??= new GetRecordOptions(); $params = new QueryParams(); $options->appendTo($params); @@ -184,20 +170,12 @@ public function getRecord(string $key, ?GetRecordOptions $options = null, int|fl } /** - * Stores a record with raw bytes and the given content type, honoring the given write options - * ({@code timeoutSecs}, {@code doNotRetryTimeouts}). {@code $value} is sent exactly as given, also - * under {@code application/json}, so it has to be valid JSON already; use {@see setRecordJson()} to - * have a value encoded. + * Stores a record of raw bytes. {@code $value} is sent as it is, also under {@code application/json}, so it + * must already be valid JSON. Use {@see setRecordJson()} to have a value encoded. * - * {@code $contentEncoding}, if given, is sent as the request's {@code Content-Encoding} header, for - * a {@code $value} that is already compressed (gzip, br, deflate, ...): the client's own best-effort - * request compression ({@see \Apify\Client\Internal\Compression}) always backs off when a {@code - * Content-Encoding} header is already present (see {@see - * \Apify\Client\Internal\HttpClientCore::call()}), so the bytes are stored exactly as given, never - * compressed a second time. Unlike the reference client's {@code setRecord()}, this has no - * "non-identity encoding on a non-binary value" validation case to replicate: a PHP string is - * already the raw-bytes case there, since JSON values go through the separate {@see setRecordJson()} - * instead of a union type on one method. + * {@code $contentEncoding} is the {@code Content-Encoding} of a {@code $value} that is already compressed, + * for example {@code gzip}, {@code br}, {@code zstd}, or {@code deflate}. The bytes are then stored as given + * and not compressed again. */ public function setRecord( string $key, @@ -220,13 +198,13 @@ public function setRecord( } /** - * Stores a record holding the JSON serialization of {@code $value}. + * Stores the JSON serialization of {@code $value} as a record. * * @param mixed $value any JSON-serializable value */ - public function setRecordJson(string $key, mixed $value): void + public function setRecordJson(string $key, mixed $value, ?SetRecordOptions $options = null): void { - $this->setRecord($key, Json::encode($value), ResourceContext::CONTENT_TYPE_JSON_CHARSET); + $this->setRecord($key, Json::encode($value), ResourceContext::CONTENT_TYPE_JSON_CHARSET, $options); } /** Deletes a record by key. */ @@ -239,9 +217,8 @@ public function deleteRecord(string $key, int|float|string|null $timeoutSecs = n } /** - * Builds a public URL for fetching the given record. It fetches the store, and if the store - * 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. The URL is built from the configured public base URL. + * Builds a public URL for a record. If the store has a URL signing secret, the URL is signed with + * HMAC-SHA256, so it works without a token. */ public function getRecordPublicUrl(string $key, int|float|string|null $timeoutSecs = null): string { @@ -257,12 +234,10 @@ public function getRecordPublicUrl(string $key, int|float|string|null $timeoutSe } /** - * Builds a public URL for listing this store's keys, forwarding the given key-listing filters - * into the URL. As with {@see getRecordPublicUrl()}, a signature is appended for private stores - * unless the caller already supplied one. {@code $expiresInSecs} optionally bounds a signed URL; it - * must be positive, otherwise an {@see InvalidArgumentException} is thrown before any request is - * sent. {@code $timeoutSecs} applies to the request that fetches the store (default: the {@code long} - * tier). + * Builds a public URL for listing the keys, with the filters of {@code $options}. As for + * {@see getRecordPublicUrl()}, the URL of a private store is signed, unless the options carry a signature. + * {@code $expiresInSecs} limits the validity of a signed URL. {@code $timeoutSecs} is for the request that + * fetches the store (default: the {@code long} tier). * * @throws InvalidArgumentException if {@code $expiresInSecs} is zero or negative */ diff --git a/src/Resource/KeyValueStoreCollectionClient.php b/src/Resource/KeyValueStoreCollectionClient.php index df81543..39d5665 100644 --- a/src/Resource/KeyValueStoreCollectionClient.php +++ b/src/Resource/KeyValueStoreCollectionClient.php @@ -37,9 +37,8 @@ public function list(?StorageListOptions $options = null, int|float|string|null } /** - * Lazily iterates over key-value stores, fetching pages on demand. The options' {@code limit} - * caps the total number of stores yielded across all pages ({@code null} = all); {@code $chunkSize} - * is the per-page size ({@code null} = the API's default page size). + * Lazily yields key-value stores. The {@code limit} option caps the total ({@code null} for all) and + * {@code $chunkSize} sets the page size ({@code null} for the API's default). * * @return Generator */ @@ -55,9 +54,8 @@ public function iterate(?StorageListOptions $options = null, ?int $chunkSize = n } /** - * Gets the store with the given name, creating it if it does not exist. An empty/{@code null} - * name creates a new unnamed store. An optional {@code $schema} (an associative array) is sent - * when creating the store, mirroring the reference client's {@code getOrCreate(name, { schema })}. + * Returns the store with the name, creating it if needed. An empty name creates an unnamed store. The + * {@code $schema} is sent when the store is created. * * @param array|null $schema */ diff --git a/src/Resource/LogClient.php b/src/Resource/LogClient.php index 9d45698..171c6e6 100644 --- a/src/Resource/LogClient.php +++ b/src/Resource/LogClient.php @@ -11,10 +11,7 @@ use Apify\Client\Options\LogOptions; use Psr\Http\Message\StreamInterface; -/** - * A client for accessing the log of an Actor build or run ({@code /v2/logs/{buildOrRunId}}, or the - * run/build-nested {@code .../log}). - */ +/** A client for the log of a build or run. */ final class LogClient { private function __construct(private ResourceContext $ctx) @@ -28,9 +25,10 @@ public static function forId(HttpClientCore $http, string $baseUrl, string $id): } /** - * Creates a log client for a run's or build's nested log endpoint (e.g. {@code .../log}). Any - * {@code $inheritedParams} (e.g. the {@code status}/{@code origin} filters pinned by a last-run - * accessor) become base params so the log of the correct run is resolved. @internal + * A client for the nested log endpoint of a build or run. The {@code $inheritedParams}, such as the status + * filter of a last-run accessor, are sent with every request. + * + * @internal */ public static function nested(HttpClientCore $http, string $base, ?QueryParams $inheritedParams = null): self { @@ -42,15 +40,9 @@ public static function nested(HttpClientCore $http, string $base, ?QueryParams $ } /** - * Fetches the log as text, or {@code null} if the log does not exist. - * - * Unlike the reference client, there is no separate {@code buffer} option: a PHP string is already - * a byte sequence, never re-decoded, so this already returns the exact bytes the API sent — the - * same thing the reference client's {@code buffer: true} opts into for its own string/Buffer split. - * - * When this client was obtained without an ID (e.g. {@see \Apify\Client\Resource\RunClient::log()} or - * {@see \Apify\Client\Resource\BuildClient::log()}), a 404 is rethrown instead, since it could mean - * either the parent resource or the log is gone. + * Fetches the log as text, or returns {@code null} if it does not exist. A client without an ID, such as + * {@see \Apify\Client\Resource\RunClient::log()} or {@see \Apify\Client\Resource\BuildClient::log()}, + * rethrows the 404. */ public function get(?LogOptions $options = null, int|float|string|null $timeoutSecs = null): ?string { @@ -60,13 +52,7 @@ public function get(?LogOptions $options = null, int|float|string|null $timeoutS return $response === null ? null : (string) $response->getBody(); } - /** - * Opens a live, streaming connection to the log and returns a stream over the log bytes. - * - * Unlike {@see get()}, this bypasses the buffered/retrying transport so the log can be followed - * in real time as the run produces it (the {@code stream=1} query parameter). Because the - * response is consumed incrementally, it is not retried. - */ + /** Opens a stream of the log, to follow it while the run produces it. The stream is not retried. */ public function stream(?LogOptions $options = null): StreamInterface { $params = new QueryParams(); diff --git a/src/Resource/NestedWebhookCollectionClient.php b/src/Resource/NestedWebhookCollectionClient.php index 99f5039..f785c9d 100644 --- a/src/Resource/NestedWebhookCollectionClient.php +++ b/src/Resource/NestedWebhookCollectionClient.php @@ -5,10 +5,8 @@ namespace Apify\Client\Resource; /** - * A read-only client for the webhooks nested under an Actor ({@code GET /v2/actors/{id}/webhooks}) - * or a task ({@code GET /v2/actor-tasks/{id}/webhooks}). These endpoints only support listing; - * webhooks are created through the account-wide {@see WebhookCollectionClient} (which targets an - * Actor or task via the webhook's {@code condition}), so {@code create} is intentionally not exposed. + * A read-only client for the webhooks of an Actor or task. Create webhooks with {@see WebhookCollectionClient}, + * which targets an Actor or task in the {@code condition} of the webhook. */ final class NestedWebhookCollectionClient extends AbstractWebhookCollectionClient { diff --git a/src/Resource/RequestQueueClient.php b/src/Resource/RequestQueueClient.php index ef8bd18..756518a 100644 --- a/src/Resource/RequestQueueClient.php +++ b/src/Resource/RequestQueueClient.php @@ -31,19 +31,15 @@ final class RequestQueueClient { /** - * The API limit on requests per batch call. {@see batchAddRequests()} splits larger input into - * chunks of this size; {@see batchDeleteRequests()} instead rejects input larger than this - * up front (a delete is idempotent, so the caller can simply call it again per chunk). + * The API limit on requests per batch call. {@see batchAddRequests()} splits larger input into chunks of + * this size, {@see batchDeleteRequests()} rejects it. */ private const MAX_REQUESTS_PER_BATCH = 25; - /** - * The API's maximum accepted request payload size (9 MiB). Batches are additionally split so no - * single batch call exceeds this, matching the reference client's {@code splitIntoJsonArrayBatches}. - */ + /** The API's maximum request payload size, 9 MiB. Batches are split to stay under it. */ private const MAX_PAYLOAD_SIZE_BYTES = 9 * 1024 * 1024; - /** Safety margin (0.01%) subtracted from the payload limit, matching the reference client. */ + /** A margin of 0.01% subtracted from the payload limit. */ private const PAYLOAD_SAFETY_BUFFER_PERCENT = 0.0001; private function __construct( @@ -56,9 +52,7 @@ private function __construct( /** * @internal * - * The per-queue {@code $options->timeoutSecs}, if given, is recorded on {@code $ctx} ({@see - * ResourceContext::withTimeout()}): every call this client makes resolves its timeout tier through - * {@see ResourceContext::resolveTimeout()}, which additionally caps the resolved value at it. + * The {@code timeoutSecs} of {@code $options} caps the timeout of every call of the client. */ public static function forId( HttpClientCore $http, @@ -72,9 +66,10 @@ public static function forId( } /** - * Creates a client for a run's default request queue (nested path only, no ID). Any - * {@code $inheritedParams} (e.g. the {@code status}/{@code origin} filters pinned by a last-run - * accessor) become base params so every request resolves the correct run's queue. @internal + * A client for the default request queue of a run, which has no ID. The {@code $inheritedParams}, such as + * the status filter of a last-run accessor, are sent with every request. + * + * @internal */ public static function nested(HttpClientCore $http, string $base, string $subPath, ?QueryParams $inheritedParams = null): self { @@ -86,9 +81,8 @@ public static function nested(HttpClientCore $http, string $base, string $subPat } /** - * Returns a copy of the client that identifies its requests with {@code $clientKey}. A stable - * client key is required to operate on locks the client itself created, and lets the API detect - * whether multiple clients access a queue. + * A copy of the client that sends {@code $clientKey}. A stable key is needed to operate on the locks the + * client created, and lets the API detect several clients on one queue. */ public function withClientKey(string $clientKey): self { @@ -104,10 +98,8 @@ private function applyClientKey(QueryParams $params): QueryParams } /** - * Fetches the queue metadata, or {@code null} if it does not exist. - * - * When this client was obtained without an ID (e.g. {@see \Apify\Client\Resource\RunClient::requestQueue()}), - * a 404 is rethrown instead, since it could mean either the parent resource or the queue is gone. + * Fetches the queue, or returns {@code null} if it does not exist. A client without an ID, such as + * {@see \Apify\Client\Resource\RunClient::requestQueue()}, rethrows the 404. */ public function get(int|float|string|null $timeoutSecs = null): ?RequestQueue { @@ -116,28 +108,22 @@ public function get(int|float|string|null $timeoutSecs = null): ?RequestQueue } /** - * Updates the queue metadata (e.g. name) and returns the updated object. + * Updates the queue, for example its name. * - * @param mixed $newFields any JSON-serializable set of fields to update + * @param mixed $newFields any JSON-serializable fields */ public function update(mixed $newFields, int|float|string|null $timeoutSecs = null): RequestQueue { return new RequestQueue($this->ctx->updateResource('', $newFields, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT))); } - /** - * Deletes the queue. A not-found is a no-op, unless this client was obtained without an ID (e.g. - * {@see \Apify\Client\Resource\RunClient::requestQueue()}), in which case it is rethrown. - */ + /** Deletes the queue. A missing queue is ignored, unless the client has no ID. */ public function delete(int|float|string|null $timeoutSecs = null): void { $this->ctx->deleteResource('', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); } - /** - * Returns the requests at the head (front) of the queue, up to {@code $limit} ({@code null} for - * the API's default limit). - */ + /** Returns up to {@code $limit} requests from the head of the queue ({@code null} for the API's default). */ public function listHead(?int $limit = null, int|float|string|null $timeoutSecs = null): RequestQueueHead { $params = new QueryParams(); @@ -175,10 +161,7 @@ public function getRequest(string $id, int|float|string|null $timeoutSecs = null return is_array($data) ? RequestQueueRequest::fromArray($data) : null; } - /** - * Updates an existing request (identified by its ID field) and returns the operation info. If - * {@code $forefront} is true, the request is moved to the front of the queue. - */ + /** Updates a request, identified by its ID. With {@code $forefront}, it moves to the front of the queue. */ public function updateRequest(RequestQueueRequest $request, bool $forefront = false, int|float|string|null $timeoutSecs = null): RequestQueueOperationInfo { $params = new QueryParams(); @@ -213,8 +196,7 @@ public function deleteRequest(string $id, int|float|string|null $timeoutSecs = n } /** - * Atomically returns and locks up to {@code $limit} requests from the head of the queue for - * {@code $lockSecs} seconds. + * Returns up to {@code $limit} requests from the head of the queue and locks them for {@code $lockSecs} seconds. */ public function listAndLockHead(int $lockSecs, ?int $limit = null, int|float|string|null $timeoutSecs = null): LockedRequestQueueHead { @@ -227,23 +209,19 @@ public function listAndLockHead(int $lockSecs, ?int $limit = null, int|float|str } /** - * Adds multiple requests to the queue. If {@code $forefront} is true, they are added to the front. + * Adds requests to the queue, at the front with {@code $forefront}. * - * The input is automatically split into chunks of at most 25 requests (the API count limit) that - * additionally respect the API's ~9 MiB payload-size limit (large {@code userData} batches are - * split so they do not 413). Requests the API returns as unprocessed in a successful response - * (typically rate-limited) are retried with exponential backoff; the per-chunk results are merged. + * The input is split into batches of at most 25 requests and 9 MiB. Requests the API reports as + * unprocessed, typically because of rate limiting, are retried with exponential backoff, and the results + * of the batches are merged. * - * Every request must carry a non-empty {@code uniqueKey}: the retry loop correlates the server's - * per-request results by uniqueKey (and the API deduplicates by it), so a request without one - * cannot be tracked. Consistent with the reference client, this method does not throw on API - * errors: if a batch call fails and the transport did not retry, that chunk's not-yet-processed - * requests are returned in {@see BatchAddResult::getUnprocessedRequests()} (earlier chunks' - * results are still returned). Invalid input (empty uniqueKey, oversized request) is rejected up - * front with {@see InvalidArgumentException} before any call. + * Every request needs a non-empty {@code uniqueKey}, which the retries use to track it. The method does + * not throw on API errors: when a batch fails, its requests that are not yet processed are returned in + * {@see BatchAddResult::getUnprocessedRequests()}, together with the results of the earlier batches. + * Invalid input is rejected before any call. * * @param list $requests - * @throws InvalidArgumentException if any request has an empty uniqueKey or is individually too large + * @throws InvalidArgumentException if a request has an empty uniqueKey or is too large */ public function batchAddRequests( array $requests, @@ -258,14 +236,8 @@ public function batchAddRequests( $payloadSizeLimitBytes = self::MAX_PAYLOAD_SIZE_BYTES - (int) ceil(self::MAX_PAYLOAD_SIZE_BYTES * self::PAYLOAD_SAFETY_BUFFER_PERCENT); - // Serialize every request once, up front: the byte lengths (which include the two extra bytes - // each item costs in the batch body - a comma or the closing bracket after it) decide the chunk - // boundaries below, and the same JSON strings are joined into each chunk's body, so nothing is - // re-encoded when the chunk is sent. Matches the reference client's `splitIntoJsonArrayBatches`. - // - // Both the empty-uniqueKey check and the per-request oversized check must run here (not inside - // the send loop): otherwise an oversized request in the middle of a large batch would only be - // discovered after earlier chunks had already been POSTed, leaving the queue partially mutated. + // Serialized once: the sizes decide the batch boundaries and the JSON is reused to build each body. + // Validated before the first call, so a failure cannot leave the queue partly updated. $serialized = []; foreach ($requests as $i => $request) { $uniqueKey = $request->getUniqueKey(); @@ -275,9 +247,7 @@ public function batchAddRequests( ); } $json = Json::encode($request->toArray()); - // +2 for the opening and closing brackets a batch of this one request alone would need - // (`[` + json + `]`), matching the reference client's exact accounting: an item that only - // just fits as the sole member of its own batch must still be accepted. + // A request alone in a batch needs the brackets as well. Such a request still fits. if (strlen($json) + 2 > $payloadSizeLimitBytes) { throw new InvalidArgumentException(sprintf( 'batchAddRequests: the request at index %d exceeds the maximum payload size (%d bytes)', @@ -298,15 +268,11 @@ public function batchAddRequests( } /** - * Splits pre-serialized requests into consecutive batches of at most {@code $maxCount} items, each - * fitting a JSON array body (items joined by commas between brackets) of at most - * {@code $maxByteLength} bytes. Ports the reference client's {@code splitIntoJsonArrayBatches}. + * Splits serialized requests into batches of at most {@code $maxCount} items and {@code $maxByteLength} + * bytes as a JSON array. * - * Every item's {@code bytes} already includes the one extra byte it costs as either a separating - * comma or the batch's closing bracket, so only the opening bracket's byte is added here. - * {@see batchAddRequests()} pre-validates each item against a two-byte-larger bound (both the - * opening and closing bracket a lone-item batch would need), so an item that fits only as the sole - * member of its own batch is still accepted here, not rejected by this narrower one-byte margin. + * The {@code bytes} of an item include its trailing comma or closing bracket, so only the opening bracket + * is added here. * * @param list $items * @return list> @@ -349,11 +315,8 @@ private function batchAddChunkWithRetries(array $chunk, bool $forefront, BatchAd try { $response = $this->batchAddChunk($remaining, $forefront, $timeoutSecs); } catch (ApifyApiException) { - // Matches the JS reference (which mandates consistent error handling): when the HTTP - // call fails and the transport did not (or was told not to) retry, the requests not yet - // processed in THIS chunk are reported as unprocessed and we stop — keeping the method's - // non-throwing contract so a multi-chunk call still returns every earlier chunk's - // already-merged results instead of aborting the whole operation. + // The requests of this batch that are not yet processed are reported as unprocessed, so the + // results of the earlier batches are kept. $unprocessed = array_column(self::itemsNotYetProcessed($chunk, $processed), 'request'); break; } @@ -376,9 +339,7 @@ private function batchAddChunkWithRetries(array $chunk, bool $forefront, BatchAd } /** - * Sends one already-serialized chunk. The chunk's {@code json} strings — each already validated - * and encoded once in {@see batchAddRequests()} — are joined directly into the batch body, so - * nothing here is JSON-encoded a second time, including on a retry of the same chunk. + * Sends one serialized batch. Its JSON is joined into the body as it is, also on a retry. * * @param list $chunk */ @@ -431,29 +392,22 @@ private static function sleepBackoff(int $attempt, int $minDelayMillis): void if ($minDelayMillis <= 0) { return; } - // (1 + random) * 2^attempt * minDelay — exponential backoff with jitter, matching the reference. + // Exponential backoff with jitter: (1 + random) * 2^attempt * minDelay. $factor = (1 + mt_rand() / mt_getrandmax()) * (2 ** $attempt); $delayMillis = (int) floor($factor * $minDelayMillis); usleep($delayMillis * 1000); } /** - * Deletes multiple requests in a single call. Each entry identifies a request to delete: set - * either {@see RequestQueueRequest::setId()} or {@see RequestQueueRequest::setUniqueKey()} (other - * fields, if present, are ignored by the API). - * - * Unlike {@see batchAddRequests()}, this does not chunk oversized input: the API caps a single - * batch at 25 requests (matching the reference client), so a larger input is rejected up front - * rather than silently split (a delete is idempotent, so callers can simply call this again per - * chunk). + * Deletes requests in one call. Each one is identified by its {@see RequestQueueRequest::setId()} or + * {@see RequestQueueRequest::setUniqueKey()}. * - * Every entry must identify the request to delete via a non-empty {@see RequestQueueRequest::setId()} - * or {@see RequestQueueRequest::setUniqueKey()}, matching the reference client's validation: - * an identifier-less entry is rejected up front rather than forwarded to the API. + * Input above the limit of 25 requests is rejected instead of split. A delete can simply be repeated per + * batch. * * @param list $requests - * @throws InvalidArgumentException if {@code $requests} is empty, exceeds the per-call limit, or - * any entry is missing a non-empty id or uniqueKey + * @throws InvalidArgumentException if {@code $requests} is empty, above the limit, or has an entry + * without an id and a uniqueKey */ public function batchDeleteRequests(array $requests, int|float|string|null $timeoutSecs = null): BatchDeleteResult { @@ -486,9 +440,7 @@ public function batchDeleteRequests(array $requests, int|float|string|null $time ); } - /** - * Lists the queue's requests with pagination. - */ + /** Lists the requests of the queue. */ public function listRequests(?ListRequestsOptions $options = null, int|float|string|null $timeoutSecs = null): RequestQueueRequestsPage { $options ??= new ListRequestsOptions(); @@ -502,8 +454,8 @@ public function listRequests(?ListRequestsOptions $options = null, int|float|str } /** - * Extends the lock on a request by {@code $lockSecs} seconds. If {@code $forefront} is true, the - * request is moved to the front when its lock expires. + * Extends the lock of a request by {@code $lockSecs} seconds. With {@code $forefront}, it moves to the front when + * the lock expires. */ public function prolongRequestLock( string $id, @@ -520,10 +472,7 @@ public function prolongRequestLock( return RequestLockInfo::fromData(Json::decodeData((string) $response->getBody())); } - /** - * Releases the lock on a request. If {@code $forefront} is true, the request is moved to the - * front of the queue. - */ + /** Releases the lock of a request. With {@code $forefront}, it moves to the front of the queue. */ public function deleteRequestLock(string $id, bool $forefront = false, int|float|string|null $timeoutSecs = null): void { $params = new QueryParams(); @@ -550,13 +499,11 @@ public function unlockRequests(int|float|string|null $timeoutSecs = null): Unloc } /** - * Lazily iterates over the queue's requests, transparently following pagination. + * Lazily yields the requests of the queue, following the pages. * - * With no options it fetches pages of up to {@see PaginateRequestsOptions::DEFAULT_MAX_PAGE_LIMIT} - * requests until the queue is exhausted. The options mirror the reference client: {@code limit} - * caps the total number of requests yielded across all pages, {@code maxPageLimit} caps the page - * size, {@code exclusiveStartId}/{@code cursor} choose the starting point (first page only), and - * {@code filter} restricts to locked/pending requests. + * Without options, the pages hold up to {@see PaginateRequestsOptions::DEFAULT_MAX_PAGE_LIMIT} requests. + * {@code limit} caps the total, {@code maxPageLimit} the page size, {@code exclusiveStartId} and + * {@code cursor} set the start of the first page, and {@code filter} selects locked or pending requests. * * @return Generator */ @@ -566,9 +513,7 @@ public function paginateRequests(?PaginateRequestsOptions $options = null, int|f $options->validate(); $maxPageLimit = $options->maxPageLimit ?? PaginateRequestsOptions::DEFAULT_MAX_PAGE_LIMIT; - // Total cap across all pages. null or 0 means "iterate all" (the API treats limit=0 as - // unset). Normalizing 0 -> null here matches iterateKeys and the offset paginator's minLimit - // convention, and stops a per-page limit=0 from short-circuiting the iteration after one page. + // A limit of 0 means no limit, as for the API. $limit = ($options->limit !== null && $options->limit > 0) ? $options->limit : null; $nextCursor = $options->cursor; $nextExclusiveStartId = $options->exclusiveStartId; // used for the first page only diff --git a/src/Resource/RequestQueueCollectionClient.php b/src/Resource/RequestQueueCollectionClient.php index 6893059..76ce5f0 100644 --- a/src/Resource/RequestQueueCollectionClient.php +++ b/src/Resource/RequestQueueCollectionClient.php @@ -37,9 +37,8 @@ public function list(?StorageListOptions $options = null, int|float|string|null } /** - * Lazily iterates over request queues, fetching pages on demand. The options' {@code limit} caps - * the total number of queues yielded across all pages ({@code null} = all); {@code $chunkSize} is - * the per-page size ({@code null} = the API's default page size). + * Lazily yields request queues. The {@code limit} option caps the total ({@code null} for all) and + * {@code $chunkSize} sets the page size ({@code null} for the API's default). * * @return Generator */ @@ -54,10 +53,7 @@ public function iterate(?StorageListOptions $options = null, ?int $chunkSize = n ); } - /** - * Gets the queue with the given name, creating it if it does not exist. An empty/{@code null} - * name creates a new unnamed queue. - */ + /** Returns the queue with the name, creating it if needed. An empty name creates an unnamed queue. */ public function getOrCreate(?string $name = null, int|float|string|null $timeoutSecs = null): RequestQueue { return new RequestQueue($this->ctx->getOrCreateNamed($name, null, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT))); diff --git a/src/Resource/RunClient.php b/src/Resource/RunClient.php index e403112..1e05404 100644 --- a/src/Resource/RunClient.php +++ b/src/Resource/RunClient.php @@ -24,10 +24,8 @@ use Psr\Http\Message\StreamInterface; /** - * A client for a specific Actor run. - * - * It provides CRUD methods plus convenience helpers (abort, metamorph, reboot, resurrect, charge, - * wait-for-finish) and accessors for the run's default storages and log. + * A client for an Actor run: its CRUD calls, abort, metamorph, reboot, resurrect, charge, and wait, and its default + * storages and log. */ final class RunClient { @@ -54,8 +52,7 @@ public function __construct( } /** - * Pins the {@code status}/{@code origin} query parameters inherited by all calls on this client - * (used by the last-run accessors). Empty values are skipped. + * Sets the status and origin filters that every call of this client sends. Used by the last-run accessors. * * @internal */ @@ -70,11 +67,9 @@ public function setLastRunParams(LastRunOptions $options): void } /** - * Fetches the run, optionally asking the API to wait up to {@code $waitForFinishSecs} seconds - * for the run to reach a terminal state. The API caps the wait at 60s, and the client additionally - * keeps it below the {@code timeoutMaxSecs} cap of a single attempt. Unless {@code $timeoutSecs} is - * given, the request timeout is that wait on top of the {@code short} tier. Returns {@code null} - * if it does not exist. + * Fetches the run, or returns {@code null} if it does not exist. With {@code $waitForFinishSecs}, the API + * holds the response until the run finishes, for at most 60 seconds and less than the request timeout. The + * request timeout then defaults to that wait plus the {@code short} tier. */ public function get(?int $waitForFinishSecs = null, int|float|string|null $timeoutSecs = null): ?ActorRun { @@ -85,9 +80,9 @@ public function get(?int $waitForFinishSecs = null, int|float|string|null $timeo } /** - * Updates the run with the given fields and returns the updated object. + * Updates the run and returns it. * - * @param mixed $newFields any JSON-serializable set of fields to update + * @param mixed $newFields any JSON-serializable fields */ public function update(mixed $newFields, int|float|string|null $timeoutSecs = null): ActorRun { @@ -101,9 +96,8 @@ public function delete(int|float|string|null $timeoutSecs = null): void } /** - * Aborts the run. If {@code $gracefully} is {@code true}, the run is signalled so it can finish - * the current request before terminating; {@code false} aborts immediately. {@code null} omits - * the parameter and lets the server apply its default (immediate abort). + * Aborts the run. With {@code $gracefully}, the run can finish its current request first. {@code null} + * leaves the choice to the API, which aborts immediately. */ public function abort(?bool $gracefully = null, int|float|string|null $timeoutSecs = null): ActorRun { @@ -116,9 +110,8 @@ public function abort(?bool $gracefully = null, int|float|string|null $timeoutSe * Transforms the run into a run of another Actor with a new input. * * @param string $targetActorId the Actor to metamorph into - * @param mixed $input the new input: a JSON-serializable array, a raw string of bytes - * sent exactly as given (pair it with a non-default - * {@code $options->contentType}), or {@code null} for none + * @param mixed $input the new input: a JSON-serializable value, a string sent as it is (set a + * matching {@code $options->contentType}), or {@code null} for none */ public function metamorph( string $targetActorId, @@ -128,8 +121,6 @@ public function metamorph( ): ActorRun { $options ??= new MetamorphOptions(); $params = new QueryParams(); - // Normalize the target Actor id to the URL-safe `username~actor-name` form (first `/`→`~`), - // matching the reference JS client, so a slash-form id is sent as the same wire value. $params->addString('targetActorId', ResourceContext::toSafeId($targetActorId)); if ($options->build !== null && $options->build !== '') { $params->addString('build', $options->build); @@ -163,11 +154,9 @@ public function resurrect(?RunResurrectOptions $options = null, int|float|string } /** - * Charges for a pay-per-event Actor run: records occurrences of a named event. Only meaningful - * for runs of pay-per-event Actors. + * Charges for events of a pay-per-event Actor run. * - * An idempotency key is always sent (auto-generated if not provided), so a charge that is retried - * by the transport is applied at most once, matching the reference client. + * The request always carries an idempotency key, so a retried charge is applied once. */ public function charge(RunChargeOptions $options, int|float|string|null $timeoutSecs = null): void { @@ -193,19 +182,15 @@ public function charge(RunChargeOptions $options, int|float|string|null $timeout ); } - /** - * Builds a per-charge idempotency key of the form {@code "{runId}-{eventName}-{millis}-{random}"}. - * It need not be cryptographically secure, only unique enough to avoid collisions within a request. - */ + /** Builds an idempotency key: {@code "{runId}-{eventName}-{millis}-{random}"}. */ private function generateIdempotencyKey(string $eventName): string { return sprintf('%s-%s-%d-%d', $this->id, $eventName, (int) round(microtime(true) * 1000), mt_rand(0, 999999)); } /** - * Polls until the run reaches a terminal state or {@code $waitSecs} elapses ({@code null} waits - * indefinitely). Returns the latest run. {@code $timeoutSecs} is the timeout of each poll request (a - * number of seconds, a tier name or {@code 'noTimeout'}, the default). + * Polls until the run finishes or {@code $waitSecs} elapse ({@code null} waits indefinitely), and returns + * the latest run. {@code $timeoutSecs} is the timeout of each poll (default {@code 'noTimeout'}). */ public function waitForFinish(?int $waitSecs = null, int|float|string|null $timeoutSecs = null): ActorRun { @@ -219,13 +204,10 @@ public function waitForFinish(?int $waitSecs = null, int|float|string|null $time } /** - * A client for this run's default dataset. Any {@code status}/{@code origin} filters pinned by a - * last-run accessor are inherited so the correct run's dataset is resolved. + * A client for the default dataset of the run. Last-run filters are inherited. * - * This client has no ID of its own, so {@code get()}/{@code delete()} throw an {@see - * \Apify\Client\Exception\ApifyApiException} on a 404 instead of resolving to {@code null}/no-op: - * the response cannot tell a missing run apart from a missing dataset (matches the reference - * client's {@code catchNotFoundForResourceOrThrow}). + * A client without an ID of its own rethrows a 404 from {@code get()} and {@code delete()}, since it cannot + * tell a missing run from a missing dataset. */ public function dataset(): DatasetClient { @@ -233,14 +215,11 @@ public function dataset(): DatasetClient } /** - * A client for this run's default key-value store. Any {@code status}/{@code origin} filters - * pinned by a last-run accessor are inherited so the correct run's store is resolved. + * A client for the default key-value store of the run. Last-run filters are inherited. * - * {@code get()}/{@code delete()} throw an {@see \Apify\Client\Exception\ApifyApiException} on a 404 - * instead of resolving to {@code null}/no-op, since this client has no ID of its own and the 404 - * could mean either the run or its store is gone. Record lookups such as {@see - * \Apify\Client\Resource\KeyValueStoreClient::getRecord()} are unaffected and still resolve to - * {@code null} for a missing record. + * A client without an ID of its own rethrows a 404 from {@code get()} and {@code delete()}, since it cannot + * tell a missing run from a missing store. Record lookups still + * return {@code null} for a missing record. */ public function keyValueStore(): KeyValueStoreClient { @@ -248,14 +227,11 @@ public function keyValueStore(): KeyValueStoreClient } /** - * A client for this run's default request queue. Any {@code status}/{@code origin} filters pinned - * by a last-run accessor are inherited so the correct run's queue is resolved. + * A client for the default request queue of the run. Last-run filters are inherited. * - * {@code get()}/{@code delete()} throw an {@see \Apify\Client\Exception\ApifyApiException} on a 404 - * instead of resolving to {@code null}/no-op, since this client has no ID of its own and the 404 - * could mean either the run or its queue is gone. {@see - * \Apify\Client\Resource\RequestQueueClient::getRequest()} is unaffected and still resolves to - * {@code null} for a missing request. + * A client without an ID of its own rethrows a 404 from {@code get()} and {@code delete()}, since it cannot + * tell a missing run from a missing queue. Request lookups still + * return {@code null} for a missing request. */ public function requestQueue(): RequestQueueClient { @@ -263,42 +239,34 @@ public function requestQueue(): RequestQueueClient } /** - * A client for accessing this run's log. Any {@code status}/{@code origin} filters pinned by a - * last-run accessor are inherited so the correct run's log is resolved. + * A client for the log of the run. Last-run filters are inherited. * - * {@code get()} throws an {@see \Apify\Client\Exception\ApifyApiException} on a 404 instead of - * resolving to {@code null}, since this client has no ID of its own and the 404 could mean either - * the run or its log is gone (matches the reference client's {@code catchNotFoundForResourceOrThrow}). + * A client without an ID of its own rethrows a 404 from {@code get()}, since it cannot tell a missing run + * from a missing log. */ public function log(): LogClient { return LogClient::nested($this->http, $this->ctx->subUrl(''), $this->ctx->baseParams); } - /** - * Opens a live stream of this run's raw log, for convenient log redirection. The caller reads - * the returned stream to completion. - */ + /** Opens a stream of the raw log of the run. Read it to the end. */ public function getStreamedLog(): StreamInterface { return $this->log()->stream(new LogOptions(raw: true)); } /** - * Lazily iterates over the items of the run's default dataset while the run is still producing - * them, and returns once the run has finished and the dataset is drained. + * Lazily yields the items of the default dataset while the run is still producing them, and returns once + * the run has finished and the dataset is drained. * - * While the run has not finished, each poll yields the rows below the dataset's {@code itemCount} - * and then waits up to {@code pollIntervalSecs} for the run to finish, so the last rows are read as - * soon as it does. Each page is requested with a {@code limit} that ends at {@code itemCount}, so it - * covers exactly the rows it asks for, whatever the filters or {@code unwind} do to the items. - * {@code itemCount} lags a few seconds behind the pushed items, so once the run reaches a terminal - * status, the rows past it are read a page at a time until none are left. On a last-run client, the - * iteration sticks to the run its first request resolves to. + * While the run is active, each poll yields the rows below the dataset's {@code itemCount}, then waits up to + * {@code pollIntervalSecs} for the run to finish. A page ends at {@code itemCount}, so it covers exactly the + * rows it asks for, whatever the filters or {@code unwind} do. {@code itemCount} lags a few seconds behind, + * so once the run is terminal, the remaining rows are read page by page. A last-run client stays on the run + * its first request resolved to. * - * @param int|float|string|null $timeoutSecs timeout of each API request (default: the {@code long} - * tier) - * @return Generator the dataset items, each decoded like {@see DatasetClient::listItems()} + * @param int|float|string|null $timeoutSecs timeout of each API request (default: the {@code long} tier) + * @return Generator the dataset items, decoded like {@see DatasetClient::listItems()} */ public function iterateDatasetItems(?RunIterateDatasetItemsOptions $options = null, int|float|string|null $timeoutSecs = null): Generator { @@ -310,8 +278,7 @@ public function iterateDatasetItems(?RunIterateDatasetItemsOptions $options = nu $end = $options->limit !== null && $options->limit > 0 ? $position + $options->limit : null; $run = $this->get(null, $timeoutSecs); - // A last-run client resolves `runs/last` on each request, so a newer run would swap the dataset - // mid-iteration: pin the iteration to the run the first request resolved to. + // A last-run client resolves `runs/last` on every request, so stay on the first run it resolved to. $runClient = $run !== null && $run->getId() !== null && $run->getId() !== $this->id ? $this->root->run($run->getId()) : $this; // One context serves both the dataset client and the page reads, so they cannot drift apart. $itemsCtx = $runClient->datasetContext(); @@ -350,9 +317,8 @@ public function iterateDatasetItems(?RunIterateDatasetItemsOptions $options = nu foreach ($page->getItems() as $item) { yield $item; } - // Only an empty page marks the end, as filters can shorten a full one. A page that `clean`, - // `skipEmpty` or `unwind` emptied past a lagging `itemCount` reports no scanned rows either, - // so a plain, unfiltered one-row read confirms the dataset really has nothing left. + // Only an empty page ends the iteration, since filters can shorten a full one. A page emptied + // past a lagging `itemCount` reports no scanned rows, so a plain one-row read confirms it. $isEmpty = $page->getCount() === 0; if ($isEmpty && (!self::hasRowChangingFilter($options) || $this->hasNoItemsAt($datasetClient, $position, $timeoutSecs))) { return; diff --git a/src/Resource/RunCollectionClient.php b/src/Resource/RunCollectionClient.php index 3df1a03..8a35453 100644 --- a/src/Resource/RunCollectionClient.php +++ b/src/Resource/RunCollectionClient.php @@ -14,11 +14,7 @@ use Apify\Client\Options\RunListOptions; use Generator; -/** - * A client for a run collection: the account-wide collection ({@code GET /v2/actor-runs}), an - * Actor's runs ({@code GET /v2/actors/{id}/runs}), or a task's runs ({@code GET - * /v2/actor-tasks/{id}/runs}). - */ +/** A client for a run collection: all runs of the account, or the runs of an Actor or task. */ final class RunCollectionClient { private ResourceContext $ctx; @@ -43,9 +39,8 @@ public function list(?ListOptions $options = null, ?RunListOptions $filter = nul } /** - * Lazily iterates over runs, fetching pages on demand and applying the run-specific filters to - * every page. The options' {@code limit} caps the total number of runs yielded across all pages - * ({@code null} = all); {@code $chunkSize} is the per-page size ({@code null} = the API's default page size). + * Lazily yields runs, with the filters applied to every page. The {@code limit} option caps the total + * ({@code null} for all) and {@code $chunkSize} sets the page size ({@code null} for the API's default). * * @return Generator */ diff --git a/src/Resource/ScheduleClient.php b/src/Resource/ScheduleClient.php index ce6dcce..05922d7 100644 --- a/src/Resource/ScheduleClient.php +++ b/src/Resource/ScheduleClient.php @@ -29,9 +29,9 @@ public function get(int|float|string|null $timeoutSecs = null): ?Schedule } /** - * Updates the schedule with the given fields and returns the updated object. + * Updates the schedule and returns it. * - * @param mixed $newFields any JSON-serializable set of fields to update + * @param mixed $newFields any JSON-serializable fields */ public function update(mixed $newFields, int|float|string|null $timeoutSecs = null): Schedule { @@ -45,11 +45,8 @@ public function delete(int|float|string|null $timeoutSecs = null): void } /** - * Fetches the schedule's invocation log: one entry per invocation, each with {@code message}, - * {@code level} and {@code createdAt}. The list is empty when the schedule has not run yet. - * - * Unlike {@see get()}, a 404 here is not swallowed: it always throws instead, since the only way - * this fixed sub-path 404s is the schedule itself being gone (matching the reference client). + * Fetches the invocation log of the schedule: one entry per invocation, with {@code message}, {@code level} + * and {@code createdAt}. A schedule that has not run has an empty log. A 404 is always thrown. * * @return list> */ diff --git a/src/Resource/ScheduleCollectionClient.php b/src/Resource/ScheduleCollectionClient.php index 103e284..1db3fac 100644 --- a/src/Resource/ScheduleCollectionClient.php +++ b/src/Resource/ScheduleCollectionClient.php @@ -37,9 +37,8 @@ public function list(?ListOptions $options = null, int|float|string|null $timeou } /** - * Lazily iterates over the account's schedules, fetching pages on demand. The options' - * {@code limit} caps the total number of schedules yielded across all pages ({@code null} = all); - * {@code $chunkSize} is the per-page size ({@code null} = the API's default page size). + * Lazily yields the account's schedules. The {@code limit} option caps the total ({@code null} for all) and + * {@code $chunkSize} sets the page size ({@code null} for the API's default). * * @return Generator */ diff --git a/src/Resource/StoreCollectionClient.php b/src/Resource/StoreCollectionClient.php index 821c56f..64f9c15 100644 --- a/src/Resource/StoreCollectionClient.php +++ b/src/Resource/StoreCollectionClient.php @@ -37,10 +37,8 @@ public function list(?StoreListOptions $options = null, int|float|string|null $t } /** - * Lazily iterates over Store Actors matching the options, fetching pages on demand. - * - * The options' {@code limit} caps the total number of Actors yielded across all pages ({@code - * null} = all); {@code $chunkSize} is the per-page size ({@code null} = the API's default page size). + * Lazily yields Store Actors matching the options. The {@code limit} option caps the total ({@code null} for all) and + * {@code $chunkSize} sets the page size ({@code null} for the API's default). * * @return Generator */ diff --git a/src/Resource/TaskClient.php b/src/Resource/TaskClient.php index c32d2f0..337b166 100644 --- a/src/Resource/TaskClient.php +++ b/src/Resource/TaskClient.php @@ -17,10 +17,8 @@ use Apify\Client\Options\TaskStartOptions; /** - * A client for a specific Actor task. - * - * Tasks are pre-configured Actor runs with stored input. The client provides CRUD methods plus - * convenience helpers to start/call the task and access its input, runs and webhooks. + * A client for a task, an Actor run with a stored input: its CRUD calls, start and call, and its input, runs, and + * webhooks. */ final class TaskClient { @@ -44,9 +42,9 @@ public function get(int|float|string|null $timeoutSecs = null): ?Task } /** - * Updates the task with the given fields and returns the updated object. + * Updates the task and returns it. * - * @param mixed $newFields any JSON-serializable set of fields to update + * @param mixed $newFields any JSON-serializable fields */ public function update(mixed $newFields, int|float|string|null $timeoutSecs = null): Task { @@ -60,14 +58,12 @@ public function delete(int|float|string|null $timeoutSecs = null): void } /** - * Publishes the task on its public landing page, by setting {@code isPublic} through - * {@see update()}. Requires write permission to the task's Actor. + * Publishes the task on its public landing page by setting {@code isPublic}. Needs write permission to the + * Actor of the task. * - * To publish, the task's Actor must be public and its {@code publicConfig.inputSchemaFields} - * and {@code publicConfig.datasetView} must be set. An Actor can have up to 10 published - * tasks and an account up to 100. If any of these conditions are not met, the request fails - * and nothing is changed (including fields unrelated to publishing). Publishing an already - * published task does nothing. + * The Actor must be public and the task needs {@code publicConfig.inputSchemaFields} and + * {@code publicConfig.datasetView}. An Actor can have 10 published tasks and an account 100. If a condition + * fails, nothing changes. Publishing a published task does nothing. */ public function publish(): Task { @@ -75,12 +71,8 @@ public function publish(): Task } /** - * Unpublishes the task from its public landing page, by setting {@code isPublic} through - * {@see update()}. Like {@see publish()}, this requires write permission to the task's Actor: - * the API requires Actor write permission for both {@code isPublic} and {@code publicConfig}. - * - * The public display configuration ({@code publicConfig}) is preserved, so the task can be - * published again without re-entering it. Unpublishing a task that is not published does + * Unpublishes the task by setting {@code isPublic}. Needs write permission to the Actor of the task. The + * {@code publicConfig} is kept, so the task can be published again. Unpublishing an unpublished task does * nothing. */ public function unpublish(): Task @@ -89,9 +81,9 @@ public function unpublish(): Task } /** - * Starts the task and returns immediately with the created run. + * Starts the task and returns the created run without waiting. * - * @param mixed $input optionally overrides the task's stored input ({@code null} to use it) + * @param mixed $input overrides the stored input ({@code null} keeps it) */ public function start( mixed $input = null, @@ -115,29 +107,25 @@ public function start( } /** - * Starts the task and waits (client-side polling) for it to finish. + * Starts the task and polls until it finishes. * - * @param mixed $input optionally overrides the task's stored input - * @param int|null $waitSecs bounds the wait; {@code null} waits indefinitely. Time spent retrying a - * {@code $options->waitForResources} start does not count toward it. - * @param int|float|string|null $timeoutSecs timeout of the start request and of every poll (a number of - * seconds, a tier name or {@code 'noTimeout'}); default {@code 'noTimeout'} + * @param mixed $input overrides the stored input + * @param int|null $waitSecs bounds the wait ({@code null} waits indefinitely). Retries of a + * {@code $options->waitForResources} start do not count. + * @param int|float|string|null $timeoutSecs timeout of the start request and of each poll (default + * {@code 'noTimeout'}) */ public function call(mixed $input = null, ?TaskStartOptions $options = null, ?int $waitSecs = null, int|float|string|null $timeoutSecs = null): ActorRun { - // Like the reference client, the start request and every poll have no request timeout by default. + // The start request and every poll have no request timeout by default. $timeoutSecs ??= TimeoutTiers::NO_TIMEOUT; $run = $this->start($input, $options, $timeoutSecs); return $this->root->run((string) $run->getId())->waitForFinish($waitSecs, $timeoutSecs); } /** - * Fetches the task's stored input. - * - * Unlike {@see get()}, a 404 here is not swallowed: it always throws instead, since the only way - * this fixed sub-path 404s is the task itself being gone (matching the reference client). A - * {@code null} return means the task's stored input JSON itself decoded to {@code null} (or the - * response body was empty) — not a missing task, which throws. + * Fetches the stored input of the task. A 404 is always thrown, never turned into {@code null}. A + * {@code null} result means the input itself is {@code null} or empty. * * @return mixed */ @@ -148,7 +136,7 @@ public function getInput(int|float|string|null $timeoutSecs = null): mixed } /** - * Replaces the task's stored input and returns the updated input. + * Replaces the stored input and returns the updated input. * * @param mixed $input any JSON-serializable value * @return mixed diff --git a/src/Resource/TaskCollectionClient.php b/src/Resource/TaskCollectionClient.php index 7c72556..53f7cdb 100644 --- a/src/Resource/TaskCollectionClient.php +++ b/src/Resource/TaskCollectionClient.php @@ -37,9 +37,8 @@ public function list(?ListOptions $options = null, int|float|string|null $timeou } /** - * Lazily iterates over the account's tasks, fetching pages on demand. The options' {@code limit} - * caps the total number of tasks yielded across all pages ({@code null} = all); {@code $chunkSize} - * is the per-page size ({@code null} = the API's default page size). + * Lazily yields the account's tasks. The {@code limit} option caps the total ({@code null} for all) and + * {@code $chunkSize} sets the page size ({@code null} for the API's default). * * @return Generator */ diff --git a/src/Resource/UserClient.php b/src/Resource/UserClient.php index bb0ae35..caf02f2 100644 --- a/src/Resource/UserClient.php +++ b/src/Resource/UserClient.php @@ -11,12 +11,7 @@ use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\User; -/** - * A client for accessing user data ({@code /v2/users/{userId}} or {@code /v2/users/me}). - * - * It also exposes the user's monthly usage and account limits, addressed by the same user ID (or - * {@code me}). - */ +/** A client for a user, with the monthly usage and account limits of the user. */ final class UserClient { private ResourceContext $ctx; @@ -28,8 +23,8 @@ public function __construct(private HttpClientCore $http, string $baseUrl, strin } /** - * Fetches the user. For {@code me} it returns private account details (via {@see User::toArray()}); - * for other users it returns the public profile. Returns {@code null} if the user does not exist. + * Fetches the user: the private account details for {@code me}, the public profile for others. Returns + * {@code null} if the user does not exist. */ public function get(int|float|string|null $timeoutSecs = null): ?User { @@ -38,8 +33,7 @@ public function get(int|float|string|null $timeoutSecs = null): ?User } /** - * Fetches the user's monthly usage for the month containing the given date (formatted as - * {@code YYYY-MM-DD}). An empty/{@code null} date reports the current month. + * Fetches the monthly usage for the month of the date ({@code YYYY-MM-DD}), by default the current month. * * @return array */ diff --git a/src/Resource/WebhookClient.php b/src/Resource/WebhookClient.php index d405c07..fef746a 100644 --- a/src/Resource/WebhookClient.php +++ b/src/Resource/WebhookClient.php @@ -30,9 +30,9 @@ public function get(int|float|string|null $timeoutSecs = null): ?Webhook } /** - * Updates the webhook with the given fields and returns the updated object. + * Updates the webhook and returns it. * - * @param mixed $newFields any JSON-serializable set of fields to update + * @param mixed $newFields any JSON-serializable fields */ public function update(mixed $newFields, int|float|string|null $timeoutSecs = null): Webhook { diff --git a/src/Resource/WebhookCollectionClient.php b/src/Resource/WebhookCollectionClient.php index 2a4fdce..e0bb098 100644 --- a/src/Resource/WebhookCollectionClient.php +++ b/src/Resource/WebhookCollectionClient.php @@ -8,11 +8,7 @@ use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\Webhook; -/** - * A client for the account-wide webhook collection ({@code GET/POST /v2/webhooks}), supporting both - * listing and creation. Webhooks nested under an Actor or task are read-only and use - * {@see NestedWebhookCollectionClient} instead. - */ +/** A client for the webhooks of the account, which lists and creates them. */ final class WebhookCollectionClient extends AbstractWebhookCollectionClient { /** diff --git a/src/Resource/WebhookDispatchCollectionClient.php b/src/Resource/WebhookDispatchCollectionClient.php index 3d14233..8015830 100644 --- a/src/Resource/WebhookDispatchCollectionClient.php +++ b/src/Resource/WebhookDispatchCollectionClient.php @@ -13,10 +13,7 @@ use Apify\Client\Options\ListOptions; use Generator; -/** - * A client for a webhook dispatch collection: the account-wide collection ({@code GET - * /v2/webhook-dispatches}) or dispatches nested under a webhook. - */ +/** A client for a webhook dispatch collection: all dispatches of the account, or those of a webhook. */ final class WebhookDispatchCollectionClient { private ResourceContext $ctx; @@ -40,9 +37,8 @@ public function list(?ListOptions $options = null, int|float|string|null $timeou } /** - * Lazily iterates over webhook dispatches, fetching pages on demand. The options' {@code limit} - * caps the total number of dispatches yielded across all pages ({@code null} = all); - * {@code $chunkSize} is the per-page size ({@code null} = the API's default page size). + * Lazily yields webhook dispatches. The {@code limit} option caps the total ({@code null} for all) and + * {@code $chunkSize} sets the page size ({@code null} for the API's default). * * @return Generator */ diff --git a/src/Version.php b/src/Version.php index 0772ed8..cbb9841 100644 --- a/src/Version.php +++ b/src/Version.php @@ -17,13 +17,13 @@ final class Version * The semantic version of this client library (see https://semver.org/). * Changes to the public interface other than additive ones are considered breaking changes. */ - public const CLIENT_VERSION = '0.13.0'; + public const CLIENT_VERSION = '0.14.0'; /** * The version of the Apify OpenAPI specification this client was generated and verified * against. Corresponds to the {@code info.version} field of the Apify OpenAPI document. */ - public const API_SPEC_VERSION = 'v2-2026-11-06T140128Z'; + public const API_SPEC_VERSION = 'v2-2026-11-09T143926Z'; private function __construct() { diff --git a/tests/Examples/ExamplesTest.php b/tests/Examples/ExamplesTest.php index 22b2695..d11b761 100644 --- a/tests/Examples/ExamplesTest.php +++ b/tests/Examples/ExamplesTest.php @@ -9,8 +9,8 @@ use Throwable; /** - * Runs each documentation example end-to-end against the live API, proving the snippets in the docs - * actually work. Skipped when {@code APIFY_TOKEN} is not set. This is the "Test examples" CI step. + * Runs each documentation example against the live API. Skipped without {@code APIFY_TOKEN}. This is the "Test + * examples" CI step. */ final class ExamplesTest extends TestCase { diff --git a/tests/Integration/ActorRunIntegrationTest.php b/tests/Integration/ActorRunIntegrationTest.php index 0709ce9..a213e09 100644 --- a/tests/Integration/ActorRunIntegrationTest.php +++ b/tests/Integration/ActorRunIntegrationTest.php @@ -98,7 +98,7 @@ public function testIterateDatasetItemsReadsItemsWhileTheRunIsStillRunning(): vo { $client = $this->requireClient(); // An Actor that pushes one item every ten seconds, three times, straight through the API - // (the Actor runtime provides the token, the dataset ID and the public API base URL). + // (the Actor runtime provides the token, the dataset ID, and the public API base URL). $script = <<<'JS' const base = (process.env.APIFY_API_PUBLIC_BASE_URL || 'https://api.apify.com').replace(/\/$/, ''); const url = `${base}/v2/datasets/${process.env.APIFY_DEFAULT_DATASET_ID}/items?token=${process.env.APIFY_TOKEN}`; @@ -137,8 +137,8 @@ public function testIterateDatasetItemsReadsItemsWhileTheRunIsStillRunning(): vo } /** - * Creates an Actor whose {@code main.js} is the given script and waits for its build to succeed; - * returns the Actor ID. The caller deletes the Actor. + * Creates an Actor with the script as {@code main.js}, waits for its build, and returns its ID. The caller deletes + * the Actor. */ private function createBuiltActor(ApifyClient $client, string $label, string $mainJs): string { diff --git a/tests/Integration/ActorServerIntegrationTest.php b/tests/Integration/ActorServerIntegrationTest.php index 8db6ea6..477ebfd 100644 --- a/tests/Integration/ActorServerIntegrationTest.php +++ b/tests/Integration/ActorServerIntegrationTest.php @@ -8,10 +8,7 @@ use Apify\Client\Exception\NotFoundException; use Apify\Client\Options\ActorServerRequestOptions; -/** - * Runs {@see \Apify\Client\Resource\ActorServerClient} against a temporary Server Actor that echoes every request - * it receives. - */ +/** Runs {@see \Apify\Client\Resource\ActorServerClient} against a temporary Server Actor that echoes every request. */ final class ActorServerIntegrationTest extends IntegrationTestCase { private const BUILD_WAIT_SECS = 300; diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index 8779a7f..9859938 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -5,19 +5,18 @@ namespace Apify\Client\Tests\Integration; use Apify\Client\ApifyClient; +use Apify\Client\Http\HttpClientInterface; +use Apify\Client\Http\HttpCompressorInterface; use Apify\Client\Options\DatasetListItemsOptions; use Apify\Client\Resource\DatasetClient; use PHPUnit\Framework\TestCase; /** - * Shared setup for the integration test suite. + * Shared setup of the integration tests. They need an {@code APIFY_TOKEN}. The API URL comes from + * {@code APIFY_API_URL}, with the {@code /v2} suffix, and defaults to {@code https://api.apify.com/v2}. * - * All integration tests require a valid {@code APIFY_TOKEN} for the test account. The API base URL - * is taken from {@code APIFY_API_URL} (which includes the {@code /v2} suffix) and falls back to - * {@code https://api.apify.com/v2}. - * - * Tests are designed to run concurrently — including against the same test account from several - * language clients at once — so every test creates uniquely-named resources and cleans them up. + * Tests may run concurrently on the same account, also from other language clients, so each one creates + * uniquely named resources and cleans them up. */ abstract class IntegrationTestCase extends TestCase { @@ -25,9 +24,7 @@ abstract class IntegrationTestCase extends TestCase private const DEFAULT_API_URL = 'https://api.apify.com/v2'; /** - * Derives the client base URL from an optional {@code APIFY_API_URL}. The variable includes the - * {@code /v2} suffix (per the integration-test contract) and falls back to the default. Since the - * client appends {@code /v2} itself, the suffix is stripped here. + * Derives the client base URL from {@code APIFY_API_URL}. The client adds {@code /v2} itself, so it is stripped. */ protected static function resolveBaseUrl(?string $apiUrl): string { @@ -42,17 +39,17 @@ protected static function resolveBaseUrl(?string $apiUrl): string } /** - * How many times, and how many seconds apart, a listing is re-read while a resource the test just - * created is missing from it. The list endpoints read from database secondaries that may lag by up - * to 90 seconds, so 30 attempts 3 seconds apart cover that window; a pass returns as soon as every - * expected ID shows up. + * How many times, and how many seconds apart, a listing is re-read while a resource the test just created is + * missing from it. The list endpoints read from secondaries that can lag by up to 90 seconds. */ private const LISTING_MAX_ATTEMPTS = 30; private const LISTING_RETRY_DELAY_SECS = 3; /** Returns a configured client, or skips the test if {@code APIFY_TOKEN} is unset. */ - protected function requireClient(): ApifyClient - { + protected function requireClient( + string|HttpCompressorInterface|null $compression = null, + ?HttpClientInterface $httpClient = null, + ): ApifyClient { $token = getenv('APIFY_TOKEN'); if ($token === false || $token === '') { self::markTestSkipped('skipping: APIFY_TOKEN is not set'); @@ -61,33 +58,25 @@ protected function requireClient(): ApifyClient return new ApifyClient( token: $token, baseUrl: self::resolveBaseUrl($apiUrl === false ? null : $apiUrl), + httpClient: $httpClient, + compression: $compression, ); } - /** - * Generates a collision-resistant resource name for test isolation. The random component lets - * the same test run in parallel (across processes and languages) without clobbering shared state. - */ + /** Generates a unique resource name, so that the same test can run in parallel. */ protected static function uniqueName(string $prefix): string { return 'php-test-' . $prefix . '-' . bin2hex(random_bytes(6)); } /** - * Repeatedly re-runs an iterate()-and-collect pass (via {@code $collect}) until every ID in - * {@code $expectedIds} has been seen, or a bounded number of attempts with backoff is exhausted. - * - * Apify's list/pagination endpoints are eventually consistent, and this suite is designed to run - * concurrently with other test runs (including other language clients) against the same shared - * test account; under that load, a resource created immediately before an {@code iterate()} call - * can take a while to be reflected in a collection listing. Retrying the whole pass (rather - * than looping forever or giving up after one try) keeps the test genuinely exercising - * {@code iterate()} while tolerating that lag: it still fails, with the same diagnostic message, - * if a resource never appears within the bounded timeout. + * Repeats an iterate-and-collect pass ({@code $collect}) until every ID in {@code $expectedIds} was seen, + * or the attempts run out. The list endpoints are eventually consistent, so a resource that was just + * created can be missing at first. * * @param list $expectedIds identifiers that must all be present in a collected pass - * @param callable(): array $collect performs one iterate() pass and returns the set - * (map of identifier => true) of everything it saw + * @param callable(): array $collect performs one pass and returns the set (identifier => true) + * of everything it saw */ protected static function assertEventuallyIterated(array $expectedIds, callable $collect, string $label): void { @@ -108,9 +97,8 @@ protected static function assertEventuallyIterated(array $expectedIds, callable } /** - * Waits until the dataset reports at least {@code $expectedItems} items. The dataset's item count, - * which its total and its scanned-rows count come from, is computed asynchronously and lags a fresh - * push. It re-reads as often as {@see assertEventuallyIterated()} does. + * Waits until the dataset reports at least {@code $expectedItems} items. Its item count is computed + * asynchronously and lags a fresh push. It re-reads as often as {@see assertEventuallyIterated()}. */ protected static function waitForDatasetItemCount(DatasetClient $dataset, int $expectedItems): void { diff --git a/tests/Integration/KeyValueStoreIntegrationTest.php b/tests/Integration/KeyValueStoreIntegrationTest.php index da8730d..444fce9 100644 --- a/tests/Integration/KeyValueStoreIntegrationTest.php +++ b/tests/Integration/KeyValueStoreIntegrationTest.php @@ -131,20 +131,14 @@ public function testSetRecordWithContentEncodingStoresPreCompressedValueAsIs(): self::assertIsString($original); $gzipped = gzencode($original); self::assertIsString($gzipped); - // A gzipped JSON payload is itself well over the 1 KiB auto-compression threshold, so - // this also proves the client's own compression genuinely backed off: if it had gzipped - // the already-gzipped bytes a second time, the API would receive double-compressed gzip - // under a single Content-Encoding: gzip header, decode it once, and store the *inner* - // gzip bytes as the record's "JSON" — which could never decode back to the original value - // below. + // The gzipped payload is above the 1 KiB compression threshold, so this shows that the client's own + // compression backed off. Compressed twice, the API would store the inner gzip bytes as the record. self::assertGreaterThan(1024, strlen($gzipped)); $kvs->setRecord('precompressed.json', $gzipped, 'application/json', contentEncoding: 'gzip'); - // The live API decompresses a Content-Encoding-tagged upload on ingest and serves the - // record's plain bytes back out (it does not echo Content-Encoding on GET, nor keep the - // compressed bytes verbatim), so the round-trip is verified through the parsed value - // rather than a byte-for-byte comparison against $gzipped. + // The API decompresses an upload on ingest and serves the plain bytes, without + // `Content-Encoding`. The round trip is verified through the parsed value. $record = $kvs->getRecord('precompressed.json'); self::assertNotNull($record); self::assertSame($original, $record->getValue()); diff --git a/tests/Integration/RecordingHttpClient.php b/tests/Integration/RecordingHttpClient.php new file mode 100644 index 0000000..259c904 --- /dev/null +++ b/tests/Integration/RecordingHttpClient.php @@ -0,0 +1,35 @@ + */ + public array $encodings = []; + + private HttpClientInterface $inner; + + public function __construct() + { + $this->inner = new GuzzleHttpClient(); + } + + public function send(RequestInterface $request, float $timeoutSecs): ResponseInterface + { + $this->encodings[] = $request->getHeaderLine('Content-Encoding'); + return $this->inner->send($request, $timeoutSecs); + } + + public function sendStreaming(RequestInterface $request, float $timeoutSecs): ResponseInterface + { + return $this->inner->sendStreaming($request, $timeoutSecs); + } +} diff --git a/tests/Integration/ZstdCompressionIntegrationTest.php b/tests/Integration/ZstdCompressionIntegrationTest.php new file mode 100644 index 0000000..3cc40f4 --- /dev/null +++ b/tests/Integration/ZstdCompressionIntegrationTest.php @@ -0,0 +1,73 @@ + 'world', 'padding' => bin2hex(random_bytes(2048))]); + self::assertIsString($json); + return $json; + } + + private function requireZstdClient(): ApifyClient + { + if (!ZstdHttpCompressor::isAvailable()) { + self::markTestSkipped('the PECL zstd extension is not loaded'); + } + $this->recorder = new RecordingHttpClient(); + return $this->requireClient('zstd', $this->recorder); + } + + public function testKeyValueStoreRecordIsStoredFromAZstdBody(): void + { + $client = $this->requireZstdClient(); + $store = $client->keyValueStores()->getOrCreate(self::uniqueName('kvs-zstd')); + try { + $kvs = $client->keyValueStore((string) $store->getId()); + $json = self::largeJson(); + + $kvs->setRecord('OUTPUT', $json, 'application/json'); + + $record = $kvs->getRecord('OUTPUT'); + self::assertNotNull($record); + self::assertSame($json, $record->getValue()); + self::assertContains('zstd', $this->recorder->encodings); + } finally { + $client->keyValueStore((string) $store->getId())->delete(); + } + } + + public function testDatasetItemsAreStoredFromAZstdBody(): void + { + $client = $this->requireZstdClient(); + $dataset = $client->datasets()->getOrCreate(self::uniqueName('ds-zstd')); + try { + $items = $client->dataset((string) $dataset->getId()); + $padding = bin2hex(random_bytes(2048)); + + $items->pushItems([['n' => 1, 'padding' => $padding], ['n' => 2, 'padding' => $padding]]); + + self::waitForDatasetItemCount($items, 2); + $stored = $items->listItems()->getItems(); + self::assertSame([1, 2], array_column($stored, 'n')); + self::assertSame($padding, $stored[0]['padding']); + self::assertContains('zstd', $this->recorder->encodings); + } finally { + $client->dataset((string) $dataset->getId())->delete(); + } + } +} diff --git a/tests/Unit/ActorServerClientTest.php b/tests/Unit/ActorServerClientTest.php index 86b437b..153d4bc 100644 --- a/tests/Unit/ActorServerClientTest.php +++ b/tests/Unit/ActorServerClientTest.php @@ -251,6 +251,21 @@ public function testRequestSendsNoRedirectsAndTheUserHeadersAsTheyAre(): void self::assertSame([false, true], $transport->withoutRedirects); } + public function testParentRequestIdIsSentAsTheRequestIdHeaderOverOnePassedInHeaders(): void + { + $transport = $this->serverTransport()->queueResponse(200, 'ok')->queueResponse(200, 'ok'); + $server = $this->client($transport)->actor('a1')->server(); + + $server->request('/', new ActorServerRequestOptions(headers: ['x-actor-request-id' => 'stale', 'X-Custom' => 'yes'], parentRequestId: 'parent-id')); + + $request = $transport->lastRequest(); + self::assertSame(['parent-id'], $request->getHeader('X-Actor-Request-ID')); + self::assertSame('yes', $request->getHeaderLine('X-Custom')); + + $server->request('/'); + self::assertFalse($transport->lastRequest()->hasHeader('X-Actor-Request-ID')); + } + public function testRequestUsesTheLongTierTimeoutByDefaultAndAnExplicitOne(): void { $transport = $this->serverTransport()->queueResponse(200, 'a')->queueResponse(200, 'b'); @@ -375,10 +390,10 @@ public function testRequestRejectsAnUnknownCompressionAndABadQueryParameter(): v $server = $this->client($transport)->actor('a1')->server(); try { - $server->request('/', new ActorServerRequestOptions(compression: 'zstd')); + $server->request('/', new ActorServerRequestOptions(compression: 'deflate')); self::fail('expected a RuntimeException'); } catch (\RuntimeException $e) { - self::assertStringContainsString('unknown compression "zstd"', $e->getMessage()); + self::assertStringContainsString('unknown compression "deflate"', $e->getMessage()); } $this->expectException(InvalidArgumentException::class); diff --git a/tests/Unit/AmbiguousNotFoundTest.php b/tests/Unit/AmbiguousNotFoundTest.php index 8e9a70a..ec2d9f7 100644 --- a/tests/Unit/AmbiguousNotFoundTest.php +++ b/tests/Unit/AmbiguousNotFoundTest.php @@ -19,11 +19,10 @@ use PHPUnit\Framework\TestCase; /** - * Covers the status-keyed {@see ApifyApiException} subclasses and the "ambiguous 404" rule: a 404 on - * a resource addressed by ID resolves to null/no-op, but a 404 on an ID-less chained resource (e.g. - * {@code run.dataset()}) or a fixed sub-path (e.g. {@code getStatistics()}) is rethrown, since the - * response cannot tell a missing parent from a missing sub-resource. Matches the reference client's - * `#1041`/`#1042`. + * Tests of the status-keyed {@see ApifyApiException} subclasses and the ambiguous 404 rule: a 404 on a resource + * addressed by ID resolves to null, but a 404 on a chained resource without an ID (for example + * {@code run.dataset()}) or a fixed sub-path (for example {@code getStatistics()}) is rethrown, since it cannot + * tell a missing parent from a missing sub-resource. */ final class AmbiguousNotFoundTest extends TestCase { @@ -56,7 +55,10 @@ public static function statusClassProvider(): array ]; } - /** @dataProvider statusClassProvider */ + /** + * @param class-string $expectedClass + * @dataProvider statusClassProvider + */ public function testBuildApiErrorReturnsStatusSpecificSubclass(int $status, string $expectedClass): void { $error = HttpClientCore::buildApiError($status, $this->notFoundBody('x'), 1, 'GET', '/x'); diff --git a/tests/Unit/ApifyClientExceptionTest.php b/tests/Unit/ApifyClientExceptionTest.php index 294cc26..96785c2 100644 --- a/tests/Unit/ApifyClientExceptionTest.php +++ b/tests/Unit/ApifyClientExceptionTest.php @@ -13,10 +13,8 @@ use PHPUnit\Framework\TestCase; /** - * Covers {@see ApifyClientException}, the common base every error the client throws for a failed API - * interaction extends, matching the reference client's {@code ApifyClientError} - * (apify/apify-client-js#1145): a single {@code instanceof ApifyClientException} check catches a failed - * (non-2xx) API call and a response body that fails to parse as JSON alike. + * Tests of {@see ApifyClientException}: one {@code instanceof} check catches a failed API call and an unparsable + * response body. */ final class ApifyClientExceptionTest extends TestCase { @@ -59,9 +57,8 @@ public function testInvalidResponseBodyExceptionIsAnApifyClientException(): void public function testTransportExceptionIsNotAnApifyClientException(): void { - // Network/timeout failures stay outside the hierarchy, matching the reference client, where - // transport errors are not instances of ApifyClientError either. - // maxRetries: 0 so the single queued failure surfaces immediately instead of being retried. + // Network failures and timeouts stay outside the hierarchy. + // maxRetries: 0, so the queued failure surfaces at once. $transport = (new MockTransport())->queueError(); $client = new ApifyClient(token: 't', maxRetries: 0, timeoutSecs: 5, httpClient: $transport); diff --git a/tests/Unit/BatchAddRequestsTest.php b/tests/Unit/BatchAddRequestsTest.php index 60f20ef..cf9d617 100644 --- a/tests/Unit/BatchAddRequestsTest.php +++ b/tests/Unit/BatchAddRequestsTest.php @@ -12,10 +12,9 @@ use PHPUnit\Framework\TestCase; /** - * Offline behavioral tests for {@see \Apify\Client\Resource\RequestQueueClient::batchAddRequests()}: - * uniqueKey validation, count/byte chunking, unprocessed-retry from a successful response, and the - * non-throwing error contract (a failed batch call reports its not-yet-processed requests as - * unprocessed rather than throwing, preserving earlier chunks' results), matching the JS reference. + * Offline tests of {@see \Apify\Client\Resource\RequestQueueClient::batchAddRequests()}: uniqueKey validation, + * chunking by count and bytes, retries of unprocessed requests, and the non-throwing contract, where a failed + * batch reports its requests as unprocessed and keeps the results of the earlier batches. */ final class BatchAddRequestsTest extends TestCase { @@ -178,10 +177,8 @@ public function testChunksByPayloadByteSize(): void } /** - * A request whose JSON is exactly 2 bytes under the payload limit must be accepted: as the sole - * member of its own single-item batch body ("[" + json + "]"), it exactly fills the limit. Matches - * the reference client's exact `byteLength + 2 > limit` accounting (ported from `#1051`); a - * stricter `+1` margin would reject this request even though it fits. + * A request whose JSON is 2 bytes under the payload limit is accepted: alone in its batch body + * ("[" + json + "]"), it fills the limit exactly. */ public function testRequestExactlyAtThePerItemSizeBoundaryIsAccepted(): void { diff --git a/tests/Unit/CompressionConfigTest.php b/tests/Unit/CompressionConfigTest.php index e0afea7..bfd0aab 100644 --- a/tests/Unit/CompressionConfigTest.php +++ b/tests/Unit/CompressionConfigTest.php @@ -7,15 +7,15 @@ use Apify\Client\ApifyClient; use Apify\Client\Http\BrotliHttpCompressor; use Apify\Client\Http\GzipHttpCompressor; +use Apify\Client\Http\ZstdHttpCompressor; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use RuntimeException; /** - * Covers the configurable {@code compression} constructor option (matches the reference client's - * `#1065`): choosing {@code 'gzip'}/{@code 'brotli'} by name, a custom quality via an explicit - * compressor instance, and that the client's default best-effort choice is unchanged when the option - * is left out. + * Tests of the {@code compression} constructor option: algorithm by name, quality through a compressor, and the + * default when it is omitted. */ final class CompressionConfigTest extends TestCase { @@ -51,6 +51,62 @@ public function testBrotliByNameIsUsedWhenExtensionAvailable(): void self::assertSame('br', $transport->lastRequest()->getHeaderLine('Content-Encoding')); } + public function testZstdByNameIsUsedWhenExtensionAvailable(): void + { + if (!function_exists('zstd_compress')) { + self::markTestSkipped('PECL zstd extension not loaded'); + } + $transport = (new MockTransport())->queueResponse(200, '{"data":{}}'); + $client = new ApifyClient(token: 't', compression: 'zstd', httpClient: $transport); + + $client->keyValueStore('s1')->setRecordJson('k', $this->largeBody()); + + self::assertSame('zstd', $transport->lastRequest()->getHeaderLine('Content-Encoding')); + } + + /** @return array */ + public static function codecsWithAnExtension(): array + { + return [ + 'brotli' => ['brotli', 'brotli_compress'], + 'zstd' => ['zstd', 'zstd_compress'], + ]; + } + + #[DataProvider('codecsWithAnExtension')] + public function testChoosingACodecWithoutItsExtensionFailsAtConstruction(string $name, string $function): void + { + if (function_exists($function)) { + self::markTestSkipped("the $name extension is loaded"); + } + $this->expectException(RuntimeException::class); + new ApifyClient(token: 't', compression: $name, httpClient: new MockTransport()); + } + + public function testZstdCompressorRoundTrips(): void + { + if (!function_exists('zstd_uncompress')) { + self::markTestSkipped('PECL zstd extension not loaded'); + } + $compressor = new ZstdHttpCompressor(); + self::assertSame('zstd', $compressor->contentEncoding()); + $zstdUncompress = 'zstd_uncompress'; + self::assertSame('payload', $zstdUncompress((string) $compressor->compress('payload'))); + } + + /** @return array */ + public static function outOfRangeZstdQualities(): array + { + return ['below the minimum' => [0], 'above the maximum' => [20]]; + } + + #[DataProvider('outOfRangeZstdQualities')] + public function testZstdCompressorRejectsOutOfRangeQuality(int $quality): void + { + $this->expectException(InvalidArgumentException::class); + new ZstdHttpCompressor($quality); + } + public function testCustomCompressorInstanceIsUsed(): void { $transport = (new MockTransport())->queueResponse(200, '{"data":{}}'); diff --git a/tests/Unit/CompressionTest.php b/tests/Unit/CompressionTest.php index c68fd2a..90e8095 100644 --- a/tests/Unit/CompressionTest.php +++ b/tests/Unit/CompressionTest.php @@ -4,11 +4,36 @@ namespace Apify\Client\Tests\Unit; +use Apify\Client\Http\HttpCompressorInterface; use Apify\Client\Internal\Compression; use PHPUnit\Framework\TestCase; final class CompressionTest extends TestCase { + /** A compressor that encodes with {@code $encode}, or fails when it returns {@code null}. */ + private static function compressor(string $encoding, callable $encode): HttpCompressorInterface + { + return new class ($encoding, $encode) implements HttpCompressorInterface { + /** @var callable(string): ?string */ + private $encode; + + public function __construct(private string $encoding, callable $encode) + { + $this->encode = $encode; + } + + public function contentEncoding(): string + { + return $this->encoding; + } + + public function compress(string $body): ?string + { + return ($this->encode)($body); + } + }; + } + /** True when at least one compression codec is available in this PHP build. */ private static function hasCodec(): bool { @@ -26,8 +51,7 @@ public function testBodyAtThresholdIsCompressed(): void if (!self::hasCodec()) { self::markTestSkipped('no compression codec available in this PHP build'); } - // A body of exactly MIN_COMPRESS_BYTES is compressed (the below-threshold case is covered by - // testSmallBodyIsNotCompressed). + // The size just below the threshold is covered by testSmallBodyIsNotCompressed. $result = Compression::maybeCompress(str_repeat('a', Compression::MIN_COMPRESS_BYTES)); self::assertNotNull($result); [$encoding, $data] = $result; @@ -72,10 +96,9 @@ public function testGzipPathWhenBrotliUnavailable(): void if (!function_exists('gzencode')) { self::markTestSkipped('zlib (gzencode) not available in this PHP build'); } - // Deterministically exercise the gzip fallback (brotli encoder absent) without depending on - // the host lacking the PECL brotli extension. + // Gzip alone, whether or not the host has the brotli extension. $original = str_repeat('payload-', 500); - $result = Compression::compressWith($original, null, static fn (string $b) => gzencode($b)); + $result = Compression::compressWith($original, [self::compressor('gzip', static fn (string $b) => gzencode($b) ?: null)]); self::assertNotNull($result); [$encoding, $data] = $result; self::assertSame('gzip', $encoding); @@ -85,16 +108,13 @@ public function testGzipPathWhenBrotliUnavailable(): void public function testBrotliPathIsPreferredWhenAvailable(): void { - // Deterministically exercise the brotli path (and its preference over gzip) without depending - // on the host having the PECL brotli extension: inject a stand-in brotli encoder and assert it - // is chosen and its output used, while a real gzip encoder is also available. + // A stand-in brotli compressor in front of gzip is chosen, whether or not the host has the extension. $original = str_repeat('payload-', 500); $marker = 'BR:' . $original; - $result = Compression::compressWith( - $original, - static fn (string $b) => 'BR:' . $b, - static fn (string $b) => gzencode($b), - ); + $result = Compression::compressWith($original, [ + self::compressor('br', static fn (string $b) => 'BR:' . $b), + self::compressor('gzip', static fn (string $b) => gzencode($b) ?: null), + ]); self::assertNotNull($result); [$encoding, $data] = $result; self::assertSame('br', $encoding); @@ -106,7 +126,6 @@ public function testRealBrotliRoundTripsWhenExtensionPresent(): void if (!function_exists('brotli_compress')) { self::markTestSkipped('PECL brotli extension not loaded'); } - // When the real extension is present, verify the actual brotli codec produces decodable bytes. $original = str_repeat('payload-', 500); $result = Compression::maybeCompress($original); self::assertNotNull($result); @@ -121,13 +140,12 @@ public function testFallsBackToGzipWhenBrotliEncoderFails(): void if (!function_exists('gzencode')) { self::markTestSkipped('zlib (gzencode) not available in this PHP build'); } - // A brotli encoder that fails (returns a non-string) must not abort compression: gzip is used. + // A failing brotli compressor does not abort the compression: gzip is used. $original = str_repeat('payload-', 500); - $result = Compression::compressWith( - $original, - static fn (string $b) => false, - static fn (string $b) => gzencode($b), - ); + $result = Compression::compressWith($original, [ + self::compressor('br', static fn (string $b) => null), + self::compressor('gzip', static fn (string $b) => gzencode($b) ?: null), + ]); self::assertNotNull($result); [$encoding, $data] = $result; self::assertSame('gzip', $encoding); @@ -137,18 +155,16 @@ public function testFallsBackToGzipWhenBrotliEncoderFails(): void public function testReturnsNullWhenNoCodecAvailable(): void { $original = str_repeat('payload-', 500); - self::assertNull(Compression::compressWith($original, null, null)); + self::assertNull(Compression::compressWith($original, [])); } public function testSmallBodyIsNotCompressedEvenWithCodecs(): void { - // The size gate applies before codec selection, so a below-threshold body is never compressed. + // The size gate applies before the compressors. $small = str_repeat('a', Compression::MIN_COMPRESS_BYTES - 1); - self::assertNull(Compression::compressWith( - $small, - static fn (string $b) => 'BR:' . $b, - static fn (string $b) => gzencode($b), - )); + self::assertNull(Compression::compressWith($small, [ + self::compressor('br', static fn (string $b) => 'BR:' . $b), + ])); } /** @@ -184,8 +200,7 @@ private static function decode(string $encoding, string $data): string { if ($encoding === 'br') { self::assertTrue(function_exists('brotli_uncompress'), 'brotli extension needed to decode'); - // Called indirectly: the symbol only exists when the PECL brotli extension is loaded, - // so a direct call would be an unresolved reference for static analysis. + // Called by name, so static analysis does not flag builds without the extension. $brotliUncompress = 'brotli_uncompress'; $decoded = $brotliUncompress($data); } else { diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php index e0e69d1..b433e19 100644 --- a/tests/Unit/ConfigTest.php +++ b/tests/Unit/ConfigTest.php @@ -46,10 +46,7 @@ public function testApiBaseUrlAppendsV2(): void self::assertSame('https://api.example.com/v2', $client->getApiBaseUrl()); } - /** - * A $baseUrl that already ends with the /v2 version path is accepted as-is instead of becoming - * .../v2/v2, matching the reference client's toApiBaseUrl. - */ + /** A {@code $baseUrl} that already ends with {@code /v2} does not become {@code .../v2/v2}. */ public function testApiBaseUrlAlreadyEndingInV2IsNotDoubled(): void { $client = new ApifyClient(token: 't', baseUrl: 'https://api.example.com/v2', httpClient: new MockTransport()); diff --git a/tests/Unit/DatasetStreamItemsTest.php b/tests/Unit/DatasetStreamItemsTest.php index 19c6174..440f228 100644 --- a/tests/Unit/DatasetStreamItemsTest.php +++ b/tests/Unit/DatasetStreamItemsTest.php @@ -11,12 +11,7 @@ use Apify\Client\Options\DownloadItemsFormat; use PHPUnit\Framework\TestCase; -/** - * Covers {@see \Apify\Client\Resource\DatasetClient::streamItems()}, this client's equivalent of the - * reference client's {@code downloadItems(format, { stream: true })} (previously its own - * {@code streamItems()} — apify/apify-client-js#1139, folded into the option a release later by - * apify/apify-client-js#1152). - */ +/** Tests of {@see \Apify\Client\Resource\DatasetClient::streamItems()}. */ final class DatasetStreamItemsTest extends TestCase { private function client(MockTransport $transport): ApifyClient diff --git a/tests/Unit/HttpClientTest.php b/tests/Unit/HttpClientTest.php index edf6bf6..7a9968c 100644 --- a/tests/Unit/HttpClientTest.php +++ b/tests/Unit/HttpClientTest.php @@ -90,8 +90,7 @@ public function testValidationErrorIsNotRetriedAndThrows(): void public function testMalformedJsonBodyOnSuccessThrowsWithoutRetry(): void { - // A successful (2xx) response arrived in full (a transfer cut short fails in the transport - // instead), so retrying would get the same unparsable body back. Matches the reference client. + // A success response arrived in full, so a retry would get the same unparsable body. $transport = (new MockTransport()) ->queueResponse(200, 'not valid JSON {') ->queueResponse(200, Json::encode(['data' => ['id' => 'unused']])); @@ -263,8 +262,7 @@ public function testDatasetItemsUseHeaderPagination(): void public function testDatasetItemsDescHeaderTakesPrecedenceOverOption(): void { - // The server-reported X-Apify-Pagination-Desc header must win over the requested option - // (matches the reference JS client), so a page always reflects what the server actually did. + // The X-Apify-Pagination-Desc header wins over the requested option. $transport = (new MockTransport())->queueResponse( 200, Json::encode([['n' => 1]]), diff --git a/tests/Unit/IterationTest.php b/tests/Unit/IterationTest.php index 5c503dd..c532da9 100644 --- a/tests/Unit/IterationTest.php +++ b/tests/Unit/IterationTest.php @@ -14,11 +14,9 @@ use PHPUnit\Framework\TestCase; /** - * Hermetic tests for the lazy-iteration helpers, driven entirely by a scripted transport. They - * exercise the paging/termination logic — over-reported total, total-cap vs. page-size split, cursor - * threading, and (matching the reference client) that the stop condition never consults a listing's - * reported {@code total}: with no explicit {@code limit}, the loop always ends only once a page scans - * no rows, so a resource that keeps growing during the iteration is not cut off at its starting size. + * Tests of the lazy iterators with a scripted transport: over-reported total, total cap against page size, + * cursor threading, and that the loop never stops on the reported {@code total}, so a resource that grows + * during the iteration is not cut off. */ final class IterationTest extends TestCase { @@ -59,11 +57,11 @@ private static function pageWithCount(array $items, int $count, int $offset): st /** * @param int ...$ids - * @return list> + * @return list> */ private static function actors(int ...$ids): array { - return array_map(static fn (int $id): array => ['id' => "a$id", 'name' => "actor$id"], $ids); + return array_values(array_map(static fn (int $id): array => ['id' => "a$id", 'name' => "actor$id"], $ids)); } public function testSinglePageThenEmptyPageEndsIteration(): void @@ -220,10 +218,8 @@ public function testDatasetIterateItemsPagesViaHeaders(): void public function testDatasetIterateItemsAdvancesByScannedCountNotItemCount(): void { - // With skipEmpty active, the API can scan more rows than it returns as items. The iterator - // must advance (and terminate) by the scanned count (`X-Apify-Pagination-Count`), not by the - // item count — otherwise it would re-request offset=2 (items returned so far) instead of - // offset=3 (rows actually scanned), re-scanning row 2 and duplicating item 'n'=2. + // With skipEmpty, the API can scan more rows than it returns. The iterator must advance by the scanned + // count (`X-Apify-Pagination-Count`), else it re-requests row 2 and duplicates item 'n'=2. $transport = (new MockTransport()) // Page 1 scans rows 0-2 (3 rows) but one is empty and skipped, so only 2 items come back. ->queueResponse(200, Json::encode([['n' => 0], ['n' => 1]]), [ @@ -258,12 +254,9 @@ public function testDatasetIterateItemsAdvancesByScannedCountNotItemCount(): voi public function testDatasetIterateItemsCapsScannedCountWhenUnwindInflatesItemsPastTheRequestedLimit(): void { - // With unwind active, a page can return MORE items than rows it scanned (the opposite of - // skipEmpty). Here the scanned-count header is also missing (e.g. stripped by a proxy), so the - // iterator falls back to the (unwind-inflated) item count — which must be capped at the - // requested per-page limit (2), since the server never scans more rows per page than that. - // Without the cap, the iterator would advance by the raw item count (6) instead of the real row - // count (2), overshooting to offset=6 and skipping rows 2-3's items entirely. + // With unwind, a page can return more items than the rows it scanned. The scanned-count header is + // missing too, so the item count is capped at the requested limit (2). Without the cap, the iterator + // would advance by 6 and skip rows 2-3. $transport = (new MockTransport()) // Page 1: rows 0-1 (2 rows, matching the requested limit), each unwound into 3 items. ->queueResponse(200, Json::encode([['n' => 0], ['n' => 1], ['n' => 2], ['n' => 3], ['n' => 4], ['n' => 5]]), [ diff --git a/tests/Unit/KeyValueStoreContentEncodingTest.php b/tests/Unit/KeyValueStoreContentEncodingTest.php index 59b57e7..4c1ee87 100644 --- a/tests/Unit/KeyValueStoreContentEncodingTest.php +++ b/tests/Unit/KeyValueStoreContentEncodingTest.php @@ -9,10 +9,8 @@ use PHPUnit\Framework\TestCase; /** - * Covers the {@code $contentEncoding} parameter of - * {@see \Apify\Client\Resource\KeyValueStoreClient::setRecord()}, matching the reference client's - * {@code contentEncoding} option of {@code setRecord()} (apify/apify-client-js#1140): a value that is - * already compressed is uploaded as is, never compressed a second time. + * Tests of {@see \Apify\Client\Resource\KeyValueStoreClient::setRecord()} with {@code $contentEncoding}: a compressed + * value is uploaded as it is. */ final class KeyValueStoreContentEncodingTest extends TestCase { diff --git a/tests/Unit/LastRunParamsTest.php b/tests/Unit/LastRunParamsTest.php index b764582..0ee7e0f 100644 --- a/tests/Unit/LastRunParamsTest.php +++ b/tests/Unit/LastRunParamsTest.php @@ -11,11 +11,9 @@ use PHPUnit\Framework\TestCase; /** - * Offline tests proving that the {@code status}/{@code origin} filters pinned by a last-run accessor - * are inherited by the run's nested storage and log accessors, so every request resolves the same - * (correct) run. This mirrors the reference JS client, where sub-resource clients inherit the - * parent's params. Without this, e.g. {@code actor.lastRun(SUCCEEDED).dataset().listItems()} would - * silently read the default dataset of the wrong run. + * Offline tests that the status and origin filters of a last-run accessor reach the nested storage and log + * accessors, so every request resolves the same run. Otherwise + * {@code actor.lastRun(SUCCEEDED).dataset().listItems()} could read the dataset of another run. */ final class LastRunParamsTest extends TestCase { diff --git a/tests/Unit/LogClientTest.php b/tests/Unit/LogClientTest.php index 17f94b1..ed1df47 100644 --- a/tests/Unit/LogClientTest.php +++ b/tests/Unit/LogClientTest.php @@ -46,10 +46,7 @@ public function testRunNestedLogGet(): void public function testGetReturnsExactBytesForNonUtf8Content(): void { - // The reference client's get({ buffer: true }) exists so binary/non-UTF-8 log bytes are not - // mangled by decoding to a string. A PHP string is already a byte sequence and get() never - // decodes it, so it already behaves like that mode with no separate option needed — this - // proves bytes that are not valid UTF-8 still round-trip exactly. + // get() never decodes the log, so bytes that are not valid UTF-8 round-trip exactly. $invalidUtf8 = "line with invalid utf-8: \xFF\xFE bytes\n"; $transport = (new MockTransport())->queueResponse(200, $invalidUtf8); $log = $this->client($transport)->log('run1')->get(); diff --git a/tests/Unit/MethodTierTest.php b/tests/Unit/MethodTierTest.php index 2e4ec84..0cff93b 100644 --- a/tests/Unit/MethodTierTest.php +++ b/tests/Unit/MethodTierTest.php @@ -11,11 +11,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -/** - * Pins the default timeout tier of the methods whose tier differs from the one a reader might expect, - * to the tier the reference client assigns them. The default tiers last 5s (short), 30s (medium) and - * 360s (long). - */ +/** Pins the default timeout tier of the methods whose tier is easy to get wrong: short 5 s, medium 30 s, long 360 s. */ final class MethodTierTest extends TestCase { private const SHORT = 5.0; diff --git a/tests/Unit/MockTransport.php b/tests/Unit/MockTransport.php index c714d89..a50432e 100644 --- a/tests/Unit/MockTransport.php +++ b/tests/Unit/MockTransport.php @@ -13,10 +13,8 @@ use RuntimeException; /** - * A scripted {@see HttpClientInterface} for offline unit tests. Each queued entry is either a - * {@see ResponseInterface} to return or a {@see TransportException} to throw, consumed in order. - * All received requests are recorded for assertions, together with whether each one was sent streaming - * and whether it was sent with redirects disabled. + * A scripted {@see HttpClientInterface} for offline tests. Queued responses or {@see TransportException}s are + * returned in order. Requests are recorded, with whether each was streaming and without redirects. */ final class MockTransport implements NoRedirectHttpClientInterface { @@ -50,10 +48,7 @@ public function queueError(bool $timeout = false): self return $this; } - /** - * Queues a fully-constructed response as-is, e.g. one built with a non-seekable body, instead of - * the plain string body {@see queueResponse()} always wraps in a seekable stream. - */ + /** Queues a response as it is, for example one with a non-seekable body. */ public function queueRawResponse(ResponseInterface $response): self { $this->queue[] = $response; @@ -68,11 +63,7 @@ public function lastRequest(): RequestInterface return $this->received[count($this->received) - 1]; } - /** - * Reads a recorded request's body as a string, transparently decompressing it when the client - * applied request compression (a {@code Content-Encoding} header). Tests that assert on the body - * shape use this so they stay agnostic to whether the payload went out compressed. - */ + /** Reads the body of a recorded request, decompressing a {@code gzip}, {@code br}, or {@code zstd} one. */ public static function readBody(RequestInterface $request): string { $raw = (string) $request->getBody(); @@ -80,10 +71,12 @@ public static function readBody(RequestInterface $request): string if ($encoding === 'gzip') { $decoded = gzdecode($raw); } elseif ($encoding === 'br') { - // brotli_uncompress only exists when the PECL brotli extension is loaded; call it - // indirectly so the symbol is not referenced statically when the extension is absent. + // Called by name, so static analysis does not flag builds without the extension. $brotliUncompress = 'brotli_uncompress'; $decoded = $brotliUncompress($raw); + } elseif ($encoding === 'zstd') { + $zstdUncompress = 'zstd_uncompress'; + $decoded = $zstdUncompress($raw); } else { return $raw; } diff --git a/tests/Unit/PathSafetyTest.php b/tests/Unit/PathSafetyTest.php index 3facbb4..e20aec9 100644 --- a/tests/Unit/PathSafetyTest.php +++ b/tests/Unit/PathSafetyTest.php @@ -10,10 +10,8 @@ use PHPUnit\Framework\TestCase; /** - * Covers URL path-segment sanitization (matches the reference client's `#1011`): an id or key - * interpolated into a request path is percent-encoded, and an empty or dot-segment value is rejected - * instead, since a URL parser resolves "." / ".." from the decoded path even when the string contains - * no literal "/" of its own. + * Tests of the path segment sanitization: an ID or key in a request path is percent-encoded, and an empty + * or dot segment is rejected, since a URL parser would resolve it to the parent. */ final class PathSafetyTest extends TestCase { @@ -103,11 +101,7 @@ public function testDotDotResourceIdRejected(): void $this->client(new MockTransport())->actor('..'); } - /** - * A record key sent as "a/../../secret" cannot escape the records/ sub-path: the "/" is - * percent-encoded into "%2F", which decodes back to a single literal segment rather than being - * resolved by a URL parser into extra path segments. - */ + /** The key "a/../../secret" cannot leave the records/ sub-path, since its "/" is encoded as "%2F". */ public function testRecordKeyWithSlashesCannotEscapeItsSegment(): void { $transport = (new MockTransport())->queueResponse(200, '', ['Content-Type' => 'text/plain']); diff --git a/tests/Unit/PlatformTest.php b/tests/Unit/PlatformTest.php index 73138c3..91cafb6 100644 --- a/tests/Unit/PlatformTest.php +++ b/tests/Unit/PlatformTest.php @@ -10,8 +10,7 @@ final class PlatformTest extends TestCase { /** - * The User-Agent OS token must be the short lowercase identifier used by the other Apify clients - * (Node's os.platform()), regardless of the uname-style value PHP_OS reports. + * The OS token of the User-Agent is the short lowercase name, whatever {@code PHP_OS} reports. * * @return array */ diff --git a/tests/Unit/PublicUrlExpiryTest.php b/tests/Unit/PublicUrlExpiryTest.php index aad9f62..3648a32 100644 --- a/tests/Unit/PublicUrlExpiryTest.php +++ b/tests/Unit/PublicUrlExpiryTest.php @@ -11,8 +11,8 @@ use PHPUnit\Framework\TestCase; /** - * A signed public URL's {@code expiresInSecs} must be positive: the signer reads zero as "never - * expires" and a negative value yields an already-expired URL, so both are rejected before any request. + * An {@code expiresInSecs} of a signed public URL must be positive. Zero would mean "never expires", and a negative + * value gives an expired URL. */ final class PublicUrlExpiryTest extends TestCase { diff --git a/tests/Unit/RequestQueueTypedResultsTest.php b/tests/Unit/RequestQueueTypedResultsTest.php index a907efa..af1b3c9 100644 --- a/tests/Unit/RequestQueueTypedResultsTest.php +++ b/tests/Unit/RequestQueueTypedResultsTest.php @@ -10,12 +10,7 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; -/** - * Offline behavioral tests for the {@see \Apify\Client\Resource\RequestQueueClient} operations that - * return typed models instead of a raw decoded array: {@code listHead()}, {@code listAndLockHead()}, - * {@code listRequests()}, {@code prolongRequestLock()}, {@code unlockRequests()}, and - * {@code batchDeleteRequests()}. - */ +/** Offline tests of the {@see \Apify\Client\Resource\RequestQueueClient} operations that return typed models. */ final class RequestQueueTypedResultsTest extends TestCase { private function client(MockTransport $transport): ApifyClient diff --git a/tests/Unit/RequestShapeTest.php b/tests/Unit/RequestShapeTest.php index 50757aa..606cd60 100644 --- a/tests/Unit/RequestShapeTest.php +++ b/tests/Unit/RequestShapeTest.php @@ -13,13 +13,11 @@ use Apify\Client\Options\RequestQueueClientOptions; use Apify\Client\Options\RunChargeOptions; use Apify\Client\Options\RunResurrectOptions; +use Apify\Client\Options\SetRecordOptions; use Apify\Client\Options\ValidateInputOptions; use PHPUnit\Framework\TestCase; -/** - * Offline request-shape tests for mutating/convenience endpoints that are risky to exercise live on - * the shared account. Each asserts the HTTP method, path, query and body the client actually sends. - */ +/** Offline tests of the method, path, query, and body that mutating and convenience calls send. */ final class RequestShapeTest extends TestCase { private function client(MockTransport $transport): ApifyClient @@ -27,6 +25,16 @@ private function client(MockTransport $transport): ApifyClient return new ApifyClient(token: 't', minDelayBetweenRetriesMillis: 1, timeoutSecs: 5, httpClient: $transport); } + public function testSetRecordJsonPassesTheWriteOptionsOn(): void + { + $transport = (new MockTransport())->queueResponse(200, ''); + + $this->client($transport)->keyValueStore('s1')->setRecordJson('k', ['a' => 1], new SetRecordOptions(timeoutSecs: 3)); + + self::assertSame([3.0], $transport->timeouts); + self::assertSame('{"a":1}', MockTransport::readBody($transport->lastRequest())); + } + public function testRunChargeSendsBodyAndIdempotencyKey(): void { $transport = (new MockTransport())->queueResponse(200, ''); @@ -83,9 +91,7 @@ public function testMetamorphSendsTargetActorIdAndInput(): void public function testMetamorphSendsRawStringInputAsIs(): void { - // A string $input is sent exactly as given (raw bytes), not JSON-encoded, so it can be - // paired with a non-default contentType — matching the reference client's raw-bytes Actor - // input support (ActorClient::start() is covered the same way below). + // A string $input is sent as it is, not JSON-encoded, so it can have another contentType. $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'r']])); $this->client($transport)->run('run1')->metamorph('apify/other', 'col_a,col_b\n1,2', new MetamorphOptions(contentType: 'text/csv')); diff --git a/tests/Unit/RunIterateDatasetItemsTest.php b/tests/Unit/RunIterateDatasetItemsTest.php index bd83a52..4b42774 100644 --- a/tests/Unit/RunIterateDatasetItemsTest.php +++ b/tests/Unit/RunIterateDatasetItemsTest.php @@ -10,8 +10,8 @@ use PHPUnit\Framework\TestCase; /** - * Offline tests for {@see \Apify\Client\Resource\RunClient::iterateDatasetItems()}, driven by a - * scripted transport. Each test queues the exact request sequence the algorithm is expected to make. + * Offline tests of {@see \Apify\Client\Resource\RunClient::iterateDatasetItems()}. Each test queues the request + * sequence it expects. */ final class RunIterateDatasetItemsTest extends TestCase { @@ -42,13 +42,13 @@ private static function scanned(int $count): array return ['X-Apify-Pagination-Count' => (string) $count]; } - /** @return list> the offset/limit query of every request, in order */ + /** @return list> the query and path of every request, in order */ private static function queries(MockTransport $transport): array { $queries = []; foreach ($transport->received as $request) { parse_str($request->getUri()->getQuery(), $query); - $queries[] = $query + ['path' => $request->getUri()->getPath()]; + $queries[] = array_filter($query, 'is_string') + ['path' => $request->getUri()->getPath()]; } return $queries; } diff --git a/tests/Unit/SignatureTest.php b/tests/Unit/SignatureTest.php index 03b1404..356ad5f 100644 --- a/tests/Unit/SignatureTest.php +++ b/tests/Unit/SignatureTest.php @@ -18,11 +18,8 @@ public function testHmacSignatureIsDeterministicAndBase62(): void } /** - * Known-answer vectors: the base62 HMAC and the base64url storage-content envelope are pinned to - * values independently computed with an arbitrary-precision bignum oracle (matching the upstream - * {@code @apify/utilities} algorithm). This guards the byte-wise base62 long division - * (leading-zero handling, alphabet ordering) and the base64url envelope against regressions that - * determinism/charset checks alone would miss. + * Known-answer vectors, computed with an arbitrary-precision oracle. They guard the base62 long division + * (leading zeros, alphabet order) and the base64url envelope. */ public function testKnownAnswerVectors(): void { diff --git a/tests/Unit/SpecSyncTest.php b/tests/Unit/SpecSyncTest.php index 1011ff6..92d5820 100644 --- a/tests/Unit/SpecSyncTest.php +++ b/tests/Unit/SpecSyncTest.php @@ -13,9 +13,8 @@ use PHPUnit\Framework\TestCase; /** - * Offline tests for the endpoint parameters the OpenAPI specification added to endpoints this client - * implements, for the by-ID user endpoints, for the schedule log, and for responses that carry a - * documented {@code null} where a value used to be. + * Offline tests of the parameters that the specification added to endpoints of this client, the by-ID user + * endpoints, the schedule log, and responses with a documented {@code null}. */ final class SpecSyncTest extends TestCase { @@ -24,7 +23,7 @@ private function client(MockTransport $transport): ApifyClient return new ApifyClient(token: 't', minDelayBetweenRetriesMillis: 1, timeoutSecs: 5, httpClient: $transport); } - /** @return array */ + /** @return array|string> */ private static function queryOf(MockTransport $transport): array { parse_str($transport->lastRequest()->getUri()->getQuery(), $query); diff --git a/tests/Unit/TaskPublishUnpublishTest.php b/tests/Unit/TaskPublishUnpublishTest.php index f3069df..266adb9 100644 --- a/tests/Unit/TaskPublishUnpublishTest.php +++ b/tests/Unit/TaskPublishUnpublishTest.php @@ -9,11 +9,9 @@ use PHPUnit\Framework\TestCase; /** - * Offline behavioral tests for {@see \Apify\Client\Resource\TaskClient::publish()} and - * {@see \Apify\Client\Resource\TaskClient::unpublish()}: both are thin wrappers around - * {@see \Apify\Client\Resource\TaskClient::update()}, so what needs covering offline is the exact - * request they send (method, path, body), independent of the live-API-dependent behavior already - * exercised by {@see \Apify\Client\Tests\Integration\TaskIntegrationTest::testPublishUnpublish()}. + * Offline tests of {@see \Apify\Client\Resource\TaskClient::publish()} and + * {@see \Apify\Client\Resource\TaskClient::unpublish()}. Both wrap + * {@see \Apify\Client\Resource\TaskClient::update()}, so the tests check the request they send. */ final class TaskPublishUnpublishTest extends TestCase { diff --git a/tests/Unit/TimeoutTiersTest.php b/tests/Unit/TimeoutTiersTest.php index 4557611..8ab69d1 100644 --- a/tests/Unit/TimeoutTiersTest.php +++ b/tests/Unit/TimeoutTiersTest.php @@ -13,11 +13,9 @@ use PHPUnit\Framework\TestCase; /** - * Covers the timeout-tier configuration (matches the reference client's `#1046`): the {@code short} and - * {@code medium} tiers default to 5s and 30s, the {@code long} tier and {@code timeoutMaxSecs} to the - * client's overall {@code timeoutSecs}; a per-call override replaces the method's tier for that call - * alone; {@code timeoutMaxSecs} caps both; and a {@code waitForFinish} request gets the API's hold on - * top of its tier. + * Tests of the timeout tiers: {@code short} and {@code medium} default to 5 s and 30 s, {@code long} and + * {@code timeoutMaxSecs} to the client's {@code timeoutSecs}. A per-call override replaces the tier for that + * call, {@code timeoutMaxSecs} caps both, and a {@code waitForFinish} request adds the API's hold to its tier. */ final class TimeoutTiersTest extends TestCase { @@ -195,7 +193,7 @@ private static function runBody(string $status): string return Json::encode(['data' => ['id' => 'run1', 'status' => $status]]); } - /** @return array */ + /** @return array|string> */ private static function queryOf(MockTransport $transport, int $index): array { parse_str($transport->received[$index]->getUri()->getQuery(), $query); diff --git a/tests/Unit/UrlNormalizerTest.php b/tests/Unit/UrlNormalizerTest.php index c4374ac..af3b436 100644 --- a/tests/Unit/UrlNormalizerTest.php +++ b/tests/Unit/UrlNormalizerTest.php @@ -17,9 +17,8 @@ use PHPUnit\Framework\TestCase; /** - * Covers the WHATWG URL normalization of the response fields the OpenAPI specification declares as - * {@code format: uri} (the reference client's `#1045`). The expected values are what `new URL(x).href` - * returns. + * Tests of the WHATWG URL normalization of the {@code format: uri} response fields. The expectations are what + * {@code new URL(x).href} returns. */ final class UrlNormalizerTest extends TestCase { diff --git a/tests/Unit/WaitForFinishTest.php b/tests/Unit/WaitForFinishTest.php index 9618440..cd60b66 100644 --- a/tests/Unit/WaitForFinishTest.php +++ b/tests/Unit/WaitForFinishTest.php @@ -10,11 +10,9 @@ use PHPUnit\Framework\TestCase; /** - * Hermetic tests for {@see \Apify\Client\Resource\RunClient::waitForFinish()} / - * {@see \Apify\Client\Resource\BuildClient::waitForFinish()}'s polling loop, driven by a scripted - * transport. They exercise the 404-tolerance grace window added alongside the reference client's - * {@code waitForFinish()} fix: a run/build that 404s right after creation (replica lag) is polled - * through, but one that keeps 404ing gives up and throws instead of polling for the whole budget. + * Tests of the polling loop of {@see \Apify\Client\Resource\RunClient::waitForFinish()} and + * {@see \Apify\Client\Resource\BuildClient::waitForFinish()}. A run or build that returns 404 right after its + * creation is polled through, but one that keeps returning 404 throws instead of polling for the whole budget. */ final class WaitForFinishTest extends TestCase { diff --git a/tests/Unit/WaitForResourcesTest.php b/tests/Unit/WaitForResourcesTest.php index 65bb66c..c9badee 100644 --- a/tests/Unit/WaitForResourcesTest.php +++ b/tests/Unit/WaitForResourcesTest.php @@ -14,15 +14,11 @@ use PHPUnit\Framework\TestCase; /** - * Tests for the {@code waitForResources} option of {@see \Apify\Client\Resource\ActorClient} and - * {@see \Apify\Client\Resource\TaskClient}'s {@code start()}/{@code call()}. Mirrors the reference - * client's {@code startWaitingForResources} behavior. + * Tests of the {@code waitForResources} option of {@see \Apify\Client\Resource\ActorClient} and + * {@see \Apify\Client\Resource\TaskClient}. * - * The retry/backoff mechanism itself is exercised directly against {@see WaitForResources::start()} - * with a near-zero {@code $cooldownSecs} override (a parameter that exists solely for this), so the - * suite does not sleep through real 10-second cooldowns. That {@code $cooldownSecs} is never passed by - * {@see \Apify\Client\Resource\ActorClient}/{@see \Apify\Client\Resource\TaskClient} themselves, so the - * real cooldown is exercised only by the integration suite. + * The retries are tested on {@see WaitForResources::start()} with a tiny {@code $cooldownSecs}, which only + * tests pass, so the suite does not wait for real 10-second cooldowns. */ final class WaitForResourcesTest extends TestCase { @@ -71,22 +67,14 @@ public function testNumberOfSecondsBoundsTheRetryingAndThrowsTheLastRejection(): throw self::rejectionError('actor-memory-limit-exceeded'); }; - // $start rejects unconditionally, so WaitForResources::start() is guaranteed to exhaust the - // 1-second budget and rethrow the last rejection; expectException() (rather than a try/catch - // with a fail()-on-fallthrough branch) asserts that, since PHPUnit itself fails the test if the - // call below returns instead of throwing, keeping the "it must throw" assertion live without a - // branch that PHPStan can (correctly, given this always-throwing $start) prove unreachable. + // The start always rejects, so the call exhausts the budget and rethrows the last rejection. $this->expectException(ApifyApiException::class); $this->expectExceptionMessage('rejected: actor-memory-limit-exceeded'); try { WaitForResources::start($start, 1, cooldownSecs: 0.001); } finally { - // Roughly 1 / 0.001 attempts before the 1-second budget runs out; a wide range keeps this - // robust to scheduling jitter while still proving it bounds the retrying rather than - // retrying forever. $waitForResources only accepts a whole number of seconds, so 1 is the - // smallest nonzero budget this can exercise without hitting the (already separately tested) - // "0 makes a single attempt" case. The `finally` runs before the exception propagates out - // of this test method to PHPUnit's expectException() machinery above. + // About 1 / 0.001 attempts fit the 1-second budget. A wide range tolerates scheduling jitter. 1 is + // the smallest nonzero budget, since only whole seconds are accepted. self::assertGreaterThan(10, $attempts); self::assertLessThan(10000, $attempts); } @@ -94,10 +82,7 @@ public function testNumberOfSecondsBoundsTheRetryingAndThrowsTheLastRejection(): public function testActorStartAndTaskStartWireTheOptionIntoWaitForResources(): void { - // Succeeding on the very first attempt needs no cooldown at all, so this confirms - // ActorClient/TaskClient actually construct the WaitForResources::start() call (rather than, - // say, silently dropping the option) without the suite paying for even one real sleep; the - // retry/cooldown mechanism itself is exercised above, directly against WaitForResources. + // A start that succeeds at once needs no cooldown, which shows that the clients pass the option on. $actorTransport = (new MockTransport())->queueResponse(201, self::runBody()); $run = $this->client($actorTransport)->actor('a1')->start(null, new ActorStartOptions(waitForResources: true)); self::assertSame('run1', $run->getId());