diff --git a/.github/workflows/php-integration-tests.yml b/.github/workflows/php-integration-tests.yml new file mode 100644 index 0000000..29b3d1a --- /dev/null +++ b/.github/workflows/php-integration-tests.yml @@ -0,0 +1,81 @@ +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. +on: + pull_request: + branches: [master] + paths: + - '**/*.php' + - 'composer.json' + - 'composer.lock' + - '.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. + - 'docs/**' + - 'README.md' + - '.github/workflows/php-integration-tests.yml' + workflow_dispatch: + +# Avoid concurrent runs of the same ref racing on the shared test account. +concurrency: + group: php-integration-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + extensions: mbstring, json, curl + coverage: none + tools: composer:v2 + + - 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). + - name: Check formatting (php-cs-fixer) + run: composer lint + + # Static-analysis gate mandated by the coding rules. + - name: Static analysis (PHPStan) + run: composer stan + + # Offline unit tests (mock transport): prove the retry/error/signature logic without the API. + - 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. + - name: Require APIFY_TOKEN secret + env: + APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }} + run: | + if [ -z "${APIFY_TOKEN}" ]; then + echo "::error::APIFY_TOKEN secret is empty or missing; integration tests would not run against the API." + exit 1 + fi + + - 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). + - name: Test examples + env: + APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }} + run: vendor/bin/phpunit --testsuite examples diff --git a/.github/workflows/php-publish.yml b/.github/workflows/php-publish.yml new file mode 100644 index 0000000..66f7f3d --- /dev/null +++ b/.github/workflows/php-publish.yml @@ -0,0 +1,114 @@ +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. +# +# 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). +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Run all checks but do not tag, release, or notify Packagist.' + type: boolean + default: false + +# Never allow two publish runs to race; publishing two releases concurrently is hard to undo. +concurrency: + group: php-publish + cancel-in-progress: false + +permissions: + contents: write # create the tagged GitHub release + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # A release must only ever be cut from master. + - name: Require master branch + run: | + if [ "${GITHUB_REF}" != "refs/heads/master" ]; then + echo "::error::Publishing is only allowed from master, but this run is on '${GITHUB_REF}'." + exit 1 + fi + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + extensions: mbstring, json, curl + coverage: none + tools: composer:v2 + + - 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. + - name: Check formatting (php-cs-fixer) + run: composer lint + + - name: Static analysis (PHPStan) + run: composer stan + + - name: Unit tests + run: vendor/bin/phpunit --testsuite unit + + - name: Resolve version from src/Version.php + id: version + run: | + version=$(php -r "require 'src/Version.php'; echo Apify\Client\Version::CLIENT_VERSION;") + if ! echo "${version}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Version::CLIENT_VERSION '${version}' is not a bare semver (X.Y.Z)." + exit 1 + fi + echo "tag=v${version}" >> "$GITHUB_OUTPUT" + echo "Resolved release tag: v${version}" + + - name: Ensure tag does not already exist + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + if git ls-remote --exit-code --tags origin "${TAG}" >/dev/null 2>&1; then + echo "::error::Tag ${TAG} already exists on origin; bump Version::CLIENT_VERSION first." + exit 1 + fi + + - name: Create and push release tag + if: ${{ github.event.inputs.dry_run != 'true' }} + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "${TAG}" -m "Release ${TAG}" + git push origin "${TAG}" + + - name: Create GitHub release + if: ${{ github.event.inputs.dry_run != 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + run: | + gh release create "${TAG}" \ + --title "${TAG}" \ + --notes "Apify PHP client ${TAG}. See CHANGELOG.md for details." + + # Notify Packagist to pull the new tag. Credentials come from repository secrets. + - 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' \ + "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/.gitignore b/.gitignore index a67d42b..ba911bc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,11 @@ composer.phar /vendor/ # Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control -# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file +# The lock file is committed so CI installs a deterministic, resolved dependency set +# (including the phpstan static-analysis gate). https://getcomposer.org/doc/01-basic-usage.md # composer.lock + +# Tooling caches +.phpunit.cache/ +.phpunit.result.cache +.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..e54062a --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,26 @@ +in(__DIR__ . '/src') + ->in(__DIR__ . '/tests'); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setRules([ + '@PSR12' => true, + 'declare_strict_types' => true, + 'array_syntax' => ['syntax' => 'short'], + 'no_unused_imports' => true, + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'single_quote' => true, + 'trailing_comma_in_multiline' => true, + 'no_trailing_whitespace' => true, + ]) + ->setFinder($finder); diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..69fccf7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +## 0.1.0 + +- Initial PHP client for the Apify API (spec `v2-2026-07-02T131926Z`). +- Resource clients for Actors, Actor versions and environment variables, builds, runs, datasets, + key-value stores, request queues, tasks, schedules, webhooks, webhook dispatches, the Apify Store, + users, and logs. +- Convenience helpers consistent with the JS reference client: `actor()->call()`/`start()`, + `validateInput()`, `defaultBuild()`, `lastRun()`, run `abort`/`metamorph`/`reboot`/`resurrect`/ + `charge`/`waitForFinish`, dataset `listItems`/`downloadItems`/`pushItems`/public URLs, key-value + store records and public URLs, request queue batch add with retries, lazy request/store iteration, + and log streaming. `lastRun(status, origin)` filters propagate to the run's nested dataset, + key-value store, request queue, and log accessors, so they resolve the same run. +- `batchAddRequests` requires a non-empty `uniqueKey` per request, splits batches by both the 25-request + count limit and the ~9 MiB payload-size limit, and retries only the requests the API reports + unprocessed in a successful response (previously it hard-coded an empty unprocessed list and could + mis-correlate requests). Consistent with the reference client, a failed batch call reports that + chunk's not-yet-processed requests as unprocessed rather than throwing. +- `datasets()->getOrCreate()` and `keyValueStores()->getOrCreate()` accept an optional `schema`. +- `requestQueue($id, RequestQueueClientOptions)` accepts `clientKey` and `timeoutSecs`; `paginateRequests()` + accepts `PaginateRequestsOptions` (`limit`, `maxPageLimit`, `exclusiveStartId`, `cursor`, `filter`). +- Replaceable HTTP transport (`HttpClientInterface`) with a default Guzzle implementation and a + PSR-18 adapter; automatic retries with exponential backoff and jitter, growing per-attempt + timeouts, and HMAC-SHA256 storage URL signing. +- Public `Version::CLIENT_VERSION` and `Version::API_SPEC_VERSION` constants. +- Integration test suite, documentation with runnable examples, and CI workflows for integration + tests and publishing. diff --git a/README.md b/README.md index c0cf6dc..defae34 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,116 @@ -# apify-client-php -Apify API client for PHP—Programmatically run Actors, manage and stream data from storages (datasets, key-value stores, request queues), schedule and monitor runs, and access the full Apify platform API. Sync and async interfaces with automatic retries and pagination. +# 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. + +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). + +## Requirements + +- PHP 8.1 or newer, with the `json`, `mbstring` and `hash` extensions. + +## Installation + +```bash +composer require apify/apify-client +``` + +## Quick start + +All snippets assume the client types are imported from their namespaces, e.g. +`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). +$run = $client->actor('apify/hello-world')->call(null, null, null); + +// Read items from the run's default dataset. +$items = $client->dataset($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). + +## Configuration + +The constructor accepts named arguments for non-default settings: + +```php +$configured = new ApifyClient( + token: 'my-api-token', + maxRetries: 5, + minDelayBetweenRetriesMillis: 1000, + timeoutSecs: 120, + userAgentSuffix: 'my-app/1.2.3', +); +``` + +| Argument | Default | Meaning | +|---|---|---| +| `token` | `null` | API token, sent as a Bearer token. | +| `baseUrl` | `https://api.apify.com` | API base URL; the `/v2` suffix is appended automatically. | +| `publicBaseUrl` | `baseUrl` | Base URL used when building public, shareable resource URLs. | +| `maxRetries` | `8` | Maximum retries for failed requests. | +| `minDelayBetweenRetriesMillis` | `500` | Minimum delay between retries (exponential backoff). | +| `maxDelayBetweenRetriesMillis` | request timeout | Upper bound on the growing inter-retry delay. | +| `timeoutSecs` | `360` | Overall per-request timeout. | +| `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. 4xx responses (other than 429) are thrown immediately as `ApifyApiException`. + +### 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: + +```php +$client = new ApifyClient(token: 'my-api-token', httpClient: new GuzzleHttpClient()); +``` + +## Error handling + +Methods that fetch a single resource return `null` when the resource does not exist (rather than +throwing). Other API failures are thrown as `Apify\Client\Exception\ApifyApiException`, which exposes +the HTTP status, API error `type`, message, attempt count, and request method/path: + +```php +try { + $client->actor('does/not-exist')->update(['title' => 'x']); +} catch (ApifyApiException $e) { + echo $e->getStatusCode() . ' ' . $e->getType() . ': ' . $e->getApiMessage() . PHP_EOL; +} +``` + +## 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. + +```php +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). + +## License + +[Apache-2.0](LICENSE). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1ae64be --- /dev/null +++ b/composer.json @@ -0,0 +1,61 @@ +{ + "name": "apify/apify-client", + "description": "Official, experimental (AI-generated and AI-maintained) Apify API client for PHP: run Actors, manage storages (datasets, key-value stores, request queues), schedules, webhooks and more.", + "type": "library", + "keywords": [ + "apify", + "api", + "client", + "actor", + "scraping", + "crawler", + "automation" + ], + "homepage": "https://github.com/apify/apify-client-php", + "license": "Apache-2.0", + "authors": [ + { + "name": "Apify", + "email": "support@apify.com", + "homepage": "https://apify.com" + } + ], + "require": { + "php": ">=8.1", + "ext-json": "*", + "ext-mbstring": "*", + "ext-hash": "*", + "psr/http-message": "^1.1 || ^2.0", + "psr/http-factory": "^1.0", + "psr/http-client": "^1.0", + "guzzlehttp/guzzle": "^7.5", + "guzzlehttp/psr7": "^2.4" + }, + "require-dev": { + "phpunit/phpunit": "^10.5", + "friendsofphp/php-cs-fixer": "^3.52", + "phpstan/phpstan": "^1.11" + }, + "autoload": { + "psr-4": { + "Apify\\Client\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Apify\\Client\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "lint": "php-cs-fixer fix --dry-run --diff", + "fix": "php-cs-fixer fix", + "stan": "phpstan analyse" + }, + "config": { + "sort-packages": true, + "platform": { + "php": "8.1.0" + } + } +} \ No newline at end of file diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..074e62a --- /dev/null +++ b/composer.lock @@ -0,0 +1,4960 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "d4905617a00927f986ae82d90507823b", + "packages": [ + { + "name": "guzzlehttp/guzzle", + "version": "7.13.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "55901a76dfd2006a0cc012b9e3c5b487f796478d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/55901a76dfd2006a0cc012b9e3c5b487f796478d", + "reference": "55901a76dfd2006a0cc012b9e3c5b487f796478d", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.5", + "guzzlehttp/psr7": "^2.12.3", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.6", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.13.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-06-29T20:14:18+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.5.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.5.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-06-02T12:23:43+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.12.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d", + "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.12.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-06-23T15:21:08+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + } + ], + "packages-dev": [ + { + "name": "clue/ndjson-react", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\NDJson\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "ergebnis/agent-detector", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/agent-detector.git", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271", + "shasum": "" + }, + "require": { + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.51.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", + "fakerphp/faker": "^1.24.1", + "infection/infection": "^0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a detector for detecting the presence of an agent.", + "homepage": "https://github.com/ergebnis/agent-detector", + "support": { + "issues": "https://github.com/ergebnis/agent-detector/issues", + "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/agent-detector" + }, + "time": "2026-05-07T08:19:07+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "friendsofphp/php-cs-fixer", + "version": "v3.95.11", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "35f98e1293283397824d7f349ce5afb8747c3cd5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/35f98e1293283397824d7f349ce5afb8747c3cd5", + "reference": "35f98e1293283397824d7f349ce5afb8747c3cd5", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ergebnis/agent-detector": "^1.2", + "ext-filter": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.3", + "php": "^7.4 || ^8.0", + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.37", + "symfony/polyfill-php80": "^1.37", + "symfony/polyfill-php81": "^1.37", + "symfony/polyfill-php84": "^1.37", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.11.0", + "infection/infection": "^0.32.7", + "justinrainbow/json-schema": "^6.10.0", + "keradus/cli-executor": "^2.3", + "mikey179/vfsstream": "^1.6.12", + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", + "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55", + "symfony/polyfill-php85": "^1.38", + "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.0", + "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.0" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." + }, + "bin": [ + "php-cs-fixer" + ], + "type": "application", + "autoload": { + "psr-4": { + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/**/Internal/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", + "keywords": [ + "Static code analysis", + "fixer", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.11" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2026-06-25T14:17:04+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "1.12.33", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", + "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-02-28T20:30:03+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "10.1.16", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=8.1", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-text-template": "^3.0.1", + "sebastian/code-unit-reverse-lookup": "^3.0.0", + "sebastian/complexity": "^3.2.0", + "sebastian/environment": "^6.1.0", + "sebastian/lines-of-code": "^2.0.2", + "sebastian/version": "^4.0.1", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^10.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:31:57+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T06:24:48+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:56:09+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T14:07:24+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:57:52+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "10.5.63", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "33198268dad71e926626b618f3ec3966661e4d90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90", + "reference": "33198268dad71e926626b618f3ec3966661e4d90", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.1", + "phpunit/php-code-coverage": "^10.1.16", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-invoker": "^4.0.0", + "phpunit/php-text-template": "^3.0.1", + "phpunit/php-timer": "^6.0.0", + "sebastian/cli-parser": "^2.0.1", + "sebastian/code-unit": "^2.0.0", + "sebastian/comparator": "^5.0.5", + "sebastian/diff": "^5.1.1", + "sebastian/environment": "^6.1.0", + "sebastian/exporter": "^5.1.4", + "sebastian/global-state": "^6.0.2", + "sebastian/object-enumerator": "^5.0.0", + "sebastian/recursion-context": "^5.0.1", + "sebastian/type": "^4.0.0", + "sebastian/version": "^4.0.1" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-01-27T05:48:37+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-12-23T15:25:20+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:12:49+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:58:43+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:59:15+00:00" + }, + { + "name": "sebastian/comparator", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/diff": "^5.0", + "sebastian/exporter": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:25:16+00:00" + }, + { + "name": "sebastian/complexity", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "68ff824baeae169ec9f2137158ee529584553799" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799", + "reference": "68ff824baeae169ec9f2137158ee529584553799", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:37:17+00:00" + }, + { + "name": "sebastian/diff", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "symfony/process": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:15:17+00:00" + }, + { + "name": "sebastian/environment", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-23T08:47:14+00:00" + }, + { + "name": "sebastian/exporter", + "version": "5.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "0735b90f4da94969541dac1da743446e276defa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:09:11+00:00" + }, + { + "name": "sebastian/global-state", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:19:19+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:38:20+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:08:32+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:06:18+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:50:56+00:00" + }, + { + "name": "sebastian/type", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:10:45+00:00" + }, + { + "name": "sebastian/version", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-07T11:34:05+00:00" + }, + { + "name": "symfony/console", + "version": "v6.4.42", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "9ef84af84a7b66396da483634227650506428639" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/9ef84af84a7b66396da483634227650506428639", + "reference": "9ef84af84a7b66396da483634227650506428639", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^5.4|^6.0|^7.0" + }, + "conflict": { + "symfony/dependency-injection": "<5.4", + "symfony/dotenv": "<5.4", + "symfony/event-dispatcher": "<5.4", + "symfony/lock": "<5.4", + "symfony/process": "<5.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v6.4.42" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-15T05:35:29+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v6.4.37", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "2e3bf817ba9347341ab15926700fb6320367c0e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/2e3bf817ba9347341ab15926700fb6320367c0e1", + "reference": "2e3bf817ba9347341ab15926700fb6320367c0e1", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<5.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/error-handler": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.37" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T14:11:12+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v6.4.39", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "c507b077756b4e3e09adbbe7975fac81cd3722ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/c507b077756b4e3e09adbbe7975fac81cd3722ca", + "reference": "c507b077756b4e3e09adbbe7975fac81cd3722ca", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^5.4|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v6.4.39" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-07T13:11:42+00:00" + }, + { + "name": "symfony/finder", + "version": "v6.4.42", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "0b73dac42493acbadbba644207a715b254e9b029" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/0b73dac42493acbadbba644207a715b254e9b029", + "reference": "0b73dac42493acbadbba644207a715b254e9b029", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "symfony/filesystem": "^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v6.4.42" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-26T15:18:24+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v6.4.30", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "eeaa8cabe54c7b3516938c72a4a161c0cc80a34f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/eeaa8cabe54c7b3516938c72a4a161c0cc80a34f", + "reference": "eeaa8cabe54c7b3516938c72a4a161c0cc80a34f", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v6.4.30" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-12T13:06:53+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T05:58:03+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:45:58+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/process", + "version": "v6.4.41", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "c8fc09bdfe9fde9aaa89b415a4477feaccec16a7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/c8fc09bdfe9fde9aaa89b415a4477feaccec16a7", + "reference": "c8fc09bdfe9fde9aaa89b415a4477feaccec16a7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v6.4.41" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T13:47:21+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-16T09:55:08+00:00" + }, + { + "name": "symfony/stopwatch", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "b67e94e06a05d9572c2fa354483b3e13e3cb1898" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/b67e94e06a05d9572c2fa354483b3e13e3cb1898", + "reference": "b67e94e06a05d9572c2fa354483b3e13e3cb1898", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a way to profile code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v6.4.24" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-10T08:14:14+00:00" + }, + { + "name": "symfony/string", + "version": "v6.4.39", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "62e3c927de664edadb5bef260987eb047a17a113" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/62e3c927de664edadb5bef260987eb047a17a113", + "reference": "62e3c927de664edadb5bef260987eb047a17a113", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/http-client": "^5.4|^6.0|^7.0", + "symfony/intl": "^6.2|^7.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v6.4.39" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-12T11:44:19+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.1", + "ext-json": "*", + "ext-mbstring": "*", + "ext-hash": "*" + }, + "platform-dev": {}, + "platform-overrides": { + "php": "8.1.0" + }, + "plugin-api-version": "2.6.0" +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..ee9cea5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,118 @@ +# Apify PHP client documentation + +> **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. + +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). + +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;`): + +```php +$client = new ApifyClient('my-api-token'); +``` + +### Namespaces + +Every class is under the `Apify\Client\` PSR-4 root. Use these when writing `use` statements: + +| 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 (the `*Options` classes) **and** enums. | `ActorListOptions`, `DatasetListItemsOptions`, `PaginateRequestsOptions`, `RequestQueueClientOptions`, `DownloadItemsFormat` | +| `Apify\Client\Http\` | The replaceable transport and its adapters. | `HttpClientInterface`, `GuzzleHttpClient`, `Psr18HttpClient` | +| `Apify\Client\Exception\` | Exceptions thrown by the client. | `ApifyApiException`, `TransportException` | + +For example, to add requests to a queue you would import the model and (optionally) the batch options: + +```php +use Apify\Client\ApifyClient; +use Apify\Client\Model\RequestQueueRequest; +use Apify\Client\Options\BatchAddRequestsOptions; +``` + +Methods that fetch a single resource return `null` when the resource does not exist, rather than +throwing. API failures are thrown as `ApifyApiException` (see [error handling](../README.md#error-handling)). + +## 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()`: + +```php +$schedule = $client->schedule('SCHEDULE_ID')->get(); +$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 +associative array (or accept an arbitrary value serialized to JSON): + +- Read: `me()->monthlyUsage(...)`, `me()->limits()`, `task($id)->getInput()`, + `build($id)->getOpenApiDefinition()`, `dataset($id)->getStatistics()`, and the raw request-queue + operations (`listRequests`, `listAndLockHead`, `prolongRequestLock`, `unlockRequests`, + `batchDeleteRequests`). +- Write: definition/`update`/`create` arguments accept any JSON-serializable value — typically 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: + +```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. + +## 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`. + +```php +$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. + +```php +$page = $client->actors()->list(new ActorListOptions(limit: 5)); +foreach ($page as $actor) { + echo $actor->getName() . PHP_EOL; +} +``` + +## 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. + +## Resource pages + +- [Actors, versions & environment variables](actors.md) +- [Builds](builds.md) +- [Runs](runs.md) +- [Storages (datasets, key-value stores, request queues)](storages.md) +- [Tasks](tasks.md) +- [Schedules](schedules.md) +- [Webhooks & dispatches](webhooks.md) +- [Store, users & logs](misc.md) +- [Models reference](models.md) +- [Options reference](options.md) +- [Runnable examples](examples.md) diff --git a/docs/actors.md b/docs/actors.md new file mode 100644 index 0000000..9699591 --- /dev/null +++ b/docs/actors.md @@ -0,0 +1,68 @@ +# Actors, versions & environment variables + +Snippets assume `$client = new ApifyClient('my-api-token');` and imported types. + +## Actor collection — `$client->actors()` + +- `list(?ActorListOptions $options): PaginationList` — list the account's Actors. +- `create(mixed $actor): Actor` — create a new Actor from a JSON-serializable definition. + +```php +$page = $client->actors()->list(new ActorListOptions(my: true, limit: 10)); + +$actor = $client->actors()->create([ + 'name' => 'my-actor', + 'isPublic' => false, + 'versions' => [[ + 'versionNumber' => '0.0', + 'sourceType' => 'SOURCE_FILES', + 'buildTag' => 'latest', + 'sourceFiles' => [], + ]], +]); +``` + +## A single Actor — `$client->actor($id)` + +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): ActorRun` — start and wait. +- `validateInput(mixed $input = null, ?ValidateInputOptions $options = null): bool` +- `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. + +```php +$run = $client->actor('apify/hello-world')->call(['name' => 'world'], new ActorStartOptions(memoryMbytes: 512), 120); +$isValid = $client->actor('apify/hello-world')->validateInput(['firstNumber' => 1]); +$lastSucceeded = $client->actor('apify/hello-world')->lastRun(new LastRunOptions(status: 'SUCCEEDED'))->get(); +``` + +## Actor versions — `$client->actor($id)->versions()` / `->version($n)` + +- Collection: `list(?ListOptions): PaginationList`, `create(mixed $version): ActorVersion`. +- Single: `get(): ?ActorVersion`, `update(mixed $newFields): ActorVersion`, `delete(): void`. + +```php +$version = $client->actor('me~my-actor')->versions()->create([ + 'versionNumber' => '0.1', + 'sourceType' => 'SOURCE_FILES', + 'sourceFiles' => [], +]); +``` + +## Environment variables — `->version($n)->envVars()` / `->envVar($name)` + +- Collection: `list(): PaginationList`, `create(ActorEnvVar $envVar): ActorEnvVar`. +- Single: `get(): ?ActorEnvVar`, `update(ActorEnvVar $envVar): ActorEnvVar`, `delete(): void`. + +```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 new file mode 100644 index 0000000..2c09c04 --- /dev/null +++ b/docs/builds.md @@ -0,0 +1,29 @@ +# Builds + +Snippets assume `$client = new ApifyClient('my-api-token');` and imported types. + +## Build collection — `$client->builds()` + +- `list(?ListOptions $options): PaginationList` — list the account's builds. + +```php +$page = $client->builds()->list(new ListOptions(limit: 20, desc: true)); +``` + +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). +- `abort(): Build` +- `delete(): void` +- `waitForFinish(?int $waitSecs = null): Build` — poll until terminal (`null` waits indefinitely). +- `getOpenApiDefinition(): ?array` — the build's generated OpenAPI document, or `null`. +- `log(): LogClient` + +```php +$build = $client->actor('me~my-actor')->build('0.0', new ActorBuildOptions(tag: 'latest')); +$finished = $client->build($build->getId())->waitForFinish(300); +echo $finished->getStatus() . PHP_EOL; +$log = $client->build($build->getId())->log()->get(); +``` diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..c8c6f0d --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,141 @@ +# 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 same programs 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 they are +guaranteed to stay runnable. + +## 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. + $items = $client->dataset($run->getDefaultDatasetId())->listItems(); + echo 'Item count: ' . $items->getCount() . PHP_EOL; +} catch (ApifyApiException $e) { + echo 'API error ' . $e->getStatusCode() . ': ' . $e->getApiMessage() . PHP_EOL; +} +``` + +## Run a store Actor and read its default dataset + +```php +$run = $client->actor('apify/hello-world')->call(null, null, 120); +$items = $client->dataset($run->getDefaultDatasetId())->listItems(); +echo 'Item count: ' . $items->getCount() . PHP_EOL; +``` + +## Each storage: create, push, read + +```php +$dataset = $client->datasets()->getOrCreate('example-ds'); +$client->dataset($dataset->getId())->pushItems([['hello' => 'world']]); +$dsItems = $client->dataset($dataset->getId())->listItems(); + +$store = $client->keyValueStores()->getOrCreate('example-kvs'); +$client->keyValueStore($store->getId())->setRecordJson('OUTPUT', ['answer' => 42]); +$record = $client->keyValueStore($store->getId())->getRecord('OUTPUT'); + +$queue = $client->requestQueues()->getOrCreate('example-rq'); +$client->requestQueue($queue->getId())->addRequest(new RequestQueueRequest('https://example.com', 'example')); +$head = $client->requestQueue($queue->getId())->listHead(10); +``` + +## Get own account details + +```php +$user = $client->me()->get(); +if ($user !== null) { + echo 'Account ' . $user->getId() . ' / ' . $user->getUsername() . PHP_EOL; +} +``` + +## Create a new Actor, build it, run it, wait, and print the finished run log + +```php +$created = $client->actors()->create([ + 'name' => 'my-example-actor', + 'isPublic' => false, + 'versions' => [[ + 'versionNumber' => '0.0', + 'sourceType' => 'SOURCE_FILES', + 'buildTag' => 'latest', + 'sourceFiles' => [ + ['name' => 'Dockerfile', 'format' => 'TEXT', 'content' => "FROM apify/actor-node:20\nCOPY . ./\nCMD node main.js"], + ['name' => 'main.js', 'format' => 'TEXT', 'content' => "console.log('hi');"], + ], + ]], +]); +try { + $build = $client->actor($created->getId())->build('0.0', new ActorBuildOptions()); + $client->build($build->getId())->waitForFinish(300); + $run = $client->actor($created->getId())->call(null, null, 120); + $log = $client->run($run->getId())->log()->get(); + if ($log !== null) { + echo $log . PHP_EOL; + } +} finally { + $client->actor($created->getId())->delete(); +} +``` + +## Start a run, wait, then fetch the Actor's last run and its storages + +```php +$started = $client->actor('apify/hello-world')->start(); +$client->run($started->getId())->waitForFinish(120); + +// Fetch the last run and read its storages via the run-nested convenience accessors. +$lastRun = $client->actor('apify/hello-world')->lastRun(new LastRunOptions(status: 'SUCCEEDED')); +$last = $lastRun->get(); +if ($last !== null) { + $lastRun->dataset()->listItems(); + $lastRun->keyValueStore()->getRecord('OUTPUT'); + $lastRun->requestQueue()->listHead(10); +} +``` + +## Lazy iteration of Store Actors + +```php +$shown = 0; +foreach ($client->store()->iterate(new StoreListOptions(limit: 10)) as $item) { + echo $item->getName() . PHP_EOL; + if (++$shown >= 5) { + break; + } +} +``` + +## Run an Actor with log redirection + +```php +// Start the run without waiting, then redirect its log to stdout live. The streaming endpoint keeps +// the connection open and emits log lines in real time; reading to EOF also waits for the run to end. +$run = $client->actor('apify/hello-world')->start(); +$stream = $client->run($run->getId())->getStreamedLog(); +while (!$stream->eof()) { + echo $stream->read(8192); +} +``` diff --git a/docs/misc.md b/docs/misc.md new file mode 100644 index 0000000..e3b45d6 --- /dev/null +++ b/docs/misc.md @@ -0,0 +1,42 @@ +# Store, users & logs + +Snippets assume `$client = new ApifyClient('my-api-token');` and imported types. + +## Apify Store — `$client->store()` + +- `list(?StoreListOptions $options): PaginationList` — one page of Store Actors. +- `iterate(?StoreListOptions $options): iterable` — lazily iterate all matching Actors, paging on demand. + +```php +$page = $client->store()->list(new StoreListOptions(search: 'scraper', limit: 10)); + +$shown = 0; +foreach ($client->store()->iterate(new StoreListOptions(limit: 50)) as $item) { + echo $item->getName() . PHP_EOL; + if (++$shown >= 5) { + break; + } +} +``` + +## Users — `$client->me()` / `$client->user($id)` + +- `get(): ?User` — for `me()`, private account details are available via `toArray()`. +- `monthlyUsage(?string $date = null): array` — current-account monthly usage (only for `me()`). +- `limits(): array`, `updateLimits(mixed $newLimits): void` — account limits (only for `me()`). + +```php +$me = $client->me()->get(); +$usage = $client->me()->monthlyUsage('2026-06-01'); +$limits = $client->me()->limits(); +$publicProfile = $client->user('some-username')->get(); +``` + +## Logs — `$client->log($buildOrRunId)` + +- `get(?LogOptions $options = null): ?string` — the full log as text. +- `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 new file mode 100644 index 0000000..e2f2c38 --- /dev/null +++ b/docs/models.md @@ -0,0 +1,230 @@ +# 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. The `RequestQueueRequest` +model is also used as an input object and therefore additionally exposes setters and a constructor +(see [Input models](#input-models)). + +## Resource models + +### `Actor` +| Getter | Description | +|---|---| +| `getId(): ?string` | The Actor ID. | +| `getUserId(): ?string` | ID of the user who owns the Actor. | +| `getName(): ?string` | The Actor's technical name. | +| `getUsername(): ?string` | Username of the Actor's owner. | +| `getTitle(): ?string` | Human-readable title. | +| `getDescription(): ?string` | Free-text description. | +| `isPublic(): ?bool` | Whether the Actor is public. | +| `getCreatedAt(): ?string` | ISO-8601 creation timestamp. | +| `getModifiedAt(): ?string` | ISO-8601 last-modification timestamp. | + +### `ActorRun` +| Getter | Description | +|---|---| +| `getId(): ?string` | The run ID. | +| `getActId(): ?string` | ID of the Actor that was run. | +| `getActorTaskId(): ?string` | ID of the task the run started from (if any). | +| `getUserId(): ?string` | ID of the user who started the run. | +| `getStatus(): ?string` | Run status (e.g. `RUNNING`, `SUCCEEDED`, `FAILED`). | +| `getStatusMessage(): ?string` | Latest human-readable status message. | +| `getStartedAt(): ?string` | ISO-8601 start timestamp. | +| `getFinishedAt(): ?string` | ISO-8601 finish timestamp (`null` while running). | +| `getBuildId(): ?string` | ID of the build the run used. | +| `getDefaultDatasetId(): ?string` | ID of the run's default dataset. | +| `getDefaultKeyValueStoreId(): ?string` | ID of the run's default key-value store. | +| `getDefaultRequestQueueId(): ?string` | ID of the run's default request queue. | +| `getContainerUrl(): ?string` | The run container's live URL. | +| `isTerminal(): bool` | Whether the run has reached a terminal status. | + +### `Build` +| Getter | Description | +|---|---| +| `getId(): ?string` | The build ID. | +| `getActId(): ?string` | ID of the Actor that was built. | +| `getStatus(): ?string` | Build status. | +| `getStartedAt(): ?string` | ISO-8601 start timestamp. | +| `getFinishedAt(): ?string` | ISO-8601 finish timestamp (`null` while building). | +| `getBuildNumber(): ?string` | The resulting build number. | +| `isTerminal(): bool` | Whether the build has reached a terminal status. | + +### `ActorVersion` +| Getter | Description | +|---|---| +| `getVersionNumber(): ?string` | The version number (e.g. `0.0`). | +| `getSourceType(): ?string` | Source type (e.g. `SOURCE_FILES`, `GIT_REPO`). | + +### `Task` +| Getter | Description | +|---|---| +| `getId(): ?string` | The task ID. | +| `getActId(): ?string` | ID of the Actor the task runs. | +| `getUserId(): ?string` | ID of the task owner. | +| `getName(): ?string` | The task's technical name. | +| `getTitle(): ?string` | Human-readable title. | +| `getCreatedAt(): ?string` | ISO-8601 creation timestamp. | +| `getModifiedAt(): ?string` | ISO-8601 last-modification timestamp. | + +### `Schedule` +| Getter | Description | +|---|---| +| `getId(): ?string` | The schedule ID. | +| `getUserId(): ?string` | ID of the schedule owner. | +| `getName(): ?string` | The schedule's name. | +| `getCronExpression(): ?string` | The cron expression that triggers the schedule. | +| `isEnabled(): ?bool` | Whether the schedule is enabled. | + +### `Webhook` +| Getter | Description | +|---|---| +| `getId(): ?string` | The webhook ID. | +| `getUserId(): ?string` | ID of the webhook owner. | +| `getRequestUrl(): ?string` | URL the webhook posts to. | +| `getEventTypes(): array` | Event types that trigger the webhook. | + +### `WebhookDispatch` +| Getter | Description | +|---|---| +| `getId(): ?string` | The dispatch ID. | +| `getWebhookId(): ?string` | ID of the webhook that was dispatched. | + +### `User` +| Getter | Description | +|---|---| +| `getId(): ?string` | The user ID. | +| `getUsername(): ?string` | The username. | + +Private account details (plan, email, etc.) returned by `me()->get()` are available via `toArray()`. + +### `ActorStoreListItem` +Returned when listing/iterating the Apify Store. +| Getter | Description | +|---|---| +| `getId(): ?string` | The Actor ID. | +| `getName(): ?string` | The Actor's technical name. | +| `getUsername(): ?string` | Username of the Actor's owner. | +| `getTitle(): ?string` | Human-readable title. | + +## Storage models + +### `Dataset` +| Getter | Description | +|---|---| +| `getId(): ?string` | The dataset ID. | +| `getName(): ?string` | The dataset name (`null` for unnamed datasets). | +| `getUserId(): ?string` | ID of the owner. | +| `getCreatedAt(): ?string` | ISO-8601 creation timestamp. | +| `getModifiedAt(): ?string` | ISO-8601 last-modification timestamp. | +| `getItemCount(): ?int` | Number of items stored. | + +### `KeyValueStore` +| Getter | Description | +|---|---| +| `getId(): ?string` | The store ID. | +| `getName(): ?string` | The store name (`null` for unnamed stores). | +| `getUserId(): ?string` | ID of the owner. | +| `getCreatedAt(): ?string` | ISO-8601 creation timestamp. | +| `getModifiedAt(): ?string` | ISO-8601 last-modification timestamp. | + +### `KeyValueStoreRecord` +| Getter | Description | +|---|---| +| `getKey(): string` | The record key. | +| `getValue(): mixed` | The record value (decoded for JSON, raw string otherwise). | +| `getContentType(): ?string` | The record's content type. | + +### `KeyValueStoreKey` +| Getter | Description | +|---|---| +| `getKey(): string` | The key name. | +| `getSize(): ?int` | The value size in bytes. | + +### `KeyValueStoreKeysPage` +One page returned by `listKeys()`. +| Getter | Description | +|---|---| +| `getItems(): array` | The `KeyValueStoreKey` items on this page. | +| `getLimit(): ?int` | The page size limit. | +| `isTruncated(): bool` | Whether more keys exist beyond this page. | +| `getExclusiveStartKey(): ?string` | The exclusive start key used for this page. | +| `getNextExclusiveStartKey(): ?string` | The start key to request the next page. | + +### `RequestQueue` +| Getter | Description | +|---|---| +| `getId(): ?string` | The queue ID. | +| `getName(): ?string` | The queue name (`null` for unnamed queues). | +| `getUserId(): ?string` | ID of the owner. | +| `getCreatedAt(): ?string` | ISO-8601 creation timestamp. | +| `getModifiedAt(): ?string` | ISO-8601 last-modification timestamp. | +| `getTotalRequestCount(): ?int` | Total number of requests ever added. | + +### `RequestQueueHead` +Returned by `listHead()` / `listAndLockHead()`. +| Getter | Description | +|---|---| +| `getItems(): array` | The `RequestQueueRequest` items at the head of the queue. | +| `getLimit(): ?int` | The requested head size limit. | + +### `RequestQueueOperationInfo` +Returned by single-request add/update operations. +| Getter | Description | +|---|---| +| `getRequestId(): ?string` | ID assigned to the request. | +| `getUniqueKey(): ?string` | The request's unique key. | + +### `BatchAddResult` +Returned by `batchAddRequests()`. +| Getter | Description | +|---|---| +| `getProcessedRequests(): array` | Requests the API accepted (as `RequestQueueOperationInfo`). | +| `getUnprocessedRequests(): array` | Requests that could not be processed. | + +## Iteration and pagination + +### `PaginationList` +Returned by every `list()` method. Implements `IteratorAggregate` and `Countable`, so it can be used +directly in `foreach` / `count()`. +| Getter | Description | +|---|---| +| `getItems(): array` | The items on this page. | +| `getTotal(): ?int` | Total number of matching items across all pages. | +| `getOffset(): ?int` | The offset this page started at. | +| `getLimit(): ?int` | The page size limit. | +| `getCount(): int` | The number of items on this page. | +| `isDesc(): bool` | Whether the listing is newest-first. | +| `getIterator(): Traversable` | Iterator over the items (used by `foreach`). | + +## Input models + +Some models double as input objects for create/update calls; they expose fluent setters in addition +to their getters. + +### `RequestQueueRequest` +Constructor: `new RequestQueueRequest(?string $url = null, ?string $uniqueKey = null, array $data = [])`. +Pass `url` and `uniqueKey` positionally for the common case; `data` seeds any additional raw fields. + +| Method | Description | +|---|---| +| `getId(): ?string` / `setId(string): self` | The request ID (assigned by the API on add). | +| `getUrl(): ?string` / `setUrl(string): self` | The request URL. | +| `getUniqueKey(): ?string` / `setUniqueKey(string): self` | The deduplication key. | +| `getMethod(): ?string` / `setMethod(string): self` | The HTTP method (defaults to `GET`). | +| `getUserData(): mixed` / `setUserData(array): self` | Arbitrary user data attached to the request. | + +### `ActorEnvVar` +Constructor: `new ActorEnvVar(?string $name = null, ?string $value = null, ?bool $isSecret = null, array $data = [])`. + +| Getter | Description | +|---|---| +| `getName(): ?string` | The environment variable name. | +| `getValue(): ?string` | The environment variable value. | +| `getIsSecret(): ?bool` | Whether the value is stored as a secret. | diff --git a/docs/options.md b/docs/options.md new file mode 100644 index 0000000..fbb9b9a --- /dev/null +++ b/docs/options.md @@ -0,0 +1,244 @@ +# Options reference + +Option objects live under the `Apify\Client\Options\` namespace. They use named constructor +arguments; every field is optional unless noted, and an unset field means "use the API default". +Pass only the arguments you need: + +```php +$options = new ActorListOptions(my: true, limit: 10); +``` + +## Listing and pagination + +### `ListOptions` +Shared pagination/ordering controls used by most `list()` methods (builds, runs, tasks, schedules, +webhooks, dispatches, Actor versions). +| Field | Type | Description | +|---|---|---| +| `offset` | `?int` | Number of items to skip. | +| `limit` | `?int` | Maximum number of items to return. | +| `desc` | `?bool` | Return items newest-first. | + +### `ActorListOptions` +| Field | Type | Description | +|---|---|---| +| `offset` | `?int` | Number of Actors to skip. | +| `limit` | `?int` | Maximum number of Actors to return. | +| `desc` | `?bool` | Return Actors newest-first. | +| `my` | `?bool` | Return only Actors owned by the current user. | +| `sortBy` | `?string` | The sort field (e.g. `createdAt`, `stats.lastRunStartedAt`). | + +### `StorageListOptions` +Used when listing datasets, key-value stores and request queues. +| Field | Type | Description | +|---|---|---| +| `offset` | `?int` | Number of items to skip. | +| `limit` | `?int` | Maximum number of items to return. | +| `desc` | `?bool` | Return items newest-first. | +| `unnamed` | `?bool` | Include unnamed storages in the result. | +| `ownership` | `?string` | Filter by ownership (e.g. `OWNED`, `ACCESSIBLE`). | + +### `RunListOptions` +Extra filters for `runs()->list()`, combined with a `ListOptions`. +| Field | Type | Description | +|---|---|---| +| `status` | `list\|null` | Filter by one or more run statuses (e.g. `SUCCEEDED`, `RUNNING`); sent comma-separated. | +| `startedAfter` | `?string` | Only runs started after this ISO-8601 timestamp. | +| `startedBefore` | `?string` | Only runs started before this ISO-8601 timestamp. | + +### `StoreListOptions` +For `store()->list()` / `store()->iterate()`. +| Field | Type | Description | +|---|---|---| +| `offset` | `?int` | Number of Actors to skip. | +| `limit` | `?int` | Maximum number of Actors to return (also the per-page size when iterating). | +| `search` | `?string` | Full-text search query. | +| `sortBy` | `?string` | The sort field (e.g. `popularity`, `newest`). | +| `category` | `?string` | Filter Actors by category. | +| `username` | `?string` | Filter Actors by owner username. | +| `pricingModel` | `?string` | Filter by pricing model (`FREE`, `FLAT_PRICE_PER_MONTH`, `PRICE_PER_DATASET_ITEM`, `PAY_PER_EVENT`). | +| `includeUnrunnableActors` | `?bool` | Include Actors the current user cannot run. | +| `allowsAgenticUsers` | `?bool` | Filter to Actors that allow agentic users. | +| `responseFormat` | `?string` | The response format (`full`, `agent`). | + +### `LastRunOptions` +For `actor()->lastRun()` / `task()->lastRun()`. +| Field | Type | Description | +|---|---|---| +| `status` | `?string` | Restrict to the last run with this status (e.g. `SUCCEEDED`). | +| `origin` | `?string` | Restrict to the last run started via this origin. | + +## Running Actors and tasks + +### `ActorStartOptions` +For `actor()->start()` / `actor()->call()`. +| Field | Type | Description | +|---|---|---| +| `build` | `?string` | Tag or number of the build to run (e.g. `latest`, `0.1.2`). | +| `memoryMbytes` | `?int` | Memory in megabytes allocated for the run. | +| `timeoutSecs` | `?int` | Run timeout in seconds (`0` means no timeout). | +| `waitForFinish` | `?int` | Max seconds to wait server-side for the run to finish (max 60). | +| `maxItems` | `?int` | Max number of dataset items to charge (pay-per-result Actors). | +| `maxTotalChargeUsd` | `?float` | Max total charge in USD (pay-per-event Actors). | +| `contentType` | `?string` | Content type of the input body (defaults to `application/json`). | +| `restartOnError` | `?bool` | Restart the run if it fails. | +| `forcePermissionLevel` | `?string` | Override the Actor's permission level (`LIMITED_PERMISSIONS`/`FULL_PERMISSIONS`). | +| `webhooks` | `array\|null` | Ad-hoc webhooks to attach to the run (base64-encoded before sending). | + +### `TaskStartOptions` +For `task()->start()` / `task()->call()`. Same fields as `ActorStartOptions` except it has no +`contentType` or `forcePermissionLevel`: `build`, `memoryMbytes`, `timeoutSecs`, `waitForFinish`, +`maxItems`, `maxTotalChargeUsd`, `restartOnError`, `webhooks`. + +### `ValidateInputOptions` +For `actor()->validateInput()`. +| Field | Type | Description | +|---|---|---| +| `build` | `?string` | Tag or number of the build whose input schema is used for validation. | +| `contentType` | `?string` | Content type of the input body (defaults to `application/json`). | + +### `MetamorphOptions` +For `run()->metamorph()`. +| Field | Type | Description | +|---|---|---| +| `build` | `?string` | Optionally pin the target Actor's build (unset for default). | +| `contentType` | `?string` | Content type of the input body (defaults to `application/json`). | + +### `RunResurrectOptions` +For `run()->resurrect()`. +| Field | Type | Description | +|---|---|---| +| `build` | `?string` | Tag or number of the build to resurrect with. | +| `memoryMbytes` | `?int` | Memory in megabytes to allocate. | +| `timeoutSecs` | `?int` | Run timeout in seconds. | +| `maxItems` | `?int` | Max dataset items to charge (pay-per-result Actors). | +| `maxTotalChargeUsd` | `?float` | Max total charge in USD (pay-per-event Actors). | +| `restartOnError` | `?bool` | Restart the run if it fails. | + +### `RunChargeOptions` +For `run()->charge()` (pay-per-event Actors). `eventName` is **required**. +| Field | Type | Description | +|---|---|---| +| `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. | + +## Builds + +### `ActorBuildOptions` +For `actor()->build()`. +| Field | Type | Description | +|---|---|---| +| `betaPackages` | `?bool` | Use beta versions of Apify packages. | +| `tag` | `?string` | Tag to apply to the build (e.g. `latest`). | +| `useCache` | `?bool` | Whether to use the Docker build cache (default true). | +| `waitForFinish` | `?int` | Max seconds to wait server-side for the build (max 60). | + +## Datasets + +### `DatasetListItemsOptions` +For `dataset()->listItems()` and `createItemsPublicUrl()`. +| Field | Type | Description | +|---|---|---| +| `offset` | `?int` | Number of items to skip. | +| `limit` | `?int` | Maximum number of items to return. | +| `desc` | `?bool` | Return items newest-first. | +| `fields` | `list\|null` | Restrict the output to these fields. | +| `outputFields` | `list\|null` | Positionally rename the selected `fields` (requires `fields`). | +| `omit` | `list\|null` | Exclude these fields from the output. | +| `skipEmpty` | `?bool` | Skip empty items. | +| `skipHidden` | `?bool` | Skip hidden fields (those starting with `#`). | +| `clean` | `?bool` | Return only clean (non-empty, non-hidden) items. | +| `unwind` | `list\|null` | Expand these fields (each array element becomes a separate item). | +| `flatten` | `list\|null` | Flatten these nested fields into dot-notation keys. | +| `view` | `?string` | Select a predefined dataset view for field selection. | +| `simplified` | `?bool` | Return simplified (flattened, cleaned) items. | +| `skipFailedPages` | `?bool` | Skip items that come from failed pages. | +| `signature` | `?string` | A pre-shared URL signature granting access without an API token. | + +### `DatasetDownloadOptions` +For `dataset()->downloadItems()` (export formatting on top of the filtering above). +| Field | Type | Description | +|---|---|---| +| `items` | `?DatasetListItemsOptions` | The shared filtering/projection options. | +| `attachment` | `?bool` | Set `Content-Disposition: attachment` on the response. | +| `bom` | `?bool` | Prepend a UTF-8 BOM (useful for Excel-compatible CSV). | +| `delimiter` | `?string` | The CSV field delimiter (default `,`). | +| `skipHeaderRow` | `?bool` | Omit the CSV header row. | +| `xmlRoot` | `?string` | Name of the root XML element (default `items`). | +| `xmlRow` | `?string` | Name of the per-item XML element (default `item`). | +| `feedTitle` | `?string` | Title used for RSS/Atom feed exports. | +| `feedDescription` | `?string` | Description used for RSS/Atom feed exports. | + +`DownloadItemsFormat` is an enum selecting the export format: `JSON`, `JSONL`, `CSV`, `HTML`, `XML`, +`RSS`, `XLSX`. + +## Key-value stores + +### `ListKeysOptions` +For `keyValueStore()->listKeys()` and `createKeysPublicUrl()`. +| Field | Type | Description | +|---|---|---| +| `limit` | `?int` | Maximum number of keys to return. | +| `exclusiveStartKey` | `?string` | List keys after this one (for pagination). | +| `prefix` | `?string` | Restrict the listing to keys with this prefix. | +| `collection` | `?string` | Restrict the listing to a named collection of keys. | +| `signature` | `?string` | A pre-shared URL signature granting access without an API token. | + +### `GetRecordOptions` +For `keyValueStore()->getRecord()`. +| Field | Type | Description | +|---|---|---| +| `attachment` | `?bool` | Controls `Content-Disposition: attachment` (default `true`; pass `null` to omit). | +| `signature` | `?string` | A pre-shared URL signature granting access without an API token. | + +### `SetRecordOptions` +For `keyValueStore()->setRecord()`. +| Field | Type | Description | +|---|---|---| +| `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`). | + +## Request queues + +### `RequestQueueClientOptions` +Passed to `requestQueue($id, ...)` to configure a specific queue client. +| Field | Type | Description | +|---|---|---| +| `clientKey` | `?string` | A stable client key (required to operate on locks the same client took). | +| `timeoutSecs` | `?float` | Per-request timeout for this queue's calls. | + +### `ListRequestsOptions` +For `requestQueue()->listRequests()`. +| Field | Type | Description | +|---|---|---| +| `limit` | `?int` | Maximum number of requests to return. | +| `exclusiveStartId` | `?string` | List requests after this ID. | +| `cursor` | `?string` | An opaque pagination cursor (alternative to `exclusiveStartId`). | +| `filter` | `list\|null` | Restrict to `locked` and/or `pending` requests. | + +### `PaginateRequestsOptions` +For `requestQueue()->paginateRequests()` (lazy iteration across pages). +| Field | Type | Description | +|---|---|---| +| `limit` | `?int` | Maximum total number of requests to iterate across all pages (`null` for no bound). | +| `maxPageLimit` | `?int` | Maximum number of requests fetched per page. | +| `exclusiveStartId` | `?string` | Start after this request ID (first page only; mutually exclusive with `cursor`). | +| `cursor` | `?string` | An opaque pagination cursor (mutually exclusive with `exclusiveStartId`). | +| `filter` | `list\|null` | Restrict to `locked` and/or `pending` requests. | + +### `BatchAddRequestsOptions` +For `requestQueue()->batchAddRequests()` (retry policy for unprocessed requests). +| Field | Type | Description | +|---|---|---| +| `maxUnprocessedRequestsRetries` | `int` | Max retries for requests the API reports unprocessed (default from the client; clamped to ≥ 0). | +| `minDelayBetweenUnprocessedRequestsRetriesMillis` | `int` | Minimum delay between those retries, in milliseconds (clamped to ≥ 0). | + +## Logs + +### `LogOptions` +For `log()->get()` / `log()->stream()`. +| Field | Type | Description | +|---|---|---| +| `raw` | `?bool` | Return the unprocessed log content (no platform post-processing). | +| `download` | `?bool` | Set `Content-Disposition` so the log is served as a download. | diff --git a/docs/runs.md b/docs/runs.md new file mode 100644 index 0000000..9315316 --- /dev/null +++ b/docs/runs.md @@ -0,0 +1,33 @@ +# Runs + +Snippets assume `$client = new ApifyClient('my-api-token');` and imported types. + +## Run collection — `$client->runs()` + +- `list(?ListOptions $options, ?RunListOptions $filter): PaginationList` — list runs. + +```php +$page = $client->runs()->list(new ListOptions(limit: 10), new RunListOptions(status: ['SUCCEEDED'])); +``` + +An Actor's or task's runs are available at `$client->actor($id)->runs()` / `$client->task($id)->runs()`. + +## A single run — `$client->run($id)` + +- `get(?int $waitForFinishSecs = null): ?ActorRun` — fetch, optionally waiting server-side (max 60s). +- `update(mixed $newFields): ActorRun` +- `delete(): void` +- `abort(?bool $gracefully = null): ActorRun` +- `metamorph(string $targetActorId, mixed $input = null, ?MetamorphOptions $options = null): ActorRun` +- `reboot(): ActorRun` +- `resurrect(?RunResurrectOptions $options = null): ActorRun` +- `charge(RunChargeOptions $options): void` — for pay-per-event Actors. +- `waitForFinish(?int $waitSecs = null): ActorRun` +- `dataset(): DatasetClient`, `keyValueStore(): KeyValueStoreClient`, `requestQueue(): RequestQueueClient` +- `log(): LogClient`, `getStreamedLog(): StreamInterface` + +```php +$run = $client->run('RUN_ID')->waitForFinish(120); +$client->run('RUN_ID')->charge(new RunChargeOptions(eventName: 'result', count: 3)); +$items = $client->run('RUN_ID')->dataset()->listItems(); +``` diff --git a/docs/schedules.md b/docs/schedules.md new file mode 100644 index 0000000..0b87435 --- /dev/null +++ b/docs/schedules.md @@ -0,0 +1,28 @@ +# Schedules + +Schedules automatically start Actor or task runs at specified times. Snippets assume +`$client = new ApifyClient('my-api-token');` and imported types. + +## Schedule collection — `$client->schedules()` + +- `list(?ListOptions $options): PaginationList` +- `create(mixed $schedule): Schedule` + +```php +$schedule = $client->schedules()->create([ + 'name' => 'nightly', + 'cronExpression' => '0 0 * * *', + 'isEnabled' => true, + 'actions' => [], +]); +``` + +## A single schedule — `$client->schedule($id)` + +- `get(): ?Schedule`, `update(mixed $newFields): Schedule`, `delete(): void` +- `getLog(): ?string` — the schedule's invocation log, or `null` if absent. + +```php +$updated = $client->schedule('SCHEDULE_ID')->update(['cronExpression' => '0 12 * * *']); +$log = $client->schedule('SCHEDULE_ID')->getLog(); +``` diff --git a/docs/storages.md b/docs/storages.md new file mode 100644 index 0000000..d464312 --- /dev/null +++ b/docs/storages.md @@ -0,0 +1,85 @@ +# 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.). + +## Datasets + +Collection — `$client->datasets()`: `list(?StorageListOptions $options = null): PaginationList`, +`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` — items decoded to arrays. +- `downloadItems(DownloadItemsFormat $format, ?DatasetDownloadOptions $options = null): string` — raw export bytes. +- `pushItems(mixed $items): void` +- `getStatistics(): ?array` +- `createItemsPublicUrl(?DatasetListItemsOptions $options = null, ?int $expiresInSecs = null): string` + +```php +$dataset = $client->datasets()->getOrCreate('my-dataset'); +$client->dataset($dataset->getId())->pushItems([['url' => 'https://a.com'], ['url' => 'https://b.com']]); +$items = $client->dataset($dataset->getId())->listItems(new DatasetListItemsOptions(limit: 100)); +$csv = $client->dataset($dataset->getId())->downloadItems(DownloadItemsFormat::CSV, new DatasetDownloadOptions(bom: true)); +``` + +## Key-value stores + +Collection — `$client->keyValueStores()`: `list(?StorageListOptions $options = null): PaginationList`, +`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` +- `recordExists(string $key): bool` +- `getRecord(string $key, ?GetRecordOptions $options = null): ?KeyValueStoreRecord` +- `setRecord(string $key, string $value, string $contentType, ?SetRecordOptions $options = null): void` +- `setRecordJson(string $key, mixed $value): void` +- `deleteRecord(string $key): void` +- `getRecordPublicUrl(string $key): string`, `createKeysPublicUrl(?ListKeysOptions, ?int $expiresInSecs): string` + +```php +$store = $client->keyValueStores()->getOrCreate('my-store'); +$client->keyValueStore($store->getId())->setRecordJson('OUTPUT', ['answer' => 42]); +$record = $client->keyValueStore($store->getId())->getRecord('OUTPUT'); +echo $record?->getValue() ?? ''; +``` + +## Request queues + +Collection — `$client->requestQueues()`: `list(?StorageListOptions $options = null): PaginationList`, +`getOrCreate(?string $name = null): RequestQueue`. + +A specific queue client is obtained with `$client->requestQueue($id, ?RequestQueueClientOptions $options = null)`. +The optional `RequestQueueClientOptions` sets a stable `clientKey` (required to operate on locks the +client created) and/or a per-request `timeoutSecs` for this queue's calls. + +Single — `$client->requestQueue($id)`: + +- `get(): ?RequestQueue`, `update(mixed $newFields): RequestQueue`, `delete(): void` +- `listHead(?int $limit = null): RequestQueueHead` +- `addRequest(RequestQueueRequest $request, bool $forefront = false): RequestQueueOperationInfo` +- `getRequest(string $id): ?RequestQueueRequest`, `updateRequest(RequestQueueRequest $request, bool $forefront = false): RequestQueueOperationInfo`, `deleteRequest(string $id): void` +- `batchAddRequests(array $requests, bool $forefront = false, ?BatchAddRequestsOptions $options = null): BatchAddResult` — every request must have a non-empty `uniqueKey`; input is split into batches of at most 25 requests that also respect the ~9 MiB payload limit. +- `batchDeleteRequests(mixed $requests): array` +- `listRequests(?ListRequestsOptions $options = null): array`, `paginateRequests(?PaginateRequestsOptions $options = null): iterable` +- `listAndLockHead(int $lockSecs, ?int $limit = null): array`, `prolongRequestLock(...)`, `deleteRequestLock(...)`, `unlockRequests(): array` +- `withClientKey(string $clientKey): RequestQueueClient` + +`paginateRequests()` accepts a `PaginateRequestsOptions` with `limit` (total across all pages), +`maxPageLimit` (page size), `exclusiveStartId`/`cursor` (starting point) and `filter` +(`locked`/`pending`). + +```php +$queue = $client->requestQueues()->getOrCreate('my-queue'); +$client->requestQueue($queue->getId())->addRequest(new RequestQueueRequest('https://example.com', 'example')); +foreach ($client->requestQueue($queue->getId())->paginateRequests(new PaginateRequestsOptions(maxPageLimit: 100)) as $request) { + echo $request->getUrl() . PHP_EOL; +} +``` diff --git a/docs/tasks.md b/docs/tasks.md new file mode 100644 index 0000000..75e7b0d --- /dev/null +++ b/docs/tasks.md @@ -0,0 +1,32 @@ +# Tasks + +Tasks are pre-configured Actor runs with stored input. Snippets assume +`$client = new ApifyClient('my-api-token');` and imported types. + +## Task collection — `$client->tasks()` + +- `list(?ListOptions $options): PaginationList` +- `create(mixed $task): Task` + +```php +$task = $client->tasks()->create([ + 'actId' => 'apify/hello-world', + 'name' => 'my-task', + 'input' => ['message' => 'hello'], +]); +``` + +## A single task — `$client->task($id)` + +- `get(): ?Task`, `update(mixed $newFields): Task`, `delete(): void` +- `start(mixed $input = null, ?TaskStartOptions $options = null): ActorRun` +- `call(mixed $input = null, ?TaskStartOptions $options = null, ?int $waitSecs = null): ActorRun` +- `getInput(): mixed`, `updateInput(mixed $input): mixed` +- `lastRun(?LastRunOptions $options = null): RunClient` +- `runs(): RunCollectionClient` +- `webhooks(): NestedWebhookCollectionClient` — read-only. + +```php +$run = $client->task('me~my-task')->call(['message' => 'override'], null, 120); +$input = $client->task('me~my-task')->getInput(); +``` diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 0000000..1faced5 --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,41 @@ +# Webhooks & dispatches + +Snippets assume `$client = new ApifyClient('my-api-token');` and imported types. + +## Webhook collection — `$client->webhooks()` + +- `list(?ListOptions $options): PaginationList` +- `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(...)` only. Create webhooks +through the account-wide collection, targeting an Actor or task via the webhook's `condition`. + +```php +$webhook = $client->webhooks()->create([ + 'eventTypes' => ['ACTOR.RUN.SUCCEEDED'], + 'condition' => ['actorId' => 'ACTOR_ID'], + 'requestUrl' => 'https://example.com/webhook', +]); +``` + +## A single webhook — `$client->webhook($id)` + +- `get(): ?Webhook`, `update(mixed $newFields): Webhook`, `delete(): void` +- `test(): WebhookDispatch` — dispatch immediately. +- `dispatches(): WebhookDispatchCollectionClient` + +```php +$dispatch = $client->webhook('WEBHOOK_ID')->test(); +$client->webhook('WEBHOOK_ID')->dispatches()->list(new ListOptions(limit: 10)); +``` + +## Webhook dispatches — `$client->webhookDispatches()` / `$client->webhookDispatch($id)` + +- Collection: `list(?ListOptions $options): PaginationList`. +- Single: `get(): ?WebhookDispatch`. + +```php +$page = $client->webhookDispatches()->list(new ListOptions(limit: 5)); +$dispatch = $client->webhookDispatch('DISPATCH_ID')->get(); +``` diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..307e3ab --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,12 @@ +# 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. +parameters: + level: 6 + paths: + - src + - tests + treatPhpDocTypesAsCertain: false diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..a003a70 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,24 @@ + + + + + tests/Unit + + + tests/Integration + + + tests/Examples + + + + + src + + + diff --git a/src/ApifyClient.php b/src/ApifyClient.php new file mode 100644 index 0000000..a0b468b --- /dev/null +++ b/src/ApifyClient.php @@ -0,0 +1,358 @@ +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. + * + * 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. + */ +final class ApifyClient +{ + /** Default base URL of the Apify API (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; + + /** Environment variable that signals the client is running on the Apify platform. */ + private const ENV_IS_AT_HOME = 'APIFY_IS_AT_HOME'; + + /** Addresses the current user ({@code /users/me}). */ + private const ME_USER_PLACEHOLDER = 'me'; + + private HttpClientCore $http; + private string $baseUrl; + private string $publicBaseUrl; + + /** + * @param string|null $token API token, sent as a Bearer token + * @param string $baseUrl API base URL; {@code /v2} is appended automatically + * @param string|null $publicBaseUrl base URL for building public, shareable resource + * URLs (defaults to {@code $baseUrl}); {@code /v2} appended + * @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 + * (defaults to the request timeout) + * @param int $timeoutSecs overall per-request timeout in seconds (default 360) + * @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 + */ + public function __construct( + ?string $token = null, + string $baseUrl = self::DEFAULT_BASE_URL, + ?string $publicBaseUrl = null, + int $maxRetries = self::DEFAULT_MAX_RETRIES, + int $minDelayBetweenRetriesMillis = self::DEFAULT_MIN_DELAY_MILLIS, + ?int $maxDelayBetweenRetriesMillis = null, + int $timeoutSecs = self::DEFAULT_TIMEOUT_SECS, + ?string $userAgentSuffix = null, + ?HttpClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ?callable $isAtHomeFn = null, + ) { + $transport = $httpClient ?? new GuzzleHttpClient(); + $factory = new HttpFactory(); + $requestFactory ??= $factory; + $streamFactory ??= $factory; + + $maxDelayMillis = $maxDelayBetweenRetriesMillis ?? $timeoutSecs * 1000; + $retry = new RetryConfig( + $maxRetries, + (float) $minDelayBetweenRetriesMillis, + (float) $maxDelayMillis, + (float) $timeoutSecs, + ); + + $userAgent = self::buildUserAgent($userAgentSuffix, $isAtHomeFn ?? self::defaultIsAtHome(...)); + $this->http = new HttpClientCore($transport, $requestFactory, $streamFactory, $token, $userAgent, $retry); + + $this->baseUrl = self::trimTrailingSlash($baseUrl) . '/v2'; + $publicSource = $publicBaseUrl ?? $baseUrl; + $this->publicBaseUrl = self::trimTrailingSlash($publicSource) . '/v2'; + } + + /** Returns the {@code User-Agent} header value this client sends. */ + public function getUserAgent(): string + { + return $this->http->userAgent(); + } + + /** Returns the fully-qualified API base URL this client targets (including the {@code /v2} suffix). */ + public function getApiBaseUrl(): string + { + return $this->baseUrl; + } + + // ----- Actor accessors ----------------------------------------------------- + + /** A client for the Actor collection (list & create Actors). */ + public function actors(): ActorCollectionClient + { + return new ActorCollectionClient($this->http, $this->baseUrl); + } + + /** A client for a specific Actor, addressed by ID or {@code username~name}. */ + 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). */ + public function builds(): BuildCollectionClient + { + return new BuildCollectionClient($this->http, $this->baseUrl, 'actor-builds'); + } + + /** A client for a specific Actor 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). */ + public function runs(): RunCollectionClient + { + return new RunCollectionClient($this->http, $this->baseUrl, 'actor-runs'); + } + + /** A client for a specific Actor run. */ + public function run(string $id): RunClient + { + return new RunClient($this->http, $this->baseUrl, 'actor-runs', $id); + } + + // ----- Dataset accessors --------------------------------------------------- + + /** A client for the dataset collection (list & get-or-create datasets). */ + public function datasets(): DatasetCollectionClient + { + return new DatasetCollectionClient($this->http, $this->baseUrl); + } + + /** A client for a specific 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. */ + 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 + { + return new RequestQueueCollectionClient($this->http, $this->baseUrl); + } + + /** + * 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. + */ + 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). */ + public function tasks(): TaskCollectionClient + { + return new TaskCollectionClient($this->http, $this->baseUrl); + } + + /** A client for a specific Actor 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). */ + public function schedules(): ScheduleCollectionClient + { + return new ScheduleCollectionClient($this->http, $this->baseUrl); + } + + /** A client for a specific 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). */ + public function webhooks(): WebhookCollectionClient + { + return new WebhookCollectionClient($this->http, $this->baseUrl); + } + + /** A client for a specific webhook. */ + public function webhook(string $id): WebhookClient + { + return new WebhookClient($this->http, $this->baseUrl, $id); + } + + /** A client for the webhook dispatch collection. */ + public function webhookDispatches(): WebhookDispatchCollectionClient + { + return new WebhookDispatchCollectionClient($this->http, $this->baseUrl, 'webhook-dispatches'); + } + + /** A client for a specific 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. */ + public function store(): StoreCollectionClient + { + return new StoreCollectionClient($this->http, $this->baseUrl); + } + + /** A client for accessing a build's or run's log. */ + public function log(string $buildOrRunId): LogClient + { + return LogClient::forId($this->http, $this->baseUrl, $buildOrRunId); + } + + /** A client for the current user ({@code /users/me}). */ + 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. */ + public function user(string $id): UserClient + { + return new UserClient($this->http, $this->baseUrl, $id); + } + + /** + * Sets the status message of the current Actor run. + * + * 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. + */ + public function setStatusMessage(string $message, bool $isTerminal = false): ActorRun + { + $runId = getenv('ACTOR_RUN_ID'); + if ($runId === false || $runId === '') { + throw new RuntimeException('ACTOR_RUN_ID environment variable is not set'); + } + return $this->run($runId)->update([ + 'statusMessage' => $message, + 'isStatusMessageTerminal' => $isTerminal, + ]); + } + + private static function trimTrailingSlash(string $value): string + { + return rtrim($value, '/'); + } + + /** + * 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). + */ + private static function defaultIsAtHome(): bool + { + $value = getenv(self::ENV_IS_AT_HOME); + return $value !== false && $value !== ''; + } + + /** + * Builds the {@code User-Agent} header value mandated by the client requirements: + * {@code ApifyClient/{version} ({os}; PHP/{phpVersion}); isAtHome/{true|false}}. + * + * @param callable():bool $isAtHomeFn + */ + private static function buildUserAgent(?string $suffix, callable $isAtHomeFn): string + { + $os = strtolower(PHP_OS_FAMILY); + $atHome = $isAtHomeFn() ? 'true' : 'false'; + $ua = sprintf('ApifyClient/%s (%s; PHP/%s); isAtHome/%s', Version::CLIENT_VERSION, $os, PHP_VERSION, $atHome); + if ($suffix !== null && $suffix !== '') { + $ua .= '; ' . $suffix; + } + return $ua; + } +} diff --git a/src/Exception/ApifyApiException.php b/src/Exception/ApifyApiException.php new file mode 100644 index 0000000..2ac5aa3 --- /dev/null +++ b/src/Exception/ApifyApiException.php @@ -0,0 +1,100 @@ +|null */ + private ?array $data; + + private string $apiMessage; + + /** + * @param array|null $data additional structured error data provided by the API + */ + public function __construct( + int $statusCode, + ?string $type, + string $message, + int $attempt, + string $httpMethod, + 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). + $errType = ($type === null || $type === '') ? 'unknown' : $type; + parent::__construct(sprintf('apify API error (status %d, type %s): %s', $statusCode, $errType, $message)); + $this->apiMessage = $message; + $this->statusCode = $statusCode; + $this->type = $type; + $this->attempt = $attempt; + $this->httpMethod = $httpMethod; + $this->path = $path; + $this->data = $data; + } + + /** The HTTP status code of the error response. */ + public function getStatusCode(): int + { + return $this->statusCode; + } + + /** The machine-readable error type returned by the API (e.g. {@code "record-not-found"}). */ + public function getType(): ?string + { + return $this->type; + } + + /** The number of the API call attempt that produced this error (1-based). */ + public function getAttempt(): int + { + return $this->attempt; + } + + /** The HTTP method of the API call (e.g. {@code "GET"}, {@code "POST"}). */ + public function getHttpMethod(): string + { + return $this->httpMethod; + } + + /** The path of the API endpoint (URL excluding origin). */ + public function getPath(): string + { + return $this->path; + } + + /** + * Additional structured data provided by the API about the error, if any. + * + * @return array|null + */ + public function getData(): ?array + { + return $this->data; + } + + /** The raw error message returned by the API, without the status/type prefix. */ + public function getApiMessage(): string + { + return $this->apiMessage; + } +} diff --git a/src/Exception/TransportException.php b/src/Exception/TransportException.php new file mode 100644 index 0000000..796e135 --- /dev/null +++ b/src/Exception/TransportException.php @@ -0,0 +1,30 @@ +timeout = $timeout; + } + + /** Whether the failure was caused by a request timeout. */ + public function isTimeout(): bool + { + return $this->timeout; + } +} diff --git a/src/Http/GuzzleHttpClient.php b/src/Http/GuzzleHttpClient.php new file mode 100644 index 0000000..d7885fc --- /dev/null +++ b/src/Http/GuzzleHttpClient.php @@ -0,0 +1,68 @@ +client = $client ?? new Guzzle(); + } + + public function send(RequestInterface $request, float $timeoutSecs): ResponseInterface + { + return $this->doSend($request, $timeoutSecs, false); + } + + public function sendStreaming(RequestInterface $request, float $timeoutSecs): ResponseInterface + { + return $this->doSend($request, $timeoutSecs, true); + } + + private function doSend(RequestInterface $request, float $timeoutSecs, bool $stream): ResponseInterface + { + try { + return $this->client->send($request, [ + 'http_errors' => false, + 'allow_redirects' => true, + 'connect_timeout' => self::CONNECT_TIMEOUT_SECS, + 'timeout' => $timeoutSecs, + 'stream' => $stream, + ]); + } catch (ConnectException $e) { + // Guzzle surfaces cURL connect/read timeouts (errno 28) as ConnectException. + throw new TransportException($e->getMessage(), $e, $this->isTimeout($e)); + } catch (GuzzleException $e) { + throw new TransportException($e->getMessage(), $e, false); + } + } + + private function isTimeout(ConnectException $e): bool + { + $context = $e->getHandlerContext(); + $errno = isset($context['errno']) ? (int) $context['errno'] : 0; + // 28 == CURLE_OPERATION_TIMEDOUT. Fall back to a substring check for non-cURL handlers. + return $errno === 28 || stripos($e->getMessage(), 'timed out') !== false; + } +} diff --git a/src/Http/HttpClientInterface.php b/src/Http/HttpClientInterface.php new file mode 100644 index 0000000..242d38d --- /dev/null +++ b/src/Http/HttpClientInterface.php @@ -0,0 +1,36 @@ +not an error at this layer — return it as a normal response. + * Only transport-level failures (connection refused, DNS, timeout) should be thrown, as an + * {@see \Apify\Client\Exception\TransportException}. + * + * 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. + */ +interface HttpClientInterface +{ + /** Sends a single request with a per-attempt timeout (seconds) and buffers the whole response. */ + 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. + */ + public function sendStreaming(RequestInterface $request, float $timeoutSecs): ResponseInterface; +} diff --git a/src/Http/Psr18HttpClient.php b/src/Http/Psr18HttpClient.php new file mode 100644 index 0000000..f575de2 --- /dev/null +++ b/src/Http/Psr18HttpClient.php @@ -0,0 +1,40 @@ +client->sendRequest($request); + } catch (ClientExceptionInterface $e) { + throw new TransportException($e->getMessage(), $e); + } + } + + public function sendStreaming(RequestInterface $request, float $timeoutSecs): ResponseInterface + { + return $this->send($request, $timeoutSecs); + } +} diff --git a/src/Internal/HttpClientCore.php b/src/Internal/HttpClientCore.php new file mode 100644 index 0000000..89a98a3 --- /dev/null +++ b/src/Internal/HttpClientCore.php @@ -0,0 +1,253 @@ +userAgent; + } + + /** The configured overall per-request timeout budget, in seconds. */ + public function requestTimeoutSecs(): float + { + return $this->retry->timeoutSecs; + } + + /** + * Sends a request with auth, User-Agent and the retry policy applied. + * + * @param array $extraHeaders + */ + public function call( + string $method, + string $url, + ?string $body = null, + string $contentType = '', + ?float $timeoutSecs = null, + bool $doNotRetryTimeouts = false, + array $extraHeaders = [] + ): ResponseInterface { + $delayMillis = $this->retry->minDelayMillis; + $maxAttempts = $this->retry->maxRetries + 1; + $path = self::extractPath($url); + $baseTimeout = $timeoutSecs ?? $this->retry->timeoutSecs; + $lastError = null; + + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + try { + $response = $this->doAttempt( + $method, + $url, + $body, + $contentType, + $extraHeaders, + $this->attemptTimeout($baseTimeout, $attempt) + ); + $status = $response->getStatusCode(); + if ($status < self::MAX_SUCCESS_STATUS) { + return $response; + } + $lastError = self::buildApiError($status, (string) $response->getBody(), $attempt, $method, $path); + $retryable = self::isStatusRetryable($status); + } catch (TransportException $e) { + $lastError = $e; + // Network/timeout failures are retryable, unless the caller opted out of retrying timeouts. + $retryable = !($doNotRetryTimeouts && $e->isTimeout()); + } + + if (!$retryable || $attempt === $maxAttempts) { + throw $lastError; + } + + $this->sleepMillis($this->randomizedDelayMillis($delayMillis)); + $delayMillis = min($delayMillis * self::BACKOFF_FACTOR, $this->retry->maxDelayMillis); + } + + // Unreachable in practice (maxAttempts >= 1); defensive. + throw $lastError ?? new TransportException('request failed with no attempts'); + } + + /** Opens a live streaming response (single attempt, no retry). Used by log streaming. */ + public function stream(string $url): ResponseInterface + { + $request = $this->buildRequest('GET', $url, null, '', []); + return $this->transport->sendStreaming($request, $this->retry->timeoutSecs); + } + + /** + * Builds a fully-prepared PSR-7 request with auth, User-Agent, content type and extra headers. + * + * @param array $extraHeaders + */ + private function buildRequest( + string $method, + string $url, + ?string $body, + string $contentType, + array $extraHeaders + ): RequestInterface { + $request = $this->requestFactory->createRequest($method, $url) + ->withHeader('User-Agent', $this->userAgent); + if ($this->token !== null && $this->token !== '') { + $request = $request->withHeader('Authorization', 'Bearer ' . $this->token); + } + if ($contentType !== '') { + $request = $request->withHeader('Content-Type', $contentType); + } + foreach ($extraHeaders as $name => $value) { + $request = $request->withHeader($name, $value); + } + if ($body !== null) { + $request = $request->withBody($this->streamFactory->createStream($body)); + } + return $request; + } + + /** + * @param array $extraHeaders + */ + private function doAttempt( + string $method, + string $url, + ?string $body, + string $contentType, + array $extraHeaders, + float $timeoutSecs + ): ResponseInterface { + $request = $this->buildRequest($method, $url, $body, $contentType, $extraHeaders); + return $this->transport->send($request, $timeoutSecs); + } + + /** + * Returns {@code min(overall, base * 2^(attempt-1))}: the first attempt uses the base timeout; + * each retry doubles it (a slow-but-progressing connection gets more time) while never exceeding + * the overall budget. + */ + private function attemptTimeout(float $base, int $attempt): float + { + $scaled = $base; + for ($i = 1; $i < $attempt; $i++) { + $scaled *= 2; + if ($scaled >= $this->retry->timeoutSecs) { + return $this->retry->timeoutSecs; + } + } + return min($scaled, $this->retry->timeoutSecs); + } + + private static function isStatusRetryable(int $status): bool + { + return $status === self::RATE_LIMIT_EXCEEDED || $status >= self::MIN_SERVER_ERROR; + } + + /** Returns a delay chosen randomly from {@code [delay, 2*delay)} (exponential backoff + jitter). */ + private function randomizedDelayMillis(float $delayMillis): float + { + if ($delayMillis <= 0) { + return $delayMillis; + } + return $delayMillis + (mt_rand() / mt_getrandmax()) * $delayMillis; + } + + private function sleepMillis(float $millis): void + { + $micros = (int) round(max(0.0, $millis) * 1000); + if ($micros > 0) { + usleep($micros); + } + } + + /** Builds an {@see ApifyApiException} from an API error response body. */ + public static function buildApiError(int $status, string $body, int $attempt, string $method, string $path): ApifyApiException + { + $type = null; + $message = null; + $data = null; + + $decoded = Json::tryDecode($body); + if (is_array($decoded) && isset($decoded['error']) && is_array($decoded['error'])) { + $error = $decoded['error']; + $type = isset($error['type']) && is_string($error['type']) ? $error['type'] : null; + $message = isset($error['message']) && is_string($error['message']) ? $error['message'] : null; + if (isset($error['data']) && is_array($error['data'])) { + /** @var array $data */ + $data = $error['data']; + } + } + + if ($message === null) { + $message = $body === '' + ? 'unexpected error with status ' . $status + : 'unexpected error: ' . $body; + } + + return new ApifyApiException($status, $type, $message, $attempt, $method, $path, $data); + } + + /** Returns the path+query portion of a URL, for error reporting. */ + public static function extractPath(string $url): string + { + $rest = $url; + $scheme = strpos($rest, '://'); + if ($scheme !== false) { + $rest = substr($rest, $scheme + 3); + } + $slash = strpos($rest, '/'); + return $slash !== false ? substr($rest, $slash) : ''; + } + + /** Reports whether an exception represents a "resource not found" API error. */ + public static function isNotFound(Throwable $e): bool + { + if (!$e instanceof ApifyApiException || $e->getStatusCode() !== self::NOT_FOUND) { + return false; + } + $type = $e->getType(); + return $type === 'record-not-found' + || $type === 'record-or-token-not-found' + || $e->getHttpMethod() === 'HEAD'; + } +} diff --git a/src/Internal/Json.php b/src/Internal/Json.php new file mode 100644 index 0000000..e145277 --- /dev/null +++ b/src/Internal/Json.php @@ -0,0 +1,63 @@ + */ + private array $pairs = []; + + /** Adds a string parameter if the value is non-null. */ + public function addString(string $key, ?string $value): self + { + if ($value !== null) { + $this->pairs[] = [$key, $value]; + } + return $this; + } + + /** Adds an integer parameter if the value is non-null. */ + public function addInt(string $key, ?int $value): self + { + if ($value !== null) { + $this->pairs[] = [$key, (string) $value]; + } + return $this; + } + + /** Adds a floating-point parameter if the value is non-null. */ + public function addFloat(string $key, ?float $value): self + { + if ($value !== null) { + // Use a locale-independent representation without a trailing ".0" for whole numbers. + $this->pairs[] = [$key, rtrim(rtrim(sprintf('%.10F', $value), '0'), '.')]; + } + 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}). + */ + public function addBool(string $key, ?bool $value): self + { + if ($value !== null) { + $this->pairs[] = [$key, $value ? '1' : '0']; + } + return $this; + } + + /** + * Adds a comma-joined list parameter if the list is non-null and non-empty. + * + * @param list|null $values + */ + public function addCsv(string $key, ?array $values): self + { + if ($values !== null && $values !== []) { + $this->pairs[] = [$key, implode(',', $values)]; + } + return $this; + } + + /** Appends an already-stringified key/value pair unconditionally. */ + public function addRaw(string $key, string $value): self + { + $this->pairs[] = [$key, $value]; + return $this; + } + + /** Returns a shallow copy of this instance. */ + public function copy(): self + { + $out = new self(); + $out->pairs = $this->pairs; + return $out; + } + + /** Appends all pairs from {@code $other} to this instance. */ + public function extend(?QueryParams $other): self + { + if ($other !== null) { + $this->pairs = array_merge($this->pairs, $other->pairs); + } + return $this; + } + + /** Appends the parameters to {@code $rawUrl} as a URL-encoded query string. */ + public function applyToUrl(string $rawUrl): string + { + if ($this->pairs === []) { + return $rawUrl; + } + $parts = []; + foreach ($this->pairs as [$key, $value]) { + $parts[] = rawurlencode($key) . '=' . rawurlencode($value); + } + $sep = str_contains($rawUrl, '?') ? '&' : '?'; + return $rawUrl . $sep . implode('&', $parts); + } +} diff --git a/src/Internal/ResourceContext.php b/src/Internal/ResourceContext.php new file mode 100644 index 0000000..de5b945 --- /dev/null +++ b/src/Internal/ResourceContext.php @@ -0,0 +1,436 @@ +baseParams = new QueryParams(); + $this->apiOrigin = self::originOf($baseUrl); + $this->publicOrigin = $this->apiOrigin; + } + + /** Sets an overall per-request timeout (seconds) for every call made through this context. */ + public function withTimeout(?float $timeoutSecs): self + { + $this->requestTimeoutSecs = $timeoutSecs; + return $this; + } + + /** The per-context request timeout (seconds), or {@code null} to use the client-wide default. */ + public function requestTimeoutSecs(): ?float + { + return $this->requestTimeoutSecs; + } + + /** Creates a context for a collection endpoint: {@code {base}/{resourcePath}}. */ + public static function collection(HttpClientCore $http, string $baseUrl, string $resourcePath): self + { + return new self($http, $baseUrl . '/' . $resourcePath, $baseUrl); + } + + /** Creates a context for a single resource: {@code {base}/{resourcePath}/{safeId}}. */ + public static function single(HttpClientCore $http, string $baseUrl, string $resourcePath, string $id): self + { + return new self($http, $baseUrl . '/' . $resourcePath . '/' . self::toSafeId($id), $baseUrl); + } + + /** Overrides the origin used when building 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. */ + 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. */ + public function publicUrl(string $subPath): string + { + $apiUrl = $this->subUrl($subPath); + if ($this->publicOrigin === $this->apiOrigin) { + return $apiUrl; + } + if (str_starts_with($apiUrl, $this->apiOrigin)) { + return $this->publicOrigin . substr($apiUrl, strlen($this->apiOrigin)); + } + return $apiUrl; + } + + /** Merges the inherited base params with per-call params. */ + 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 + */ + public function getResource(string $subPath, QueryParams $params): mixed + { + try { + return $this->getResourceRequired($subPath, $params); + } catch (ApifyApiException $e) { + if (HttpClientCore::isNotFound($e)) { + return null; + } + throw $e; + } + } + + /** + * GET a single resource, returning its decoded {@code data} (propagates errors). + * + * @return mixed + */ + public function getResourceRequired(string $subPath, QueryParams $params): mixed + { + $url = $this->mergedParams($params)->applyToUrl($this->subUrl($subPath)); + $response = $this->http->call('GET', $url, timeoutSecs: $this->requestTimeoutSecs); + return Json::decodeData((string) $response->getBody()); + } + + /** + * PUT to update a resource with a JSON-serializable body, returning the decoded {@code data}. + * + * @return array + */ + public function updateResource(string $subPath, mixed $body): array + { + $url = $this->mergedParams(new QueryParams())->applyToUrl($this->subUrl($subPath)); + $response = $this->http->call('PUT', $url, Json::encode($body), self::CONTENT_TYPE_JSON, timeoutSecs: $this->requestTimeoutSecs); + return self::asArray(Json::decodeData((string) $response->getBody())); + } + + /** Performs a DELETE; a not-found is treated as a successful no-op. */ + public function deleteResource(string $subPath): void + { + $url = $this->mergedParams(new QueryParams())->applyToUrl($this->subUrl($subPath)); + try { + $this->http->call('DELETE', $url, timeoutSecs: $this->requestTimeoutSecs); + } catch (ApifyApiException $e) { + if (!HttpClientCore::isNotFound($e)) { + throw $e; + } + } + } + + /** + * GET a paginated listing and build a {@see PaginationList} with each item hydrated. + * + * @template T + * @param callable(array):T $hydrate + * @return PaginationList + */ + public function listResource(string $subPath, QueryParams $params, callable $hydrate): PaginationList + { + $data = $this->getResourceRequired($subPath, $params); + return PaginationList::fromData($data, $hydrate); + } + + /** + * POST to create a resource with a JSON-serializable body, returning the decoded {@code data}. + * + * @return array + */ + public function createResource(QueryParams $params, mixed $body): array + { + $url = $this->mergedParams($params)->applyToUrl($this->subUrl('')); + $response = $this->http->call('POST', $url, Json::encode($body), self::CONTENT_TYPE_JSON, timeoutSecs: $this->requestTimeoutSecs); + return self::asArray(Json::decodeData((string) $response->getBody())); + } + + /** + * 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 })}. + * + * @param array|null $schema + * @return array + */ + public function getOrCreateNamed(?string $name, ?array $schema = null): array + { + $params = new QueryParams(); + if ($name !== null && $name !== '') { + $params->addString('name', $name); + } + $url = $params->applyToUrl($this->subUrl('')); + $response = $schema !== null + ? $this->http->call('POST', $url, Json::encode(['schema' => $schema]), self::CONTENT_TYPE_JSON, timeoutSecs: $this->requestTimeoutSecs) + : $this->http->call('POST', $url, timeoutSecs: $this->requestTimeoutSecs); + return self::asArray(Json::decodeData((string) $response->getBody())); + } + + /** + * POST with an optional raw body and content type, unwrapping the data envelope. + * + * @return array + */ + public function postWithBody(string $subPath, QueryParams $params, ?string $body, string $contentType): array + { + $url = $this->mergedParams($params)->applyToUrl($this->subUrl($subPath)); + $response = $this->http->call('POST', $url, $body, $contentType, timeoutSecs: $this->requestTimeoutSecs); + 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 + */ + public function postWithBodyNoEnvelope(string $subPath, QueryParams $params, ?string $body, string $contentType): mixed + { + $url = $this->mergedParams($params)->applyToUrl($this->subUrl($subPath)); + $response = $this->http->call('POST', $url, $body, $contentType, timeoutSecs: $this->requestTimeoutSecs); + return Json::decode((string) $response->getBody()); + } + + /** + * DELETE with a JSON body (used for batch request deletion), unwrapping the data envelope. + * + * @return array + */ + public function deleteWithBody(string $subPath, QueryParams $params, mixed $body): array + { + $url = $this->mergedParams($params)->applyToUrl($this->subUrl($subPath)); + $response = $this->http->call('DELETE', $url, Json::encode($body), self::CONTENT_TYPE_JSON, timeoutSecs: $this->requestTimeoutSecs); + return self::asArray(Json::decodeData((string) $response->getBody())); + } + + /** GET returning the raw response (no data envelope). Returns {@code null} on not-found. */ + public function getRaw(string $subPath, QueryParams $params): ?ResponseInterface + { + $url = $this->mergedParams($params)->applyToUrl($this->subUrl($subPath)); + try { + return $this->http->call('GET', $url, timeoutSecs: $this->requestTimeoutSecs); + } catch (ApifyApiException $e) { + if (HttpClientCore::isNotFound($e)) { + return null; + } + throw $e; + } + } + + /** HEAD request; returns whether the resource exists. */ + public function headExists(string $subPath, QueryParams $params): bool + { + $url = $this->mergedParams($params)->applyToUrl($this->subUrl($subPath)); + try { + $this->http->call('HEAD', $url, timeoutSecs: $this->requestTimeoutSecs); + return true; + } catch (ApifyApiException $e) { + if (HttpClientCore::isNotFound($e)) { + return false; + } + throw $e; + } + } + + /** PUT with raw bytes and a content type, with an explicit per-request timeout and retry control. */ + public function putRaw( + string $subPath, + QueryParams $params, + string $body, + string $contentType, + ?float $timeoutSecs = null, + bool $doNotRetryTimeouts = false + ): void { + $url = $this->mergedParams($params)->applyToUrl($this->subUrl($subPath)); + $this->http->call('PUT', $url, $body, $contentType, $timeoutSecs ?? $this->requestTimeoutSecs, $doNotRetryTimeouts); + } + + // ---- Wait-for-finish ------------------------------------------------------ + + /** + * The largest server-side {@code waitForFinish} value that is safe to send: below the configured + * per-request timeout by a safety margin (or the API's 60s cap when no finite timeout is set). + */ + private function serverWaitCapSecs(): int + { + $configured = (int) $this->http->requestTimeoutSecs(); + return $configured > 0 + ? max(0, $configured - self::WAIT_TIMEOUT_MARGIN_SECS) + : self::WAIT_REQUEST_SECS; + } + + /** + * Clamps a caller-supplied server-side {@code waitForFinish} value (seconds) to the server wait + * cap, so a synchronous get/wait never asks the server to hold the connection longer than the + * client's own per-request timeout. Returns {@code null} for a {@code null} input. + */ + public function clampServerWait(?int $waitForFinishSecs): ?int + { + if ($waitForFinishSecs === null) { + return null; + } + return min(max(0, $waitForFinishSecs), $this->serverWaitCapSecs()); + } + + /** + * 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. A transient 404 (replica lag) is + * treated as "not yet available". + * + * @param callable(array):bool $isTerminal + * @return array + */ + public function waitForFinish(?int $waitSecs, string $resourceName, callable $isTerminal): array + { + $effectiveWaitSecs = $waitSecs !== null + ? min(max($waitSecs, 0), self::MAX_WAIT_FOR_FINISH_SECS) + : self::MAX_WAIT_FOR_FINISH_SECS; + $budgetMillis = $effectiveWaitSecs * 1000; + $start = self::nowMillis(); + $serverWaitCap = $this->serverWaitCapSecs(); + + $resource = null; + $present = false; + + while (true) { + $elapsed = self::nowMillis() - $start; + $remainingSecs = intdiv($budgetMillis - $elapsed, 1000); + $requestSecs = min(min(max($remainingSecs, 0), self::WAIT_REQUEST_SECS), $serverWaitCap); + + $params = new QueryParams(); + $params->addInt('waitForFinish', $requestSecs); + + $data = $this->getResource('', $params); + if (is_array($data)) { + $resource = $data; + $present = true; + if ($isTerminal($resource)) { + return $resource; + } + } + + if (self::nowMillis() - $start >= $budgetMillis) { + break; + } + usleep((int) (self::WAIT_POLL_INTERVAL_SECS * 1_000_000)); + } + + if ($present && $resource !== null) { + return $resource; + } + throw new RuntimeException( + sprintf('waiting for %s to finish failed: cannot fetch %s details from the server', $resourceName, $resourceName) + ); + } + + private static function nowMillis(): int + { + return (int) round(microtime(true) * 1000); + } + + /** + * 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. + * + * @return array + */ + private static function asArray(mixed $value): array + { + return is_array($value) ? $value : []; + } + + // ---- 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 the first {@code /} of an id is replaced with {@code ~}. + */ + public static function toSafeId(string $id): string + { + $slash = strpos($id, '/'); + return $slash === false ? $id : substr($id, 0, $slash) . '~' . substr($id, $slash + 1); + } + + /** + * Percent-encodes a single URL path segment, so values interpolated into the path (record keys, + * request IDs) cannot break out of the segment. + */ + public static function encodePathSegment(string $input): string + { + return rawurlencode($input); + } + + /** Extracts the origin ({@code scheme://host[:port]}) from a URL, dropping any path. */ + public static function originOf(string $rawUrl): string + { + $rest = $rawUrl; + $scheme = ''; + $pos = strpos($rest, '://'); + if ($pos !== false) { + $scheme = substr($rest, 0, $pos + 3); + $rest = substr($rest, $pos + 3); + } + $slash = strpos($rest, '/'); + if ($slash !== false) { + $rest = substr($rest, 0, $slash); + } + return $scheme . $rest; + } +} diff --git a/src/Internal/RetryConfig.php b/src/Internal/RetryConfig.php new file mode 100644 index 0000000..372dd92 --- /dev/null +++ b/src/Internal/RetryConfig.php @@ -0,0 +1,25 @@ + $digits */ + $digits = array_values(unpack('C*', $binary) ?: []); + + $result = ''; + while ($digits !== []) { + $remainder = 0; + $quotient = []; + foreach ($digits as $byte) { + $accumulator = $remainder * 256 + $byte; + $q = intdiv($accumulator, 62); + $remainder = $accumulator % 62; + if ($quotient !== [] || $q !== 0) { + $quotient[] = $q; + } + } + $result = self::BASE62_ALPHABET[$remainder] . $result; + $digits = $quotient; + } + + return $result === '' ? '0' : $result; + } +} diff --git a/src/Internal/Statuses.php b/src/Internal/Statuses.php new file mode 100644 index 0000000..d1edc28 --- /dev/null +++ b/src/Internal/Statuses.php @@ -0,0 +1,26 @@ +getString('id'); + } + + /** The ID of the user who owns the Actor. */ + public function getUserId(): ?string + { + return $this->getString('userId'); + } + + /** The technical name of the Actor (used in API paths). */ + public function getName(): ?string + { + return $this->getString('name'); + } + + /** The username of the Actor's owner. */ + public function getUsername(): ?string + { + return $this->getString('username'); + } + + /** The human-readable title shown in the UI. */ + public function getTitle(): ?string + { + return $this->getString('title'); + } + + /** A description of what the Actor does. */ + public function getDescription(): ?string + { + return $this->getString('description'); + } + + /** Whether the Actor is publicly available in Apify Store. */ + public function isPublic(): ?bool + { + return $this->getBool('isPublic'); + } + + /** When the Actor was created (ISO-8601 string). */ + public function getCreatedAt(): ?string + { + return $this->getString('createdAt'); + } + + /** When the Actor was last modified (ISO-8601 string). */ + public function getModifiedAt(): ?string + { + return $this->getString('modifiedAt'); + } +} diff --git a/src/Model/ActorEnvVar.php b/src/Model/ActorEnvVar.php new file mode 100644 index 0000000..5175014 --- /dev/null +++ b/src/Model/ActorEnvVar.php @@ -0,0 +1,52 @@ + $data raw data (used when hydrating from the API) + */ + public function __construct(?string $name = null, ?string $value = null, ?bool $isSecret = null, array $data = []) + { + if ($name !== null) { + $data['name'] = $name; + } + if ($value !== null) { + $data['value'] = $value; + } + if ($isSecret !== null) { + $data['isSecret'] = $isSecret; + } + parent::__construct($data); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self(null, null, null, $data); + } + + /** The environment variable name. */ + public function getName(): ?string + { + return $this->getString('name'); + } + + /** The environment variable value. */ + public function getValue(): ?string + { + return $this->getString('value'); + } + + /** Whether the value is stored as a secret. */ + public function getIsSecret(): ?bool + { + return $this->getBool('isSecret'); + } +} diff --git a/src/Model/ActorRun.php b/src/Model/ActorRun.php new file mode 100644 index 0000000..90c1c5d --- /dev/null +++ b/src/Model/ActorRun.php @@ -0,0 +1,99 @@ +getString('id'); + } + + /** The ID of the Actor that produced this run. */ + public function getActId(): ?string + { + return $this->getString('actId'); + } + + /** The ID of the task that started this run, if any. */ + public function getActorTaskId(): ?string + { + return $this->getString('actorTaskId'); + } + + /** The ID of the user who owns the run. */ + public function getUserId(): ?string + { + return $this->getString('userId'); + } + + /** + * 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}. + */ + public function getStatus(): ?string + { + return $this->getString('status'); + } + + /** An optional human-readable status message. */ + public function getStatusMessage(): ?string + { + return $this->getString('statusMessage'); + } + + /** When the run started (ISO-8601 string). */ + public function getStartedAt(): ?string + { + return $this->getString('startedAt'); + } + + /** When the run finished (absent while still running). */ + public function getFinishedAt(): ?string + { + return $this->getString('finishedAt'); + } + + /** The ID of the build used for the run. */ + public function getBuildId(): ?string + { + return $this->getString('buildId'); + } + + /** The ID of the run's default dataset. */ + public function getDefaultDatasetId(): ?string + { + return $this->getString('defaultDatasetId'); + } + + /** The ID of the run's default key-value store. */ + public function getDefaultKeyValueStoreId(): ?string + { + return $this->getString('defaultKeyValueStoreId'); + } + + /** The ID of the run's default request queue. */ + public function getDefaultRequestQueueId(): ?string + { + return $this->getString('defaultRequestQueueId'); + } + + /** The URL of the run's container (for live access). */ + public function getContainerUrl(): ?string + { + return $this->getString('containerUrl'); + } + + /** Whether the run has reached a terminal (finished) status. */ + public function isTerminal(): bool + { + return Statuses::isTerminal($this->getStatus()); + } +} diff --git a/src/Model/ActorStoreListItem.php b/src/Model/ActorStoreListItem.php new file mode 100644 index 0000000..47faf29 --- /dev/null +++ b/src/Model/ActorStoreListItem.php @@ -0,0 +1,33 @@ +getString('id'); + } + + /** The technical name of the Actor. */ + public function getName(): ?string + { + return $this->getString('name'); + } + + /** The username of the Actor's owner. */ + public function getUsername(): ?string + { + return $this->getString('username'); + } + + /** The human-readable title. */ + public function getTitle(): ?string + { + return $this->getString('title'); + } +} diff --git a/src/Model/ActorVersion.php b/src/Model/ActorVersion.php new file mode 100644 index 0000000..08b914c --- /dev/null +++ b/src/Model/ActorVersion.php @@ -0,0 +1,21 @@ +getString('versionNumber'); + } + + /** How the version's source is provided (e.g. {@code "SOURCE_FILES"}). */ + public function getSourceType(): ?string + { + return $this->getString('sourceType'); + } +} diff --git a/src/Model/ApifyResource.php b/src/Model/ApifyResource.php new file mode 100644 index 0000000..b5b34c1 --- /dev/null +++ b/src/Model/ApifyResource.php @@ -0,0 +1,84 @@ + $data the raw decoded resource object + */ + public function __construct(protected array $data) + { + } + + /** + * The full raw resource object, including fields not mapped to a typed getter. + * + * @return array + */ + public function toArray(): array + { + return $this->data; + } + + /** + * A single raw field by key ({@code null} if absent). + * + * @return mixed + */ + public function get(string $key): mixed + { + return $this->data[$key] ?? null; + } + + protected function getString(string $key): ?string + { + $value = $this->data[$key] ?? null; + if (is_string($value)) { + return $value; + } + return (is_int($value) || is_float($value)) ? (string) $value : null; + } + + protected function getInt(string $key): ?int + { + $value = $this->data[$key] ?? null; + if (is_int($value)) { + return $value; + } + return is_numeric($value) ? (int) $value : null; + } + + protected function getBool(string $key): ?bool + { + $value = $this->data[$key] ?? null; + return is_bool($value) ? $value : null; + } + + /** + * @return list|null + */ + protected function getStringList(string $key): ?array + { + $value = $this->data[$key] ?? null; + if (!is_array($value)) { + return null; + } + $strings = []; + foreach ($value as $item) { + if (is_scalar($item)) { + $strings[] = (string) $item; + } + } + return $strings; + } +} diff --git a/src/Model/BatchAddResult.php b/src/Model/BatchAddResult.php new file mode 100644 index 0000000..51a759e --- /dev/null +++ b/src/Model/BatchAddResult.php @@ -0,0 +1,70 @@ + $processedRequests + * @param list $unprocessedRequests + */ + public function __construct( + private array $processedRequests = [], + private array $unprocessedRequests = [], + ) { + } + + /** + * The requests the API successfully added. + * + * @return list + */ + public function getProcessedRequests(): array + { + return $this->processedRequests; + } + + /** + * The requests the API did not process. + * + * @return list + */ + public function getUnprocessedRequests(): array + { + return $this->unprocessedRequests; + } + + /** + * @param list $processedRequests + * @internal + */ + public function setProcessedRequests(array $processedRequests): void + { + $this->processedRequests = $processedRequests; + } + + /** + * @param list $unprocessedRequests + * @internal + */ + public function setUnprocessedRequests(array $unprocessedRequests): void + { + $this->unprocessedRequests = $unprocessedRequests; + } + + /** + * Appends another result's requests into this one (used to merge per-chunk batch results). + * + * @internal + */ + public function merge(BatchAddResult $other): void + { + $this->processedRequests = array_merge($this->processedRequests, $other->processedRequests); + $this->unprocessedRequests = array_merge($this->unprocessedRequests, $other->unprocessedRequests); + } +} diff --git a/src/Model/Build.php b/src/Model/Build.php new file mode 100644 index 0000000..6901a92 --- /dev/null +++ b/src/Model/Build.php @@ -0,0 +1,57 @@ +getString('id'); + } + + /** The ID of the Actor this build belongs to. */ + public function getActId(): ?string + { + return $this->getString('actId'); + } + + /** + * 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}. + */ + public function getStatus(): ?string + { + return $this->getString('status'); + } + + /** When the build started (ISO-8601 string). */ + public function getStartedAt(): ?string + { + return $this->getString('startedAt'); + } + + /** When the build finished (absent while still building). */ + public function getFinishedAt(): ?string + { + return $this->getString('finishedAt'); + } + + /** The human-readable build number (e.g. {@code "0.1.2"}). */ + public function getBuildNumber(): ?string + { + return $this->getString('buildNumber'); + } + + /** Whether the build has reached a terminal (finished) status. */ + public function isTerminal(): bool + { + return Statuses::isTerminal($this->getStatus()); + } +} diff --git a/src/Model/Dataset.php b/src/Model/Dataset.php new file mode 100644 index 0000000..5459974 --- /dev/null +++ b/src/Model/Dataset.php @@ -0,0 +1,45 @@ +getString('id'); + } + + /** The dataset name (empty for unnamed datasets). */ + public function getName(): ?string + { + return $this->getString('name'); + } + + /** The ID of the user who owns the dataset. */ + public function getUserId(): ?string + { + return $this->getString('userId'); + } + + /** When the dataset was created (ISO-8601 string). */ + public function getCreatedAt(): ?string + { + return $this->getString('createdAt'); + } + + /** When the dataset was last modified (ISO-8601 string). */ + public function getModifiedAt(): ?string + { + return $this->getString('modifiedAt'); + } + + /** The number of items currently stored. */ + public function getItemCount(): ?int + { + return $this->getInt('itemCount'); + } +} diff --git a/src/Model/KeyValueStore.php b/src/Model/KeyValueStore.php new file mode 100644 index 0000000..50e7e3d --- /dev/null +++ b/src/Model/KeyValueStore.php @@ -0,0 +1,39 @@ +getString('id'); + } + + /** The store name (empty for unnamed stores). */ + public function getName(): ?string + { + return $this->getString('name'); + } + + /** The ID of the user who owns the store. */ + public function getUserId(): ?string + { + return $this->getString('userId'); + } + + /** When the store was created (ISO-8601 string). */ + public function getCreatedAt(): ?string + { + return $this->getString('createdAt'); + } + + /** When the store was last modified (ISO-8601 string). */ + public function getModifiedAt(): ?string + { + return $this->getString('modifiedAt'); + } +} diff --git a/src/Model/KeyValueStoreKey.php b/src/Model/KeyValueStoreKey.php new file mode 100644 index 0000000..a9d47bd --- /dev/null +++ b/src/Model/KeyValueStoreKey.php @@ -0,0 +1,21 @@ +getString('key'); + } + + /** The record size in bytes. */ + public function getSize(): ?int + { + return $this->getInt('size'); + } +} diff --git a/src/Model/KeyValueStoreKeysPage.php b/src/Model/KeyValueStoreKeysPage.php new file mode 100644 index 0000000..92dbcd7 --- /dev/null +++ b/src/Model/KeyValueStoreKeysPage.php @@ -0,0 +1,76 @@ + $items + */ + public function __construct( + private array $items, + private int $limit, + private bool $isTruncated, + private ?string $exclusiveStartKey, + private ?string $nextExclusiveStartKey, + ) { + } + + /** + * @param mixed $data the decoded keys-page object + */ + public static function fromData(mixed $data): self + { + $data = is_array($data) ? $data : []; + $rawItems = (isset($data['items']) && is_array($data['items'])) ? array_values($data['items']) : []; + $items = array_map( + static fn ($item) => new KeyValueStoreKey(is_array($item) ? $item : []), + $rawItems + ); + + return new self( + $items, + (int) ($data['limit'] ?? count($items)), + (bool) ($data['isTruncated'] ?? false), + isset($data['exclusiveStartKey']) ? (string) $data['exclusiveStartKey'] : null, + isset($data['nextExclusiveStartKey']) ? (string) $data['nextExclusiveStartKey'] : null, + ); + } + + /** + * The listed keys. + * + * @return list + */ + public function getItems(): array + { + return $this->items; + } + + /** The maximum number of keys requested. */ + public function getLimit(): int + { + return $this->limit; + } + + /** Whether more keys are available. */ + public function isTruncated(): bool + { + return $this->isTruncated; + } + + /** The key the listing started after. */ + public function getExclusiveStartKey(): ?string + { + return $this->exclusiveStartKey; + } + + /** The key to pass to fetch the next page. */ + public function getNextExclusiveStartKey(): ?string + { + return $this->nextExclusiveStartKey; + } +} diff --git a/src/Model/KeyValueStoreRecord.php b/src/Model/KeyValueStoreRecord.php new file mode 100644 index 0000000..c398eef --- /dev/null +++ b/src/Model/KeyValueStoreRecord.php @@ -0,0 +1,37 @@ +key; + } + + /** The raw record bytes, as a string. */ + public function getValue(): string + { + return $this->value; + } + + /** The record's MIME type, as reported by the API. */ + public function getContentType(): ?string + { + return $this->contentType; + } +} diff --git a/src/Model/PaginationList.php b/src/Model/PaginationList.php new file mode 100644 index 0000000..b7842a0 --- /dev/null +++ b/src/Model/PaginationList.php @@ -0,0 +1,127 @@ + + */ +final class PaginationList implements IteratorAggregate, \Countable +{ + /** + * @param list $items + */ + public function __construct( + private array $items, + private int $total, + private int $offset, + private int $limit, + private int $count, + private bool $desc, + ) { + } + + /** + * @template TItem + * @param mixed $data the decoded paginated object + * @param callable(array):TItem $hydrate maps each raw item to a model + * @return self + */ + public static function fromData(mixed $data, callable $hydrate): self + { + $data = is_array($data) ? $data : []; + $rawItems = (isset($data['items']) && is_array($data['items'])) ? array_values($data['items']) : []; + $items = array_map( + static fn ($item) => $hydrate(is_array($item) ? $item : []), + $rawItems + ); + + return new self( + $items, + (int) ($data['total'] ?? count($items)), + (int) ($data['offset'] ?? 0), + (int) ($data['limit'] ?? count($items)), + (int) ($data['count'] ?? count($items)), + (bool) ($data['desc'] ?? false), + ); + } + + /** + * Builds a page directly from items and metadata (used by the dataset-items endpoint, which + * returns a bare array with pagination in response headers). + * + * @template TItem + * @param list $items + * @return self + */ + public static function fromItems(array $items, int $total, int $offset, int $limit, int $count, bool $desc): self + { + return new self($items, $total, $offset, $limit, $count, $desc); + } + + /** + * The items of this page (never {@code null}). + * + * @return list + */ + public function getItems(): array + { + return $this->items; + } + + /** Total number of items available across all pages. */ + public function getTotal(): int + { + return $this->total; + } + + /** Number of items skipped at the start. */ + public function getOffset(): int + { + return $this->offset; + } + + /** Maximum number of items the API would return for this request. */ + public function getLimit(): int + { + return $this->limit; + } + + /** Number of items actually returned in this page. */ + public function getCount(): int + { + return $this->count; + } + + /** Whether the items are in descending order. */ + public function isDesc(): bool + { + return $this->desc; + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->items); + } + + public function count(): int + { + return count($this->items); + } +} diff --git a/src/Model/RequestQueue.php b/src/Model/RequestQueue.php new file mode 100644 index 0000000..cb146ed --- /dev/null +++ b/src/Model/RequestQueue.php @@ -0,0 +1,45 @@ +getString('id'); + } + + /** The queue name (empty for unnamed queues). */ + public function getName(): ?string + { + return $this->getString('name'); + } + + /** The ID of the user who owns the queue. */ + public function getUserId(): ?string + { + return $this->getString('userId'); + } + + /** When the queue was created (ISO-8601 string). */ + public function getCreatedAt(): ?string + { + return $this->getString('createdAt'); + } + + /** When the queue was last modified (ISO-8601 string). */ + public function getModifiedAt(): ?string + { + return $this->getString('modifiedAt'); + } + + /** The total number of requests ever added. */ + public function getTotalRequestCount(): ?int + { + return $this->getInt('totalRequestCount'); + } +} diff --git a/src/Model/RequestQueueHead.php b/src/Model/RequestQueueHead.php new file mode 100644 index 0000000..ffffc28 --- /dev/null +++ b/src/Model/RequestQueueHead.php @@ -0,0 +1,60 @@ + $items + */ + public function __construct( + private array $items, + private int $limit, + private bool $hadMultipleClients, + ) { + } + + /** + * @param mixed $data the decoded queue-head object + */ + public static function fromData(mixed $data): self + { + $data = is_array($data) ? $data : []; + $rawItems = (isset($data['items']) && is_array($data['items'])) ? array_values($data['items']) : []; + $items = array_map( + static fn ($item) => RequestQueueRequest::fromArray(is_array($item) ? $item : []), + $rawItems + ); + + return new self( + $items, + (int) ($data['limit'] ?? count($items)), + (bool) ($data['hadMultipleClients'] ?? false), + ); + } + + /** + * The requests at the head of the queue. + * + * @return list + */ + public function getItems(): array + { + return $this->items; + } + + /** The maximum number of requests requested. */ + public function getLimit(): int + { + return $this->limit; + } + + /** Whether multiple clients have accessed the queue. */ + public function hadMultipleClients(): bool + { + return $this->hadMultipleClients; + } +} diff --git a/src/Model/RequestQueueOperationInfo.php b/src/Model/RequestQueueOperationInfo.php new file mode 100644 index 0000000..14ff80a --- /dev/null +++ b/src/Model/RequestQueueOperationInfo.php @@ -0,0 +1,36 @@ +getString('requestId'); + } + + /** + * The unique key of the affected request. Populated for batch-add results; may be {@code null} + * for single add/update operations. + */ + public function getUniqueKey(): ?string + { + return $this->getString('uniqueKey'); + } + + /** Whether the request was already in the queue. */ + public function wasAlreadyPresent(): ?bool + { + return $this->getBool('wasAlreadyPresent'); + } + + /** Whether the request had already been handled. */ + public function wasAlreadyHandled(): ?bool + { + return $this->getBool('wasAlreadyHandled'); + } +} diff --git a/src/Model/RequestQueueRequest.php b/src/Model/RequestQueueRequest.php new file mode 100644 index 0000000..d499894 --- /dev/null +++ b/src/Model/RequestQueueRequest.php @@ -0,0 +1,98 @@ + $data raw data (used when hydrating from the API) + */ + public function __construct(?string $url = null, ?string $uniqueKey = null, array $data = []) + { + if ($url !== null) { + $data['url'] = $url; + } + if ($uniqueKey !== null) { + $data['uniqueKey'] = $uniqueKey; + } + parent::__construct($data); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self(null, null, $data); + } + + /** The unique request ID (assigned by the API; absent on create). */ + public function getId(): ?string + { + return $this->getString('id'); + } + + public function setId(string $id): self + { + $this->data['id'] = $id; + return $this; + } + + /** The request URL. */ + public function getUrl(): ?string + { + return $this->getString('url'); + } + + public function setUrl(string $url): self + { + $this->data['url'] = $url; + return $this; + } + + /** The deduplication key for the request. */ + public function getUniqueKey(): ?string + { + return $this->getString('uniqueKey'); + } + + public function setUniqueKey(string $uniqueKey): self + { + $this->data['uniqueKey'] = $uniqueKey; + return $this; + } + + /** The HTTP method (e.g. {@code "GET"}, {@code "POST"}). */ + public function getMethod(): ?string + { + return $this->getString('method'); + } + + public function setMethod(string $method): self + { + $this->data['method'] = $method; + return $this; + } + + /** + * Arbitrary user-attached metadata. + * + * @return mixed + */ + public function getUserData(): mixed + { + return $this->get('userData'); + } + + public function setUserData(mixed $userData): self + { + $this->data['userData'] = $userData; + return $this; + } +} diff --git a/src/Model/Schedule.php b/src/Model/Schedule.php new file mode 100644 index 0000000..ce19577 --- /dev/null +++ b/src/Model/Schedule.php @@ -0,0 +1,39 @@ +getString('id'); + } + + /** The ID of the user who owns the schedule. */ + public function getUserId(): ?string + { + return $this->getString('userId'); + } + + /** The schedule name. */ + public function getName(): ?string + { + return $this->getString('name'); + } + + /** The cron expression governing when the schedule fires. */ + public function getCronExpression(): ?string + { + return $this->getString('cronExpression'); + } + + /** Whether the schedule is currently active. */ + public function isEnabled(): ?bool + { + return $this->getBool('isEnabled'); + } +} diff --git a/src/Model/Task.php b/src/Model/Task.php new file mode 100644 index 0000000..9cb1dd0 --- /dev/null +++ b/src/Model/Task.php @@ -0,0 +1,51 @@ +getString('id'); + } + + /** The ID of the Actor this task runs. */ + public function getActId(): ?string + { + return $this->getString('actId'); + } + + /** The ID of the user who owns the task. */ + public function getUserId(): ?string + { + return $this->getString('userId'); + } + + /** The technical name of the task. */ + public function getName(): ?string + { + return $this->getString('name'); + } + + /** The human-readable title shown in the UI. */ + public function getTitle(): ?string + { + return $this->getString('title'); + } + + /** When the task was created (ISO-8601 string). */ + public function getCreatedAt(): ?string + { + return $this->getString('createdAt'); + } + + /** When the task was last modified (ISO-8601 string). */ + public function getModifiedAt(): ?string + { + return $this->getString('modifiedAt'); + } +} diff --git a/src/Model/User.php b/src/Model/User.php new file mode 100644 index 0000000..d62a18d --- /dev/null +++ b/src/Model/User.php @@ -0,0 +1,24 @@ +getString('id'); + } + + /** The user's username. */ + public function getUsername(): ?string + { + return $this->getString('username'); + } +} diff --git a/src/Model/Webhook.php b/src/Model/Webhook.php new file mode 100644 index 0000000..9b4b4ae --- /dev/null +++ b/src/Model/Webhook.php @@ -0,0 +1,37 @@ +getString('id'); + } + + /** The ID of the user who owns the webhook. */ + public function getUserId(): ?string + { + return $this->getString('userId'); + } + + /** The URL the webhook posts to. */ + public function getRequestUrl(): ?string + { + return $this->getString('requestUrl'); + } + + /** + * The events that trigger the webhook. + * + * @return list|null + */ + public function getEventTypes(): ?array + { + return $this->getStringList('eventTypes'); + } +} diff --git a/src/Model/WebhookDispatch.php b/src/Model/WebhookDispatch.php new file mode 100644 index 0000000..dcfb31d --- /dev/null +++ b/src/Model/WebhookDispatch.php @@ -0,0 +1,21 @@ +getString('id'); + } + + /** The ID of the webhook that produced this dispatch. */ + public function getWebhookId(): ?string + { + return $this->getString('webhookId'); + } +} diff --git a/src/Options/ActorBuildOptions.php b/src/Options/ActorBuildOptions.php new file mode 100644 index 0000000..5306fb8 --- /dev/null +++ b/src/Options/ActorBuildOptions.php @@ -0,0 +1,32 @@ +addBool('betaPackages', $this->betaPackages) + ->addString('tag', $this->tag) + ->addBool('useCache', $this->useCache) + ->addInt('waitForFinish', $this->waitForFinish); + } +} diff --git a/src/Options/ActorListOptions.php b/src/Options/ActorListOptions.php new file mode 100644 index 0000000..b05acb9 --- /dev/null +++ b/src/Options/ActorListOptions.php @@ -0,0 +1,35 @@ +addInt('offset', $this->offset) + ->addInt('limit', $this->limit) + ->addBool('desc', $this->desc) + ->addBool('my', $this->my) + ->addString('sortBy', $this->sortBy); + } +} diff --git a/src/Options/ActorStartOptions.php b/src/Options/ActorStartOptions.php new file mode 100644 index 0000000..d15fe5c --- /dev/null +++ b/src/Options/ActorStartOptions.php @@ -0,0 +1,83 @@ +|null $webhooks ad-hoc webhooks to attach to this run; serialized to + * base64-encoded JSON 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"}). */ + public readonly ?string $build = null, + /** Memory in megabytes allocated for the run. */ + public readonly ?int $memoryMbytes = null, + /** Timeout for the run in seconds (0 means no timeout). */ + public readonly ?int $timeoutSecs = null, + /** Maximum seconds to wait server-side for the run to finish (max 60). */ + public readonly ?int $waitForFinish = null, + /** Maximum number of dataset items to charge (pay-per-result Actors). */ + public readonly ?int $maxItems = null, + /** Maximum total charge in USD (pay-per-event Actors). */ + public readonly ?float $maxTotalChargeUsd = null, + /** The content type of the input body. Defaults to {@code application/json}. */ + public readonly ?string $contentType = null, + /** 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}/ + * {@code FULL_PERMISSIONS}). + */ + public readonly ?string $forcePermissionLevel = null, + public readonly ?array $webhooks = null, + ) { + } + + /** @internal */ + public function contentTypeOrDefault(): string + { + return ($this->contentType !== null && $this->contentType !== '') + ? $this->contentType + : ResourceContext::CONTENT_TYPE_JSON; + } + + /** @internal */ + public function appendTo(QueryParams $q): void + { + $q->addString('build', $this->build) + ->addInt('memory', $this->memoryMbytes) + ->addInt('timeout', $this->timeoutSecs) + ->addInt('waitForFinish', $this->waitForFinish) + ->addInt('maxItems', $this->maxItems) + ->addFloat('maxTotalChargeUsd', $this->maxTotalChargeUsd) + ->addBool('restartOnError', $this->restartOnError) + ->addString('forcePermissionLevel', $this->forcePermissionLevel) + ->addString('webhooks', self::encodeWebhooks($this->webhooks)); + } + + /** + * 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. + * + * @param list|null $webhooks + * @internal + */ + public static function encodeWebhooks(?array $webhooks): ?string + { + if ($webhooks === null) { + return null; + } + return base64_encode(Json::encode($webhooks)); + } +} diff --git a/src/Options/BatchAddRequestsOptions.php b/src/Options/BatchAddRequestsOptions.php new file mode 100644 index 0000000..f3bb7d9 --- /dev/null +++ b/src/Options/BatchAddRequestsOptions.php @@ -0,0 +1,32 @@ +maxUnprocessedRequestsRetries = max(0, $maxUnprocessedRequestsRetries); + $this->minDelayBetweenUnprocessedRequestsRetriesMillis = max(0, $minDelayBetweenUnprocessedRequestsRetriesMillis); + } +} diff --git a/src/Options/DatasetDownloadOptions.php b/src/Options/DatasetDownloadOptions.php new file mode 100644 index 0000000..e17b93c --- /dev/null +++ b/src/Options/DatasetDownloadOptions.php @@ -0,0 +1,52 @@ +items !== null) { + $this->items->appendTo($q); + } + $q->addBool('attachment', $this->attachment) + ->addBool('bom', $this->bom) + ->addString('delimiter', $this->delimiter) + ->addBool('skipHeaderRow', $this->skipHeaderRow) + ->addString('xmlRoot', $this->xmlRoot) + ->addString('xmlRow', $this->xmlRow) + ->addString('feedTitle', $this->feedTitle) + ->addString('feedDescription', $this->feedDescription); + } +} diff --git a/src/Options/DatasetListItemsOptions.php b/src/Options/DatasetListItemsOptions.php new file mode 100644 index 0000000..7080eb1 --- /dev/null +++ b/src/Options/DatasetListItemsOptions.php @@ -0,0 +1,70 @@ +|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 + */ + public function __construct( + /** Number of items to skip. */ + public readonly ?int $offset = null, + /** Maximum number of items to return. */ + public readonly ?int $limit = null, + /** Return items newest-first. */ + public readonly ?bool $desc = null, + public readonly ?array $fields = null, + public readonly ?array $outputFields = null, + public readonly ?array $omit = null, + /** Skip empty items. */ + public readonly ?bool $skipEmpty = null, + /** Skip hidden fields (those starting with {@code "#"}). */ + public readonly ?bool $skipHidden = null, + /** Return only clean (non-empty, non-hidden) items. */ + public readonly ?bool $clean = null, + public readonly ?array $unwind = null, + public readonly ?array $flatten = null, + /** Select a predefined dataset view for field selection. */ + public readonly ?string $view = null, + /** Return simplified (flattened, cleaned) items. */ + public readonly ?bool $simplified = null, + /** Skip items that come from failed pages. */ + public readonly ?bool $skipFailedPages = null, + /** A pre-shared URL signature granting access without an API token. */ + public readonly ?string $signature = null, + ) { + } + + /** @internal */ + public function appendTo(QueryParams $q): void + { + $q->addInt('offset', $this->offset) + ->addInt('limit', $this->limit) + ->addBool('desc', $this->desc) + ->addCsv('fields', $this->fields) + ->addCsv('outputFields', $this->outputFields) + ->addCsv('omit', $this->omit) + ->addBool('skipEmpty', $this->skipEmpty) + ->addBool('skipHidden', $this->skipHidden) + ->addBool('clean', $this->clean) + ->addCsv('unwind', $this->unwind) + ->addCsv('flatten', $this->flatten) + ->addString('view', $this->view) + ->addBool('simplified', $this->simplified) + ->addBool('skipFailedPages', $this->skipFailedPages) + ->addString('signature', $this->signature); + } +} diff --git a/src/Options/DownloadItemsFormat.php b/src/Options/DownloadItemsFormat.php new file mode 100644 index 0000000..26d715b --- /dev/null +++ b/src/Options/DownloadItemsFormat.php @@ -0,0 +1,24 @@ +addBool('attachment', $this->attachment)->addString('signature', $this->signature); + } +} diff --git a/src/Options/LastRunOptions.php b/src/Options/LastRunOptions.php new file mode 100644 index 0000000..25750ba --- /dev/null +++ b/src/Options/LastRunOptions.php @@ -0,0 +1,24 @@ +addInt('limit', $this->limit) + ->addString('exclusiveStartKey', $this->exclusiveStartKey) + ->addString('prefix', $this->prefix) + ->addString('collection', $this->collection) + ->addString('signature', $this->signature); + } +} diff --git a/src/Options/ListOptions.php b/src/Options/ListOptions.php new file mode 100644 index 0000000..c16ea64 --- /dev/null +++ b/src/Options/ListOptions.php @@ -0,0 +1,33 @@ +addInt('offset', $this->offset) + ->addInt('limit', $this->limit) + ->addBool('desc', $this->desc); + } +} diff --git a/src/Options/ListRequestsOptions.php b/src/Options/ListRequestsOptions.php new file mode 100644 index 0000000..c7a81a4 --- /dev/null +++ b/src/Options/ListRequestsOptions.php @@ -0,0 +1,64 @@ +|null $filter restrict the listing to requests in the given states; each + * value must be {@see FILTER_LOCKED} or {@see FILTER_PENDING} + */ + public function __construct( + /** Maximum number of requests to return. */ + public readonly ?int $limit = null, + /** List requests after this ID. */ + public readonly ?string $exclusiveStartId = null, + /** An opaque pagination cursor (alternative to {@code exclusiveStartId}). */ + public readonly ?string $cursor = null, + public readonly ?array $filter = null, + ) { + } + + /** Validates the options for API-level constraints. @internal */ + public function validate(): void + { + if ($this->exclusiveStartId !== null && $this->cursor !== null) { + throw new InvalidArgumentException( + 'ListRequestsOptions: exclusiveStartId and cursor are mutually exclusive' + ); + } + if ($this->filter !== null) { + foreach ($this->filter as $f) { + if ($f !== self::FILTER_LOCKED && $f !== self::FILTER_PENDING) { + throw new InvalidArgumentException(sprintf( + 'ListRequestsOptions: filter entries must be "%s" or "%s", got "%s"', + self::FILTER_LOCKED, + self::FILTER_PENDING, + $f + )); + } + } + } + } + + /** @internal */ + public function appendTo(QueryParams $q): void + { + $q->addInt('limit', $this->limit) + ->addString('exclusiveStartId', $this->exclusiveStartId) + ->addString('cursor', $this->cursor) + ->addCsv('filter', $this->filter !== null ? array_values($this->filter) : null); + } +} diff --git a/src/Options/LogOptions.php b/src/Options/LogOptions.php new file mode 100644 index 0000000..2b6a4e4 --- /dev/null +++ b/src/Options/LogOptions.php @@ -0,0 +1,25 @@ +addBool('raw', $this->raw)->addBool('download', $this->download); + } +} diff --git a/src/Options/MetamorphOptions.php b/src/Options/MetamorphOptions.php new file mode 100644 index 0000000..866d218 --- /dev/null +++ b/src/Options/MetamorphOptions.php @@ -0,0 +1,27 @@ +contentType !== null && $this->contentType !== '') + ? $this->contentType + : ResourceContext::CONTENT_TYPE_JSON; + } +} diff --git a/src/Options/PaginateRequestsOptions.php b/src/Options/PaginateRequestsOptions.php new file mode 100644 index 0000000..6678f07 --- /dev/null +++ b/src/Options/PaginateRequestsOptions.php @@ -0,0 +1,63 @@ +|null $filter restrict the iteration to requests in the given states; each + * value must be {@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). */ + public readonly ?int $limit = null, + /** Maximum number of requests fetched per page (defaults to {@see DEFAULT_MAX_PAGE_LIMIT}). */ + public readonly ?int $maxPageLimit = null, + /** Start iterating after this request ID (first page only; mutually exclusive with cursor). */ + public readonly ?string $exclusiveStartId = null, + /** An opaque pagination cursor to start from (mutually exclusive with {@code exclusiveStartId}). */ + public readonly ?string $cursor = null, + public readonly ?array $filter = null, + ) { + } + + /** Validates the options for API-level constraints. @internal */ + public function validate(): void + { + if ($this->exclusiveStartId !== null && $this->cursor !== null) { + throw new InvalidArgumentException( + 'PaginateRequestsOptions: exclusiveStartId and cursor are mutually exclusive' + ); + } + if ($this->filter !== null) { + foreach ($this->filter as $f) { + if ($f !== self::FILTER_LOCKED && $f !== self::FILTER_PENDING) { + throw new InvalidArgumentException(sprintf( + 'PaginateRequestsOptions: filter entries must be "%s" or "%s", got "%s"', + self::FILTER_LOCKED, + self::FILTER_PENDING, + $f + )); + } + } + } + } +} diff --git a/src/Options/RequestQueueClientOptions.php b/src/Options/RequestQueueClientOptions.php new file mode 100644 index 0000000..a934645 --- /dev/null +++ b/src/Options/RequestQueueClientOptions.php @@ -0,0 +1,26 @@ +count ?? 1; + } +} diff --git a/src/Options/RunListOptions.php b/src/Options/RunListOptions.php new file mode 100644 index 0000000..175dfa1 --- /dev/null +++ b/src/Options/RunListOptions.php @@ -0,0 +1,35 @@ +|null $status filter by one or more run statuses (e.g. {@code "SUCCEEDED"}, + * {@code "RUNNING"}); sent as a comma-separated list + */ + public function __construct( + public readonly ?array $status = null, + /** Filter to runs started after this ISO-8601 timestamp. */ + public readonly ?string $startedAfter = null, + /** Filter to runs started before this ISO-8601 timestamp. */ + public readonly ?string $startedBefore = null, + ) { + } + + /** @internal */ + public function appendTo(QueryParams $q): void + { + $q->addCsv('status', $this->status !== null ? array_values($this->status) : null) + ->addString('startedAfter', $this->startedAfter) + ->addString('startedBefore', $this->startedBefore); + } +} diff --git a/src/Options/RunResurrectOptions.php b/src/Options/RunResurrectOptions.php new file mode 100644 index 0000000..7cac906 --- /dev/null +++ b/src/Options/RunResurrectOptions.php @@ -0,0 +1,38 @@ +addString('build', $this->build) + ->addInt('memory', $this->memoryMbytes) + ->addInt('timeout', $this->timeoutSecs) + ->addInt('maxItems', $this->maxItems) + ->addFloat('maxTotalChargeUsd', $this->maxTotalChargeUsd) + ->addBool('restartOnError', $this->restartOnError); + } +} diff --git a/src/Options/SetRecordOptions.php b/src/Options/SetRecordOptions.php new file mode 100644 index 0000000..f1e517d --- /dev/null +++ b/src/Options/SetRecordOptions.php @@ -0,0 +1,24 @@ +addInt('offset', $this->offset) + ->addInt('limit', $this->limit) + ->addBool('desc', $this->desc) + ->addBool('unnamed', $this->unnamed) + ->addString('ownership', $this->ownership); + } +} diff --git a/src/Options/StoreListOptions.php b/src/Options/StoreListOptions.php new file mode 100644 index 0000000..abf95d6 --- /dev/null +++ b/src/Options/StoreListOptions.php @@ -0,0 +1,70 @@ +limit, + $this->search, + $this->sortBy, + $this->category, + $this->username, + $this->pricingModel, + $this->includeUnrunnableActors, + $this->allowsAgenticUsers, + $this->responseFormat, + ); + } + + /** @internal */ + public function appendTo(QueryParams $q): void + { + $q->addInt('offset', $this->offset) + ->addInt('limit', $this->limit) + ->addString('search', $this->search) + ->addString('sortBy', $this->sortBy) + ->addString('category', $this->category) + ->addString('username', $this->username) + ->addString('pricingModel', $this->pricingModel) + ->addBool('includeUnrunnableActors', $this->includeUnrunnableActors) + ->addBool('allowsAgenticUsers', $this->allowsAgenticUsers) + ->addString('responseFormat', $this->responseFormat); + } +} diff --git a/src/Options/TaskStartOptions.php b/src/Options/TaskStartOptions.php new file mode 100644 index 0000000..67ce5dd --- /dev/null +++ b/src/Options/TaskStartOptions.php @@ -0,0 +1,51 @@ +|null $webhooks ad-hoc webhooks to attach (serialized to base64-encoded JSON) + */ + public function __construct( + /** The tag or number of the build to run (e.g. {@code "latest"}, {@code "0.1.2"}). */ + public readonly ?string $build = null, + /** Memory in megabytes allocated for the run. */ + public readonly ?int $memoryMbytes = null, + /** Timeout for the run in seconds (0 means no timeout). */ + public readonly ?int $timeoutSecs = null, + /** Maximum seconds to wait server-side for the run to finish (max 60). */ + public readonly ?int $waitForFinish = null, + /** Maximum number of dataset items to charge (pay-per-result Actors). */ + public readonly ?int $maxItems = null, + /** Maximum total charge in USD (pay-per-event Actors). */ + public readonly ?float $maxTotalChargeUsd = null, + /** If {@code true}, restart the run if it fails. */ + public readonly ?bool $restartOnError = null, + public readonly ?array $webhooks = null, + ) { + } + + /** @internal */ + public function appendTo(QueryParams $q): void + { + $q->addString('build', $this->build) + ->addInt('memory', $this->memoryMbytes) + ->addInt('timeout', $this->timeoutSecs) + ->addInt('waitForFinish', $this->waitForFinish) + ->addInt('maxItems', $this->maxItems) + ->addFloat('maxTotalChargeUsd', $this->maxTotalChargeUsd) + ->addBool('restartOnError', $this->restartOnError) + ->addString('webhooks', ActorStartOptions::encodeWebhooks($this->webhooks)); + } +} diff --git a/src/Options/ValidateInputOptions.php b/src/Options/ValidateInputOptions.php new file mode 100644 index 0000000..eff5784 --- /dev/null +++ b/src/Options/ValidateInputOptions.php @@ -0,0 +1,34 @@ +contentType !== null && $this->contentType !== '') + ? $this->contentType + : ResourceContext::CONTENT_TYPE_JSON; + } + + /** @internal */ + public function appendTo(QueryParams $q): void + { + $q->addString('build', $this->build); + } +} diff --git a/src/Resource/AbstractWebhookCollectionClient.php b/src/Resource/AbstractWebhookCollectionClient.php new file mode 100644 index 0000000..32543d4 --- /dev/null +++ b/src/Resource/AbstractWebhookCollectionClient.php @@ -0,0 +1,43 @@ +ctx = ResourceContext::collection($http, $baseUrl, 'webhooks'); + } + + /** + * Lists webhooks. + * + * @return PaginationList + */ + public function list(?ListOptions $options = null): PaginationList + { + $params = new QueryParams(); + ($options ?? new ListOptions())->appendTo($params); + return $this->ctx->listResource('', $params, static fn (array $d) => new Webhook($d)); + } +} diff --git a/src/Resource/ActorClient.php b/src/Resource/ActorClient.php new file mode 100644 index 0000000..76805b6 --- /dev/null +++ b/src/Resource/ActorClient.php @@ -0,0 +1,172 @@ +ctx = ResourceContext::single($http, $baseUrl, 'actors', $id); + } + + /** The Actor's ID (or {@code username~name}) as provided. */ + public function getId(): string + { + return $this->id; + } + + /** Fetches the Actor object, or {@code null} if it does not exist. */ + public function get(): ?Actor + { + $data = $this->ctx->getResource('', new QueryParams()); + return is_array($data) ? new Actor($data) : null; + } + + /** + * Updates the Actor with the given fields and returns the updated object. + * + * @param mixed $newFields any JSON-serializable set of fields to update + */ + public function update(mixed $newFields): Actor + { + return new Actor($this->ctx->updateResource('', $newFields)); + } + + /** Deletes the Actor. */ + public function delete(): void + { + $this->ctx->deleteResource(''); + } + + /** + * Starts the Actor and returns immediately with the created run. + * + * @param mixed $input any JSON-serializable value (or {@code null} for no input) + */ + public function start(mixed $input = null, ?ActorStartOptions $options = null): ActorRun + { + $options ??= new ActorStartOptions(); + $params = new QueryParams(); + $options->appendTo($params); + $body = $input === null ? null : Json::encode($input); + return new ActorRun($this->ctx->postWithBody('runs', $params, $body, $options->contentTypeOrDefault())); + } + + /** + * Starts the Actor and waits (client-side polling) for it to finish. + * + * @param mixed $input any JSON-serializable value (or {@code null} for no input) + * @param int|null $waitSecs bounds the wait; {@code null} waits indefinitely + */ + public function call(mixed $input = null, ?ActorStartOptions $options = null, ?int $waitSecs = null): ActorRun + { + $run = $this->start($input, $options); + return $this->root->run((string) $run->getId())->waitForFinish($waitSecs); + } + + /** + * Validates {@code input} against the Actor's input schema and returns whether it is valid. + * + * @param mixed $input any JSON-serializable value (or {@code null}) + */ + public function validateInput(mixed $input = null, ?ValidateInputOptions $options = null): bool + { + $options ??= new ValidateInputOptions(); + $params = new QueryParams(); + $options->appendTo($params); + $body = $input === null ? null : Json::encode($input); + // The validate-input endpoint returns a bare {"valid": } object, not the standard + // {"data": ...} envelope, so parse it without unwrapping. + $result = $this->ctx->postWithBodyNoEnvelope('validate-input', $params, $body, $options->contentTypeOrDefault()); + return is_array($result) && ($result['valid'] ?? false) === true; + } + + /** Builds the given version of the Actor and returns the created build. */ + public function build(string $versionNumber, ?ActorBuildOptions $options = null): Build + { + $params = new QueryParams(); + $params->addString('version', $versionNumber); + ($options ?? new ActorBuildOptions())->appendTo($params); + return new Build($this->ctx->postWithBody('builds', $params, null, ResourceContext::CONTENT_TYPE_JSON)); + } + + /** + * 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. + */ + public function defaultBuild(?int $waitForFinish = null): BuildClient + { + $params = new QueryParams(); + // Clamp the server-side wait below the per-request timeout, consistent with run/build get(). + $params->addInt('waitForFinish', $this->ctx->clampServerWait($waitForFinish)); + $data = $this->ctx->getResourceRequired('builds/default', $params); + $build = new Build(is_array($data) ? $data : []); + return new BuildClient($this->http, $this->baseUrl, (string) $build->getId()); + } + + /** Returns a client for the last run of this Actor, optionally filtered by status and/or origin. */ + public function lastRun(?LastRunOptions $options = null): RunClient + { + $client = new RunClient($this->http, $this->ctx->subUrl(''), 'runs', 'last'); + $client->setLastRunParams($options ?? new LastRunOptions()); + return $client; + } + + /** A client for this Actor's build collection. */ + public function builds(): BuildCollectionClient + { + return new BuildCollectionClient($this->http, $this->ctx->subUrl(''), 'builds'); + } + + /** A client for this Actor's run collection. */ + public function runs(): RunCollectionClient + { + return new RunCollectionClient($this->http, $this->ctx->subUrl(''), 'runs'); + } + + /** A client for a specific version of this Actor. */ + public function version(string $versionNumber): ActorVersionClient + { + return new ActorVersionClient($this->http, $this->ctx->subUrl(''), $versionNumber); + } + + /** A client for this Actor's version collection. */ + public function versions(): ActorVersionCollectionClient + { + return new ActorVersionCollectionClient($this->http, $this->ctx->subUrl('')); + } + + /** A read-only client for this Actor's webhook collection ({@code GET /v2/actors/{id}/webhooks}). */ + public function webhooks(): NestedWebhookCollectionClient + { + return new NestedWebhookCollectionClient($this->http, $this->ctx->subUrl('')); + } +} diff --git a/src/Resource/ActorCollectionClient.php b/src/Resource/ActorCollectionClient.php new file mode 100644 index 0000000..f39b9e5 --- /dev/null +++ b/src/Resource/ActorCollectionClient.php @@ -0,0 +1,46 @@ +ctx = ResourceContext::collection($http, $baseUrl, 'actors'); + } + + /** + * Lists the account's Actors. + * + * @return PaginationList + */ + public function list(?ActorListOptions $options = null): PaginationList + { + $params = new QueryParams(); + ($options ?? new ActorListOptions())->appendTo($params); + return $this->ctx->listResource('', $params, static fn (array $d) => new Actor($d)); + } + + /** + * Creates a new Actor. + * + * @param mixed $actor any JSON-serializable Actor definition + */ + public function create(mixed $actor): Actor + { + return new Actor($this->ctx->createResource(new QueryParams(), $actor)); + } +} diff --git a/src/Resource/ActorEnvVarClient.php b/src/Resource/ActorEnvVarClient.php new file mode 100644 index 0000000..ba88504 --- /dev/null +++ b/src/Resource/ActorEnvVarClient.php @@ -0,0 +1,44 @@ +ctx = ResourceContext::single($http, $versionUrl, 'env-vars', $name); + } + + /** Fetches the environment variable, or {@code null} if it does not exist. */ + public function get(): ?ActorEnvVar + { + $data = $this->ctx->getResource('', new QueryParams()); + return is_array($data) ? ActorEnvVar::fromArray($data) : null; + } + + /** Updates the environment variable and returns the updated object. */ + public function update(ActorEnvVar $envVar): ActorEnvVar + { + return ActorEnvVar::fromArray($this->ctx->updateResource('', $envVar->toArray())); + } + + /** Deletes the environment variable. */ + public function delete(): void + { + $this->ctx->deleteResource(''); + } +} diff --git a/src/Resource/ActorEnvVarCollectionClient.php b/src/Resource/ActorEnvVarCollectionClient.php new file mode 100644 index 0000000..77f80aa --- /dev/null +++ b/src/Resource/ActorEnvVarCollectionClient.php @@ -0,0 +1,42 @@ +ctx = ResourceContext::collection($http, $versionUrl, 'env-vars'); + } + + /** + * Lists the version's environment variables. + * + * @return PaginationList + */ + public function list(): PaginationList + { + return $this->ctx->listResource('', new QueryParams(), static fn (array $d) => ActorEnvVar::fromArray($d)); + } + + /** Creates a new environment variable. */ + public function create(ActorEnvVar $envVar): ActorEnvVar + { + return ActorEnvVar::fromArray($this->ctx->createResource(new QueryParams(), $envVar->toArray())); + } +} diff --git a/src/Resource/ActorVersionClient.php b/src/Resource/ActorVersionClient.php new file mode 100644 index 0000000..ca104fe --- /dev/null +++ b/src/Resource/ActorVersionClient.php @@ -0,0 +1,62 @@ +ctx = ResourceContext::single($http, $actorUrl, 'versions', $versionNumber); + $this->versionUrl = $this->ctx->subUrl(''); + } + + /** Fetches the version, or {@code null} if it does not exist. */ + public function get(): ?ActorVersion + { + $data = $this->ctx->getResource('', new QueryParams()); + return is_array($data) ? new ActorVersion($data) : null; + } + + /** + * Updates the version with the given fields and returns the updated object. + * + * @param mixed $newFields any JSON-serializable set of fields to update + */ + public function update(mixed $newFields): ActorVersion + { + return new ActorVersion($this->ctx->updateResource('', $newFields)); + } + + /** Deletes the version. */ + public function delete(): void + { + $this->ctx->deleteResource(''); + } + + /** A client for a specific environment variable of this version. */ + public function envVar(string $name): ActorEnvVarClient + { + return new ActorEnvVarClient($this->http, $this->versionUrl, $name); + } + + /** A client for this version's environment variable collection. */ + public function envVars(): ActorEnvVarCollectionClient + { + return new ActorEnvVarCollectionClient($this->http, $this->versionUrl); + } +} diff --git a/src/Resource/ActorVersionCollectionClient.php b/src/Resource/ActorVersionCollectionClient.php new file mode 100644 index 0000000..2a21b48 --- /dev/null +++ b/src/Resource/ActorVersionCollectionClient.php @@ -0,0 +1,46 @@ +ctx = ResourceContext::collection($http, $actorUrl, 'versions'); + } + + /** + * Lists the Actor's versions. + * + * @return PaginationList + */ + public function list(?ListOptions $options = null): PaginationList + { + $params = new QueryParams(); + ($options ?? new ListOptions())->appendTo($params); + return $this->ctx->listResource('', $params, static fn (array $d) => new ActorVersion($d)); + } + + /** + * Creates a new Actor version. + * + * @param mixed $version any JSON-serializable version definition + */ + public function create(mixed $version): ActorVersion + { + return new ActorVersion($this->ctx->createResource(new QueryParams(), $version)); + } +} diff --git a/src/Resource/BuildClient.php b/src/Resource/BuildClient.php new file mode 100644 index 0000000..bfa1f36 --- /dev/null +++ b/src/Resource/BuildClient.php @@ -0,0 +1,83 @@ +ctx = ResourceContext::single($http, $baseUrl, 'actor-builds', $id); + } + + /** + * Fetches the build, optionally asking the API to wait up to {@code $waitForFinishSecs} seconds + * (max 60) for the build to finish before responding. Returns {@code null} if it does not exist. + */ + public function get(?int $waitForFinishSecs = null): ?Build + { + $params = new QueryParams(); + // Clamp to the client's per-request timeout so a short custom timeout doesn't abort the call. + $params->addInt('waitForFinish', $this->ctx->clampServerWait($waitForFinishSecs)); + $data = $this->ctx->getResource('', $params); + return is_array($data) ? new Build($data) : null; + } + + /** Aborts the build and returns its updated state. */ + public function abort(): Build + { + return new Build($this->ctx->postWithBody('abort', new QueryParams(), null, '')); + } + + /** Deletes the build. */ + public function delete(): void + { + $this->ctx->deleteResource(''); + } + + /** + * Polls until the build reaches a terminal state or {@code $waitSecs} elapses ({@code null} waits + * indefinitely). Returns the latest build. + */ + public function waitForFinish(?int $waitSecs = null): Build + { + $data = $this->ctx->waitForFinish( + $waitSecs, + 'build', + static fn (array $d): bool => (new Build($d))->isTerminal() + ); + return new Build($data); + } + + /** + * Returns the OpenAPI definition generated for the build, or {@code null} if unavailable. + * + * @return array|null the raw OpenAPI document + */ + public function getOpenApiDefinition(): ?array + { + $response = $this->ctx->getRaw('openapi.json', new QueryParams()); + if ($response === null) { + return null; + } + $decoded = Json::decode((string) $response->getBody()); + return is_array($decoded) ? $decoded : null; + } + + /** A client for accessing this build's log. */ + public function log(): LogClient + { + return LogClient::nested($this->http, $this->ctx->subUrl('')); + } +} diff --git a/src/Resource/BuildCollectionClient.php b/src/Resource/BuildCollectionClient.php new file mode 100644 index 0000000..492d380 --- /dev/null +++ b/src/Resource/BuildCollectionClient.php @@ -0,0 +1,39 @@ +ctx = ResourceContext::collection($http, $baseUrl, $resourcePath); + } + + /** + * Lists builds. + * + * @return PaginationList + */ + public function list(?ListOptions $options = null): PaginationList + { + $params = new QueryParams(); + ($options ?? new ListOptions())->appendTo($params); + return $this->ctx->listResource('', $params, static fn (array $d) => new Build($d)); + } +} diff --git a/src/Resource/DatasetClient.php b/src/Resource/DatasetClient.php new file mode 100644 index 0000000..d80350e --- /dev/null +++ b/src/Resource/DatasetClient.php @@ -0,0 +1,185 @@ +baseParams = $inheritedParams->copy(); + } + return new self($http, $ctx); + } + + /** @internal */ + public function withPublicBase(string $publicBaseUrl): self + { + $this->ctx->withPublicOrigin($publicBaseUrl); + return $this; + } + + /** Fetches the dataset metadata, or {@code null} if it does not exist. */ + public function get(): ?Dataset + { + $data = $this->ctx->getResource('', new QueryParams()); + return is_array($data) ? new Dataset($data) : null; + } + + /** + * Updates the dataset metadata (e.g. name, title) and returns the updated object. + * + * @param mixed $newFields any JSON-serializable set of fields to update + */ + public function update(mixed $newFields): Dataset + { + return new Dataset($this->ctx->updateResource('', $newFields)); + } + + /** Deletes the dataset. */ + public function delete(): void + { + $this->ctx->deleteResource(''); + } + + /** + * Lists items from the dataset, each decoded to a PHP value (associative array for objects). + * + * 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. + * + * @return PaginationList + */ + public function listItems(?DatasetListItemsOptions $options = null): PaginationList + { + $options ??= new DatasetListItemsOptions(); + $params = new QueryParams(); + $options->appendTo($params); + $url = $this->ctx->mergedParams($params)->applyToUrl($this->ctx->subUrl('items')); + $response = $this->http->call('GET', $url); + + $items = Json::decode((string) $response->getBody()); + $items = is_array($items) ? array_values($items) : []; + $count = count($items); + + return PaginationList::fromItems( + $items, + $this->headerInt($response, 'X-Apify-Pagination-Total', $count), + $this->headerInt($response, 'X-Apify-Pagination-Offset', 0), + $this->headerInt($response, 'X-Apify-Pagination-Limit', $count), + $count, + $options->desc ?? false, + ); + } + + /** + * 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. + */ + public function downloadItems(DownloadItemsFormat $format, ?DatasetDownloadOptions $options = null): string + { + $params = new QueryParams(); + $params->addString('format', $format->value); + ($options ?? new DatasetDownloadOptions())->appendTo($params); + $url = $this->ctx->mergedParams($params)->applyToUrl($this->ctx->subUrl('items')); + $response = $this->http->call('GET', $url); + return (string) $response->getBody(); + } + + /** + * Pushes one or more items to the dataset. + * + * @param mixed $items must serialize to a JSON object or an array of objects + */ + public function pushItems(mixed $items): void + { + $url = $this->ctx->mergedParams(new QueryParams())->applyToUrl($this->ctx->subUrl('items')); + $this->http->call( + 'POST', + $url, + Json::encode($items), + ResourceContext::CONTENT_TYPE_JSON_CHARSET + ); + } + + /** + * Returns statistical information about the dataset, or {@code null} if unavailable. + * + * @return array|null + */ + public function getStatistics(): ?array + { + $response = $this->ctx->getRaw('statistics', new QueryParams()); + if ($response === null) { + return null; + } + $decoded = Json::decodeData((string) $response->getBody()); + return is_array($decoded) ? $decoded : null; + } + + /** + * Builds a public URL for downloading this dataset's 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). The URL is built from the configured public base URL. + */ + public function createItemsPublicUrl(?DatasetListItemsOptions $options = null, ?int $expiresInSecs = null): string + { + $options ??= new DatasetListItemsOptions(); + $params = new QueryParams(); + $options->appendTo($params); + // 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). + if ($options->signature === null) { + $dataset = $this->get(); + if ($dataset !== null) { + $secret = $dataset->get('urlSigningSecretKey'); + if (is_string($secret)) { + $signature = Signatures::signStorageContent($secret, (string) $dataset->getId(), $expiresInSecs); + $params->addString('signature', $signature); + } + } + } + return $params->applyToUrl($this->ctx->publicUrl('items')); + } + + private function headerInt(ResponseInterface $response, string $name, int $fallback): int + { + $value = $response->getHeaderLine($name); + return $value === '' ? $fallback : (int) $value; + } +} diff --git a/src/Resource/DatasetCollectionClient.php b/src/Resource/DatasetCollectionClient.php new file mode 100644 index 0000000..7fe101b --- /dev/null +++ b/src/Resource/DatasetCollectionClient.php @@ -0,0 +1,48 @@ +ctx = ResourceContext::collection($http, $baseUrl, 'datasets'); + } + + /** + * Lists datasets. + * + * @return PaginationList + */ + public function list(?StorageListOptions $options = null): PaginationList + { + $params = new QueryParams(); + ($options ?? new StorageListOptions())->appendTo($params); + return $this->ctx->listResource('', $params, static fn (array $d) => new Dataset($d)); + } + + /** + * 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 })}. + * + * @param array|null $schema + */ + public function getOrCreate(?string $name = null, ?array $schema = null): Dataset + { + return new Dataset($this->ctx->getOrCreateNamed($name, $schema)); + } +} diff --git a/src/Resource/KeyValueStoreClient.php b/src/Resource/KeyValueStoreClient.php new file mode 100644 index 0000000..13f2047 --- /dev/null +++ b/src/Resource/KeyValueStoreClient.php @@ -0,0 +1,185 @@ +baseParams = $inheritedParams->copy(); + } + return new self($ctx); + } + + /** @internal */ + public function withPublicBase(string $publicBaseUrl): self + { + $this->ctx->withPublicOrigin($publicBaseUrl); + return $this; + } + + /** Fetches the store metadata, or {@code null} if it does not exist. */ + public function get(): ?KeyValueStore + { + $data = $this->ctx->getResource('', new QueryParams()); + return is_array($data) ? new KeyValueStore($data) : null; + } + + /** + * Updates the store metadata (e.g. name) and returns the updated object. + * + * @param mixed $newFields any JSON-serializable set of fields to update + */ + public function update(mixed $newFields): KeyValueStore + { + return new KeyValueStore($this->ctx->updateResource('', $newFields)); + } + + /** Deletes the store. */ + public function delete(): void + { + $this->ctx->deleteResource(''); + } + + /** Lists the keys stored in this key-value store. */ + public function listKeys(?ListKeysOptions $options = null): KeyValueStoreKeysPage + { + $params = new QueryParams(); + ($options ?? new ListKeysOptions())->appendTo($params); + return KeyValueStoreKeysPage::fromData($this->ctx->getResourceRequired('keys', $params)); + } + + /** Reports whether a record with the given key exists. */ + public function recordExists(string $key): bool + { + return $this->ctx->headExists('records/' . ResourceContext::encodePathSegment($key), new QueryParams()); + } + + /** + * 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. + */ + public function getRecord(string $key, ?GetRecordOptions $options = 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); + $response = $this->ctx->getRaw('records/' . ResourceContext::encodePathSegment($key), $params); + if ($response === null) { + return null; + } + $contentType = $response->getHeaderLine('Content-Type'); + return new KeyValueStoreRecord($key, (string) $response->getBody(), $contentType === '' ? null : $contentType); + } + + /** + * Stores a record with raw bytes and the given content type, honoring the given write options + * ({@code timeoutSecs}, {@code doNotRetryTimeouts}). + */ + public function setRecord(string $key, string $value, string $contentType, ?SetRecordOptions $options = null): void + { + $options ??= new SetRecordOptions(); + $timeoutSecs = $options->timeoutSecs !== null ? (float) $options->timeoutSecs : null; + $this->ctx->putRaw( + 'records/' . ResourceContext::encodePathSegment($key), + new QueryParams(), + $value, + $contentType, + $timeoutSecs, + $options->doNotRetryTimeouts + ); + } + + /** + * Stores a record holding the JSON serialization of {@code $value}. + * + * @param mixed $value any JSON-serializable value + */ + public function setRecordJson(string $key, mixed $value): void + { + $this->setRecord($key, Json::encode($value), ResourceContext::CONTENT_TYPE_JSON_CHARSET); + } + + /** Deletes a record by key. */ + public function deleteRecord(string $key): void + { + $this->ctx->deleteResource('records/' . ResourceContext::encodePathSegment($key)); + } + + /** + * 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. + */ + public function getRecordPublicUrl(string $key): string + { + $params = new QueryParams(); + $store = $this->get(); + if ($store !== null) { + $secret = $store->get('urlSigningSecretKey'); + if (is_string($secret)) { + $params->addString('signature', Signatures::createHmacSignature($secret, $key)); + } + } + return $params->applyToUrl($this->ctx->publicUrl('records/' . ResourceContext::encodePathSegment($key))); + } + + /** + * 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. + */ + public function createKeysPublicUrl(?ListKeysOptions $options = null, ?int $expiresInSecs = null): string + { + $options ??= new ListKeysOptions(); + $params = new QueryParams(); + $options->appendTo($params); + if ($options->signature === null) { + $store = $this->get(); + if ($store !== null) { + $secret = $store->get('urlSigningSecretKey'); + if (is_string($secret)) { + $params->addString( + 'signature', + Signatures::signStorageContent($secret, (string) $store->getId(), $expiresInSecs) + ); + } + } + } + return $params->applyToUrl($this->ctx->publicUrl('keys')); + } +} diff --git a/src/Resource/KeyValueStoreCollectionClient.php b/src/Resource/KeyValueStoreCollectionClient.php new file mode 100644 index 0000000..144fac1 --- /dev/null +++ b/src/Resource/KeyValueStoreCollectionClient.php @@ -0,0 +1,48 @@ +ctx = ResourceContext::collection($http, $baseUrl, 'key-value-stores'); + } + + /** + * Lists key-value stores. + * + * @return PaginationList + */ + public function list(?StorageListOptions $options = null): PaginationList + { + $params = new QueryParams(); + ($options ?? new StorageListOptions())->appendTo($params); + return $this->ctx->listResource('', $params, static fn (array $d) => new KeyValueStore($d)); + } + + /** + * 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 })}. + * + * @param array|null $schema + */ + public function getOrCreate(?string $name = null, ?array $schema = null): KeyValueStore + { + return new KeyValueStore($this->ctx->getOrCreateNamed($name, $schema)); + } +} diff --git a/src/Resource/LogClient.php b/src/Resource/LogClient.php new file mode 100644 index 0000000..71e690c --- /dev/null +++ b/src/Resource/LogClient.php @@ -0,0 +1,79 @@ +baseParams = $inheritedParams->copy(); + } + return new self($http, $ctx); + } + + /** Fetches the log as text, or {@code null} if the log does not exist. */ + public function get(?LogOptions $options = null): ?string + { + $params = new QueryParams(); + ($options ?? new LogOptions())->appendTo($params); + $response = $this->ctx->getRaw('', $params); + 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. + */ + public function stream(?LogOptions $options = null): StreamInterface + { + $params = new QueryParams(); + $params->addBool('stream', true); + ($options ?? new LogOptions())->appendTo($params); + $url = $this->ctx->mergedParams($params)->applyToUrl($this->ctx->subUrl('')); + + $response = $this->http->stream($url); + if ($response->getStatusCode() >= HttpClientCore::MAX_SUCCESS_STATUS) { + $body = (string) $response->getBody(); + throw HttpClientCore::buildApiError( + $response->getStatusCode(), + $body, + 1, + 'GET', + HttpClientCore::extractPath($url) + ); + } + return $response->getBody(); + } +} diff --git a/src/Resource/NestedWebhookCollectionClient.php b/src/Resource/NestedWebhookCollectionClient.php new file mode 100644 index 0000000..99f5039 --- /dev/null +++ b/src/Resource/NestedWebhookCollectionClient.php @@ -0,0 +1,15 @@ +timeoutSecs; + $ctx->withTimeout($timeoutSecs); + return new self($http, $ctx, $options?->clientKey, $timeoutSecs); + } + + /** + * 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 + */ + public static function nested(HttpClientCore $http, string $base, string $subPath, ?QueryParams $inheritedParams = null): self + { + $ctx = ResourceContext::collection($http, $base, $subPath); + if ($inheritedParams !== null) { + $ctx->baseParams = $inheritedParams->copy(); + } + return new self($http, $ctx, null); + } + + /** + * 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. + */ + public function withClientKey(string $clientKey): self + { + return new self($this->http, $this->ctx, $clientKey, $this->timeoutSecs); + } + + private function applyClientKey(QueryParams $params): QueryParams + { + if ($this->clientKey !== null && $this->clientKey !== '') { + $params->addString('clientKey', $this->clientKey); + } + return $params; + } + + /** Fetches the queue metadata, or {@code null} if it does not exist. */ + public function get(): ?RequestQueue + { + $data = $this->ctx->getResource('', new QueryParams()); + return is_array($data) ? new RequestQueue($data) : null; + } + + /** + * Updates the queue metadata (e.g. name) and returns the updated object. + * + * @param mixed $newFields any JSON-serializable set of fields to update + */ + public function update(mixed $newFields): RequestQueue + { + return new RequestQueue($this->ctx->updateResource('', $newFields)); + } + + /** Deletes the queue. */ + public function delete(): void + { + $this->ctx->deleteResource(''); + } + + /** + * Returns the requests at the head (front) of the queue, up to {@code $limit} ({@code null} for + * the server default). + */ + public function listHead(?int $limit = null): RequestQueueHead + { + $params = new QueryParams(); + $params->addInt('limit', $limit); + $this->applyClientKey($params); + return RequestQueueHead::fromData($this->ctx->getResourceRequired('head', $params)); + } + + /** Adds a request to the queue. If {@code $forefront} is true, it is added to the front. */ + public function addRequest(RequestQueueRequest $request, bool $forefront = false): RequestQueueOperationInfo + { + $params = new QueryParams(); + $params->addBool('forefront', $forefront); + $this->applyClientKey($params); + $data = $this->ctx->postWithBody('requests', $params, Json::encode($request->toArray()), ResourceContext::CONTENT_TYPE_JSON); + return new RequestQueueOperationInfo($data); + } + + /** Fetches a request by ID, or {@code null} if it does not exist. */ + public function getRequest(string $id): ?RequestQueueRequest + { + $data = $this->ctx->getResource('requests/' . ResourceContext::encodePathSegment($id), new QueryParams()); + 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. + */ + public function updateRequest(RequestQueueRequest $request, bool $forefront = false): RequestQueueOperationInfo + { + $params = new QueryParams(); + $params->addBool('forefront', $forefront); + $this->applyClientKey($params); + $url = $this->ctx->mergedParams($params) + ->applyToUrl($this->ctx->subUrl('requests/' . ResourceContext::encodePathSegment((string) $request->getId()))); + $response = $this->http->call('PUT', $url, Json::encode($request->toArray()), ResourceContext::CONTENT_TYPE_JSON, timeoutSecs: $this->timeoutSecs); + $data = Json::decodeData((string) $response->getBody()); + return new RequestQueueOperationInfo(is_array($data) ? $data : []); + } + + /** Deletes a request by ID. */ + public function deleteRequest(string $id): void + { + $params = $this->applyClientKey(new QueryParams()); + $url = $this->ctx->mergedParams($params) + ->applyToUrl($this->ctx->subUrl('requests/' . ResourceContext::encodePathSegment($id))); + try { + $this->http->call('DELETE', $url, timeoutSecs: $this->timeoutSecs); + } catch (ApifyApiException $e) { + if (!HttpClientCore::isNotFound($e)) { + throw $e; + } + } + } + + /** + * Atomically returns and locks up to {@code $limit} requests from the head of the queue for + * {@code $lockSecs} seconds. Returns the raw locked-head object. + * + * @return array + */ + public function listAndLockHead(int $lockSecs, ?int $limit = null): array + { + $params = new QueryParams(); + $params->addInt('lockSecs', $lockSecs)->addInt('limit', $limit); + $this->applyClientKey($params); + return $this->ctx->postWithBody('head/lock', $params, null, ''); + } + + /** + * Adds multiple requests to the queue. If {@code $forefront} is true, they are added to the front. + * + * 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. + * + * 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. + * + * @param list $requests + * @throws InvalidArgumentException if any request has an empty uniqueKey or is individually too large + */ + public function batchAddRequests( + array $requests, + bool $forefront = false, + ?BatchAddRequestsOptions $options = null + ): BatchAddResult { + $options ??= new BatchAddRequestsOptions(); + $requests = array_values($requests); + + foreach ($requests as $i => $request) { + $uniqueKey = $request->getUniqueKey(); + if ($uniqueKey === null || $uniqueKey === '') { + throw new InvalidArgumentException( + sprintf('batchAddRequests: the request at index %d is missing a non-empty uniqueKey', $i) + ); + } + } + + $payloadSizeLimitBytes = self::MAX_PAYLOAD_SIZE_BYTES + - (int) ceil(self::MAX_PAYLOAD_SIZE_BYTES * self::PAYLOAD_SAFETY_BUFFER_PERCENT); + + $merged = new BatchAddResult(); + $index = 0; + $count = count($requests); + while ($index < $count) { + // Bound each batch first by the count limit (25), then by payload byte size. + $countSlice = array_slice($requests, $index, self::MAX_REQUESTS_PER_BATCH); + $chunk = self::sliceByByteLength($countSlice, $payloadSizeLimitBytes, $index); + $merged->merge($this->batchAddChunkWithRetries($chunk, $forefront, $options)); + $index += count($chunk); + } + return $merged; + } + + /** + * Returns the longest leading run of {@code $requests} whose combined JSON payload stays under + * {@code $maxByteLength}, always keeping at least one request so iteration makes progress. Ports + * the reference client's {@code sliceArrayByByteLength}. + * + * @param list $requests + * @return list + * @throws InvalidArgumentException if a single request exceeds {@code $maxByteLength} + */ + private static function sliceByByteLength(array $requests, int $maxByteLength, int $startIndex): array + { + $payloads = array_map(static fn (RequestQueueRequest $r) => $r->toArray(), $requests); + if (strlen(Json::encode($payloads)) < $maxByteLength) { + return $requests; + } + + $sliced = []; + $byteLength = 2; // the two bytes of an empty array "[]" + foreach ($requests as $i => $request) { + $itemBytes = strlen(Json::encode($request->toArray())); + if ($itemBytes > $maxByteLength) { + throw new InvalidArgumentException(sprintf( + 'batchAddRequests: the request at index %d exceeds the maximum payload size (%d bytes)', + $startIndex + $i, + $maxByteLength + )); + } + if ($byteLength + $itemBytes >= $maxByteLength) { + break; + } + $byteLength += $itemBytes; + $sliced[] = $request; + } + + // Guarantee forward progress: keep at least the first request (it fits under the hard max). + if ($sliced === []) { + $sliced[] = $requests[0]; + } + return $sliced; + } + + /** + * @param list $chunk + */ + private function batchAddChunkWithRetries(array $chunk, bool $forefront, BatchAddRequestsOptions $options): BatchAddResult + { + $maxRetries = $options->maxUnprocessedRequestsRetries; + $minDelayMillis = $options->minDelayBetweenUnprocessedRequestsRetriesMillis; + + $remaining = $chunk; + /** @var list $processed */ + $processed = []; + /** @var list $unprocessed */ + $unprocessed = []; + + for ($attempt = 0; $attempt <= $maxRetries; $attempt++) { + try { + $response = $this->batchAddChunk($remaining, $forefront); + } 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. + $unprocessed = self::requestsNotYetProcessed($chunk, $processed); + break; + } + $processed = array_merge($processed, $response->getProcessedRequests()); + // Only requests the API reports as unprocessed in this SUCCESSFUL response are retried. + $unprocessed = $response->getUnprocessedRequests(); + $remaining = self::requestsNotYetProcessed($chunk, $processed); + if ($remaining === []) { + break; + } + if ($attempt < $maxRetries) { + self::sleepBackoff($attempt, $minDelayMillis); + } + } + + $result = new BatchAddResult(); + $result->setProcessedRequests($processed); + $result->setUnprocessedRequests($unprocessed); + return $result; + } + + /** + * @param list $requests + */ + private function batchAddChunk(array $requests, bool $forefront): BatchAddResult + { + $params = new QueryParams(); + $params->addBool('forefront', $forefront); + $this->applyClientKey($params); + $payload = array_map(static fn (RequestQueueRequest $r) => $r->toArray(), $requests); + $data = $this->ctx->postWithBody('requests/batch', $params, Json::encode($payload), ResourceContext::CONTENT_TYPE_JSON); + + $rawProcessed = (isset($data['processedRequests']) && is_array($data['processedRequests'])) ? $data['processedRequests'] : []; + $processed = array_map( + static fn ($p) => new RequestQueueOperationInfo(is_array($p) ? $p : []), + array_values($rawProcessed) + ); + $rawUnprocessed = (isset($data['unprocessedRequests']) && is_array($data['unprocessedRequests'])) ? $data['unprocessedRequests'] : []; + $unprocessed = array_map( + static fn ($u) => RequestQueueRequest::fromArray(is_array($u) ? $u : []), + array_values($rawUnprocessed) + ); + return new BatchAddResult($processed, $unprocessed); + } + + /** + * @param list $chunk + * @param list $processed + * @return list + */ + private static function requestsNotYetProcessed(array $chunk, array $processed): array + { + $processedKeys = []; + foreach ($processed as $info) { + $key = $info->getUniqueKey(); + if ($key !== null) { + $processedKeys[$key] = true; + } + } + $remaining = []; + foreach ($chunk as $request) { + if (!isset($processedKeys[(string) $request->getUniqueKey()])) { + $remaining[] = $request; + } + } + return $remaining; + } + + 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. + $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 (e.g. by id or + * uniqueKey). Returns the raw batch result. + * + * @param mixed $requests + * @return array + */ + public function batchDeleteRequests(mixed $requests): array + { + $params = $this->applyClientKey(new QueryParams()); + return $this->ctx->deleteWithBody('requests/batch', $params, $requests); + } + + /** + * Lists the queue's requests with pagination. Returns the raw response. + * + * @return array + */ + public function listRequests(?ListRequestsOptions $options = null): array + { + $options ??= new ListRequestsOptions(); + $options->validate(); + $params = new QueryParams(); + $options->appendTo($params); + $this->applyClientKey($params); + $data = $this->ctx->getResourceRequired('requests', $params); + return is_array($data) ? $data : []; + } + + /** + * 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. Returns the raw response. + * + * @return array + */ + public function prolongRequestLock(string $id, int $lockSecs, bool $forefront = false): array + { + $params = new QueryParams(); + $params->addInt('lockSecs', $lockSecs)->addBool('forefront', $forefront); + $this->applyClientKey($params); + $url = $this->ctx->mergedParams($params) + ->applyToUrl($this->ctx->subUrl('requests/' . ResourceContext::encodePathSegment($id) . '/lock')); + $response = $this->http->call('PUT', $url, null, '', timeoutSecs: $this->timeoutSecs); + $data = Json::decodeData((string) $response->getBody()); + return is_array($data) ? $data : []; + } + + /** + * Releases the lock on a request. If {@code $forefront} is true, the request is moved to the + * front of the queue. + */ + public function deleteRequestLock(string $id, bool $forefront = false): void + { + $params = new QueryParams(); + $params->addBool('forefront', $forefront); + $this->applyClientKey($params); + $url = $this->ctx->mergedParams($params) + ->applyToUrl($this->ctx->subUrl('requests/' . ResourceContext::encodePathSegment($id) . '/lock')); + try { + $this->http->call('DELETE', $url, timeoutSecs: $this->timeoutSecs); + } catch (ApifyApiException $e) { + if (!HttpClientCore::isNotFound($e)) { + throw $e; + } + } + } + + /** + * Releases all locks the client holds on this queue's requests. Returns the raw response. + * + * @return array + */ + public function unlockRequests(): array + { + $params = $this->applyClientKey(new QueryParams()); + return $this->ctx->postWithBody('requests/unlock', $params, null, ''); + } + + /** + * Lazily iterates over the queue's requests, transparently following pagination. + * + * 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. + * + * @return Generator + */ + public function paginateRequests(?PaginateRequestsOptions $options = null): Generator + { + $options ??= new PaginateRequestsOptions(); + $options->validate(); + + $maxPageLimit = $options->maxPageLimit ?? PaginateRequestsOptions::DEFAULT_MAX_PAGE_LIMIT; + $limit = $options->limit; // total across all pages; null = unbounded + $nextCursor = $options->cursor; + $nextExclusiveStartId = $options->exclusiveStartId; // used for the first page only + $iterated = 0; + + while (true) { + $pageLimit = $limit !== null ? min($maxPageLimit, $limit - $iterated) : $maxPageLimit; + + $page = $this->listRequests(new ListRequestsOptions( + limit: $pageLimit, + exclusiveStartId: $nextExclusiveStartId, + cursor: $nextCursor, + filter: $options->filter, + )); + + $items = (isset($page['items']) && is_array($page['items'])) ? array_values($page['items']) : []; + if ($items === []) { + return; + } + foreach ($items as $item) { + yield RequestQueueRequest::fromArray(is_array($item) ? $item : []); + } + $iterated += count($items); + + $nextCursor = (isset($page['nextCursor']) && is_string($page['nextCursor'])) ? $page['nextCursor'] : null; + if (($limit !== null && $iterated >= $limit) || $nextCursor === null || $nextCursor === '') { + return; + } + // After the first page, paginate purely by cursor. + $nextExclusiveStartId = null; + } + } +} diff --git a/src/Resource/RequestQueueCollectionClient.php b/src/Resource/RequestQueueCollectionClient.php new file mode 100644 index 0000000..8d5cebb --- /dev/null +++ b/src/Resource/RequestQueueCollectionClient.php @@ -0,0 +1,45 @@ +ctx = ResourceContext::collection($http, $baseUrl, 'request-queues'); + } + + /** + * Lists request queues. + * + * @return PaginationList + */ + public function list(?StorageListOptions $options = null): PaginationList + { + $params = new QueryParams(); + ($options ?? new StorageListOptions())->appendTo($params); + return $this->ctx->listResource('', $params, static fn (array $d) => new RequestQueue($d)); + } + + /** + * Gets the queue with the given name, creating it if it does not exist. An empty/{@code null} + * name creates a new unnamed queue. + */ + public function getOrCreate(?string $name = null): RequestQueue + { + return new RequestQueue($this->ctx->getOrCreateNamed($name)); + } +} diff --git a/src/Resource/RunClient.php b/src/Resource/RunClient.php new file mode 100644 index 0000000..dbe4bc7 --- /dev/null +++ b/src/Resource/RunClient.php @@ -0,0 +1,226 @@ +ctx = ResourceContext::single($http, $baseUrl, $resourcePath, $id); + } + + /** + * 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. + * + * @internal + */ + public function setLastRunParams(LastRunOptions $options): void + { + if ($options->status !== null && $options->status !== '') { + $this->ctx->baseParams->addRaw('status', $options->status); + } + if ($options->origin !== null && $options->origin !== '') { + $this->ctx->baseParams->addRaw('origin', $options->origin); + } + } + + /** + * Fetches the run, optionally asking the API to wait up to {@code $waitForFinishSecs} seconds + * (max 60) for the run to reach a terminal state. Returns {@code null} if it does not exist. + */ + public function get(?int $waitForFinishSecs = null): ?ActorRun + { + $params = new QueryParams(); + $params->addInt('waitForFinish', $this->ctx->clampServerWait($waitForFinishSecs)); + $data = $this->ctx->getResource('', $params); + return is_array($data) ? new ActorRun($data) : null; + } + + /** + * Updates the run with the given fields and returns the updated object. + * + * @param mixed $newFields any JSON-serializable set of fields to update + */ + public function update(mixed $newFields): ActorRun + { + return new ActorRun($this->ctx->updateResource('', $newFields)); + } + + /** Deletes the run. */ + public function delete(): void + { + $this->ctx->deleteResource(''); + } + + /** + * 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). + */ + public function abort(?bool $gracefully = null): ActorRun + { + $params = new QueryParams(); + $params->addBool('gracefully', $gracefully); + return new ActorRun($this->ctx->postWithBody('abort', $params, null, '')); + } + + /** + * 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 ({@code null} for none) + */ + public function metamorph(string $targetActorId, mixed $input = null, ?MetamorphOptions $options = null): ActorRun + { + $options ??= new MetamorphOptions(); + $params = new QueryParams(); + $params->addString('targetActorId', $targetActorId); + if ($options->build !== null && $options->build !== '') { + $params->addString('build', $options->build); + } + $body = $input === null ? null : Json::encode($input); + return new ActorRun($this->ctx->postWithBody('metamorph', $params, $body, $options->contentTypeOrDefault())); + } + + /** Reboots the run (restarts its container while keeping the same run). */ + public function reboot(): ActorRun + { + return new ActorRun($this->ctx->postWithBody('reboot', new QueryParams(), null, '')); + } + + /** Resurrects a finished run, starting it again from the beginning. */ + public function resurrect(?RunResurrectOptions $options = null): ActorRun + { + $params = new QueryParams(); + ($options ?? new RunResurrectOptions())->appendTo($params); + return new ActorRun($this->ctx->postWithBody('resurrect', $params, null, '')); + } + + /** + * Charges for a pay-per-event Actor run: records occurrences of a named event. Only meaningful + * for runs of pay-per-event Actors. + * + * 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. + */ + public function charge(RunChargeOptions $options): void + { + if ($options->eventName === '') { + throw new InvalidArgumentException('RunChargeOptions.eventName is required and must not be empty'); + } + $idempotencyKey = $options->idempotencyKey; + if ($idempotencyKey === null || $idempotencyKey === '') { + $idempotencyKey = $this->generateIdempotencyKey($options->eventName); + } + $body = ['eventName' => $options->eventName, 'count' => $options->countValue()]; + $this->http->call( + 'POST', + $this->ctx->subUrl('charge'), + Json::encode($body), + ResourceContext::CONTENT_TYPE_JSON, + null, + false, + [self::CHARGE_IDEMPOTENCY_HEADER => $idempotencyKey] + ); + } + + /** + * 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. + */ + 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. + */ + public function waitForFinish(?int $waitSecs = null): ActorRun + { + $data = $this->ctx->waitForFinish( + $waitSecs, + 'run', + static fn (array $d): bool => (new ActorRun($d))->isTerminal() + ); + return new ActorRun($data); + } + + /** + * 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. + */ + public function dataset(): DatasetClient + { + return DatasetClient::nested($this->http, $this->ctx->subUrl(''), 'dataset', $this->ctx->baseParams); + } + + /** + * 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. + */ + public function keyValueStore(): KeyValueStoreClient + { + return KeyValueStoreClient::nested($this->http, $this->ctx->subUrl(''), 'key-value-store', $this->ctx->baseParams); + } + + /** + * 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. + */ + public function requestQueue(): RequestQueueClient + { + return RequestQueueClient::nested($this->http, $this->ctx->subUrl(''), 'request-queue', $this->ctx->baseParams); + } + + /** + * 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. + */ + 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. + */ + public function getStreamedLog(): StreamInterface + { + return $this->log()->stream(new LogOptions(raw: true)); + } +} diff --git a/src/Resource/RunCollectionClient.php b/src/Resource/RunCollectionClient.php new file mode 100644 index 0000000..e94000b --- /dev/null +++ b/src/Resource/RunCollectionClient.php @@ -0,0 +1,42 @@ +ctx = ResourceContext::collection($http, $baseUrl, $resourcePath); + } + + /** + * Lists runs, applying the standard pagination and the run-specific filters. + * + * @return PaginationList + */ + public function list(?ListOptions $options = null, ?RunListOptions $filter = null): PaginationList + { + $params = new QueryParams(); + ($options ?? new ListOptions())->appendTo($params); + ($filter ?? new RunListOptions())->appendTo($params); + return $this->ctx->listResource('', $params, static fn (array $d) => new ActorRun($d)); + } +} diff --git a/src/Resource/ScheduleClient.php b/src/Resource/ScheduleClient.php new file mode 100644 index 0000000..4fe2104 --- /dev/null +++ b/src/Resource/ScheduleClient.php @@ -0,0 +1,52 @@ +ctx = ResourceContext::single($http, $baseUrl, 'schedules', $id); + } + + /** Fetches the schedule, or {@code null} if it does not exist. */ + public function get(): ?Schedule + { + $data = $this->ctx->getResource('', new QueryParams()); + return is_array($data) ? new Schedule($data) : null; + } + + /** + * Updates the schedule with the given fields and returns the updated object. + * + * @param mixed $newFields any JSON-serializable set of fields to update + */ + public function update(mixed $newFields): Schedule + { + return new Schedule($this->ctx->updateResource('', $newFields)); + } + + /** Deletes the schedule. */ + public function delete(): void + { + $this->ctx->deleteResource(''); + } + + /** Fetches the schedule's invocation log as text, or {@code null} if absent. */ + public function getLog(): ?string + { + $response = $this->ctx->getRaw('log', new QueryParams()); + return $response === null ? null : (string) $response->getBody(); + } +} diff --git a/src/Resource/ScheduleCollectionClient.php b/src/Resource/ScheduleCollectionClient.php new file mode 100644 index 0000000..b1a161f --- /dev/null +++ b/src/Resource/ScheduleCollectionClient.php @@ -0,0 +1,46 @@ +ctx = ResourceContext::collection($http, $baseUrl, 'schedules'); + } + + /** + * Lists the account's schedules. + * + * @return PaginationList + */ + public function list(?ListOptions $options = null): PaginationList + { + $params = new QueryParams(); + ($options ?? new ListOptions())->appendTo($params); + return $this->ctx->listResource('', $params, static fn (array $d) => new Schedule($d)); + } + + /** + * Creates a new schedule. + * + * @param mixed $schedule any JSON-serializable schedule definition + */ + public function create(mixed $schedule): Schedule + { + return new Schedule($this->ctx->createResource(new QueryParams(), $schedule)); + } +} diff --git a/src/Resource/StoreCollectionClient.php b/src/Resource/StoreCollectionClient.php new file mode 100644 index 0000000..9b12370 --- /dev/null +++ b/src/Resource/StoreCollectionClient.php @@ -0,0 +1,60 @@ +ctx = ResourceContext::collection($http, $baseUrl, 'store'); + } + + /** + * Returns a single page of Store Actors matching the options. + * + * @return PaginationList + */ + public function list(?StoreListOptions $options = null): PaginationList + { + $params = new QueryParams(); + ($options ?? new StoreListOptions())->appendTo($params); + return $this->ctx->listResource('', $params, static fn (array $d) => new ActorStoreListItem($d)); + } + + /** + * Lazily iterates over all Store Actors matching the options, fetching pages on demand. The + * options' {@code limit} (if set) is used as the per-page size. + * + * @return Generator + */ + public function iterate(?StoreListOptions $options = null): Generator + { + $options ??= new StoreListOptions(); + $offset = $options->offset ?? 0; + while (true) { + $page = $this->list($options->withOffset($offset)); + $items = $page->getItems(); + foreach ($items as $item) { + yield $item; + } + $offset += count($items); + if ($items === [] || $offset >= $page->getTotal()) { + return; + } + } + } +} diff --git a/src/Resource/TaskClient.php b/src/Resource/TaskClient.php new file mode 100644 index 0000000..437c04c --- /dev/null +++ b/src/Resource/TaskClient.php @@ -0,0 +1,132 @@ +ctx = ResourceContext::single($http, $baseUrl, 'actor-tasks', $id); + } + + /** Fetches the task object, or {@code null} if it does not exist. */ + public function get(): ?Task + { + $data = $this->ctx->getResource('', new QueryParams()); + return is_array($data) ? new Task($data) : null; + } + + /** + * Updates the task with the given fields and returns the updated object. + * + * @param mixed $newFields any JSON-serializable set of fields to update + */ + public function update(mixed $newFields): Task + { + return new Task($this->ctx->updateResource('', $newFields)); + } + + /** Deletes the task. */ + public function delete(): void + { + $this->ctx->deleteResource(''); + } + + /** + * Starts the task and returns immediately with the created run. + * + * @param mixed $input optionally overrides the task's stored input ({@code null} to use it) + */ + public function start(mixed $input = null, ?TaskStartOptions $options = null): ActorRun + { + $params = new QueryParams(); + ($options ?? new TaskStartOptions())->appendTo($params); + $body = $input === null ? null : Json::encode($input); + return new ActorRun($this->ctx->postWithBody('runs', $params, $body, ResourceContext::CONTENT_TYPE_JSON)); + } + + /** + * Starts the task and waits (client-side polling) for it to finish. + * + * @param mixed $input optionally overrides the task's stored input + * @param int|null $waitSecs bounds the wait; {@code null} waits indefinitely + */ + public function call(mixed $input = null, ?TaskStartOptions $options = null, ?int $waitSecs = null): ActorRun + { + $run = $this->start($input, $options); + return $this->root->run((string) $run->getId())->waitForFinish($waitSecs); + } + + /** + * Fetches the task's stored input, or {@code null} if none is set. + * + * @return mixed + */ + public function getInput(): mixed + { + $response = $this->ctx->getRaw('input', new QueryParams()); + return $response === null ? null : Json::decode((string) $response->getBody()); + } + + /** + * Replaces the task's stored input and returns the updated input. + * + * @param mixed $input any JSON-serializable value + * @return mixed + */ + public function updateInput(mixed $input): mixed + { + $response = $this->http->call( + 'PUT', + $this->ctx->subUrl('input'), + Json::encode($input), + ResourceContext::CONTENT_TYPE_JSON + ); + return Json::decode((string) $response->getBody()); + } + + /** Returns a client for the last run of this task, optionally filtered by status and/or origin. */ + public function lastRun(?LastRunOptions $options = null): RunClient + { + $client = new RunClient($this->http, $this->ctx->subUrl(''), 'runs', 'last'); + $client->setLastRunParams($options ?? new LastRunOptions()); + return $client; + } + + /** A client for this task's run collection. */ + public function runs(): RunCollectionClient + { + return new RunCollectionClient($this->http, $this->ctx->subUrl(''), 'runs'); + } + + /** A read-only client for this task's webhook collection ({@code GET /v2/actor-tasks/{id}/webhooks}). */ + public function webhooks(): NestedWebhookCollectionClient + { + return new NestedWebhookCollectionClient($this->http, $this->ctx->subUrl('')); + } +} diff --git a/src/Resource/TaskCollectionClient.php b/src/Resource/TaskCollectionClient.php new file mode 100644 index 0000000..4473a42 --- /dev/null +++ b/src/Resource/TaskCollectionClient.php @@ -0,0 +1,46 @@ +ctx = ResourceContext::collection($http, $baseUrl, 'actor-tasks'); + } + + /** + * Lists the account's tasks. + * + * @return PaginationList + */ + public function list(?ListOptions $options = null): PaginationList + { + $params = new QueryParams(); + ($options ?? new ListOptions())->appendTo($params); + return $this->ctx->listResource('', $params, static fn (array $d) => new Task($d)); + } + + /** + * Creates a new task. + * + * @param mixed $task any JSON-serializable task definition + */ + public function create(mixed $task): Task + { + return new Task($this->ctx->createResource(new QueryParams(), $task)); + } +} diff --git a/src/Resource/UserClient.php b/src/Resource/UserClient.php new file mode 100644 index 0000000..b7b139b --- /dev/null +++ b/src/Resource/UserClient.php @@ -0,0 +1,96 @@ +ctx = ResourceContext::single($http, $baseUrl, 'users', $id); + $this->isMe = $id === self::ME; + } + + /** + * 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. + */ + public function get(): ?User + { + $data = $this->ctx->getResource('', new QueryParams()); + return is_array($data) ? new User($data) : null; + } + + /** + * Fetches the current account'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. Only available + * for {@code me}. + * + * @return array + */ + public function monthlyUsage(?string $date = null): array + { + $this->requireMe(); + $params = new QueryParams(); + if ($date !== null && $date !== '') { + $params->addString('date', $date); + } + $data = $this->ctx->getResourceRequired('usage/monthly', $params); + return is_array($data) ? $data : []; + } + + /** + * Fetches the current account's resource limits. Only available for {@code me}. + * + * @return array + */ + public function limits(): array + { + $this->requireMe(); + $data = $this->ctx->getResourceRequired('limits', new QueryParams()); + return is_array($data) ? $data : []; + } + + /** + * Updates the current account's resource limits. Only available for {@code me}. + * + * @param mixed $newLimits any JSON-serializable limits object + */ + public function updateLimits(mixed $newLimits): void + { + $this->requireMe(); + $this->http->call( + 'PUT', + $this->ctx->subUrl('limits'), + Json::encode($newLimits), + ResourceContext::CONTENT_TYPE_JSON + ); + } + + private function requireMe(): void + { + if (!$this->isMe) { + throw new LogicException('this operation is only available for the current user (use me())'); + } + } +} diff --git a/src/Resource/WebhookClient.php b/src/Resource/WebhookClient.php new file mode 100644 index 0000000..3d90f76 --- /dev/null +++ b/src/Resource/WebhookClient.php @@ -0,0 +1,58 @@ +ctx = ResourceContext::single($http, $baseUrl, 'webhooks', $id); + } + + /** Fetches the webhook, or {@code null} if it does not exist. */ + public function get(): ?Webhook + { + $data = $this->ctx->getResource('', new QueryParams()); + return is_array($data) ? new Webhook($data) : null; + } + + /** + * Updates the webhook with the given fields and returns the updated object. + * + * @param mixed $newFields any JSON-serializable set of fields to update + */ + public function update(mixed $newFields): Webhook + { + return new Webhook($this->ctx->updateResource('', $newFields)); + } + + /** Deletes the webhook. */ + public function delete(): void + { + $this->ctx->deleteResource(''); + } + + /** Dispatches the webhook immediately and returns the resulting dispatch. */ + public function test(): WebhookDispatch + { + return new WebhookDispatch($this->ctx->postWithBody('test', new QueryParams(), null, '')); + } + + /** A client for this webhook's dispatch collection. */ + public function dispatches(): WebhookDispatchCollectionClient + { + return new WebhookDispatchCollectionClient($this->http, $this->ctx->subUrl(''), 'dispatches'); + } +} diff --git a/src/Resource/WebhookCollectionClient.php b/src/Resource/WebhookCollectionClient.php new file mode 100644 index 0000000..a837b9f --- /dev/null +++ b/src/Resource/WebhookCollectionClient.php @@ -0,0 +1,26 @@ +ctx->createResource(new QueryParams(), $webhook)); + } +} diff --git a/src/Resource/WebhookDispatchClient.php b/src/Resource/WebhookDispatchClient.php new file mode 100644 index 0000000..85d7e4c --- /dev/null +++ b/src/Resource/WebhookDispatchClient.php @@ -0,0 +1,29 @@ +ctx = ResourceContext::single($http, $baseUrl, 'webhook-dispatches', $id); + } + + /** Fetches the dispatch, or {@code null} if it does not exist. */ + public function get(): ?WebhookDispatch + { + $data = $this->ctx->getResource('', new QueryParams()); + return is_array($data) ? new WebhookDispatch($data) : null; + } +} diff --git a/src/Resource/WebhookDispatchCollectionClient.php b/src/Resource/WebhookDispatchCollectionClient.php new file mode 100644 index 0000000..a337db8 --- /dev/null +++ b/src/Resource/WebhookDispatchCollectionClient.php @@ -0,0 +1,39 @@ +ctx = ResourceContext::collection($http, $baseUrl, $resourcePath); + } + + /** + * Lists webhook dispatches. + * + * @return PaginationList + */ + public function list(?ListOptions $options = null): PaginationList + { + $params = new QueryParams(); + ($options ?? new ListOptions())->appendTo($params); + return $this->ctx->listResource('', $params, static fn (array $d) => new WebhookDispatch($d)); + } +} diff --git a/src/Version.php b/src/Version.php new file mode 100644 index 0000000..a3150f7 --- /dev/null +++ b/src/Version.php @@ -0,0 +1,31 @@ +actors()->create([ + 'name' => 'php-example-actor-' . bin2hex(random_bytes(4)), + 'isPublic' => false, + 'versions' => [[ + 'versionNumber' => '0.0', + 'sourceType' => 'SOURCE_FILES', + 'buildTag' => 'latest', + 'sourceFiles' => [ + ['name' => 'Dockerfile', 'format' => 'TEXT', 'content' => "FROM apify/actor-node:20\nCOPY . ./\nCMD node main.js"], + ['name' => 'main.js', 'format' => 'TEXT', 'content' => "console.log('hi');"], + ], + ]], + ]); + try { + $build = $client->actor((string) $created->getId())->build('0.0', new ActorBuildOptions()); + $client->build((string) $build->getId())->waitForFinish(300); + $run = $client->actor((string) $created->getId())->call(null, null, 120); + $log = $client->run((string) $run->getId())->log()->get(); + if ($log !== null) { + echo $log . PHP_EOL; + } + } finally { + $client->actor((string) $created->getId())->delete(); + } + } +} diff --git a/tests/Examples/DocSnippetsTest.php b/tests/Examples/DocSnippetsTest.php new file mode 100644 index 0000000..fa7c9d0 --- /dev/null +++ b/tests/Examples/DocSnippetsTest.php @@ -0,0 +1,106 @@ + + */ + public static function snippetProvider(): array + { + $root = dirname(__DIR__, 2); + $files = array_merge( + glob($root . '/docs/*.md') ?: [], + [$root . '/README.md'], + ); + + $cases = []; + foreach ($files as $file) { + $contents = (string) file_get_contents($file); + if (preg_match_all('/```php\n(.*?)```/s', $contents, $matches, PREG_OFFSET_CAPTURE)) { + foreach ($matches[1] as $i => [$code]) { + $name = basename($file) . '#' . ($i + 1); + $cases[$name] = [$code, $i + 1, basename($file)]; + } + } + } + return $cases; + } + + /** + * @dataProvider snippetProvider + */ + public function testSnippetIsValidPhp(string $code, int $index, string $file): void + { + // A snippet that already opens with `imports() + . "\$client = new \\Apify\\Client\\ApifyClient('token');\n" + . $code . "\n"; + } + + $tmp = tempnam(sys_get_temp_dir(), 'apify_snippet_') . '.php'; + file_put_contents($tmp, $harness); + try { + $output = []; + $exit = 0; + exec(escapeshellarg(PHP_BINARY) . ' -l ' . escapeshellarg($tmp) . ' 2>&1', $output, $exit); + self::assertSame(0, $exit, sprintf("snippet %s#%d is not valid PHP:\n%s", $file, $index, implode("\n", $output))); + } finally { + @unlink($tmp); + } + } + + /** Imports for the short class names used across the docs, so snippets can use them directly. */ + private function imports(): string + { + $classes = [ + 'Apify\Client\ApifyClient', + 'Apify\Client\Version', + 'Apify\Client\Model\RequestQueueRequest', + 'Apify\Client\Model\ActorEnvVar', + 'Apify\Client\Exception\ApifyApiException', + 'Apify\Client\Http\GuzzleHttpClient', + 'Apify\Client\Http\Psr18HttpClient', + 'Apify\Client\Options\ActorListOptions', + 'Apify\Client\Options\ActorStartOptions', + 'Apify\Client\Options\ActorBuildOptions', + 'Apify\Client\Options\ListOptions', + 'Apify\Client\Options\StorageListOptions', + 'Apify\Client\Options\StoreListOptions', + 'Apify\Client\Options\RunListOptions', + 'Apify\Client\Options\DatasetListItemsOptions', + 'Apify\Client\Options\DatasetDownloadOptions', + 'Apify\Client\Options\DownloadItemsFormat', + 'Apify\Client\Options\ListKeysOptions', + 'Apify\Client\Options\GetRecordOptions', + 'Apify\Client\Options\SetRecordOptions', + 'Apify\Client\Options\ListRequestsOptions', + 'Apify\Client\Options\PaginateRequestsOptions', + 'Apify\Client\Options\RequestQueueClientOptions', + 'Apify\Client\Options\LastRunOptions', + 'Apify\Client\Options\LogOptions', + 'Apify\Client\Options\MetamorphOptions', + 'Apify\Client\Options\RunResurrectOptions', + 'Apify\Client\Options\RunChargeOptions', + 'Apify\Client\Options\ValidateInputOptions', + 'Apify\Client\Options\BatchAddRequestsOptions', + ]; + return implode('', array_map(static fn (string $c): string => "use $c;\n", $classes)); + } +} diff --git a/tests/Examples/ExamplesTest.php b/tests/Examples/ExamplesTest.php new file mode 100644 index 0000000..22b2695 --- /dev/null +++ b/tests/Examples/ExamplesTest.php @@ -0,0 +1,66 @@ + + */ + public static function exampleProvider(): array + { + return [ + 'RunStoreActor' => [RunStoreActor::class], + 'Storages' => [Storages::class], + 'GetAccount' => [GetAccount::class], + 'CreateBuildRunActor' => [CreateBuildRunActor::class], + 'RunAndLastRunStorages' => [RunAndLastRunStorages::class], + 'IterateStore' => [IterateStore::class], + 'LogRedirection' => [LogRedirection::class], + ]; + } + + /** + * @param class-string $exampleClass + * @dataProvider exampleProvider + */ + public function testExampleRuns(string $exampleClass): void + { + $client = $this->client(); + ob_start(); + try { + /** @var callable $runner */ + $runner = [$exampleClass, 'run']; + $runner($client); + } catch (Throwable $e) { + ob_end_clean(); + self::fail($exampleClass . ' failed: ' . $e->getMessage()); + } + ob_end_clean(); + self::assertTrue(true); + } +} diff --git a/tests/Examples/GetAccount.php b/tests/Examples/GetAccount.php new file mode 100644 index 0000000..d9f4819 --- /dev/null +++ b/tests/Examples/GetAccount.php @@ -0,0 +1,19 @@ +me()->get(); + if ($user !== null) { + echo 'Account ' . $user->getId() . ' / ' . $user->getUsername() . PHP_EOL; + } + } +} diff --git a/tests/Examples/IterateStore.php b/tests/Examples/IterateStore.php new file mode 100644 index 0000000..d860bf4 --- /dev/null +++ b/tests/Examples/IterateStore.php @@ -0,0 +1,23 @@ +store()->iterate(new StoreListOptions(limit: 10)) as $item) { + echo $item->getName() . PHP_EOL; + if (++$shown >= 5) { + break; + } + } + } +} diff --git a/tests/Examples/LogRedirection.php b/tests/Examples/LogRedirection.php new file mode 100644 index 0000000..194d2b6 --- /dev/null +++ b/tests/Examples/LogRedirection.php @@ -0,0 +1,26 @@ +actor('apify/hello-world')->start(); + + // Open a live streaming connection to the run's log (the `stream=1` endpoint) and redirect it + // to stdout as the run produces it. The server keeps the connection open and emits log lines + // in real time; the stream ends once the run finishes, so reading it to EOF also waits for + // the run to complete. + $stream = $client->run((string) $run->getId())->getStreamedLog(); + while (!$stream->eof()) { + echo $stream->read(8192); + } + } +} diff --git a/tests/Examples/RunAndLastRunStorages.php b/tests/Examples/RunAndLastRunStorages.php new file mode 100644 index 0000000..3f1b563 --- /dev/null +++ b/tests/Examples/RunAndLastRunStorages.php @@ -0,0 +1,32 @@ +actor('apify/hello-world')->start(); + $client->run((string) $started->getId())->waitForFinish(120); + + // Fetch the Actor's last successful run and read its storages through the run-nested + // convenience accessors, which resolve the last run's dataset, key-value store and + // request queue without needing their individual IDs. + $lastRun = $client->actor('apify/hello-world')->lastRun(new LastRunOptions(status: 'SUCCEEDED')); + $last = $lastRun->get(); + if ($last !== null) { + $lastRun->dataset()->listItems(new DatasetListItemsOptions()); + $lastRun->keyValueStore()->getRecord('OUTPUT'); + $lastRun->requestQueue()->listHead(10); + echo 'Last run: ' . $last->getId() . PHP_EOL; + } + } +} diff --git a/tests/Examples/RunStoreActor.php b/tests/Examples/RunStoreActor.php new file mode 100644 index 0000000..d316161 --- /dev/null +++ b/tests/Examples/RunStoreActor.php @@ -0,0 +1,19 @@ +actor('apify/hello-world')->call(null, null, 120); + $items = $client->dataset((string) $run->getDefaultDatasetId())->listItems(new DatasetListItemsOptions()); + echo 'Item count: ' . $items->getCount() . PHP_EOL; + } +} diff --git a/tests/Examples/Storages.php b/tests/Examples/Storages.php new file mode 100644 index 0000000..a19fdad --- /dev/null +++ b/tests/Examples/Storages.php @@ -0,0 +1,47 @@ +datasets()->getOrCreate('php-example-ds-' . bin2hex(random_bytes(4))); + try { + $client->dataset((string) $dataset->getId())->pushItems([['hello' => 'world']]); + $items = $client->dataset((string) $dataset->getId())->listItems(new DatasetListItemsOptions()); + echo 'Dataset items: ' . $items->getCount() . PHP_EOL; + } finally { + $client->dataset((string) $dataset->getId())->delete(); + } + + // Key-value store + $store = $client->keyValueStores()->getOrCreate('php-example-kvs-' . bin2hex(random_bytes(4))); + try { + $client->keyValueStore((string) $store->getId())->setRecordJson('OUTPUT', ['answer' => 42]); + $record = $client->keyValueStore((string) $store->getId())->getRecord('OUTPUT'); + echo 'KVS record: ' . ($record?->getValue() ?? '') . PHP_EOL; + } finally { + $client->keyValueStore((string) $store->getId())->delete(); + } + + // Request queue + $queue = $client->requestQueues()->getOrCreate('php-example-rq-' . bin2hex(random_bytes(4))); + try { + $client->requestQueue((string) $queue->getId()) + ->addRequest(new RequestQueueRequest('https://example.com', 'example')); + $head = $client->requestQueue((string) $queue->getId())->listHead(10); + echo 'Queue head size: ' . count($head->getItems()) . PHP_EOL; + } finally { + $client->requestQueue((string) $queue->getId())->delete(); + } + } +} diff --git a/tests/Integration/ActorIntegrationTest.php b/tests/Integration/ActorIntegrationTest.php new file mode 100644 index 0000000..65b3b5a --- /dev/null +++ b/tests/Integration/ActorIntegrationTest.php @@ -0,0 +1,101 @@ +requireClient(); + $page = $client->actors()->list(new ActorListOptions(my: true, limit: 5)); + self::assertLessThanOrEqual(5, count($page->getItems())); + self::assertSame(count($page->getItems()), $page->getCount()); + self::assertGreaterThanOrEqual(count($page->getItems()), $page->getTotal()); + } + + public function testGetActor(): void + { + $client = $this->requireClient(); + $created = $client->actors()->create(self::minimalActor(self::uniqueName('get'))); + try { + $got = $client->actor((string) $created->getId())->get(); + self::assertNotNull($got); + self::assertSame($created->getId(), $got->getId()); + } finally { + $client->actor((string) $created->getId())->delete(); + } + } + + public function testActorCrudFlow(): void + { + $client = $this->requireClient(); + $created = $client->actors()->create(self::minimalActor(self::uniqueName('crud'))); + try { + $actor = $client->actor((string) $created->getId()); + self::assertNotNull($actor->get()); + $updated = $actor->update(['title' => 'Updated Title']); + self::assertSame('Updated Title', $updated->getTitle()); + $actor->builds()->list(new ListOptions()); + $actor->versions()->list(new ListOptions()); + } finally { + $client->actor((string) $created->getId())->delete(); + } + } + + public function testActorVersionCrudFlow(): void + { + $client = $this->requireClient(); + $created = $client->actors()->create(self::minimalActor(self::uniqueName('ver'))); + try { + $actor = $client->actor((string) $created->getId()); + $version = $actor->versions()->create([ + 'versionNumber' => '0.1', + 'sourceType' => 'SOURCE_FILES', + 'buildTag' => 'latest', + 'sourceFiles' => [], + ]); + self::assertSame('0.1', $version->getVersionNumber()); + self::assertNotNull($actor->version('0.1')->get()); + $actor->versions()->list(new ListOptions()); + $actor->version('0.1')->update([ + 'buildTag' => 'beta', + 'sourceType' => 'SOURCE_FILES', + 'sourceFiles' => [], + ]); + $actor->version('0.1')->delete(); + } finally { + $client->actor((string) $created->getId())->delete(); + } + } + + public function testValidateInput(): void + { + $client = $this->requireClient(); + // apify/hello-world is a public store Actor; validate-input is read-only and returns + // {"valid": }. A well-formed input validates true. + self::assertTrue($client->actor('apify/hello-world')->validateInput(['firstNumber' => 1])); + } + + public function testActorEnvVarCrudFlow(): void + { + $client = $this->requireClient(); + $created = $client->actors()->create(self::minimalActor(self::uniqueName('env'))); + try { + $actor = $client->actor((string) $created->getId()); + $envVars = $actor->version('0.0')->envVars(); + $envVars->create(new ActorEnvVar('MY_VAR', 'value1')); + self::assertNotNull($actor->version('0.0')->envVar('MY_VAR')->get()); + $envVars->list(); + $actor->version('0.0')->envVar('MY_VAR')->update(new ActorEnvVar('MY_VAR', 'value2')); + $actor->version('0.0')->envVar('MY_VAR')->delete(); + } finally { + $client->actor((string) $created->getId())->delete(); + } + } +} diff --git a/tests/Integration/ActorRunIntegrationTest.php b/tests/Integration/ActorRunIntegrationTest.php new file mode 100644 index 0000000..3202cbd --- /dev/null +++ b/tests/Integration/ActorRunIntegrationTest.php @@ -0,0 +1,54 @@ +requireClient(); + $page = $client->runs()->list(new ListOptions(limit: 5), new RunListOptions()); + self::assertLessThanOrEqual(5, count($page->getItems())); + self::assertSame(count($page->getItems()), $page->getCount()); + self::assertGreaterThanOrEqual(count($page->getItems()), $page->getTotal()); + } + + public function testRunActorAndReadOutputs(): void + { + $client = $this->requireClient(); + $run = $client->actor('apify/hello-world')->call(null, null, 120); + self::assertSame('SUCCEEDED', $run->getStatus()); + + self::assertNotNull($client->run((string) $run->getId())->get()); + + $log = $client->run((string) $run->getId())->log()->get(); + self::assertNotNull($log); + self::assertNotSame('', $log); + + $client->run((string) $run->getId())->dataset()->listItems(new DatasetListItemsOptions()); + $client->run((string) $run->getId())->keyValueStore()->getRecord('OUTPUT'); + } + + public function testLastRunAccess(): void + { + $client = $this->requireClient(); + $client->actor('apify/hello-world')->call(null, null, 120); + + $lastRun = $client->actor('apify/hello-world')->lastRun(new LastRunOptions(status: 'SUCCEEDED'))->get(); + self::assertNotNull($lastRun); + self::assertSame('SUCCEEDED', $lastRun->getStatus()); + + $byOrigin = $client->actor('apify/hello-world') + ->lastRun(new LastRunOptions(status: 'SUCCEEDED', origin: 'API')) + ->get(); + self::assertNotNull($byOrigin); + self::assertSame('SUCCEEDED', $byOrigin->getStatus()); + } +} diff --git a/tests/Integration/BuildIntegrationTest.php b/tests/Integration/BuildIntegrationTest.php new file mode 100644 index 0000000..c80f2f1 --- /dev/null +++ b/tests/Integration/BuildIntegrationTest.php @@ -0,0 +1,37 @@ +requireClient(); + $page = $client->builds()->list(new ListOptions(limit: 5)); + self::assertLessThanOrEqual(5, count($page->getItems())); + self::assertSame(count($page->getItems()), $page->getCount()); + self::assertGreaterThanOrEqual(count($page->getItems()), $page->getTotal()); + } + + public function testBuildActorFlow(): void + { + $client = $this->requireClient(); + $created = $client->actors()->create(self::minimalActor(self::uniqueName('build'))); + try { + $build = $client->actor((string) $created->getId())->build('0.0', new ActorBuildOptions()); + $finished = $client->build((string) $build->getId())->waitForFinish(300); + self::assertTrue($finished->isTerminal(), 'build did not finish: ' . $finished->getStatus()); + + self::assertNotNull($client->build((string) $build->getId())->get()); + $client->build((string) $build->getId())->log()->get(); + $client->build((string) $build->getId())->getOpenApiDefinition(); + } finally { + $client->actor((string) $created->getId())->delete(); + } + } +} diff --git a/tests/Integration/DatasetIntegrationTest.php b/tests/Integration/DatasetIntegrationTest.php new file mode 100644 index 0000000..095809d --- /dev/null +++ b/tests/Integration/DatasetIntegrationTest.php @@ -0,0 +1,70 @@ +requireClient(); + $page = $client->datasets()->list(new StorageListOptions(limit: 5)); + self::assertLessThanOrEqual(5, count($page->getItems())); + self::assertSame(count($page->getItems()), $page->getCount()); + self::assertGreaterThanOrEqual(count($page->getItems()), $page->getTotal()); + } + + public function testGetDataset(): void + { + $client = $this->requireClient(); + $ds = $client->datasets()->getOrCreate(self::uniqueName('ds-get')); + try { + $got = $client->dataset((string) $ds->getId())->get(); + self::assertNotNull($got); + self::assertSame($ds->getId(), $got->getId()); + } finally { + $client->dataset((string) $ds->getId())->delete(); + } + } + + public function testDatasetCrudFlow(): void + { + $client = $this->requireClient(); + $ds = $client->datasets()->getOrCreate(self::uniqueName('ds-crud')); + try { + $dataset = $client->dataset((string) $ds->getId()); + self::assertNotNull($dataset->get()); + + $dataset->pushItems([ + ['url' => 'https://a.com', 'n' => 1], + ['url' => 'https://b.com', 'n' => 2], + ['url' => 'https://c.com', 'n' => 3], + ]); + + $page = $dataset->listItems(new DatasetListItemsOptions()); + self::assertSame(3, $page->getCount()); + self::assertCount(3, $page->getItems()); + self::assertSame(1, $page->getItems()[0]['n']); + + $csv = $dataset->downloadItems(DownloadItemsFormat::CSV, new DatasetDownloadOptions(bom: true)); + self::assertStringContainsString('url', $csv); + + $url = $dataset->createItemsPublicUrl(new DatasetListItemsOptions()); + self::assertNotSame('', $url); + + $dataset->getStatistics(); + + $updated = $dataset->update(['name' => self::uniqueName('ds-renamed')]); + self::assertNotNull($updated->getName()); + self::assertNotSame('', $updated->getName()); + } finally { + $client->dataset((string) $ds->getId())->delete(); + } + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php new file mode 100644 index 0000000..2955c7d --- /dev/null +++ b/tests/Integration/IntegrationTestCase.php @@ -0,0 +1,95 @@ + + */ + protected static function minimalActor(string $name): array + { + return [ + 'name' => $name, + 'isPublic' => false, + 'description' => 'Integration test actor', + 'versions' => [[ + 'versionNumber' => '0.0', + 'sourceType' => 'SOURCE_FILES', + 'buildTag' => 'latest', + 'sourceFiles' => [ + [ + 'name' => 'Dockerfile', + 'format' => 'TEXT', + 'content' => "FROM apify/actor-node:20\nCOPY . ./\nCMD node main.js", + ], + [ + 'name' => 'main.js', + 'format' => 'TEXT', + 'content' => "console.log('hello from php client test');", + ], + ], + ]], + ]; + } +} diff --git a/tests/Integration/KeyValueStoreIntegrationTest.php b/tests/Integration/KeyValueStoreIntegrationTest.php new file mode 100644 index 0000000..e360728 --- /dev/null +++ b/tests/Integration/KeyValueStoreIntegrationTest.php @@ -0,0 +1,90 @@ +requireClient(); + $page = $client->keyValueStores()->list(new StorageListOptions(limit: 5)); + self::assertLessThanOrEqual(5, count($page->getItems())); + self::assertSame(count($page->getItems()), $page->getCount()); + self::assertGreaterThanOrEqual(count($page->getItems()), $page->getTotal()); + } + + public function testGetKeyValueStore(): void + { + $client = $this->requireClient(); + $store = $client->keyValueStores()->getOrCreate(self::uniqueName('kvs-get')); + try { + $got = $client->keyValueStore((string) $store->getId())->get(); + self::assertNotNull($got); + self::assertSame($store->getId(), $got->getId()); + } finally { + $client->keyValueStore((string) $store->getId())->delete(); + } + } + + public function testRecordKeyWithSpecialChars(): void + { + $client = $this->requireClient(); + $store = $client->keyValueStores()->getOrCreate(self::uniqueName('kvs-special')); + try { + $kvs = $client->keyValueStore((string) $store->getId()); + $key = "weird-key!'()"; + $kvs->setRecordJson($key, ['ok' => true]); + self::assertTrue($kvs->recordExists($key)); + self::assertNotNull($kvs->getRecord($key)); + $kvs->deleteRecord($key); + } finally { + $client->keyValueStore((string) $store->getId())->delete(); + } + } + + public function testKeyValueStoreCrudFlow(): void + { + $client = $this->requireClient(); + $store = $client->keyValueStores()->getOrCreate(self::uniqueName('kvs-crud')); + try { + $kvs = $client->keyValueStore((string) $store->getId()); + self::assertNotNull($kvs->get()); + $kvs->setRecordJson('OUTPUT', ['hello' => 'world']); + self::assertTrue($kvs->recordExists('OUTPUT')); + $record = $kvs->getRecord('OUTPUT'); + self::assertNotNull($record); + self::assertStringContainsString('world', $record->getValue()); + $kvs->getRecord('OUTPUT', new GetRecordOptions(attachment: false)); + $keys = $kvs->listKeys(new ListKeysOptions()); + self::assertNotEmpty($keys->getItems()); + $kvs->update(['name' => self::uniqueName('kvs-renamed')]); + $kvs->deleteRecord('OUTPUT'); + } finally { + $client->keyValueStore((string) $store->getId())->delete(); + } + } + + public function testRecordPublicUrlIsFetchable(): void + { + $client = $this->requireClient(); + $store = $client->keyValueStores()->getOrCreate(self::uniqueName('kvs-pub')); + try { + $kvs = $client->keyValueStore((string) $store->getId()); + $kvs->setRecordJson('OUTPUT', ['pub' => true]); + $url = $kvs->getRecordPublicUrl('OUTPUT'); + self::assertNotSame('', $url); + + $response = (new Guzzle())->get($url, ['http_errors' => false]); + self::assertLessThan(300, $response->getStatusCode(), 'expected success fetching public url'); + } finally { + $client->keyValueStore((string) $store->getId())->delete(); + } + } +} diff --git a/tests/Integration/RequestQueueIntegrationTest.php b/tests/Integration/RequestQueueIntegrationTest.php new file mode 100644 index 0000000..6c037e2 --- /dev/null +++ b/tests/Integration/RequestQueueIntegrationTest.php @@ -0,0 +1,120 @@ +requireClient(); + $page = $client->requestQueues()->list(new StorageListOptions(limit: 5)); + self::assertLessThanOrEqual(5, count($page->getItems())); + self::assertSame(count($page->getItems()), $page->getCount()); + self::assertGreaterThanOrEqual(count($page->getItems()), $page->getTotal()); + } + + public function testGetRequestQueue(): void + { + $client = $this->requireClient(); + $rq = $client->requestQueues()->getOrCreate(self::uniqueName('rq-get')); + try { + $got = $client->requestQueue((string) $rq->getId())->get(); + self::assertNotNull($got); + self::assertSame($rq->getId(), $got->getId()); + } finally { + $client->requestQueue((string) $rq->getId())->delete(); + } + } + + public function testRequestQueueCrudFlow(): void + { + $client = $this->requireClient(); + $rq = $client->requestQueues()->getOrCreate(self::uniqueName('rq-crud')); + try { + $queue = $client->requestQueue((string) $rq->getId()); + self::assertNotNull($queue->get()); + + $info = $queue->addRequest((new RequestQueueRequest('https://example.com', 'example'))->setMethod('GET')); + self::assertNotNull($info->getRequestId()); + self::assertNotSame('', $info->getRequestId()); + + $got = $queue->getRequest((string) $info->getRequestId()); + self::assertNotNull($got); + self::assertSame('https://example.com', $got->getUrl()); + + self::assertNotEmpty($queue->listHead(10)->getItems()); + $queue->update(['name' => self::uniqueName('rq-renamed')]); + $queue->deleteRequest((string) $info->getRequestId()); + } finally { + $client->requestQueue((string) $rq->getId())->delete(); + } + } + + public function testRequestQueuePaginateMultiplePages(): void + { + $client = $this->requireClient(); + $rq = $client->requestQueues()->getOrCreate(self::uniqueName('rq-page')); + try { + $queue = $client->requestQueue((string) $rq->getId()); + $total = 5; + for ($i = 0; $i < $total; $i++) { + $url = 'https://example.com/' . $i; + $queue->addRequest(new RequestQueueRequest($url, $url)); + } + $seen = []; + foreach ($queue->paginateRequests(new PaginateRequestsOptions(maxPageLimit: 2)) as $request) { + $seen[(string) $request->getUrl()] = true; + } + self::assertCount($total, $seen); + } finally { + $client->requestQueue((string) $rq->getId())->delete(); + } + } + + public function testRequestQueueBatchAddRequests(): void + { + $client = $this->requireClient(); + $rq = $client->requestQueues()->getOrCreate(self::uniqueName('rq-batch')); + try { + $queue = $client->requestQueue((string) $rq->getId()); + $total = 30; // > 25, so the client must split into multiple chunks + $requests = []; + for ($i = 0; $i < $total; $i++) { + $url = 'https://batch.example.com/' . $i; + $requests[] = new RequestQueueRequest($url, $url); + } + $result = $queue->batchAddRequests($requests); + self::assertCount($total, $result->getProcessedRequests()); + self::assertSame([], $result->getUnprocessedRequests()); + } finally { + $client->requestQueue((string) $rq->getId())->delete(); + } + } + + public function testRequestQueueLockLifecycle(): void + { + $client = $this->requireClient(); + $rq = $client->requestQueues()->getOrCreate(self::uniqueName('rq-lock')); + try { + $queue = $client->requestQueue((string) $rq->getId())->withClientKey('php-test-client-key'); + $info = $queue->addRequest(new RequestQueueRequest('https://lock.example.com', 'lock')); + self::assertArrayHasKey('items', $queue->listRequests(new ListRequestsOptions())); + $queue->listRequests(new ListRequestsOptions( + filter: [ListRequestsOptions::FILTER_LOCKED, ListRequestsOptions::FILTER_PENDING] + )); + self::assertArrayHasKey('items', $queue->listAndLockHead(60, 10)); + $queue->prolongRequestLock((string) $info->getRequestId(), 30); + $queue->deleteRequestLock((string) $info->getRequestId()); + self::assertIsArray($queue->unlockRequests()); + } finally { + $client->requestQueue((string) $rq->getId())->delete(); + } + } +} diff --git a/tests/Integration/ScheduleIntegrationTest.php b/tests/Integration/ScheduleIntegrationTest.php new file mode 100644 index 0000000..3693abd --- /dev/null +++ b/tests/Integration/ScheduleIntegrationTest.php @@ -0,0 +1,63 @@ + + */ + private static function scheduleDef(string $name): array + { + return [ + 'name' => $name, + 'cronExpression' => '0 0 * * *', + 'isEnabled' => false, + 'isExclusive' => true, + 'actions' => [], + ]; + } + + public function testListSchedules(): void + { + $client = $this->requireClient(); + $page = $client->schedules()->list(new ListOptions(limit: 5)); + self::assertLessThanOrEqual(5, count($page->getItems())); + self::assertSame(count($page->getItems()), $page->getCount()); + self::assertGreaterThanOrEqual(count($page->getItems()), $page->getTotal()); + } + + public function testGetSchedule(): void + { + $client = $this->requireClient(); + $sch = $client->schedules()->create(self::scheduleDef(self::uniqueName('sch-get'))); + try { + $got = $client->schedule((string) $sch->getId())->get(); + self::assertNotNull($got); + self::assertSame($sch->getId(), $got->getId()); + } finally { + $client->schedule((string) $sch->getId())->delete(); + } + } + + public function testScheduleCrudFlow(): void + { + $client = $this->requireClient(); + $sch = $client->schedules()->create(self::scheduleDef(self::uniqueName('sch-crud'))); + try { + $schedule = $client->schedule((string) $sch->getId()); + self::assertNotNull($schedule->get()); + $updated = $schedule->update(['cronExpression' => '0 12 * * *']); + self::assertSame('0 12 * * *', $updated->getCronExpression()); + // A fresh schedule may have no log yet (null), which is a valid result — we only assert + // the call itself succeeds. + $schedule->getLog(); + } finally { + $client->schedule((string) $sch->getId())->delete(); + } + } +} diff --git a/tests/Integration/StoreIntegrationTest.php b/tests/Integration/StoreIntegrationTest.php new file mode 100644 index 0000000..3d633ba --- /dev/null +++ b/tests/Integration/StoreIntegrationTest.php @@ -0,0 +1,31 @@ +requireClient(); + $page = $client->store()->list(new StoreListOptions(limit: 5)); + self::assertLessThanOrEqual(5, count($page->getItems())); + } + + public function testIterateStore(): void + { + $client = $this->requireClient(); + $count = 0; + foreach ($client->store()->iterate(new StoreListOptions(limit: 5)) as $item) { + self::assertNotNull($item->getId()); + self::assertNotSame('', $item->getId()); + if (++$count >= 12) { + break; + } + } + self::assertGreaterThanOrEqual(12, $count, 'expected to iterate at least 12 store actors'); + } +} diff --git a/tests/Integration/TaskIntegrationTest.php b/tests/Integration/TaskIntegrationTest.php new file mode 100644 index 0000000..eb3a8b2 --- /dev/null +++ b/tests/Integration/TaskIntegrationTest.php @@ -0,0 +1,62 @@ + + */ + private static function taskDef(string $name): array + { + return [ + 'actId' => 'apify/hello-world', + 'name' => $name, + 'options' => ['build' => 'latest', 'memoryMbytes' => 256, 'timeoutSecs' => 60], + 'input' => ['message' => 'hello'], + ]; + } + + public function testListTasks(): void + { + $client = $this->requireClient(); + $page = $client->tasks()->list(new ListOptions(limit: 5)); + self::assertLessThanOrEqual(5, count($page->getItems())); + self::assertSame(count($page->getItems()), $page->getCount()); + self::assertGreaterThanOrEqual(count($page->getItems()), $page->getTotal()); + } + + public function testGetTask(): void + { + $client = $this->requireClient(); + $task = $client->tasks()->create(self::taskDef(self::uniqueName('task-get'))); + try { + $got = $client->task((string) $task->getId())->get(); + self::assertNotNull($got); + self::assertSame($task->getId(), $got->getId()); + } finally { + $client->task((string) $task->getId())->delete(); + } + } + + public function testTaskCrudFlow(): void + { + $client = $this->requireClient(); + $task = $client->tasks()->create(self::taskDef(self::uniqueName('task-crud'))); + try { + $tc = $client->task((string) $task->getId()); + self::assertNotNull($tc->get()); + $tc->updateInput(['message' => 'updated']); + self::assertNotNull($tc->getInput()); + $tc->update(['name' => self::uniqueName('task-renamed')]); + $tc->runs()->list(new ListOptions(), new RunListOptions()); + } finally { + $client->task((string) $task->getId())->delete(); + } + } +} diff --git a/tests/Integration/UserIntegrationTest.php b/tests/Integration/UserIntegrationTest.php new file mode 100644 index 0000000..ebe8803 --- /dev/null +++ b/tests/Integration/UserIntegrationTest.php @@ -0,0 +1,35 @@ +requireClient(); + $user = $client->me()->get(); + self::assertNotNull($user); + self::assertNotNull($user->getId()); + self::assertNotSame('', $user->getId()); + } + + public function testGetMonthlyUsage(): void + { + $client = $this->requireClient(); + self::assertNotEmpty($client->me()->monthlyUsage()); + } + + public function testGetMonthlyUsageForDate(): void + { + $client = $this->requireClient(); + self::assertNotEmpty($client->me()->monthlyUsage('2026-06-01')); + } + + public function testGetLimits(): void + { + $client = $this->requireClient(); + self::assertNotEmpty($client->me()->limits()); + } +} diff --git a/tests/Integration/WebhookIntegrationTest.php b/tests/Integration/WebhookIntegrationTest.php new file mode 100644 index 0000000..40b95e2 --- /dev/null +++ b/tests/Integration/WebhookIntegrationTest.php @@ -0,0 +1,84 @@ + + */ + private static function webhookDef(string $url): array + { + return [ + 'isAdHoc' => true, + 'eventTypes' => ['ACTOR.RUN.SUCCEEDED'], + 'condition' => ['actorRunId' => 'ZZZZZZZZZZZZZZZZZ'], + 'requestUrl' => $url, + ]; + } + + public function testListWebhooks(): void + { + $client = $this->requireClient(); + $page = $client->webhooks()->list(new ListOptions(limit: 5)); + self::assertLessThanOrEqual(5, count($page->getItems())); + self::assertSame(count($page->getItems()), $page->getCount()); + self::assertGreaterThanOrEqual(count($page->getItems()), $page->getTotal()); + } + + public function testListWebhookDispatches(): void + { + $client = $this->requireClient(); + $page = $client->webhookDispatches()->list(new ListOptions(limit: 5)); + self::assertLessThanOrEqual(5, count($page->getItems())); + self::assertSame(count($page->getItems()), $page->getCount()); + self::assertGreaterThanOrEqual(count($page->getItems()), $page->getTotal()); + } + + public function testGetWebhook(): void + { + $client = $this->requireClient(); + $wh = $client->webhooks()->create(self::webhookDef('https://example.com/webhook')); + try { + $got = $client->webhook((string) $wh->getId())->get(); + self::assertNotNull($got); + self::assertSame($wh->getId(), $got->getId()); + } finally { + $client->webhook((string) $wh->getId())->delete(); + } + } + + public function testGetWebhookDispatch(): void + { + $client = $this->requireClient(); + $wh = $client->webhooks()->create(self::webhookDef('https://example.com/webhook')); + try { + $dispatch = $client->webhook((string) $wh->getId())->test(); + $got = $client->webhookDispatch((string) $dispatch->getId())->get(); + self::assertNotNull($got); + self::assertSame($dispatch->getId(), $got->getId()); + } finally { + $client->webhook((string) $wh->getId())->delete(); + } + } + + public function testWebhookCrudFlow(): void + { + $client = $this->requireClient(); + $wh = $client->webhooks()->create(self::webhookDef('https://example.com/webhook')); + try { + $webhook = $client->webhook((string) $wh->getId()); + self::assertNotNull($webhook->get()); + $updated = $webhook->update(['requestUrl' => 'https://example.com/updated']); + self::assertSame('https://example.com/updated', $updated->getRequestUrl()); + $webhook->dispatches()->list(new ListOptions()); + $webhook->test(); + } finally { + $client->webhook((string) $wh->getId())->delete(); + } + } +} diff --git a/tests/Unit/BatchAddRequestsTest.php b/tests/Unit/BatchAddRequestsTest.php new file mode 100644 index 0000000..ae650dd --- /dev/null +++ b/tests/Unit/BatchAddRequestsTest.php @@ -0,0 +1,188 @@ + $uniqueKeys + * @param list $unprocessedKeys + */ + private function batchResponse(array $uniqueKeys, array $unprocessedKeys = []): string + { + $processed = array_map( + static fn (string $k) => [ + 'uniqueKey' => $k, + 'requestId' => 'id-' . $k, + 'wasAlreadyPresent' => false, + 'wasAlreadyHandled' => false, + ], + $uniqueKeys + ); + $unprocessed = array_map( + static fn (string $k) => ['uniqueKey' => $k, 'url' => 'https://x/' . $k, 'method' => 'GET'], + $unprocessedKeys + ); + return Json::encode(['data' => ['processedRequests' => $processed, 'unprocessedRequests' => $unprocessed]]); + } + + public function testMissingUniqueKeyThrowsBeforeAnyCall(): void + { + $transport = new MockTransport(); + $requests = [new RequestQueueRequest('https://a.com')]; // no uniqueKey + + try { + $this->client($transport)->requestQueue('q1')->batchAddRequests($requests); + self::fail('expected InvalidArgumentException'); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('uniqueKey', $e->getMessage()); + } + self::assertSame(0, $transport->callCount()); + } + + public function testApiErrorReportedAsUnprocessedNotThrown(): void + { + // Consistent with the JS reference, a failed batch call does not throw: the not-yet-processed + // requests are returned as unprocessed and the method keeps its non-throwing contract. + $transport = (new MockTransport())->queueResponse(403, Json::encode(['error' => ['type' => 'insufficient-permissions', 'message' => 'nope']])); + $requests = [new RequestQueueRequest('https://a.com', 'a')]; + + $result = $this->client($transport)->requestQueue('q1')->batchAddRequests($requests, false, $this->fastOptions()); + + self::assertSame([], $result->getProcessedRequests()); + self::assertCount(1, $result->getUnprocessedRequests()); + self::assertSame('a', $result->getUnprocessedRequests()[0]->getUniqueKey()); + } + + public function testMultiChunkPreservesEarlierChunksWhenLaterChunkFails(): void + { + // 30 requests → two chunks (25 + 5). The first chunk succeeds; the second fails with a 403. + // The already-processed first chunk must still be returned, not discarded by the failure. + $keys = array_map(static fn (int $i) => 'u' . $i, range(0, 29)); + $transport = (new MockTransport()) + ->queueResponse(200, $this->batchResponse(array_slice($keys, 0, 25))) + ->queueResponse(403, Json::encode(['error' => ['type' => 'x', 'message' => 'boom']])); + $requests = array_map(static fn (string $k) => new RequestQueueRequest('https://x/' . $k, $k), $keys); + + $result = $this->client($transport)->requestQueue('q1')->batchAddRequests($requests, false, $this->fastOptions()); + + self::assertCount(25, $result->getProcessedRequests()); + self::assertCount(5, $result->getUnprocessedRequests()); + } + + public function testRetriesOnlyUnprocessedFromSuccessfulResponse(): void + { + // First response processes r0 and reports r1 unprocessed; second processes r1. + $transport = (new MockTransport()) + ->queueResponse(200, $this->batchResponse(['r0'], ['r1'])) + ->queueResponse(200, $this->batchResponse(['r1'])); + $requests = [new RequestQueueRequest('https://a.com', 'r0'), new RequestQueueRequest('https://b.com', 'r1')]; + + $result = $this->client($transport)->requestQueue('q1')->batchAddRequests($requests, false, $this->fastOptions()); + + self::assertSame(2, $transport->callCount()); + self::assertCount(2, $result->getProcessedRequests()); + self::assertSame([], $result->getUnprocessedRequests()); + + // The retry must send only the still-unprocessed request (r1), not the whole batch again. + $retryBody = Json::decode((string) $transport->received[1]->getBody()); + self::assertIsArray($retryBody); + self::assertCount(1, $retryBody); + self::assertSame('r1', $retryBody[0]['uniqueKey']); + } + + public function testUnprocessedReportedAfterRetriesExhausted(): void + { + // Every attempt leaves r0 unprocessed; after maxRetries+1 calls it is reported, without throwing. + $transport = new MockTransport(); + for ($i = 0; $i < 3; $i++) { + $transport->queueResponse(200, $this->batchResponse([], ['r0'])); + } + $requests = [new RequestQueueRequest('https://a.com', 'r0')]; + + $result = $this->client($transport)->requestQueue('q1')->batchAddRequests($requests, false, $this->fastOptions(2)); + + self::assertSame(3, $transport->callCount()); // 1 + 2 retries + self::assertSame([], $result->getProcessedRequests()); + self::assertCount(1, $result->getUnprocessedRequests()); + self::assertSame('r0', $result->getUnprocessedRequests()[0]->getUniqueKey()); + } + + public function testChunksByCountLimit(): void + { + $keys = array_map(static fn (int $i) => 'u' . $i, range(0, 29)); // 30 > 25 + $transport = (new MockTransport()) + ->queueResponse(200, $this->batchResponse(array_slice($keys, 0, 25))) + ->queueResponse(200, $this->batchResponse(array_slice($keys, 25))); + $requests = array_map(static fn (string $k) => new RequestQueueRequest('https://x/' . $k, $k), $keys); + + $result = $this->client($transport)->requestQueue('q1')->batchAddRequests($requests, false, $this->fastOptions()); + + self::assertSame(2, $transport->callCount()); + self::assertCount(30, $result->getProcessedRequests()); + // First batch must respect the 25-request count limit. + $firstBody = Json::decode((string) $transport->received[0]->getBody()); + self::assertIsArray($firstBody); + self::assertCount(25, $firstBody); + } + + public function testChunksByPayloadByteSize(): void + { + // Three ~4 MiB requests exceed the ~9 MiB payload limit: split into [b0,b1] then [b2]. + $big = str_repeat('x', 4 * 1024 * 1024); + $keys = ['b0', 'b1', 'b2']; + $transport = (new MockTransport()) + ->queueResponse(200, $this->batchResponse(['b0', 'b1'])) + ->queueResponse(200, $this->batchResponse(['b2'])); + $requests = array_map( + static fn (string $k) => (new RequestQueueRequest('https://x/' . $k, $k))->setUserData(['blob' => $big]), + $keys + ); + + $result = $this->client($transport)->requestQueue('q1')->batchAddRequests($requests, false, $this->fastOptions()); + + self::assertSame(2, $transport->callCount()); + self::assertCount(3, $result->getProcessedRequests()); + $firstBody = Json::decode((string) $transport->received[0]->getBody()); + self::assertIsArray($firstBody); + self::assertCount(2, $firstBody); // byte limit, not the count limit, governed here + } + + public function testOversizedSingleRequestThrows(): void + { + $huge = str_repeat('x', 10 * 1024 * 1024); // > 9 MiB on its own + $requests = [(new RequestQueueRequest('https://a.com', 'big'))->setUserData(['blob' => $huge])]; + + $this->expectException(InvalidArgumentException::class); + $this->client(new MockTransport())->requestQueue('q1')->batchAddRequests($requests); + } +} diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php new file mode 100644 index 0000000..1c62308 --- /dev/null +++ b/tests/Unit/ConfigTest.php @@ -0,0 +1,50 @@ + false); + $ua = $client->getUserAgent(); + + $expected = sprintf( + 'ApifyClient/%s (%s; PHP/%s); isAtHome/false', + Version::CLIENT_VERSION, + strtolower(PHP_OS_FAMILY), + PHP_VERSION, + ); + self::assertSame($expected, $ua); + } + + public function testUserAgentIsAtHomeTrueAndSuffix(): void + { + $client = new ApifyClient( + token: 't', + userAgentSuffix: 'my-suffix', + httpClient: new MockTransport(), + isAtHomeFn: static fn (): bool => true, + ); + self::assertStringContainsString('isAtHome/true', $client->getUserAgent()); + self::assertStringEndsWith('; my-suffix', $client->getUserAgent()); + } + + public function testApiBaseUrlAppendsV2(): void + { + $client = new ApifyClient(token: 't', baseUrl: 'https://api.example.com/', httpClient: new MockTransport()); + self::assertSame('https://api.example.com/v2', $client->getApiBaseUrl()); + } + + public function testVersionConstants(): void + { + self::assertMatchesRegularExpression('/^\d+\.\d+\.\d+$/', Version::CLIENT_VERSION); + self::assertStringStartsWith('v2-', Version::API_SPEC_VERSION); + } +} diff --git a/tests/Unit/HttpClientTest.php b/tests/Unit/HttpClientTest.php new file mode 100644 index 0000000..c88a6a6 --- /dev/null +++ b/tests/Unit/HttpClientTest.php @@ -0,0 +1,146 @@ +queueResponse(200, Json::encode(['data' => ['id' => 'abc']])); + $this->client($transport)->actor('abc')->get(); + + $request = $transport->lastRequest(); + self::assertSame('Bearer test-token', $request->getHeaderLine('Authorization')); + self::assertStringStartsWith('ApifyClient/', $request->getHeaderLine('User-Agent')); + } + + public function testDataEnvelopeIsUnwrapped(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'act1', 'name' => 'my-actor']])); + $actor = $this->client($transport)->actor('act1')->get(); + + self::assertNotNull($actor); + self::assertSame('act1', $actor->getId()); + self::assertSame('my-actor', $actor->getName()); + } + + public function testNotFoundReturnsNull(): void + { + $body = Json::encode(['error' => ['type' => 'record-not-found', 'message' => 'not here']]); + $transport = (new MockTransport())->queueResponse(404, $body); + self::assertNull($this->client($transport)->actor('missing')->get()); + } + + public function testServerErrorsAreRetriedThenSucceed(): void + { + $transport = (new MockTransport()) + ->queueResponse(500, Json::encode(['error' => ['type' => 'server', 'message' => 'boom']])) + ->queueResponse(200, Json::encode(['data' => ['id' => 'ok']])); + + $actor = $this->client($transport)->actor('x')->get(); + self::assertSame('ok', $actor?->getId()); + self::assertSame(2, $transport->callCount()); + } + + public function testValidationErrorIsNotRetriedAndThrows(): void + { + $transport = (new MockTransport()) + ->queueResponse(400, Json::encode(['error' => ['type' => 'bad-input', 'message' => 'invalid']])); + + try { + $this->client($transport)->actors()->create(['name' => 'x']); + self::fail('expected ApifyApiException'); + } catch (ApifyApiException $e) { + self::assertSame(400, $e->getStatusCode()); + self::assertSame('bad-input', $e->getType()); + self::assertStringContainsString('invalid', $e->getApiMessage()); + self::assertSame(1, $transport->callCount()); + } + } + + public function testTransportErrorsAreRetried(): void + { + $transport = (new MockTransport()) + ->queueError() + ->queueResponse(200, Json::encode(['data' => ['id' => 'recovered']])); + $actor = $this->client($transport)->actor('x')->get(); + self::assertSame('recovered', $actor?->getId()); + self::assertSame(2, $transport->callCount()); + } + + public function testBooleanQueryParamsEncodedAsOneZero(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['items' => [], 'total' => 0]])); + $this->client($transport)->actors()->list(new \Apify\Client\Options\ActorListOptions(my: true, limit: 5)); + + $query = $transport->lastRequest()->getUri()->getQuery(); + self::assertStringContainsString('my=1', $query); + self::assertStringContainsString('limit=5', $query); + } + + public function testListUnwrapsPaginationEnvelope(): void + { + $body = Json::encode(['data' => [ + 'total' => 2, + 'offset' => 0, + 'limit' => 10, + 'count' => 2, + 'desc' => false, + 'items' => [['id' => 'a'], ['id' => 'b']], + ]]); + $transport = (new MockTransport())->queueResponse(200, $body); + $page = $this->client($transport)->actors()->list(); + + self::assertSame(2, $page->getTotal()); + self::assertCount(2, $page->getItems()); + self::assertSame('a', $page->getItems()[0]->getId()); + } + + public function testDatasetItemsUseHeaderPagination(): void + { + $transport = (new MockTransport())->queueResponse( + 200, + Json::encode([['n' => 1], ['n' => 2], ['n' => 3]]), + [ + 'X-Apify-Pagination-Total' => '42', + 'X-Apify-Pagination-Offset' => '0', + 'X-Apify-Pagination-Limit' => '3', + ] + ); + $page = $this->client($transport)->dataset('ds1')->listItems(); + + self::assertSame(42, $page->getTotal()); + self::assertSame(3, $page->getCount()); + self::assertSame(1, $page->getItems()[0]['n']); + } + + public function testValidateInputParsesBareObject(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['valid' => true])); + self::assertTrue($this->client($transport)->actor('apify/hello-world')->validateInput(['x' => 1])); + } + + public function testSafeIdReplacesFirstSlashWithTilde(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'x']])); + $this->client($transport)->actor('apify/hello-world')->get(); + self::assertStringContainsString('/actors/apify~hello-world', (string) $transport->lastRequest()->getUri()); + } +} diff --git a/tests/Unit/LastRunParamsTest.php b/tests/Unit/LastRunParamsTest.php new file mode 100644 index 0000000..b764582 --- /dev/null +++ b/tests/Unit/LastRunParamsTest.php @@ -0,0 +1,126 @@ +client($transport) + ->actor('me~a') + ->lastRun(new LastRunOptions(status: 'SUCCEEDED', origin: 'API')); + } + + public function testNestedDatasetListItemsInheritsStatusAndOrigin(): void + { + $transport = (new MockTransport())->queueResponse(200, '[]'); + $this->lastRun($transport)->dataset()->listItems(); + + $uri = (string) $transport->lastRequest()->getUri(); + self::assertStringContainsString('/actors/me~a/runs/last/dataset/items', $uri); + self::assertStringContainsString('status=SUCCEEDED', $uri); + self::assertStringContainsString('origin=API', $uri); + } + + public function testNestedDatasetDownloadItemsInheritsStatusAndOrigin(): void + { + $transport = (new MockTransport())->queueResponse(200, '[]'); + $this->lastRun($transport)->dataset()->downloadItems(DownloadItemsFormat::CSV); + + $uri = (string) $transport->lastRequest()->getUri(); + self::assertStringContainsString('/actors/me~a/runs/last/dataset/items', $uri); + self::assertStringContainsString('format=csv', $uri); + self::assertStringContainsString('status=SUCCEEDED', $uri); + self::assertStringContainsString('origin=API', $uri); + } + + public function testNestedDatasetPushItemsInheritsStatusAndOrigin(): void + { + $transport = (new MockTransport())->queueResponse(200, ''); + $this->lastRun($transport)->dataset()->pushItems([['a' => 1]]); + + $request = $transport->lastRequest(); + self::assertSame('POST', $request->getMethod()); + $uri = (string) $request->getUri(); + self::assertStringContainsString('/actors/me~a/runs/last/dataset/items', $uri); + self::assertStringContainsString('status=SUCCEEDED', $uri); + self::assertStringContainsString('origin=API', $uri); + self::assertSame([['a' => 1]], Json::decode((string) $request->getBody())); + } + + public function testNestedKeyValueStoreListKeysInheritsStatusAndOrigin(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['items' => []]])); + $this->lastRun($transport)->keyValueStore()->listKeys(); + + $uri = (string) $transport->lastRequest()->getUri(); + self::assertStringContainsString('/actors/me~a/runs/last/key-value-store/keys', $uri); + self::assertStringContainsString('status=SUCCEEDED', $uri); + self::assertStringContainsString('origin=API', $uri); + } + + public function testNestedKeyValueStoreGetRecordInheritsStatusAndOrigin(): void + { + $transport = (new MockTransport())->queueResponse(200, 'hello'); + $this->lastRun($transport)->keyValueStore()->getRecord('my-key'); + + $uri = (string) $transport->lastRequest()->getUri(); + self::assertStringContainsString('/actors/me~a/runs/last/key-value-store/records/my-key', $uri); + self::assertStringContainsString('status=SUCCEEDED', $uri); + self::assertStringContainsString('origin=API', $uri); + } + + public function testNestedRequestQueueListHeadInheritsStatusAndOrigin(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['items' => []]])); + $this->lastRun($transport)->requestQueue()->listHead(); + + $uri = (string) $transport->lastRequest()->getUri(); + self::assertStringContainsString('/actors/me~a/runs/last/request-queue/head', $uri); + self::assertStringContainsString('status=SUCCEEDED', $uri); + self::assertStringContainsString('origin=API', $uri); + } + + public function testNestedLogGetInheritsStatusAndOrigin(): void + { + $transport = (new MockTransport())->queueResponse(200, 'log line'); + $this->lastRun($transport)->log()->get(); + + $uri = (string) $transport->lastRequest()->getUri(); + self::assertStringContainsString('/actors/me~a/runs/last/log', $uri); + self::assertStringContainsString('status=SUCCEEDED', $uri); + self::assertStringContainsString('origin=API', $uri); + } + + public function testNestedStorageWithoutLastRunFilterSendsNoStatusOrigin(): void + { + // A plain run (not a last-run accessor) pins no filters, so nested requests carry none. + $transport = (new MockTransport())->queueResponse(200, '[]'); + $this->client($transport)->run('run1')->dataset()->listItems(); + + $uri = (string) $transport->lastRequest()->getUri(); + self::assertStringContainsString('/actor-runs/run1/dataset/items', $uri); + self::assertStringNotContainsString('status=', $uri); + self::assertStringNotContainsString('origin=', $uri); + } +} diff --git a/tests/Unit/LogClientTest.php b/tests/Unit/LogClientTest.php new file mode 100644 index 0000000..d4ab553 --- /dev/null +++ b/tests/Unit/LogClientTest.php @@ -0,0 +1,58 @@ +queueResponse(200, "line1\nline2\n"); + $log = $this->client($transport)->log('run1')->get(); + + self::assertSame("line1\nline2\n", $log); + $request = $transport->lastRequest(); + self::assertSame('GET', $request->getMethod()); + self::assertStringContainsString('/logs/run1', (string) $request->getUri()); + } + + public function testMissingLogReturnsNull(): void + { + $body = json_encode(['error' => ['type' => 'record-not-found', 'message' => 'no log']]); + $transport = (new MockTransport())->queueResponse(404, (string) $body); + self::assertNull($this->client($transport)->log('missing')->get()); + } + + public function testRunNestedLogGet(): void + { + $transport = (new MockTransport())->queueResponse(200, 'run log'); + $log = $this->client($transport)->run('run1')->log()->get(new LogOptions(raw: true)); + + self::assertSame('run log', $log); + $uri = (string) $transport->lastRequest()->getUri(); + self::assertStringContainsString('/actor-runs/run1/log', $uri); + self::assertStringContainsString('raw=1', $uri); + } + + public function testStreamedLogUsesStreamQueryAndReturnsReadableStream(): void + { + $transport = (new MockTransport())->queueResponse(200, 'streamed log body'); + $stream = $this->client($transport)->run('run1')->getStreamedLog(); + + $uri = (string) $transport->lastRequest()->getUri(); + self::assertStringContainsString('/actor-runs/run1/log', $uri); + self::assertStringContainsString('stream=1', $uri); + self::assertStringContainsString('raw=1', $uri); + self::assertSame('streamed log body', (string) $stream); + } +} diff --git a/tests/Unit/MockTransport.php b/tests/Unit/MockTransport.php new file mode 100644 index 0000000..c4871ea --- /dev/null +++ b/tests/Unit/MockTransport.php @@ -0,0 +1,76 @@ + */ + private array $queue = []; + + /** @var list */ + public array $received = []; + + /** @var list The per-request timeout (seconds) each call was made with, in order. */ + public array $timeouts = []; + + /** + * @param array $headers + */ + public function queueResponse(int $status, string $body = '', array $headers = []): self + { + $this->queue[] = new Response($status, $headers, $body); + return $this; + } + + public function queueError(bool $timeout = false): self + { + $this->queue[] = new TransportException('mock transport failure', null, $timeout); + return $this; + } + + public function lastRequest(): RequestInterface + { + if ($this->received === []) { + throw new RuntimeException('no request was received'); + } + return $this->received[count($this->received) - 1]; + } + + public function callCount(): int + { + return count($this->received); + } + + public function send(RequestInterface $request, float $timeoutSecs): ResponseInterface + { + $this->received[] = $request; + $this->timeouts[] = $timeoutSecs; + if ($this->queue === []) { + throw new RuntimeException('MockTransport queue is empty'); + } + $next = array_shift($this->queue); + if ($next instanceof TransportException) { + throw $next; + } + return $next; + } + + public function sendStreaming(RequestInterface $request, float $timeoutSecs): ResponseInterface + { + return $this->send($request, $timeoutSecs); + } +} diff --git a/tests/Unit/RequestShapeTest.php b/tests/Unit/RequestShapeTest.php new file mode 100644 index 0000000..7cf1eb4 --- /dev/null +++ b/tests/Unit/RequestShapeTest.php @@ -0,0 +1,170 @@ +queueResponse(200, ''); + $this->client($transport)->run('run1')->charge(new RunChargeOptions(eventName: 'result', count: 3)); + + $request = $transport->lastRequest(); + self::assertSame('POST', $request->getMethod()); + self::assertStringContainsString('/actor-runs/run1/charge', (string) $request->getUri()); + self::assertNotSame('', $request->getHeaderLine('idempotency-key')); + self::assertSame(['eventName' => 'result', 'count' => 3], Json::decode((string) $request->getBody())); + } + + public function testRunChargeUsesProvidedIdempotencyKey(): void + { + $transport = (new MockTransport())->queueResponse(200, ''); + $this->client($transport)->run('run1')->charge(new RunChargeOptions(eventName: 'e', idempotencyKey: 'fixed-key')); + self::assertSame('fixed-key', $transport->lastRequest()->getHeaderLine('idempotency-key')); + } + + public function testMetamorphSendsTargetActorIdAndInput(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'r']])); + $this->client($transport)->run('run1')->metamorph('apify/other', ['x' => 1], new MetamorphOptions(build: 'latest')); + + $request = $transport->lastRequest(); + self::assertSame('POST', $request->getMethod()); + $uri = (string) $request->getUri(); + self::assertStringContainsString('/actor-runs/run1/metamorph', $uri); + self::assertStringContainsString('targetActorId=apify%2Fother', $uri); + self::assertStringContainsString('build=latest', $uri); + self::assertSame(['x' => 1], Json::decode((string) $request->getBody())); + } + + public function testResurrectSendsOptions(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'r']])); + $this->client($transport)->run('run1')->resurrect(new RunResurrectOptions(build: 'beta', memoryMbytes: 1024)); + + $uri = (string) $transport->lastRequest()->getUri(); + self::assertStringContainsString('/actor-runs/run1/resurrect', $uri); + self::assertStringContainsString('build=beta', $uri); + self::assertStringContainsString('memory=1024', $uri); + } + + public function testRebootPostsToRebootPath(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'r']])); + $this->client($transport)->run('run1')->reboot(); + + $request = $transport->lastRequest(); + self::assertSame('POST', $request->getMethod()); + self::assertStringContainsString('/actor-runs/run1/reboot', (string) $request->getUri()); + } + + public function testAbortSendsGracefullyFlag(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'r']])); + $this->client($transport)->run('run1')->abort(true); + self::assertStringContainsString('gracefully=1', (string) $transport->lastRequest()->getUri()); + } + + public function testDefaultBuildFetchesBuildsDefault(): void + { + // Ample per-request timeout so the server-side wait clamp leaves waitForFinish=10 intact. + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'build1']])); + $client = new ApifyClient(token: 't', minDelayBetweenRetriesMillis: 1, timeoutSecs: 60, httpClient: $transport); + $client->actor('me~a')->defaultBuild(10); + + $request = $transport->lastRequest(); + self::assertSame('GET', $request->getMethod()); + $uri = (string) $request->getUri(); + self::assertStringContainsString('/actors/me~a/builds/default', $uri); + self::assertStringContainsString('waitForFinish=10', $uri); + } + + public function testDefaultBuildClampsWaitToPerRequestTimeout(): void + { + // With a small per-request timeout, the server-side wait is clamped down so the server never + // holds the connection past the client's socket timeout (consistent with run/build get()). + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'build1']])); + $client = new ApifyClient(token: 't', minDelayBetweenRetriesMillis: 1, timeoutSecs: 5, httpClient: $transport); + $client->actor('me~a')->defaultBuild(120); + + self::assertStringContainsString('waitForFinish=0', (string) $transport->lastRequest()->getUri()); + } + + public function testRequestQueueOptionsApplyClientKey(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['items' => []]])); + $this->client($transport) + ->requestQueue('q1', new RequestQueueClientOptions(clientKey: 'ck-123')) + ->listHead(5); + + self::assertStringContainsString('clientKey=ck-123', (string) $transport->lastRequest()->getUri()); + } + + public function testRequestQueueOptionsApplyTimeout(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['items' => []]])); + $this->client($transport) + ->requestQueue('q1', new RequestQueueClientOptions(timeoutSecs: 2.0)) + ->listHead(5); + + // The per-queue timeout must be threaded down to the transport (first attempt uses it directly). + self::assertSame(2.0, $transport->timeouts[0]); + } + + public function testCreateItemsPublicUrlDoesNotDuplicateCallerSignature(): void + { + // When the caller already supplies a signature, the client must not fetch the dataset to + // compute a second one — the URL would otherwise carry two conflicting `signature` params. + $transport = new MockTransport(); // no responses queued: get() must not be called + $url = $this->client($transport) + ->dataset('ds1') + ->createItemsPublicUrl(new DatasetListItemsOptions(signature: 'caller-sig')); + + self::assertSame(0, $transport->callCount()); + self::assertSame(1, substr_count($url, 'signature=')); + self::assertStringContainsString('signature=caller-sig', $url); + } + + public function testCreateItemsPublicUrlSignsPrivateDatasetWhenNoSignatureGiven(): void + { + // Private datasets (those exposing urlSigningSecretKey) get a single computed signature. + $transport = (new MockTransport())->queueResponse( + 200, + Json::encode(['data' => ['id' => 'ds1', 'urlSigningSecretKey' => 'secret']]) + ); + $url = $this->client($transport)->dataset('ds1')->createItemsPublicUrl(); + + self::assertSame(1, substr_count($url, 'signature=')); + } + + public function testUpdateLimitsPutsToMeLimits(): void + { + $transport = (new MockTransport())->queueResponse(200, ''); + $this->client($transport)->me()->updateLimits(['maxMonthlyUsageUsd' => 100]); + + $request = $transport->lastRequest(); + self::assertSame('PUT', $request->getMethod()); + self::assertStringContainsString('/users/me/limits', (string) $request->getUri()); + self::assertSame(['maxMonthlyUsageUsd' => 100], Json::decode((string) $request->getBody())); + } +} diff --git a/tests/Unit/SignatureTest.php b/tests/Unit/SignatureTest.php new file mode 100644 index 0000000..03b1404 --- /dev/null +++ b/tests/Unit/SignatureTest.php @@ -0,0 +1,52 @@ +