From e8820f8ea7aad5ee01060ac3ee73cf3a216ce336 Mon Sep 17 00:00:00 2001 From: Evan Date: Thu, 6 Aug 2026 08:10:08 +0000 Subject: [PATCH 1/2] feat: unify the SDK with the Seam SDKs for other languages The Python, Ruby, and JavaScript SDKs share a runtime core, a common README skeleton, and a test suite aligned to the same baseline. This SDK had the codegen and release tooling but not the runtime surface. This brings it in line. Added: - Personal access token authentication, with the seam-workspace header, and token format validation that rejects client session tokens, JWTs, and publishable keys with a specific message. - from_api_key, from_personal_access_token, and from_client factories. - SeamMultiWorkspace for the endpoints that are not scoped to a workspace. - SeamWebhook, verifying incoming webhooks with svix. - SEAM_ENDPOINT support, plus the deprecated SEAM_API_URL and its warnings. - Retries, two by default with exponential backoff, via caseyamcl/guzzle_retry_middleware. A request that never reached the server is always retried; a status code is only retried for idempotent methods, since retrying a POST the server may already have processed could duplicate a write. The other Seam SDKs make the same trade. - HTTP layer configuration: guzzle_options, retries, and an injectable client. - A client level wait_for_action_attempt default, accepting a bool or a timeout and polling_interval. - A test suite covering auth, env, headers, errors, malformed responses, retries, pagination, serialization, action attempts, and webhooks, run against @seamapi/fake-seam-connect. - Psalm, wired into composer lint. Fixed: - Responses in the 3xx range were treated as successful. - The Seam error check accepted any body with a truthy error key. It now checks the content type and that error.type and error.message are strings, matching the other SDKs. - throw_http_errors let Guzzle throw before the SDK could map the error, making the whole error mapping unreachable. The option is gone. - Malformed JSON silently decoded to null and then failed on property access. - Non-Seam error responses raised an exception built from a fabricated request rather than the real one. - getRequestId returned an empty string rather than null when the header was absent, and the fallback error type was unknown rather than unknown_error. - HttpInvalidInputError never actually overrode the error code, and the action attempt errors wrote to an undeclared property. - Paginator::firstPage indexed its cache unconditionally, and the null cursor guard was unreachable. BREAKING CHANGE: The client is Seam\Seam; Seam\SeamClient remains as a deprecated alias. The constructor takes named options, so endpoint is no longer the second positional argument, and throw_http_errors is removed. Exceptions moved to the Seam\Exceptions namespace. poll_until_ready is removed in favor of wait_for_action_attempt, whose defaults change from 20s/0.4s to 10s/1s. $seam->client is a Seam\Http\SeamHttpClient rather than a Guzzle client. The $api_key property and the global LTS_VERSION constant are removed. Responses in the 3xx range are no longer treated as successful. Requests are now retried. Pagination metadata is a Seam\Pagination object. PHP 8.1 or later is required, and svix/svix is a new dependency. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m --- .gitattributes | 2 +- .github/workflows/check.yml | 12 +- .gitignore | 2 +- README.md | 374 +- codegen/layouts/partials/client-class.hbs | 20 +- codegen/layouts/partials/poll-until-ready.hbs | 24 - codegen/layouts/partials/route-method.hbs | 24 +- codegen/layouts/seam-client.hbs | 212 +- codegen/lib/handlebars-helpers.ts | 10 +- codegen/lib/layouts/route.ts | 115 +- codegen/lib/routes.ts | 2 +- codegen/smith.ts | 2 +- composer.json | 27 +- composer.lock | 5112 ++++++++++++++--- package-lock.json | 105 + package.json | 4 +- phpunit.xml.dist | 15 +- psalm.xml | 20 + src/Auth.php | 149 + src/Exceptions/ActionAttemptError.php | 8 +- src/Exceptions/ActionAttemptFailedError.php | 20 +- src/Exceptions/ActionAttemptTimeoutError.php | 9 +- src/Exceptions/HttpApiError.php | 28 +- src/Exceptions/HttpInvalidInputError.php | 13 +- src/Exceptions/HttpUnauthorizedError.php | 20 +- src/Exceptions/InvalidOptionsError.php | 16 + src/Exceptions/InvalidTokenError.php | 15 + src/Exceptions/SeamException.php | 9 + src/Http/ResolveActionAttempt.php | 103 + src/Http/SeamHttpClient.php | 287 + src/Options.php | 103 + src/Pagination.php | 32 + src/Paginator.php | 57 +- src/Routes/AccessCodesClient.php | 74 +- src/Routes/AccessCodesSimulateClient.php | 33 +- src/Routes/AccessCodesUnmanagedClient.php | 48 +- src/Routes/AccessGrantsClient.php | 58 +- src/Routes/AccessGrantsUnmanagedClient.php | 36 +- src/Routes/AccessMethodsClient.php | 115 +- src/Routes/AccessMethodsUnmanagedClient.php | 29 +- src/Routes/AcsAccessGroupsClient.php | 55 +- src/Routes/AcsClient.php | 29 +- src/Routes/AcsCredentialsClient.php | 62 +- src/Routes/AcsEncodersClient.php | 95 +- src/Routes/AcsEncodersSimulateClient.php | 41 +- src/Routes/AcsEntrancesClient.php | 65 +- src/Routes/AcsSystemsClient.php | 37 +- src/Routes/AcsUsersClient.php | 60 +- src/Routes/ActionAttemptsClient.php | 83 +- src/Routes/ClientSessionsClient.php | 39 +- src/Routes/ConnectWebviewsClient.php | 34 +- src/Routes/ConnectedAccountsClient.php | 45 +- .../ConnectedAccountsSimulateClient.php | 23 +- src/Routes/CustomersClient.php | 27 +- src/Routes/DevicesClient.php | 40 +- src/Routes/DevicesSimulateClient.php | 57 +- src/Routes/DevicesUnmanagedClient.php | 28 +- src/Routes/EventsClient.php | 21 +- src/Routes/InstantKeysClient.php | 27 +- src/Routes/LocksClient.php | 95 +- src/Routes/LocksSimulateClient.php | 66 +- src/Routes/NoiseSensorsClient.php | 27 +- .../NoiseSensorsNoiseThresholdsClient.php | 63 +- src/Routes/NoiseSensorsSimulateClient.php | 23 +- src/Routes/PhonesClient.php | 33 +- src/Routes/PhonesSimulateClient.php | 23 +- src/Routes/SpacesClient.php | 98 +- src/Routes/ThermostatsClient.php | 260 +- src/Routes/ThermostatsDailyProgramsClient.php | 80 +- src/Routes/ThermostatsSchedulesClient.php | 65 +- src/Routes/ThermostatsSimulateClient.php | 33 +- src/Routes/UserIdentitiesClient.php | 107 +- src/Routes/UserIdentitiesUnmanagedClient.php | 36 +- src/Routes/WebhooksClient.php | 47 +- src/Routes/WorkspacesClient.php | 48 +- src/Seam.php | 256 + src/SeamClient.php | 156 +- src/SeamMultiWorkspace.php | 91 + src/SeamWebhook.php | 53 + src/Token.php | 67 + src/WorkspacesProxy.php | 54 + tests/ApiKeyTest.php | 89 + tests/ClientTest.php | 168 + tests/EnvTest.php | 143 + tests/HeadersTest.php | 141 + tests/HttpErrorTest.php | 117 +- tests/MalformedResponseTest.php | 112 + tests/PackageVersionTest.php | 4 +- tests/PaginatorTest.php | 123 + tests/PersonalAccessTokenTest.php | 140 + tests/RetryTest.php | 139 + tests/SeamWebhookTest.php | 103 + tests/SerializationTest.php | 55 + tests/Support/FakeSeamConnect.php | 171 + tests/Support/FakeSeamConnectTestCase.php | 45 + tests/Support/RecordingClient.php | 107 + tests/WaitForActionAttemptTest.php | 283 + 97 files changed, 9326 insertions(+), 2607 deletions(-) delete mode 100644 codegen/layouts/partials/poll-until-ready.hbs create mode 100644 psalm.xml create mode 100644 src/Auth.php create mode 100644 src/Exceptions/InvalidOptionsError.php create mode 100644 src/Exceptions/InvalidTokenError.php create mode 100644 src/Exceptions/SeamException.php create mode 100644 src/Http/ResolveActionAttempt.php create mode 100644 src/Http/SeamHttpClient.php create mode 100644 src/Options.php create mode 100644 src/Pagination.php create mode 100644 src/Seam.php create mode 100644 src/SeamMultiWorkspace.php create mode 100644 src/SeamWebhook.php create mode 100644 src/Token.php create mode 100644 src/WorkspacesProxy.php create mode 100644 tests/ApiKeyTest.php create mode 100644 tests/ClientTest.php create mode 100644 tests/EnvTest.php create mode 100644 tests/HeadersTest.php create mode 100644 tests/MalformedResponseTest.php create mode 100644 tests/PaginatorTest.php create mode 100644 tests/PersonalAccessTokenTest.php create mode 100644 tests/RetryTest.php create mode 100644 tests/SeamWebhookTest.php create mode 100644 tests/SerializationTest.php create mode 100644 tests/Support/FakeSeamConnect.php create mode 100644 tests/Support/FakeSeamConnectTestCase.php create mode 100644 tests/Support/RecordingClient.php create mode 100644 tests/WaitForActionAttemptTest.php diff --git a/.gitattributes b/.gitattributes index 641e8a3b..a4e1b5c5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,6 @@ src/Resources/*.php linguist-generated src/Routes/*.php linguist-generated -src/SeamClient.php linguist-generated +src/Seam.php linguist-generated # Keep development files out of the published package. # GitHub builds the archives Composer downloads as dist with git archive, diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 95ca05d1..749b0e39 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -20,7 +20,10 @@ jobs: os: - ubuntu-latest php: - - '8.0' + - '8.1' + - '8.2' + - '8.3' + - '8.4' - '8.5' include: - os: ubuntu-latest @@ -44,7 +47,7 @@ jobs: fail-fast: false matrix: php: - - '8.0' + - '8.1' - '8.5' steps: - name: Checkout @@ -73,7 +76,10 @@ jobs: os: - ubuntu-latest php: - - '8.0' + - '8.1' + - '8.2' + - '8.3' + - '8.4' - '8.5' include: - os: ubuntu-latest diff --git a/.gitignore b/.gitignore index 78aaf436..b75b485f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,7 @@ vendor tmp # PHPUnit result cache -.phpunit.result.cache +.phpunit.cache/ # Build directories package diff --git a/README.md b/README.md index 567a864c..35ef6120 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,167 @@ # Seam PHP SDK -Control locks, lights and other internet of things devices with Seam's simple API. +[![Packagist](https://img.shields.io/packagist/v/seamapi/seam.svg)](https://packagist.org/packages/seamapi/seam) +[![Seam LTS Version](https://img.shields.io/badge/Seam_LTS-1.0.0-blue)](https://docs.seam.co/lts) +[![GitHub Actions](https://github.com/seamapi/php/actions/workflows/check.yml/badge.svg)](https://github.com/seamapi/php/actions/workflows/check.yml) -Check out [the documentation](https://docs.seam.co) or the usage below. +PHP SDK for the Seam API. + +## Description + +[Seam] makes it easy to integrate IoT devices with your applications. +This is an official SDK for the Seam API. +Please refer to the official [Seam Docs] to get started. + +Parts of this SDK are generated from always up-to-date type information +provided by [@seamapi/types]. +This ensures all API methods, request shapes, and response shapes are +accurate and fully typed. + +The underlying HTTP client is [Guzzle]. + +[Seam]: https://www.seam.co/ +[Seam Docs]: https://docs.seam.co/latest/ +[@seamapi/types]: https://github.com/seamapi/types/ +[Guzzle]: https://docs.guzzlephp.org/ + +## Installation + +Add this as a dependency to your project using [Composer] with + +``` +$ composer require seamapi/seam +``` + +[Composer]: https://getcomposer.org/ ## Usage +> [!NOTE] +> These examples assume `SEAM_API_KEY` is set in your environment. + +### Examples + +#### List devices + ```php -$seam = new Seam\SeamClient("YOUR_API_KEY"); +$seam = new Seam\Seam(); -# Create a Connect Webview to login to a provider -$connect_webview = $seam->connect_webviews->create( - accepted_providers: ["august"] -); +$devices = $seam->devices->list(); +``` -print "Please Login at this url: " . $connect_webview->url; +#### Unlock a door -# Poll until connect webview is completed -while (true) { - $connect_webview = $seam->connect_webviews->get( - $connect_webview->connect_webview_id - ); - if ($connect_webview->status == "authorized") { - break; - } else { - sleep(1); - } -} +```php +$seam = new Seam\Seam(); -$connected_account = $seam->connected_accounts->get( - $connect_webview->connected_account_id -); +$lock = $seam->locks->get(name: "Front Door"); +$seam->locks->unlock_door($lock->device_id); +``` + +### Authentication Method + +The SDK supports two authentication mechanisms. +Configure either by passing the corresponding options to the `Seam` +constructor, or with the more ergonomic static factory methods. -print "Looks like you connected with " . - json_encode($connected_account->user_identifier); +#### API Key + +An API key is scoped to a single workspace and should only be used on the +server. Obtain one from the Seam Console. + +```php +// Set the SEAM_API_KEY environment variable +$seam = new Seam\Seam(); -$devices = $seam->devices->list( - connected_account_id: $connected_account->connected_account_id +// Pass as an option to the constructor +$seam = new Seam\Seam(api_key: "your-api-key"); + +// Use the factory method +$seam = Seam\Seam::from_api_key("your-api-key"); +``` + +#### Personal Access Token + +A Personal Access Token is scoped to a Seam Console user. +It must be used with a workspace id. + +```php +// Pass as options to the constructor +$seam = new Seam\Seam( + personal_access_token: "your-personal-access-token", + workspace_id: "your-workspace-id" ); -print "You have " . count($devices) . " devices"; +// Use the factory method +$seam = Seam\Seam::from_personal_access_token( + "your-personal-access-token", + "your-workspace-id" +); +``` + +### Action Attempts + +Some operations tell a device to do something, and the device may take time +to report back. Those endpoints return an [action attempt]. + +By default the SDK waits for the action attempt to finish: + +- It polls up to a timeout at a polling interval. +- It returns a fresh copy of the successful action attempt. +- It throws `Seam\Exceptions\ActionAttemptFailedError` if the action failed. +- It throws `Seam\Exceptions\ActionAttemptTimeoutError` if the timeout + elapses first. + +Both errors extend `Seam\Exceptions\ActionAttemptError` and expose the action +attempt with `getActionAttempt()`. -$device_id = $devices[0]->device_id; +[action attempt]: https://docs.seam.co/latest/core-concepts/action-attempts -# Lock a Door -$seam->locks->lock_door($device_id); +```php +use Seam\Exceptions\ActionAttemptFailedError; +use Seam\Exceptions\ActionAttemptTimeoutError; + +try { + $seam->locks->unlock_door($device_id); +} catch (ActionAttemptFailedError $error) { + print "Could not unlock the door: " . $error->getMessage(); + print "Error code: " . $error->getErrorCode(); +} catch (ActionAttemptTimeoutError $error) { + print "The door did not unlock in time"; + print "Action attempt: " . $error->getActionAttempt()->action_attempt_id; +} +``` + +Waiting may be disabled for the whole client: -$updated_device = $seam->devices->get($device_id); -$updated_device->properties->locked; // true +```php +$seam = new Seam\Seam(wait_for_action_attempt: false); + +$action_attempt = $seam->locks->unlock_door($device_id); +$action_attempt->status; // "pending" +``` -# Unlock a Door -$seam->locks->unlock_door($device_id); -$updated_device->properties->locked; // false +or for a single request: -# Create an access code on a device -$access_code = $seam->access_codes->create( - device_id: $device_id, - code: "1234", - name: "Test Code" +```php +$action_attempt = $seam->locks->unlock_door( + $device_id, + wait_for_action_attempt: false ); +``` + +The timeout and polling interval, both in seconds, may be configured either +on the client or per request: -# Check the status of an access code -$access_code->status; // 'setting' (it will go to 'set' when active on the device) +```php +$seam = new Seam\Seam( + wait_for_action_attempt: ["timeout" => 30.0, "polling_interval" => 2.0] +); -$seam->access_codes->delete($access_code->access_code_id); +$seam->locks->unlock_door( + $device_id, + wait_for_action_attempt: ["timeout" => 5.0] +); ``` ### Pagination @@ -153,21 +251,171 @@ $pages = $seam->createPaginator( $connectedAccounts = $pages->flattenToArray(); ``` -## Installation +### Interacting with Multiple Workspaces + +Some endpoints are not scoped to a workspace. Use `SeamMultiWorkspace` with a +personal access token to reach them. + +```php +$seam = Seam\SeamMultiWorkspace::from_personal_access_token( + "your-personal-access-token" +); + +// List workspaces authorized for this Personal Access Token +$workspaces = $seam->workspaces->list(); + +$workspace = $seam->workspaces->create( + name: "New Workspace", + connect_partner_name: "Your Company" +); +``` + +### Webhooks + +Seam delivers webhooks with [Svix]. Verify and parse an incoming request with +`SeamWebhook`, which returns the typed event. + +[Svix]: https://www.svix.com/ + +```php +$webhook = new Seam\SeamWebhook($webhook_secret); + +try { + $event = $webhook->verify($request_body, $request_headers); + print $event->event_type; +} catch (Svix\Exception\WebhookVerificationException $error) { + http_response_code(400); +} +``` + +### Advanced Usage + +#### Setting the endpoint + +The endpoint may be set with the `SEAM_ENDPOINT` environment variable, or +passed directly. + +```php +$seam = new Seam\Seam(endpoint: "https://example.com"); +``` + +#### Configuring the Guzzle client + +Pass any [Guzzle request option] with `guzzle_options`. They are merged into +the client the SDK builds, so the authorization and SDK headers are kept. + +[Guzzle request option]: https://docs.guzzlephp.org/en/stable/request-options.html -To install the latest version of the automatically generated SDK, run: +```php +$seam = new Seam\Seam( + guzzle_options: [ + "timeout" => 30, + "headers" => ["X-Custom-Header" => "value"], + "proxy" => "http://localhost:8125", + ] +); +``` + +> [!NOTE] +> Unlike the other Seam SDKs, this one sets a default request timeout of 60 +> seconds so a hung connection eventually fails. Pass `timeout` to change it, +> or `0` to disable it. + +#### Retries + +Failed requests are retried twice by default, with exponential backoff. + +A request that never reached the server, e.g. a connection failure, is always +retried. A request that did reach the server is only retried on a retryable +status when the HTTP method is idempotent. Every Seam endpoint is a `POST`, so +retrying one that the server may already have processed could duplicate a +write. The other Seam SDKs make the same trade. + +```php +// Retry more times +$seam = new Seam\Seam(retries: 5); + +// Turn retries off +$seam = new Seam\Seam(retries: 0); +``` + +#### Overriding the client + +Pass an already configured Guzzle client. It carries its own endpoint and +authorization, so no other authentication option may be given alongside it. -`composer require seamapi/seam` +```php +$client = new GuzzleHttp\Client([ + "base_uri" => "https://connect.getseam.com", + "headers" => ["authorization" => "Bearer " . $api_key], +]); + +$seam = Seam\Seam::from_client($client); +``` + +#### Errors -If you want to install our previous handwritten version, run: +Every exception the SDK raises implements `Seam\Exceptions\SeamException`. -`composer require seamapi/seam:1.1` +| Error | Raised when | +| --------------------------- | -------------------------------------------------- | +| `HttpApiError` | The API returned an error response. | +| `HttpUnauthorizedError` | The credentials were rejected. | +| `HttpInvalidInputError` | The request parameters were rejected. | +| `InvalidOptionsError` | The client options are incomplete or incompatible. | +| `InvalidTokenError` | The token is of the wrong kind or format. | +| `ActionAttemptFailedError` | An action attempt finished in the error state. | +| `ActionAttemptTimeoutError` | An action attempt did not finish in time. | + +An error response that is not shaped like a Seam error, e.g. a gateway +returning HTML, surfaces as the underlying `GuzzleHttp\Exception\ +BadResponseException` instead. Transport failures surface as the +corresponding Guzzle exception. + +```php +use Seam\Exceptions\HttpApiError; +use Seam\Exceptions\HttpInvalidInputError; + +try { + $seam->devices->get($device_id); +} catch (HttpInvalidInputError $error) { + print_r($error->getValidationErrorMessages("device_id")); +} catch (HttpApiError $error) { + print $error->getErrorCode(); + print $error->getStatusCode(); + print $error->getRequestId(); +} +``` + +## Upgrading from 3.x + +Version 4 brings this SDK in line with the Seam SDKs for other languages. + +- PHP 8.1 or later is now required. +- The client class is `Seam\Seam`. `Seam\SeamClient` still works as a + deprecated alias. +- The constructor takes named options. `$endpoint` is no longer the second + positional argument, and `$throw_http_errors` is gone; API errors always + raise a Seam exception. +- Exceptions moved to the `Seam\Exceptions` namespace, e.g. + `Seam\HttpApiError` is now `Seam\Exceptions\HttpApiError`. +- `$seam->action_attempts->poll_until_ready()` was removed. Use + `wait_for_action_attempt`, which now also accepts a `timeout` and + `polling_interval`. The defaults changed from 20s/0.4s to 10s/1s. +- `$seam->client` is a `Seam\Http\SeamHttpClient`. The Guzzle client is + available with `$seam->client->get_client()`. +- The `$seam->api_key` property and the global `LTS_VERSION` constant were + removed. Use `Seam\Seam::LTS_VERSION`. +- Responses in the 3xx range are no longer treated as successful. +- Requests are now retried; see [Retries](#retries). +- Pagination metadata is a `Seam\Pagination` object rather than a + `stdClass`. ## Development and Testing ### Quickstart -Install [PHP](https://www.php.net/) 8.0 or later, +Install [PHP](https://www.php.net/) 8.1 or later, [Composer](https://getcomposer.org/) and [Node.js](https://nodejs.org/), then run @@ -186,13 +434,13 @@ View them with $ composer run-script --list ``` -| Task | Command | -| ----------------- | ------------------ | -| Run the tests | `composer test` | -| Lint | `composer lint` | -| Format | `npm run format` | -| Build the package | `composer build` | -| Generate the SDK | `npm run generate` | +| Task | Command | +| ---------------------- | ------------------ | +| Run the tests | `composer test` | +| Lint and analyze types | `composer lint` | +| Format | `npm run format` | +| Build the package | `composer build` | +| Generate the SDK | `npm run generate` | Formatting is handled by [Prettier](https://prettier.io/) via [@prettier/plugin-php](https://github.com/prettier/plugin-php), @@ -215,10 +463,20 @@ $ composer test -- tests/MyTest.php PHPUnit is configured in `phpunit.xml.dist`. +Static analysis is handled by [Psalm](https://psalm.dev/), configured in +`psalm.xml` and run as part of `composer lint`. The generated sources under +`src/Resources` and `src/Routes` are excluded, since analyzing them would only +create pressure to change the generator. + ### Requirements -This package supports PHP 8.0 and later. -Continuous integration exercises both ends of that range, PHP 8.0 and 8.5. +This package supports PHP 8.1 and later. +Continuous integration exercises every supported version, PHP 8.1 through 8.5. + +The test suite runs against [@seamapi/fake-seam-connect], which is started +automatically for each test, so `npm install` must have been run first. + +[@seamapi/fake-seam-connect]: https://github.com/seamapi/fake-seam-connect ### Publishing diff --git a/codegen/layouts/partials/client-class.hbs b/codegen/layouts/partials/client-class.hbs index a205fc3f..79d6dd3d 100644 --- a/codegen/layouts/partials/client-class.hbs +++ b/codegen/layouts/partials/client-class.hbs @@ -1,6 +1,11 @@ class {{clientName}}Client { - private SeamClient $seam; + private SeamHttpClient $client; + + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; {{#if hasChildClients}} {{#each childClients}} public {{clientName}}Client ${{namespace}}; @@ -8,18 +13,19 @@ class {{clientName}}Client {{else}} {{/if}} - public function __construct(SeamClient $seam) + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; {{#each childClients}} - $this->{{namespace}} = new {{clientName}}Client($seam); + $this->{{namespace}} = new {{clientName}}Client($client, $defaults); {{/each}} } {{#each methods}} {{> route-method}} {{/each}} -{{#if isActionAttempts}} -{{> poll-until-ready}} -{{/if}} } diff --git a/codegen/layouts/partials/poll-until-ready.hbs b/codegen/layouts/partials/poll-until-ready.hbs deleted file mode 100644 index 87ce726d..00000000 --- a/codegen/layouts/partials/poll-until-ready.hbs +++ /dev/null @@ -1,24 +0,0 @@ - public function poll_until_ready(string $action_attempt_id, float $timeout = 20.0): ActionAttempt - { - $seam = $this->seam; - $time_waiting = 0.0; - $polling_interval = 0.4; - $action_attempt = $seam->action_attempts->get($action_attempt_id); - - while ($action_attempt->status == 'pending') { - $action_attempt = $seam->action_attempts->get( - $action_attempt->action_attempt_id - ); - if ($time_waiting > $timeout) { - throw new ActionAttemptTimeoutError($action_attempt, $timeout); - } - $time_waiting += $polling_interval; - usleep($polling_interval * 1000000); - } - - if ($action_attempt->status == 'error') { - throw new ActionAttemptFailedError($action_attempt); - } - - return $action_attempt; - } diff --git a/codegen/layouts/partials/route-method.hbs b/codegen/layouts/partials/route-method.hbs index 9c73dfd9..bf344f74 100644 --- a/codegen/layouts/partials/route-method.hbs +++ b/codegen/layouts/partials/route-method.hbs @@ -3,14 +3,18 @@ {{#if hasParams}} $request_payload = []; -{{#each paramNames}} - if (${{this}} !== null) { - $request_payload["{{this}}"] = ${{this}}; +{{#each parameters}} +{{#if required}} + $request_payload["{{name}}"] = ${{name}}; +{{else}} + if (${{name}} !== null) { + $request_payload["{{name}}"] = ${{name}}; } +{{/if}} {{/each}} {{/if}} - {{#unless returnsVoid}}$res = {{/unless}}$this->seam->request( + {{#unless returnsVoid}}$res = {{/unless}}$this->client->request( "POST", "{{path}}", {{#if hasParams}} @@ -19,13 +23,11 @@ ); {{#if usesActionAttempt}} - if (!$wait_for_action_attempt) { - return {{returnResource}}::from_json($res->{{returnPath}}); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready($res->action_attempt->action_attempt_id); - - return $action_attempt; + return ResolveActionAttempt::resolve_action_attempt( + {{returnResource}}::from_json($res->{{returnPath}}), + $this->client, + $wait_for_action_attempt ?? $this->defaults["wait_for_action_attempt"] + ); {{else}} {{#if usesOnResponse}} diff --git a/codegen/layouts/seam-client.hbs b/codegen/layouts/seam-client.hbs index d39a7c01..c452d625 100644 --- a/codegen/layouts/seam-client.hbs +++ b/codegen/layouts/seam-client.hbs @@ -5,96 +5,158 @@ namespace Seam; {{#each useStatements}} use {{this}}; {{/each}} -use Seam\Utils\PackageVersion; -use GuzzleHttp\Client as HTTPClient; -use \Exception as Exception; -use Seam\HttpApiError; -use Seam\HttpUnauthorizedError; -use Seam\HttpInvalidInputError; - -define('LTS_VERSION', '1.0.0'); - -class SeamClient +use GuzzleHttp\ClientInterface; +use Seam\Http\SeamHttpClient; + +/** + * Client for the Seam API. + * + * Authenticate with an API key, which is scoped to a single workspace, or with + * a personal access token together with the id of the workspace to act on. + * When neither is given, the SEAM_API_KEY environment variable is used. + * + * @see https://docs.seam.co/ + */ +class Seam { {{#each parentClients}} public {{clientName}}Client ${{namespace}}; {{/each}} - public string $api_key; - public HTTPClient $client; - public string $ltsVersion = LTS_VERSION; - + /** + * The long term support version of the Seam API this SDK targets. + */ + public const LTS_VERSION = SeamHttpClient::LTS_VERSION; + + public SeamHttpClient $client; + + /** + * Default request options applied to every call, currently just + * wait_for_action_attempt. + * + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + public array $defaults; + + /** + * @param bool|array{timeout?: float, polling_interval?: float}|null $wait_for_action_attempt Whether to wait for action attempts to finish, optionally with timeout and polling_interval in seconds. Defaults to true. + * @param array $guzzle_options Options merged into the underlying Guzzle client, e.g. timeout or headers. + * @param int|null $retries How many times to retry a failed request. Defaults to 2; pass 0 to disable. + * @param ClientInterface|null $client A preconfigured Guzzle client. It carries its own endpoint and authorization, so no other authentication option may be given with it. + */ public function __construct( - $api_key = null, - $endpoint = "https://connect.getseam.com", - $throw_http_errors = false + ?string $api_key = null, + ?string $personal_access_token = null, + ?string $workspace_id = null, + ?string $endpoint = null, + bool|array|null $wait_for_action_attempt = null, + array $guzzle_options = [], + ?int $retries = null, + ?ClientInterface $client = null ) { - $this->api_key = $api_key ?: (getenv('SEAM_API_KEY') ?: null); - $seam_sdk_version = PackageVersion::get(); - $this->client = new HTTPClient([ - "base_uri" => $endpoint, - "timeout" => 60.0, - "headers" => [ - "Authorization" => "Bearer " . $this->api_key, - "User-Agent" => "Seam PHP Client ". $seam_sdk_version, - "seam-sdk-name" => "seamapi/php", - "seam-sdk-version" => $seam_sdk_version, - "seam-lts-version" => $this->ltsVersion - ], - "http_errors" => $throw_http_errors, - ]); + $this->defaults = [ + "wait_for_action_attempt" => $wait_for_action_attempt ?? true, + ]; + + // A client carries its own endpoint and authorization, so the + // authentication options are only read when one has to be built. + $this->client = $client !== null + ? SeamHttpClient::from_client($client) + : new SeamHttpClient( + Options::get_endpoint($endpoint), + Auth::get_auth_headers($api_key, $personal_access_token, $workspace_id), + $guzzle_options, + $retries + ); + {{#each parentClients}} - $this->{{namespace}} = new {{clientName}}Client($this); + $this->{{namespace}} = new {{clientName}}Client($this->client, $this->defaults); {{/each}} } + /** + * Creates a client authorized with an API key. + */ + public static function from_api_key( + string $api_key, + ?string $endpoint = null, + bool|array|null $wait_for_action_attempt = null, + array $guzzle_options = [], + ?int $retries = null + ): static { + return new static( + api_key: $api_key, + endpoint: $endpoint, + wait_for_action_attempt: $wait_for_action_attempt, + guzzle_options: $guzzle_options, + retries: $retries + ); + } + + /** + * Creates a client authorized with a personal access token, scoped to the + * given workspace. + */ + public static function from_personal_access_token( + string $personal_access_token, + string $workspace_id, + ?string $endpoint = null, + bool|array|null $wait_for_action_attempt = null, + array $guzzle_options = [], + ?int $retries = null + ): static { + return new static( + personal_access_token: $personal_access_token, + workspace_id: $workspace_id, + endpoint: $endpoint, + wait_for_action_attempt: $wait_for_action_attempt, + retries: $retries, + guzzle_options: $guzzle_options + ); + } + + /** + * Creates a client from a preconfigured Guzzle client. + */ + public static function from_client( + ClientInterface $client, + bool|array|null $wait_for_action_attempt = null + ): static { + return new static( + client: $client, + wait_for_action_attempt: $wait_for_action_attempt + ); + } + + public function lts_version(): string + { + return self::LTS_VERSION; + } + + /** + * Makes a request against the Seam API with this client's authorization. + * + * @param array|object|null $json + * @param array|null $query + */ public function request( - $method, - $path, - $json = null, - $query = null, - ) { - $options = [ - "json" => $json, - "query" => $query, - ]; - $options = array_filter($options, fn($option) => $option !== null); - - $response = $this->client->request($method, $path, $options); - $status_code = $response->getStatusCode(); - $request_id = $response->getHeaderLine("seam-request-id"); - - $res_json = null; - try { - $res_json = json_decode($response->getBody()); - } catch (Exception $ignoreError) { - } - - if ($status_code >= 400) { - if ($status_code === 401) { - throw new HttpUnauthorizedError($request_id); - } - - if (($res_json->error ?? null) != null) { - if ($res_json->error->type === 'invalid_input') { - throw new HttpInvalidInputError($res_json->error, $status_code, $request_id); - } - - throw new HttpApiError($res_json->error, $status_code, $request_id); - } - - throw \GuzzleHttp\Exception\RequestException::create( - new \GuzzleHttp\Psr7\Request($method, $path), - $response - ); - } - - return $res_json; + string $method, + string $path, + mixed $json = null, + ?array $query = null + ): mixed { + return $this->client->request($method, $path, $json, $query); } - public function createPaginator($request, $params = []) + /** + * Creates a paginator for a list endpoint. + * + * @param callable $request Invokes the list method with a params array, e.g. fn($params) => $seam->devices->list(...$params) + * @param array $params + */ + public function createPaginator(callable $request, array $params = []): Paginator { - return new Paginator($request, $params); + return new Paginator($request, $params); } } diff --git a/codegen/lib/handlebars-helpers.ts b/codegen/lib/handlebars-helpers.ts index 263be9ce..62b6574a 100644 --- a/codegen/lib/handlebars-helpers.ts +++ b/codegen/lib/handlebars-helpers.ts @@ -9,7 +9,13 @@ export interface DeprecatedPhpDocContext { export interface MethodPhpDocContext extends DeprecatedPhpDocContext { returnType: string responseDescription: string - parameters: Array<{ name: string; type: string; description: string }> + // The endpoint parameters plus the SDK level ones, e.g. + // wait_for_action_attempt, so editors surface all of them. + documentedParameters: Array<{ + name: string + type: string + description: string + }> } export const resourcePhpDoc = (context: DeprecatedPhpDocContext): string => @@ -25,7 +31,7 @@ export const methodPhpDoc = (context: MethodPhpDocContext): string => createPhpDoc( context.description, [ - ...context.parameters.map( + ...context.documentedParameters.map( (parameter) => `@param ${parameter.type} $${parameter.name}${parameter.description === '' ? '' : ` ${parameter.description}`}`, ), diff --git a/codegen/lib/layouts/route.ts b/codegen/lib/layouts/route.ts index eb32944a..0cc1ac3e 100644 --- a/codegen/lib/layouts/route.ts +++ b/codegen/lib/layouts/route.ts @@ -8,12 +8,9 @@ import { sortPhpClientMethodParameters, } from '../class-model.js' -const seamClientClass = 'Seam\\SeamClient' +const seamHttpClientClass = 'Seam\\Http\\SeamHttpClient' +const resolveActionAttemptClass = 'Seam\\Http\\ResolveActionAttempt' const resourcesNamespace = 'Seam\\Resources' -const actionAttemptErrorClasses = [ - 'Seam\\ActionAttemptFailedError', - 'Seam\\ActionAttemptTimeoutError', -] export interface MethodLayoutContext { methodName: string @@ -21,12 +18,21 @@ export interface MethodLayoutContext { responseDescription: string isDeprecated: boolean deprecationMessage: string - parameters: Array<{ name: string; type: string; description: string }> + parameters: Array<{ + name: string + type: string + description: string + required: boolean + }> + documentedParameters: Array<{ + name: string + type: string + description: string + }> path: string returnType: string hasParams: boolean signatureParams: string - paramNames: string[] usesActionAttempt: boolean usesOnResponse: boolean returnsVoid: boolean @@ -40,21 +46,34 @@ export interface ClientLayoutContext { hasChildClients: boolean childClients: Array<{ clientName: string; namespace: string }> methods: MethodLayoutContext[] - isActionAttempts: boolean } export interface RouteLayoutContext extends ClientLayoutContext { useStatements: string[] } +const waitForActionAttemptParameter = { + name: 'wait_for_action_attempt', + type: 'bool|array|null', + description: + 'Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client.', + required: false, +} + +const onResponseParameter = { + name: 'on_response', + type: 'callable|null', + description: + 'Called with the raw response envelope, used by the paginator to read the pagination metadata.', + required: false, +} + const getMethodLayoutContext = ( method: PhpClientMethod, - clientName: string, ): MethodLayoutContext => { const { methodName, path, parameters, returnResource, returnPath } = method - const usesActionAttempt = - returnResource === 'ActionAttempt' && clientName !== 'ActionAttempts' + const usesActionAttempt = returnResource === 'ActionAttempt' const usesOnResponse = parameters.some((p) => p.name === 'page_cursor') && methodName === 'list' const returnsVoid = returnResource === '' @@ -71,26 +90,42 @@ const getMethodLayoutContext = ( (p) => `${!(p.required ?? false) && p.type !== 'mixed' ? '?' : ''}${p.type} $${p.name}${(p.required ?? false) ? '' : ' = null'}`, ) - .concat(usesActionAttempt ? ['bool $wait_for_action_attempt = true'] : []) + .concat( + usesActionAttempt + ? ['bool|array|null $wait_for_action_attempt = null'] + : [], + ) .concat(usesOnResponse ? ['?callable $on_response = null'] : []) .join(', ') + const documentedEndpointParameters = sortedParameters.map( + ({ name, type, description, required }) => ({ + name, + type, + description, + required: required ?? false, + }), + ) + return { methodName, description: method.description, responseDescription: method.responseDescription, isDeprecated: method.isDeprecated, deprecationMessage: method.deprecationMessage, - parameters: sortedParameters.map(({ name, type, description }) => ({ - name, - type, - description, - })), + // The request payload is built from the endpoint parameters alone. + parameters: documentedEndpointParameters, + // The SDK level parameters are documented alongside them so editors + // surface all of them, but they never reach the payload. + documentedParameters: [ + ...documentedEndpointParameters, + ...(usesActionAttempt ? [waitForActionAttemptParameter] : []), + ...(usesOnResponse ? [onResponseParameter] : []), + ], path, returnType, hasParams: parameters.length > 0, signatureParams, - paramNames: sortedParameters.map((p) => p.name), usesActionAttempt, usesOnResponse, returnsVoid, @@ -100,44 +135,34 @@ const getMethodLayoutContext = ( } } -// Child clients live in the same namespace as their parent, so only the -// SeamClient, the resource classes returned by the methods, and the action -// attempt errors thrown by poll_until_ready need importing. -const getUseStatements = ( - client: PhpClient, - isActionAttempts: boolean, -): string[] => { +// Child clients live in the same namespace as their parent, so only the HTTP +// client, the action attempt resolver, and the resource classes returned by +// the methods need importing. +const getUseStatements = (client: PhpClient): string[] => { const resourceNames = new Set( client.methods .map((m) => m.returnResource) .filter((resourceName) => resourceName !== ''), ) - if (isActionAttempts) resourceNames.add('ActionAttempt') + const usesActionAttempt = resourceNames.has('ActionAttempt') return [ - seamClientClass, + seamHttpClientClass, + ...(usesActionAttempt ? [resolveActionAttemptClass] : []), ...[...resourceNames].map((name) => `${resourcesNamespace}\\${name}`), - ...(isActionAttempts ? actionAttemptErrorClasses : []), ].sort((a, b) => a.localeCompare(b)) } export const setRouteLayoutContext = ( client: PhpClient, -): RouteLayoutContext => { - const isActionAttempts = client.clientName === 'ActionAttempts' - - return { - useStatements: getUseStatements(client, isActionAttempts), - clientName: client.clientName, - hasChildClients: client.childClientIdentifiers.length > 0, - childClients: client.childClientIdentifiers.map((i) => ({ - clientName: i.clientName, - namespace: i.namespace, - })), - methods: client.methods.map((m) => - getMethodLayoutContext(m, client.clientName), - ), - isActionAttempts, - } -} +): RouteLayoutContext => ({ + useStatements: getUseStatements(client), + clientName: client.clientName, + hasChildClients: client.childClientIdentifiers.length > 0, + childClients: client.childClientIdentifiers.map((i) => ({ + clientName: i.clientName, + namespace: i.namespace, + })), + methods: client.methods.map(getMethodLayoutContext), +}) diff --git a/codegen/lib/routes.ts b/codegen/lib/routes.ts index f18f155a..e49bb63b 100644 --- a/codegen/lib/routes.ts +++ b/codegen/lib/routes.ts @@ -21,7 +21,7 @@ interface Metadata { const resourcesPath = 'src/Resources' const routesPath = 'src/Routes' -const seamClientPath = 'src/SeamClient.php' +const seamClientPath = 'src/Seam.php' export const routes = ( files: Metalsmith.Files, diff --git a/codegen/smith.ts b/codegen/smith.ts index 744efd43..ba1384fa 100644 --- a/codegen/smith.ts +++ b/codegen/smith.ts @@ -12,7 +12,7 @@ import { helpers, routes } from './lib/index.js' const rootDir = dirname(fileURLToPath(import.meta.url)) await Promise.all([ - deleteAsync(['./src/Resources', './src/Routes', './src/SeamClient.php']), + deleteAsync(['./src/Resources', './src/Routes', './src/Seam.php']), ]) const partials = await getHandlebarsPartials(`${rootDir}/layouts/partials`) diff --git a/composer.json b/composer.json index 6827b5ce..486452eb 100644 --- a/composer.json +++ b/composer.json @@ -25,19 +25,18 @@ "source": "https://github.com/seamapi/php" }, "require": { - "php": "^8.0", - "guzzlehttp/guzzle": "^7.5" + "php": "^8.1", + "caseyamcl/guzzle_retry_middleware": "^2.13", + "guzzlehttp/guzzle": "^7.5", + "svix/svix": "^1.40" }, "require-dev": { - "phpunit/phpunit": "^9.5", - "squizlabs/php_codesniffer": "^3.7" + "phpunit/phpunit": "^10.5", + "vimeo/psalm": "^6.0" }, "autoload": { "psr-4": { - "Seam\\": [ - "src/", - "src/Exceptions/" - ] + "Seam\\": "src/" }, "classmap": [ "src/Resources/" @@ -58,7 +57,7 @@ "/.env.example", "/.github", "/.npmrc", - "/.phpunit.result.cache", + "/.phpunit.cache", "/.prettierignore", "/.prettierrc.json", "/.releaserc.json", @@ -69,6 +68,7 @@ "/package.json", "/phpunit.xml.dist", "/pkg", + "/psalm.xml", "/tests", "/tmp", "/tsconfig.json", @@ -81,16 +81,19 @@ "test": "phpunit", "lint": [ "@lint:composer", - "@lint:syntax" + "@lint:syntax", + "@lint:types" ], "lint:composer": "@composer validate --strict", - "lint:syntax": "! find src tests -name '*.php' -exec php -l {} \\; | grep -v '^No syntax errors detected'" + "lint:syntax": "! find src tests -name '*.php' -exec php -l {} \\; | grep -v '^No syntax errors detected'", + "lint:types": "psalm --no-cache" }, "scripts-descriptions": { "build": "Build a distributable archive of this package into pkg/.", "test": "Run the test suite.", "lint": "Run all lint checks.", "lint:composer": "Validate composer.json and composer.lock.", - "lint:syntax": "Check every PHP source file for syntax errors." + "lint:syntax": "Check every PHP source file for syntax errors.", + "lint:types": "Run static analysis with Psalm." } } diff --git a/composer.lock b/composer.lock index bc6794ae..a604302c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,38 +4,114 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "47b28c106be0ff4f75680c1fe23e931a", + "content-hash": "25d67117228a0425a94bd9f40c53f692", "packages": [ + { + "name": "caseyamcl/guzzle_retry_middleware", + "version": "v2.13.0", + "source": { + "type": "git", + "url": "https://github.com/caseyamcl/guzzle_retry_middleware.git", + "reference": "17c9299cde438b00bbeb099c6480319a81636a60" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/caseyamcl/guzzle_retry_middleware/zipball/17c9299cde438b00bbeb099c6480319a81636a60", + "reference": "17c9299cde438b00bbeb099c6480319a81636a60", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.3|^7.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "jaschilz/php-coverage-badger": "^2.0", + "nesbot/carbon": "^2.0|^3.0", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^7.5|^8.0|^9.0", + "squizlabs/php_codesniffer": "^3.5", + "symfony/var-dumper": "^5.0|^6.0|^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleRetry\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Casey McLaughlin", + "email": "caseyamcl@gmail.com", + "homepage": "https://caseymclaughlin.com", + "role": "Developer" + } + ], + "description": "Guzzle v6+ retry middleware that handles 429/503 status codes and connection timeouts", + "homepage": "https://github.com/caseyamcl/guzzle_retry_middleware", + "keywords": [ + "Guzzle", + "back-off", + "caseyamcl", + "guzzle_retry_middleware", + "middleware", + "retry", + "retry-after" + ], + "support": { + "issues": "https://github.com/caseyamcl/guzzle_retry_middleware/issues", + "source": "https://github.com/caseyamcl/guzzle_retry_middleware/tree/v2.13.0" + }, + "funding": [ + { + "url": "https://github.com/caseyamcl", + "type": "github" + } + ], + "time": "2025-07-11T12:33:22+00:00" + }, { "name": "guzzlehttp/guzzle", - "version": "7.5.0", + "version": "7.15.3", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "b50a2a1251152e43f6a37f0fa053e730a67d25ba" + "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b50a2a1251152e43f6a37f0fa053e730a67d25ba", - "reference": "b50a2a1251152e43f6a37f0fa053e730a67d25ba", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", + "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^1.5", - "guzzlehttp/psr7": "^1.9 || ^2.4", + "guzzlehttp/promises": "^2.5.2", + "guzzlehttp/psr7": "^2.13", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.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.1", + "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "php-http/client-integration-tests": "^3.0", - "phpunit/phpunit": "^8.5.29 || ^9.5.23", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -48,9 +124,6 @@ "bamarni-bin": { "bin-links": true, "forward-command": false - }, - "branch-alias": { - "dev-master": "7.5-dev" } }, "autoload": { @@ -116,7 +189,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.5.0" + "source": "https://github.com/guzzle/guzzle/tree/7.15.3" }, "funding": [ { @@ -132,38 +205,38 @@ "type": "tidelift" } ], - "time": "2022-08-28T15:39:27+00:00" + "time": "2026-08-05T19:48:21+00:00" }, { "name": "guzzlehttp/promises", - "version": "1.5.2", + "version": "2.5.2", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "b94b2807d85443f9719887892882d0329d1e2598" + "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/b94b2807d85443f9719887892882d0329d1e2598", - "reference": "b94b2807d85443f9719887892882d0329d1e2598", + "url": "https://api.github.com/repos/guzzle/promises/zipball/2823687acff28b2dbe67b2508a6b300e2c3fa4ce", + "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce", "shasum": "" }, "require": { - "php": ">=5.5" + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" }, "require-dev": { - "symfony/phpunit-bridge": "^4.4 || ^5.1" + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "1.5-dev" + "bamarni-bin": { + "bin-links": true, + "forward-command": false } }, "autoload": { - "files": [ - "src/functions_include.php" - ], "psr-4": { "GuzzleHttp\\Promise\\": "src/" } @@ -200,7 +273,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/1.5.2" + "source": "https://github.com/guzzle/promises/tree/2.5.2" }, "funding": [ { @@ -216,36 +289,39 @@ "type": "tidelift" } ], - "time": "2022-08-28T14:55:35+00:00" + "time": "2026-08-05T19:30:54+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.4.1", + "version": "2.13.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "69568e4293f4fa993f3b0e51c9723e1e17c41379" + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/69568e4293f4fa993f3b0e51c9723e1e17c41379", - "reference": "69568e4293f4fa993f3b0e51c9723e1e17c41379", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4", + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", "psr/http-factory": "^1.0", - "psr/http-message": "^1.0", - "ralouphie/getallheaders": "^3.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.1", - "http-interop/http-factory-tests": "^0.9", - "phpunit/phpunit": "^8.5.29 || ^9.5.23" + "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" @@ -255,9 +331,6 @@ "bamarni-bin": { "bin-links": true, "forward-command": false - }, - "branch-alias": { - "dev-master": "2.4-dev" } }, "autoload": { @@ -319,7 +392,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.4.1" + "source": "https://github.com/guzzle/psr7/tree/2.13.0" }, "funding": [ { @@ -335,25 +408,25 @@ "type": "tidelift" } ], - "time": "2022-08-28T14:45:39+00:00" + "time": "2026-07-16T22:23:49+00:00" }, { "name": "psr/http-client", - "version": "1.0.1", + "version": "1.0.3", "source": { "type": "git", "url": "https://github.com/php-fig/http-client.git", - "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621" + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-client/zipball/2dfb5f6c5eff0e91e20e913f8c5452ed95b86621", - "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621", + "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" + "psr/http-message": "^1.0 || ^2.0" }, "type": "library", "extra": { @@ -373,7 +446,7 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "homepage": "https://www.php-fig.org/" } ], "description": "Common interface for HTTP clients", @@ -385,27 +458,27 @@ "psr-18" ], "support": { - "source": "https://github.com/php-fig/http-client/tree/master" + "source": "https://github.com/php-fig/http-client" }, - "time": "2020-06-29T06:28:15+00:00" + "time": "2023-09-23T14:17:50+00:00" }, { "name": "psr/http-factory", - "version": "1.0.1", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/php-fig/http-factory.git", - "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be" + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/12ac7fcd07e5b077433f5f2bee95b3a771bf61be", - "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", "shasum": "" }, "require": { - "php": ">=7.0.0", - "psr/http-message": "^1.0" + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" }, "type": "library", "extra": { @@ -425,10 +498,10 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "homepage": "https://www.php-fig.org/" } ], - "description": "Common interfaces for PSR-7 HTTP message factories", + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", "keywords": [ "factory", "http", @@ -440,31 +513,31 @@ "response" ], "support": { - "source": "https://github.com/php-fig/http-factory/tree/master" + "source": "https://github.com/php-fig/http-factory" }, - "time": "2019-04-30T12:38:16+00:00" + "time": "2024-04-15T12:06:14+00:00" }, { "name": "psr/http-message", - "version": "1.0.1", + "version": "2.0", "source": { "type": "git", "url": "https://github.com/php-fig/http-message.git", - "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", - "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { @@ -479,7 +552,7 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "homepage": "https://www.php-fig.org/" } ], "description": "Common interface for HTTP messages", @@ -493,9 +566,9 @@ "response" ], "support": { - "source": "https://github.com/php-fig/http-message/tree/master" + "source": "https://github.com/php-fig/http-message/tree/2.0" }, - "time": "2016-08-06T14:39:51+00:00" + "time": "2023-04-04T09:54:51+00:00" }, { "name": "ralouphie/getallheaders", @@ -541,31 +614,84 @@ }, "time": "2019-03-08T08:55:37+00:00" }, + { + "name": "svix/svix", + "version": "v1.99.1", + "source": { + "type": "git", + "url": "https://github.com/svix/svix-webhooks.git", + "reference": "6cd12c1d6d19c6d222bab889d08315f6469702c8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/svix/svix-webhooks/zipball/6cd12c1d6d19c6d222bab889d08315f6469702c8", + "reference": "6cd12c1d6d19c6d222bab889d08315f6469702c8", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/guzzle": "^7.0", + "php": ">=8.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": ">=10.0 <=12.9" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svix\\": "php/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Svix", + "email": "support@svix.com", + "homepage": "http://www.svix.com" + } + ], + "description": "Svix PHP Library", + "homepage": "https://www.svix.com", + "keywords": [ + "api", + "webhooks" + ], + "support": { + "issues": "https://github.com/svix/svix-webhooks/issues", + "source": "https://github.com/svix/svix-webhooks/tree/v1.99.1" + }, + "time": "2026-07-23T16:05:36+00:00" + }, { "name": "symfony/deprecation-contracts", - "version": "v3.0.2", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "26954b3d62a6c5fd0ea8a2a00c0353a14978d05c" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/26954b3d62a6c5fd0ea8a2a00c0353a14978d05c", - "reference": "26954b3d62a6c5fd0ea8a2a00c0353a14978d05c", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { - "php": ">=8.0.2" + "php": ">=8.1" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.0-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { @@ -590,7 +716,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.0.2" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -601,47 +727,51 @@ "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": "2022-01-02T09:55:41+00:00" - } - ], - "packages-dev": [ + "time": "2026-06-05T06:23:12+00:00" + }, { - "name": "doctrine/instantiator", - "version": "1.4.1", + "name": "symfony/polyfill-php80", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/doctrine/instantiator.git", - "reference": "10dcfce151b967d20fde1b34ae6640712c3891bc" + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/10dcfce151b967d20fde1b34ae6640712c3891bc", - "reference": "10dcfce151b967d20fde1b34ae6640712c3891bc", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" - }, - "require-dev": { - "doctrine/coding-standard": "^9", - "ext-pdo": "*", - "ext-phar": "*", - "phpbench/phpbench": "^0.16 || ^1", - "phpstan/phpstan": "^1.4", - "phpstan/phpstan-phpunit": "^1", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "vimeo/psalm": "^4.22" + "php": ">=7.2" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" - } + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -649,1330 +779,1819 @@ ], "authors": [ { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "homepage": "https://ocramius.github.io/" + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", "keywords": [ - "constructor", - "instantiate" + "compatibility", + "polyfill", + "portable", + "shim" ], "support": { - "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/1.4.1" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { - "url": "https://www.doctrine-project.org/sponsorship.html", + "url": "https://symfony.com/sponsor", "type": "custom" }, { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2022-03-03T08:28:38+00:00" - }, + "time": "2026-04-10T16:19:22+00:00" + } + ], + "packages-dev": [ { - "name": "myclabs/deep-copy", - "version": "1.11.0", + "name": "amphp/amp", + "version": "v3.1.3", "source": { "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "14daed4296fae74d9e3201d2c4925d1acb7aa614" + "url": "https://github.com/amphp/amp.git", + "reference": "73c38b323ff8d790abf0f76c56fcc892bda4111a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/14daed4296fae74d9e3201d2c4925d1acb7aa614", - "reference": "14daed4296fae74d9e3201d2c4925d1acb7aa614", + "url": "https://api.github.com/repos/amphp/amp/zipball/73c38b323ff8d790abf0f76c56fcc892bda4111a", + "reference": "73c38b323ff8d790abf0f76c56fcc892bda4111a", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3,<3.2.2" + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, "require-dev": { - "doctrine/collections": "^1.6.8", - "doctrine/common": "^2.13.3 || ^3.2.2", - "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { "files": [ - "src/DeepCopy/deep_copy.php" + "src/functions.php", + "src/Future/functions.php", + "src/Internal/functions.php" ], "psr-4": { - "DeepCopy\\": "src/DeepCopy/" + "Amp\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "Create deep copies (clones) of your objects", + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", "keywords": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" ], "support": { - "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.11.0" + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v3.1.3" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://github.com/amphp", + "type": "github" } ], - "time": "2022-03-03T13:19:32+00:00" + "time": "2026-07-19T17:59:20+00:00" }, { - "name": "nikic/php-parser", - "version": "v4.15.1", + "name": "amphp/byte-stream", + "version": "v2.1.2", "source": { "type": "git", - "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "0ef6c55a3f47f89d7a374e6f835197a0b5fcf900" + "url": "https://github.com/amphp/byte-stream.git", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/0ef6c55a3f47f89d7a374e6f835197a0b5fcf900", - "reference": "0ef6c55a3f47f89d7a374e6f835197a0b5fcf900", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46", "shasum": "" }, "require": { - "ext-tokenizer": "*", - "php": ">=7.0" + "amphp/amp": "^3", + "amphp/parser": "^1.1", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2.3" }, "require-dev": { - "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.22.1" }, - "bin": [ - "bin/php-parse" - ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.9-dev" - } - }, "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], "psr-4": { - "PhpParser\\": "lib/PhpParser" + "Amp\\ByteStream\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Nikita Popov" + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" } ], - "description": "A PHP parser written in PHP", + "description": "A stream abstraction to make working with non-blocking I/O simple.", + "homepage": "https://amphp.org/byte-stream", "keywords": [ - "parser", - "php" + "amp", + "amphp", + "async", + "io", + "non-blocking", + "stream" ], "support": { - "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.1" + "issues": "https://github.com/amphp/byte-stream/issues", + "source": "https://github.com/amphp/byte-stream/tree/v2.1.2" }, - "time": "2022-09-04T07:30:47+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T17:10:27+00:00" }, { - "name": "phar-io/manifest", - "version": "2.0.3", + "name": "amphp/cache", + "version": "v2.0.1", "source": { "type": "git", - "url": "https://github.com/phar-io/manifest.git", - "reference": "97803eca37d319dfa7826cc2437fc020857acb53" + "url": "https://github.com/amphp/cache.git", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53", - "reference": "97803eca37d319dfa7826cc2437fc020857acb53", + "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-phar": "*", - "ext-xmlwriter": "*", - "phar-io/version": "^3.0.1", - "php": "^7.2 || ^8.0" + "amphp/amp": "^3", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" }, + "type": "library", "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Amp\\Cache\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" + "name": "Niklas Keller", + "email": "me@kelunik.com" }, { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" } ], - "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "description": "A fiber-aware cache API based on Amp and Revolt.", + "homepage": "https://amphp.org/cache", "support": { - "issues": "https://github.com/phar-io/manifest/issues", - "source": "https://github.com/phar-io/manifest/tree/2.0.3" + "issues": "https://github.com/amphp/cache/issues", + "source": "https://github.com/amphp/cache/tree/v2.0.1" }, - "time": "2021-07-20T11:28:43+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:38:06+00:00" }, { - "name": "phar-io/version", - "version": "3.2.1", + "name": "amphp/dns", + "version": "v2.4.0", "source": { "type": "git", - "url": "https://github.com/phar-io/version.git", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + "url": "https://github.com/amphp/dns.git", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/process": "^2", + "daverandom/libdns": "^2.0.2", + "ext-filter": "*", + "ext-json": "*", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" }, "type": "library", "autoload": { - "classmap": [ - "src/" - ] + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Dns\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" + "name": "Chris Wright", + "email": "addr@daverandom.com" }, { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" } ], - "description": "Library for handling version information and constraints", + "description": "Async DNS resolution for Amp.", + "homepage": "https://github.com/amphp/dns", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "dns", + "resolve" + ], "support": { - "issues": "https://github.com/phar-io/version/issues", - "source": "https://github.com/phar-io/version/tree/3.2.1" + "issues": "https://github.com/amphp/dns/issues", + "source": "https://github.com/amphp/dns/tree/v2.4.0" }, - "time": "2022-02-21T01:04:05+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-01-19T15:43:40+00:00" }, { - "name": "phpunit/php-code-coverage", - "version": "9.2.17", + "name": "amphp/parallel", + "version": "v2.4.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "aa94dc41e8661fe90c7316849907cba3007b10d8" + "url": "https://github.com/amphp/parallel.git", + "reference": "37f5b2754fadc229c00f9416bd68fb8d04529a81" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/aa94dc41e8661fe90c7316849907cba3007b10d8", - "reference": "aa94dc41e8661fe90c7316849907cba3007b10d8", + "url": "https://api.github.com/repos/amphp/parallel/zipball/37f5b2754fadc229c00f9416bd68fb8d04529a81", + "reference": "37f5b2754fadc229c00f9416bd68fb8d04529a81", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-libxml": "*", - "ext-xmlwriter": "*", - "nikic/php-parser": "^4.14", - "php": ">=7.3", - "phpunit/php-file-iterator": "^3.0.3", - "phpunit/php-text-template": "^2.0.2", - "sebastian/code-unit-reverse-lookup": "^2.0.2", - "sebastian/complexity": "^2.0", - "sebastian/environment": "^5.1.2", - "sebastian/lines-of-code": "^1.0.3", - "sebastian/version": "^3.0.1", - "theseer/tokenizer": "^1.2.0" + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/pipeline": "^1", + "amphp/process": "^2", + "amphp/serialization": "^1", + "amphp/socket": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1" }, "require-dev": { - "phpunit/phpunit": "^9.3" - }, - "suggest": { - "ext-pcov": "*", - "ext-xdebug": "*" + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "9.2-dev" - } - }, "autoload": { - "classmap": [ - "src/" - ] + "files": [ + "src/Context/functions.php", + "src/Context/Internal/functions.php", + "src/Ipc/functions.php", + "src/Worker/functions.php" + ], + "psr-4": { + "Amp\\Parallel\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" } ], - "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", - "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "description": "Parallel processing component for Amp.", + "homepage": "https://github.com/amphp/parallel", "keywords": [ - "coverage", - "testing", - "xunit" + "async", + "asynchronous", + "concurrent", + "multi-processing", + "multi-threading" ], "support": { - "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.17" + "issues": "https://github.com/amphp/parallel/issues", + "source": "https://github.com/amphp/parallel/tree/v2.4.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/amphp", "type": "github" } ], - "time": "2022-08-30T12:24:04+00:00" + "time": "2026-05-16T16:54:01+00:00" }, { - "name": "phpunit/php-file-iterator", - "version": "3.0.6", + "name": "amphp/parser", + "version": "v1.1.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" + "url": "https://github.com/amphp/parser.git", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", - "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=7.4" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Amp\\Parser\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" } ], - "description": "FilterIterator implementation that filters files based on a list of suffixes.", - "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "description": "A generator parser to make streaming parsers simple.", + "homepage": "https://github.com/amphp/parser", "keywords": [ - "filesystem", - "iterator" + "async", + "non-blocking", + "parser", + "stream" ], "support": { - "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + "issues": "https://github.com/amphp/parser/issues", + "source": "https://github.com/amphp/parser/tree/v1.1.1" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/amphp", "type": "github" } ], - "time": "2021-12-02T12:48:52+00:00" + "time": "2024-03-21T19:16:53+00:00" }, { - "name": "phpunit/php-invoker", - "version": "3.1.1", + "name": "amphp/pipeline", + "version": "v1.2.7", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + "url": "https://github.com/amphp/pipeline.git", + "reference": "cf2d67696c2015ea7c7fd8f6ec5f8ed7c2d17c17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/cf2d67696c2015ea7c7fd8f6ec5f8ed7c2d17c17", + "reference": "cf2d67696c2015ea7c7fd8f6ec5f8ed7c2d17c17", "shasum": "" }, "require": { - "php": ">=7.3" + "amphp/amp": "^3", + "php": ">=8.1", + "revolt/event-loop": "^1" }, "require-dev": { - "ext-pcntl": "*", - "phpunit/phpunit": "^9.3" - }, - "suggest": { - "ext-pcntl": "*" + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.1-dev" - } - }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Amp\\Pipeline\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" } ], - "description": "Invoke callables with a timeout", - "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "description": "Asynchronous iterators and operators.", + "homepage": "https://amphp.org/pipeline", "keywords": [ - "process" + "amp", + "amphp", + "async", + "io", + "iterator", + "non-blocking" ], "support": { - "issues": "https://github.com/sebastianbergmann/php-invoker/issues", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + "issues": "https://github.com/amphp/pipeline/issues", + "source": "https://github.com/amphp/pipeline/tree/v1.2.7" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/amphp", "type": "github" } ], - "time": "2020-09-28T05:58:55+00:00" + "time": "2026-07-26T14:50:43+00:00" }, { - "name": "phpunit/php-text-template", - "version": "2.0.4", + "name": "amphp/process", + "version": "v2.1.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + "url": "https://github.com/amphp/process.git", + "reference": "583959df17d00304ad7b0b32285373f985935643" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "url": "https://api.github.com/repos/amphp/process/zipball/583959df17d00304ad7b0b32285373f985935643", + "reference": "583959df17d00304ad7b0b32285373f985935643", "shasum": "" }, "require": { - "php": ">=7.3" + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" - ] + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Process\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" } ], - "description": "Simple template engine.", - "homepage": "https://github.com/sebastianbergmann/php-text-template/", - "keywords": [ - "template" - ], + "description": "A fiber-aware process manager based on Amp and Revolt.", + "homepage": "https://amphp.org/process", "support": { - "issues": "https://github.com/sebastianbergmann/php-text-template/issues", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + "issues": "https://github.com/amphp/process/issues", + "source": "https://github.com/amphp/process/tree/v2.1.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/amphp", "type": "github" } ], - "time": "2020-10-26T05:33:50+00:00" + "time": "2026-05-31T15:11:55+00:00" }, { - "name": "phpunit/php-timer", - "version": "5.0.3", + "name": "amphp/serialization", + "version": "v1.1.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + "url": "https://github.com/amphp/serialization.git", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=7.4" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "amphp/php-cs-fixer-config": "^2", + "ext-json": "*", + "ext-zlib": "*", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" - ] + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Serialization\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" } ], - "description": "Utility class for timing", - "homepage": "https://github.com/sebastianbergmann/php-timer/", + "description": "Serialization tools for IPC and data storage in PHP.", + "homepage": "https://github.com/amphp/serialization", "keywords": [ - "timer" + "async", + "asynchronous", + "serialization", + "serialize" ], "support": { - "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + "issues": "https://github.com/amphp/serialization/issues", + "source": "https://github.com/amphp/serialization/tree/v1.1.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/amphp", "type": "github" } ], - "time": "2020-10-26T13:16:10+00:00" + "time": "2026-04-05T15:59:53+00:00" }, { - "name": "phpunit/phpunit", - "version": "9.5.24", + "name": "amphp/socket", + "version": "v2.4.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "d0aa6097bef9fd42458a9b3c49da32c6ce6129c5" + "url": "https://github.com/amphp/socket.git", + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d0aa6097bef9fd42458a9b3c49da32c6ce6129c5", - "reference": "d0aa6097bef9fd42458a9b3c49da32c6ce6129c5", + "url": "https://api.github.com/repos/amphp/socket/zipball/dadb63c5d3179fd83803e29dfeac27350e619314", + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.3.1", - "ext-dom": "*", - "ext-json": "*", - "ext-libxml": "*", - "ext-mbstring": "*", - "ext-xml": "*", - "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.10.1", - "phar-io/manifest": "^2.0.3", - "phar-io/version": "^3.0.2", - "php": ">=7.3", - "phpunit/php-code-coverage": "^9.2.13", - "phpunit/php-file-iterator": "^3.0.5", - "phpunit/php-invoker": "^3.1.1", - "phpunit/php-text-template": "^2.0.3", - "phpunit/php-timer": "^5.0.2", - "sebastian/cli-parser": "^1.0.1", - "sebastian/code-unit": "^1.0.6", - "sebastian/comparator": "^4.0.5", - "sebastian/diff": "^4.0.3", - "sebastian/environment": "^5.1.3", - "sebastian/exporter": "^4.0.3", - "sebastian/global-state": "^5.0.1", - "sebastian/object-enumerator": "^4.0.3", - "sebastian/resource-operations": "^3.0.3", - "sebastian/type": "^3.1", - "sebastian/version": "^3.0.2" + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/dns": "^2", + "ext-openssl": "*", + "kelunik/certificate": "^1.1", + "league/uri": "^7", + "league/uri-interfaces": "^7", + "php": ">=8.1", + "revolt/event-loop": "^1" }, - "suggest": { - "ext-soap": "*", - "ext-xdebug": "*" + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/process": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" }, - "bin": [ - "phpunit" - ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "9.5-dev" - } - }, "autoload": { "files": [ - "src/Framework/Assert/Functions.php" + "src/functions.php", + "src/Internal/functions.php", + "src/SocketAddress/functions.php" ], - "classmap": [ - "src/" - ] + "psr-4": { + "Amp\\Socket\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" } ], - "description": "The PHP Unit Testing framework.", - "homepage": "https://phpunit.de/", + "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", + "homepage": "https://github.com/amphp/socket", "keywords": [ - "phpunit", - "testing", - "xunit" + "amp", + "async", + "encryption", + "non-blocking", + "sockets", + "tcp", + "tls" ], "support": { - "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.24" + "issues": "https://github.com/amphp/socket/issues", + "source": "https://github.com/amphp/socket/tree/v2.4.0" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/amphp", "type": "github" } ], - "time": "2022-08-30T07:42:16+00:00" + "time": "2026-04-19T15:09:56+00:00" }, { - "name": "sebastian/cli-parser", - "version": "1.0.1", + "name": "amphp/sync", + "version": "v2.3.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2" + "url": "https://github.com/amphp/sync.git", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/442e7c7e687e42adc03470c7b668bc4b2402c0b2", - "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2", + "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1", "shasum": "" }, "require": { - "php": ">=7.3" + "amphp/amp": "^3", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" - ] + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Sync\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" } ], - "description": "Library for parsing CLI options", - "homepage": "https://github.com/sebastianbergmann/cli-parser", + "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", + "homepage": "https://github.com/amphp/sync", + "keywords": [ + "async", + "asynchronous", + "mutex", + "semaphore", + "synchronization" + ], "support": { - "issues": "https://github.com/sebastianbergmann/cli-parser/issues", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.1" + "issues": "https://github.com/amphp/sync/issues", + "source": "https://github.com/amphp/sync/tree/v2.3.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/amphp", "type": "github" } ], - "time": "2020-09-28T06:08:49+00:00" + "time": "2024-08-03T19:31:26+00:00" }, { - "name": "sebastian/code-unit", - "version": "1.0.8", + "name": "composer/pcre", + "version": "3.4.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", "shasum": "" }, "require": { - "php": ">=7.3" + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" }, "type": "library", "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "3.x-dev" } }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Composer\\Pcre\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" } ], - "description": "Collection of value objects that represent the PHP code units", - "homepage": "https://github.com/sebastianbergmann/code-unit", + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], "support": { - "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } ], - "time": "2020-10-26T13:08:54+00:00" + "time": "2026-06-07T11:47:49+00:00" }, { - "name": "sebastian/code-unit-reverse-lookup", - "version": "2.0.3", + "name": "composer/semver", + "version": "3.4.4", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", "shasum": "" }, "require": { - "php": ">=7.3" + "php": "^5.3.2 || ^7.0 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "3.x-dev" } }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Composer\\Semver\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "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": "Looks up which function or method a line of code belongs to", - "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], "support": { - "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + "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://github.com/sebastianbergmann", + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", "type": "github" } ], - "time": "2020-09-28T05:30:19+00:00" + "time": "2025-08-20T19:15:30+00:00" }, { - "name": "sebastian/comparator", - "version": "4.0.8", + "name": "composer/xdebug-handler", + "version": "3.0.5", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a" + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/diff": "^4.0", - "sebastian/exporter": "^4.0" + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "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" + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" } ], - "description": "Provides the functionality to compare PHP values for equality", - "homepage": "https://github.com/sebastianbergmann/comparator", + "description": "Restarts a process without Xdebug.", "keywords": [ - "comparator", - "compare", - "equality" + "Xdebug", + "performance" ], "support": { - "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8" + "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://github.com/sebastianbergmann", + "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": "2022-09-14T12:41:17+00:00" + "time": "2024-05-06T16:37:16+00:00" }, { - "name": "sebastian/complexity", - "version": "2.0.2", + "name": "danog/advanced-json-rpc", + "version": "v3.2.3", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "739b35e53379900cc9ac327b2147867b8b6efd88" + "url": "https://github.com/danog/php-advanced-json-rpc.git", + "reference": "ae703ea7b4811797a10590b6078de05b3b33dd91" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/739b35e53379900cc9ac327b2147867b8b6efd88", - "reference": "739b35e53379900cc9ac327b2147867b8b6efd88", + "url": "https://api.github.com/repos/danog/php-advanced-json-rpc/zipball/ae703ea7b4811797a10590b6078de05b3b33dd91", + "reference": "ae703ea7b4811797a10590b6078de05b3b33dd91", "shasum": "" }, "require": { - "nikic/php-parser": "^4.7", - "php": ">=7.3" + "netresearch/jsonmapper": "^5", + "php": ">=8.1", + "phpdocumentor/reflection-docblock": "^4.3.4 || ^5.0.0 || ^6" + }, + "replace": { + "felixfbecker/php-advanced-json-rpc": "^3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^9" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "AdvancedJsonRpc\\": "lib/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "ISC" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Felix Becker", + "email": "felix.b@outlook.com" + }, + { + "name": "Daniil Gentili", + "email": "daniil@daniil.it" } ], - "description": "Library for calculating the complexity of PHP code units", - "homepage": "https://github.com/sebastianbergmann/complexity", + "description": "A more advanced JSONRPC implementation", "support": { - "issues": "https://github.com/sebastianbergmann/complexity/issues", - "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.2" + "issues": "https://github.com/danog/php-advanced-json-rpc/issues", + "source": "https://github.com/danog/php-advanced-json-rpc/tree/v3.2.3" }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2020-10-26T15:52:27+00:00" + "time": "2026-01-12T21:07:10+00:00" }, { - "name": "sebastian/diff", - "version": "4.0.4", + "name": "daverandom/libdns", + "version": "v2.1.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d" + "url": "https://github.com/DaveRandom/LibDNS.git", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/3461e3fccc7cfdfc2720be910d3bd73c69be590d", - "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d", + "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", "shasum": "" }, "require": { - "php": ">=7.3" + "ext-ctype": "*", + "php": ">=7.1" }, - "require-dev": { - "phpunit/phpunit": "^9.3", - "symfony/process": "^4.2 || ^5" + "suggest": { + "ext-intl": "Required for IDN support" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" - ] + "files": [ + "src/functions.php" + ], + "psr-4": { + "LibDNS\\": "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" - } + "MIT" ], - "description": "Diff implementation", - "homepage": "https://github.com/sebastianbergmann/diff", + "description": "DNS protocol implementation written in pure PHP", "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" + "dns" ], "support": { - "issues": "https://github.com/sebastianbergmann/diff/issues", - "source": "https://github.com/sebastianbergmann/diff/tree/4.0.4" + "issues": "https://github.com/DaveRandom/LibDNS/issues", + "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "time": "2024-04-12T12:12:48+00:00" + }, + { + "name": "dnoegel/php-xdg-base-dir", + "version": "v0.1.1", + "source": { + "type": "git", + "url": "https://github.com/dnoegel/php-xdg-base-dir.git", + "reference": "8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dnoegel/php-xdg-base-dir/zipball/8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd", + "reference": "8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "require-dev": { + "phpunit/phpunit": "~7.0|~6.0|~5.0|~4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "XdgBaseDir\\": "src/" } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" ], - "time": "2020-10-26T13:10:38+00:00" + "description": "implementation of xdg base directory specification for php", + "support": { + "issues": "https://github.com/dnoegel/php-xdg-base-dir/issues", + "source": "https://github.com/dnoegel/php-xdg-base-dir/tree/v0.1.1" + }, + "time": "2019-12-04T15:06:13+00:00" }, { - "name": "sebastian/environment", - "version": "5.1.4", + "name": "doctrine/deprecations", + "version": "1.1.6", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7" + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/1b5dff7bb151a4db11d49d90e5408e4e938270f7", - "reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { - "php": ">=7.3" + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" }, "suggest": { - "ext-posix": "*" + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "felixfbecker/language-server-protocol", + "version": "v1.5.3", + "source": { + "type": "git", + "url": "https://github.com/felixfbecker/php-language-server-protocol.git", + "reference": "a9e113dbc7d849e35b8776da39edaf4313b7b6c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/felixfbecker/php-language-server-protocol/zipball/a9e113dbc7d849e35b8776da39edaf4313b7b6c9", + "reference": "a9e113dbc7d849e35b8776da39edaf4313b7b6c9", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpstan/phpstan": "*", + "squizlabs/php_codesniffer": "^3.1", + "vimeo/psalm": "^4.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.1-dev" + "dev-master": "1.x-dev" } }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "LanguageServerProtocol\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "ISC" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Felix Becker", + "email": "felix.b@outlook.com" } ], - "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "http://www.github.com/sebastianbergmann/environment", + "description": "PHP classes for the Language Server Protocol", "keywords": [ - "Xdebug", - "environment", - "hhvm" + "language", + "microsoft", + "php", + "server" ], "support": { - "issues": "https://github.com/sebastianbergmann/environment/issues", - "source": "https://github.com/sebastianbergmann/environment/tree/5.1.4" + "issues": "https://github.com/felixfbecker/php-language-server-protocol/issues", + "source": "https://github.com/felixfbecker/php-language-server-protocol/tree/v1.5.3" }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2022-04-03T09:37:03+00:00" + "time": "2024-04-30T00:40:11+00:00" }, { - "name": "sebastian/exporter", - "version": "4.0.5", + "name": "fidry/cpu-core-counter", + "version": "1.3.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d" + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d", - "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/recursion-context": "^4.0" + "php": "^7.2 || ^8.0" }, "require-dev": { - "ext-mbstring": "*", - "phpunit/phpunit": "^9.3" + "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", - "extra": { - "branch-alias": { - "dev-master": "4.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "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" + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" } ], - "description": "Provides the functionality to export PHP variables for visualization", - "homepage": "https://www.github.com/sebastianbergmann/exporter", + "description": "Tiny utility to get the number of CPU cores.", "keywords": [ - "export", - "exporter" + "CPU", + "core" ], "support": { - "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.5" + "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/sebastianbergmann", + "url": "https://github.com/theofidry", "type": "github" } ], - "time": "2022-09-14T06:03:37+00:00" + "time": "2025-08-14T07:29:31+00:00" }, { - "name": "sebastian/global-state", - "version": "5.0.5", + "name": "kelunik/certificate", + "version": "v1.1.3", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2" + "url": "https://github.com/kelunik/certificate.git", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/0ca8db5a5fc9c8646244e629625ac486fa286bf2", - "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2", + "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "ext-openssl": "*", + "php": ">=7.0" }, "require-dev": { - "ext-dom": "*", - "phpunit/phpunit": "^9.3" + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^6 | 7 | ^8 | ^9" }, - "suggest": { - "ext-uopz": "*" + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Kelunik\\Certificate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Access certificate details and transform between different formats.", + "keywords": [ + "DER", + "certificate", + "certificates", + "openssl", + "pem", + "x509" + ], + "support": { + "issues": "https://github.com/kelunik/certificate/issues", + "source": "https://github.com/kelunik/certificate/tree/v1.1.3" + }, + "time": "2023-02-03T21:26:53+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "7.x-dev" } }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "League\\Uri\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" } ], - "description": "Snapshotting of global state", - "homepage": "http://www.github.com/sebastianbergmann/global-state", + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", "keywords": [ - "global state" + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" ], "support": { - "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.5" + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/sponsors/nyamsprod", "type": "github" } ], - "time": "2022-02-14T08:28:10+00:00" + "time": "2026-03-15T20:22:25+00:00" }, { - "name": "sebastian/lines-of-code", - "version": "1.0.3", + "name": "league/uri-interfaces", + "version": "7.8.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc" + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/c1c2e997aa3146983ed888ad08b15470a2e22ecc", - "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", "shasum": "" }, "require": { - "nikic/php-parser": "^4.6", - "php": ">=7.3" + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" }, - "require-dev": { - "phpunit/phpunit": "^9.3" + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "7.x-dev" } }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "League\\Uri\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" } ], - "description": "Library for counting the lines of code in PHP source code", - "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], "support": { - "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.3" + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/sponsors/nyamsprod", "type": "github" } ], - "time": "2020-11-28T06:42:11+00:00" + "time": "2026-03-08T20:05:35+00:00" }, { - "name": "sebastian/object-enumerator", - "version": "4.0.4", + "name": "myclabs/deep-copy", + "version": "1.13.4", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + "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": "netresearch/jsonmapper", + "version": "v5.0.1", + "source": { + "type": "git", + "url": "https://github.com/cweiske/jsonmapper.git", + "reference": "980674efdda65913492d29a8fd51c82270dd37bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/980674efdda65913492d29a8fd51c82270dd37bb", + "reference": "980674efdda65913492d29a8fd51c82270dd37bb", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-spl": "*", + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "~7.5 || ~8.0 || ~9.0 || ~10.0", + "squizlabs/php_codesniffer": "~3.5" + }, + "type": "library", + "autoload": { + "psr-0": { + "JsonMapper": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "OSL-3.0" + ], + "authors": [ + { + "name": "Christian Weiske", + "email": "cweiske@cweiske.de", + "homepage": "http://github.com/cweiske/jsonmapper/", + "role": "Developer" + } + ], + "description": "Map nested JSON structures onto PHP classes", + "support": { + "email": "cweiske@cweiske.de", + "issues": "https://github.com/cweiske/jsonmapper/issues", + "source": "https://github.com/cweiske/jsonmapper/tree/v5.0.1" + }, + "time": "2026-02-22T16:28:03+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "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.8.0" + }, + "time": "2026-07-04T14:30:18+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": "4.0-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { @@ -1985,277 +2604,2783 @@ "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" + "email": "sebastian@phpunit.de", + "role": "Developer" } ], - "description": "Traverses array structures and object graphs to enumerate all referenced objects", - "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", "support": { - "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/theseer", "type": "github" } ], - "time": "2020-10-26T13:12:34+00:00" + "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": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + }, + "time": "2026-03-18T20:49:53+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + }, + "time": "2026-01-06T21:53:42+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" + }, + "time": "2026-07-08T07:01:06+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.64", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "0e8c1d19cea35ad97d4887f363d07c78e30fbf06" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0e8c1d19cea35ad97d4887f363d07c78e30fbf06", + "reference": "0e8c1d19cea35ad97d4887f363d07c78e30fbf06", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "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.64" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:50:35+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/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": "revolt/event-loop", + "version": "v1.0.9", + "source": { + "type": "git", + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "44061cf513e53c6200372fc935ac42271566295d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/44061cf513e53c6200372fc935ac42271566295d", + "reference": "44061cf513e53c6200372fc935ac42271566295d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Revolt\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "ceesjank@gmail.com" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Rock-solid event loop for concurrent PHP applications.", + "keywords": [ + "async", + "asynchronous", + "concurrency", + "event", + "event-loop", + "non-blocking", + "scheduler" + ], + "support": { + "issues": "https://github.com/revoltphp/event-loop/issues", + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.9" + }, + "time": "2026-05-16T17:55:38+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": "spatie/array-to-xml", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/spatie/array-to-xml.git", + "reference": "88b2f3852a922dd73177a68938f8eb2ec70c7224" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/array-to-xml/zipball/88b2f3852a922dd73177a68938f8eb2ec70c7224", + "reference": "88b2f3852a922dd73177a68938f8eb2ec70c7224", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": "^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.2", + "pestphp/pest": "^1.21", + "spatie/pest-plugin-snapshots": "^1.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Spatie\\ArrayToXml\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://freek.dev", + "role": "Developer" + } + ], + "description": "Convert an array to xml", + "homepage": "https://github.com/spatie/array-to-xml", + "keywords": [ + "array", + "convert", + "xml" + ], + "support": { + "source": "https://github.com/spatie/array-to-xml/tree/3.4.4" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-12-15T09:00:41+00:00" + }, + { + "name": "symfony/console", + "version": "v8.1.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "535e18a1b8925f6c01a55b171d157ab66c2ace15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/535e18a1b8925f6c01a55b171d157ab66c2ace15", + "reference": "535e18a1b8925f6c01a55b171d157ab66c2ace15", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4.6|^8.0.6" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.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/v8.1.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-07-27T13:58:19+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v8.1.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "17856b7a222664a26a5ea1cb06ee0721c2438217" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/17856b7a222664a26a5ea1cb06ee0721c2438217", + "reference": "17856b7a222664a26a5ea1cb06ee0721c2438217", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.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/v8.1.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-07-22T15:42:13+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.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "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.41.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-07-28T08:25:59+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": "sebastian/object-reflector", - "version": "2.0.4", + "name": "symfony/polyfill-php84", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.3" + "php": ">=7.2" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "2.0-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, "classmap": [ - "src/" + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Allows reflection of object attributes, including inherited and non-public ones", - "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], "support": { - "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "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": "2020-10-26T13:14:26+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { - "name": "sebastian/recursion-context", - "version": "4.0.4", + "name": "symfony/polyfill-php85", + "version": "v1.41.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172" + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/cd9d8cf3c5804de4341c283ed787f099f5506172", - "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", "shasum": "" }, "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.3" + "php": ">=7.2" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "4.0-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, "classmap": [ - "src/" + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { - "name": "Adam Harvey", - "email": "aharvey@php.net" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Provides functionality to recursively process PHP variables", - "homepage": "http://www.github.com/sebastianbergmann/recursion-context", + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], "support": { - "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.4" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "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": "2020-10-26T13:17:30+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { - "name": "sebastian/resource-operations", - "version": "3.0.3", + "name": "symfony/service-contracts", + "version": "v3.7.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8" + "url": "https://github.com/symfony/service-contracts.git", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", - "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" }, - "require-dev": { - "phpunit/phpunit": "^9.0" + "conflict": { + "ext-psr": "<1.1|>=2" }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "3.7-dev" } }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Provides a list of PHP built-in functions that operate on resources", - "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], "support": { - "issues": "https://github.com/sebastianbergmann/resource-operations/issues", - "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.3" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "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": "2020-09-28T06:45:17+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { - "name": "sebastian/type", - "version": "3.2.0", + "name": "symfony/string", + "version": "v8.1.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/type.git", - "reference": "fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e" + "url": "https://github.com/symfony/string.git", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e", - "reference": "fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e", + "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" }, "require-dev": { - "phpunit/phpunit": "^9.5" + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.2-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Collection of value objects that represent the types of the PHP type system", - "homepage": "https://github.com/sebastianbergmann/type", + "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": { - "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/3.2.0" + "source": "https://github.com/symfony/string/tree/v8.1.2" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "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": "2022-09-12T14:47:03+00:00" + "time": "2026-07-28T07:35:25+00:00" }, { - "name": "sebastian/version", - "version": "3.0.2", + "name": "theseer/tokenizer", + "version": "1.3.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c6c1022351a901512170118436c764e473f6de8c" + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", - "reference": "c6c1022351a901512170118436c764e473f6de8c", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { - "php": ">=7.3" + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0-dev" - } - }, "autoload": { "classmap": [ "src/" @@ -2267,130 +5392,207 @@ ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" } ], - "description": "Library that helps with managing the version number of Git-hosted PHP projects", - "homepage": "https://github.com/sebastianbergmann/version", + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { - "issues": "https://github.com/sebastianbergmann/version/issues", - "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/theseer", "type": "github" } ], - "time": "2020-09-28T06:39:44+00:00" + "time": "2025-11-17T20:03:58+00:00" }, { - "name": "squizlabs/php_codesniffer", - "version": "3.7.1", + "name": "vimeo/psalm", + "version": "6.16.1", "source": { "type": "git", - "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "1359e176e9307e906dc3d890bcc9603ff6d90619" + "url": "https://github.com/vimeo/psalm.git", + "reference": "f1f5de594dc76faf8784e02d3dc4716c91c6f6ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/1359e176e9307e906dc3d890bcc9603ff6d90619", - "reference": "1359e176e9307e906dc3d890bcc9603ff6d90619", + "url": "https://api.github.com/repos/vimeo/psalm/zipball/f1f5de594dc76faf8784e02d3dc4716c91c6f6ac", + "reference": "f1f5de594dc76faf8784e02d3dc4716c91c6f6ac", "shasum": "" }, "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/parallel": "^2.3", + "composer-runtime-api": "^2", + "composer/semver": "^1.4 || ^2.0 || ^3.0", + "composer/xdebug-handler": "^2.0 || ^3.0", + "danog/advanced-json-rpc": "^3.1", + "dnoegel/php-xdg-base-dir": "^0.1.1", + "ext-ctype": "*", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", "ext-simplexml": "*", "ext-tokenizer": "*", - "ext-xmlwriter": "*", - "php": ">=5.4.0" + "felixfbecker/language-server-protocol": "^1.5.3", + "fidry/cpu-core-counter": "^0.4.1 || ^0.5.1 || ^1.0.0", + "netresearch/jsonmapper": "^5.0", + "nikic/php-parser": "^5.0.0", + "php": "~8.1.31 || ~8.2.27 || ~8.3.16 || ~8.4.3 || ~8.5.0", + "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0", + "spatie/array-to-xml": "^2.17.0 || ^3.0", + "symfony/console": "^6.0 || ^7.0 || ^8.0", + "symfony/filesystem": "~6.3.12 || ~6.4.3 || ^7.0.3 || ^8.0", + "symfony/polyfill-php84": "^1.31.0" + }, + "provide": { + "psalm/psalm": "self.version" }, "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" + "amphp/phpunit-util": "^3", + "bamarni/composer-bin-plugin": "^1.4", + "brianium/paratest": "^6.9", + "danog/class-finder": "^0.4.8", + "dg/bypass-finals": "^1.5", + "ext-curl": "*", + "mockery/mockery": "^1.5", + "nunomaduro/mock-final-classes": "^1.1", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpdoc-parser": "^1.6", + "phpunit/phpunit": "^9.6", + "psalm/plugin-mockery": "^1.1", + "psalm/plugin-phpunit": "^0.19", + "slevomat/coding-standard": "^8.4", + "squizlabs/php_codesniffer": "^3.6", + "symfony/process": "^6.0 || ^7.0 || ^8.0" + }, + "suggest": { + "ext-curl": "In order to send data to shepherd", + "ext-igbinary": "^2.0.5 is required, used to serialize caching data" }, "bin": [ - "bin/phpcs", - "bin/phpcbf" + "psalm", + "psalm-language-server", + "psalm-plugin", + "psalm-refactor", + "psalm-review", + "psalter" ], - "type": "library", + "type": "project", "extra": { "branch-alias": { - "dev-master": "3.x-dev" + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev", + "dev-3.x": "3.x-dev", + "dev-4.x": "4.x-dev", + "dev-5.x": "5.x-dev", + "dev-6.x": "6.x-dev", + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psalm\\": "src/Psalm/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Greg Sherwood", - "role": "lead" + "name": "Matthew Brown" + }, + { + "name": "Daniil Gentili", + "email": "daniil@daniil.it" } ], - "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", - "homepage": "https://github.com/squizlabs/PHP_CodeSniffer", + "description": "A static analysis tool for finding errors in PHP applications", "keywords": [ - "phpcs", - "standards" + "code", + "inspection", + "php", + "static analysis" ], "support": { - "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues", - "source": "https://github.com/squizlabs/PHP_CodeSniffer", - "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" + "docs": "https://psalm.dev/docs", + "issues": "https://github.com/vimeo/psalm/issues", + "source": "https://github.com/vimeo/psalm" }, - "time": "2022-06-18T07:21:10+00:00" + "time": "2026-03-19T10:56:09+00:00" }, { - "name": "theseer/tokenizer", - "version": "1.2.1", + "name": "webmozart/assert", + "version": "2.4.1", "source": { "type": "git", - "url": "https://github.com/theseer/tokenizer.git", - "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e" + "url": "https://github.com/webmozarts/assert.git", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/34a41e998c2183e22995f158c581e7b5e755ab9e", - "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-tokenizer": "*", - "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" }, "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Webmozart\\Assert\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], - "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], "support": { - "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.1" + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.1" }, - "funding": [ - { - "url": "https://github.com/theseer", - "type": "github" - } - ], - "time": "2021-07-28T10:34:58+00:00" + "time": "2026-06-15T15:31:57+00:00" } ], "aliases": [], @@ -2399,7 +5601,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^8.0" + "php": "^8.1" }, "platform-dev": {}, "plugin-api-version": "2.6.0" diff --git a/package-lock.json b/package-lock.json index dc179e80..35c7c127 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "devDependencies": { "@prettier/plugin-php": "^0.25.0", "@seamapi/blueprint": "^1.2.0", + "@seamapi/fake-seam-connect": "1.86.0", "@seamapi/smith": "^1.1.0", "@seamapi/types": "1.985.0", "change-case": "^5.4.4", @@ -819,6 +820,42 @@ "npm": ">=10.0.0" } }, + "node_modules/@seamapi/fake-devicedb": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@seamapi/fake-devicedb/-/fake-devicedb-1.6.1.tgz", + "integrity": "sha512-w4Ar/s2kPnE5ExJSlpD3sKL8lkF+rLHRROArIRxtR2reHfnDSVwnDt9TzBYkHqgMP/x4o7LUzlRRpobn2xn24A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.12.0", + "npm": ">= 9.0.0" + }, + "optionalDependencies": { + "zod": "^3.21.4", + "zustand": "^4.3.7", + "zustand-hoist": "^2.0.0" + } + }, + "node_modules/@seamapi/fake-seam-connect": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@seamapi/fake-seam-connect/-/fake-seam-connect-1.86.0.tgz", + "integrity": "sha512-iO5fwtSIPhzmIiLxrFDtCYF/7HTb+ywcGmc3WzWt8Sr0bLQlmwCyTJ8YYZy++Hx0FsnNpa5AmlJuJGul9Y5gZA==", + "dev": true, + "license": "MIT", + "bin": { + "fake-seam-connect": "dist/server.js" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">= 9.0.0" + }, + "optionalDependencies": { + "@seamapi/fake-devicedb": ">=1.0.0-rc.0", + "zustand": "^4.3.7", + "zustand-hoist": "^2.0.0" + } + }, "node_modules/@seamapi/smith": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@seamapi/smith/-/smith-1.1.0.tgz", @@ -4881,6 +4918,18 @@ "license": "MIT", "peer": true }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -5866,6 +5915,17 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "dev": true, + "license": "MIT", + "optional": true, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/ware": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ware/-/ware-1.3.0.tgz", @@ -6065,6 +6125,51 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/zustand-hoist": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/zustand-hoist/-/zustand-hoist-2.0.1.tgz", + "integrity": "sha512-Lhvv3RlLQx1NSUtuhk8jegXe1Wyav9RAOnLd4CRs1SbB5qcFoarAGQTE43vIxXizrm1UQJl1q5uRbOZuXGXGpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.12.0", + "npm": ">= 9.0.0" + }, + "peerDependencies": { + "zustand": ">=4.0.0" + } } } } diff --git a/package.json b/package.json index da30a5a8..c1f2a9ed 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "lint": "eslint .", "postlint": "prettier --check --ignore-path .gitignore --ignore-path .prettierignore .", "format": "prettier --write --ignore-path .gitignore --ignore-path .prettierignore .", - "preformat": "eslint --fix ." + "preformat": "eslint --fix .", + "start": "fake-seam-connect --seed" }, "engines": { "node": ">=22.11.0", @@ -32,6 +33,7 @@ "devDependencies": { "@prettier/plugin-php": "^0.25.0", "@seamapi/blueprint": "^1.2.0", + "@seamapi/fake-seam-connect": "1.86.0", "@seamapi/smith": "^1.1.0", "@seamapi/types": "1.985.0", "change-case": "^5.4.4", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e953736f..f29c67a2 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,9 +1,9 @@ tests - + src - + + + src/Resources + src/Routes + + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 00000000..f3cb128b --- /dev/null +++ b/psalm.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/src/Auth.php b/src/Auth.php new file mode 100644 index 00000000..127ae84e --- /dev/null +++ b/src/Auth.php @@ -0,0 +1,149 @@ + + */ + public static function get_auth_headers( + ?string $api_key = null, + ?string $personal_access_token = null, + ?string $workspace_id = null, + ): array { + // The environment is only consulted when no credential was passed at + // all, so an explicit personal access token is not second guessed by + // a stray SEAM_API_KEY. + if ($api_key === null && $personal_access_token === null) { + $api_key = Options::get_env("SEAM_API_KEY"); + } + + if ( + Options::is_seam_options_with_api_key( + $api_key, + $personal_access_token, + ) + ) { + return self::get_auth_headers_for_api_key($api_key); + } + + if ( + Options::is_seam_options_with_personal_access_token( + $personal_access_token, + $api_key, + $workspace_id, + ) + ) { + return self::get_auth_headers_for_personal_access_token( + $personal_access_token, + $workspace_id, + ); + } + + throw new InvalidOptionsError( + "Must specify an api_key or personal_access_token. " . + "Attempted reading configuration from the environment, " . + "but the environment variable SEAM_API_KEY is not set.", + ); + } + + /** + * @return array + */ + public static function get_auth_headers_for_api_key(string $api_key): array + { + if (Token::is_client_session_token($api_key)) { + throw new InvalidTokenError( + "A Client Session Token cannot be used as an api_key", + ); + } + + if (Token::is_jwt($api_key)) { + throw new InvalidTokenError("A JWT cannot be used as an api_key"); + } + + if (Token::is_access_token($api_key)) { + throw new InvalidTokenError( + "An Access Token cannot be used as an api_key", + ); + } + + if (Token::is_publishable_key($api_key)) { + throw new InvalidTokenError( + "A Publishable Key cannot be used as an api_key", + ); + } + + if (!Token::is_seam_token($api_key)) { + throw new InvalidTokenError("Unknown or invalid api_key format"); + } + + return ["authorization" => "Bearer " . $api_key]; + } + + /** + * @return array + */ + public static function get_auth_headers_for_personal_access_token( + string $personal_access_token, + string $workspace_id, + ): array { + self::assert_personal_access_token($personal_access_token); + + return [ + "authorization" => "Bearer " . $personal_access_token, + "seam-workspace" => $workspace_id, + ]; + } + + /** + * Headers for a personal access token that is not scoped to a workspace. + * + * @return array + */ + public static function get_auth_headers_for_multi_workspace_personal_access_token( + string $personal_access_token, + ): array { + self::assert_personal_access_token($personal_access_token); + + return ["authorization" => "Bearer " . $personal_access_token]; + } + + private static function assert_personal_access_token(string $token): void + { + if (Token::is_client_session_token($token)) { + throw new InvalidTokenError( + "A Client Session Token cannot be used as a personal_access_token", + ); + } + + if (Token::is_jwt($token)) { + throw new InvalidTokenError( + "A JWT cannot be used as a personal_access_token", + ); + } + + if (Token::is_publishable_key($token)) { + throw new InvalidTokenError( + "A Publishable Key cannot be used as a personal_access_token", + ); + } + + if (!Token::is_access_token($token)) { + throw new InvalidTokenError( + "Unknown or invalid personal_access_token format", + ); + } + } +} diff --git a/src/Exceptions/ActionAttemptError.php b/src/Exceptions/ActionAttemptError.php index 26530d06..7368770b 100644 --- a/src/Exceptions/ActionAttemptError.php +++ b/src/Exceptions/ActionAttemptError.php @@ -1,17 +1,19 @@ name = get_class($this); $this->actionAttempt = $actionAttempt; } diff --git a/src/Exceptions/ActionAttemptFailedError.php b/src/Exceptions/ActionAttemptFailedError.php index 59e307fe..d2d08a37 100644 --- a/src/Exceptions/ActionAttemptFailedError.php +++ b/src/Exceptions/ActionAttemptFailedError.php @@ -1,20 +1,32 @@ error->message, $actionAttempt); - $this->name = get_class($this); - $this->errorCode = $actionAttempt->error->type; + parent::__construct( + $actionAttempt->error->message ?? "Action attempt failed", + $actionAttempt, + ); + $this->errorCode = $actionAttempt->error->type ?? "unknown_error"; } + /** + * The action attempt error type. + * + * Named `getErrorCode` rather than `getCode` because `Exception::getCode` + * is final and returns an int. This is the equivalent of the `code` + * property on the same error in the other Seam SDKs. + */ public function getErrorCode(): string { return $this->errorCode; diff --git a/src/Exceptions/ActionAttemptTimeoutError.php b/src/Exceptions/ActionAttemptTimeoutError.php index d0b6a343..87cfa2bc 100644 --- a/src/Exceptions/ActionAttemptTimeoutError.php +++ b/src/Exceptions/ActionAttemptTimeoutError.php @@ -1,9 +1,15 @@ name = get_class($this); } } diff --git a/src/Exceptions/HttpApiError.php b/src/Exceptions/HttpApiError.php index a79a8dd5..960b1c10 100644 --- a/src/Exceptions/HttpApiError.php +++ b/src/Exceptions/HttpApiError.php @@ -1,27 +1,32 @@ message ?? "Unknown error"; - parent::__construct($message); - $this->errorCode = $error->type ?? "unknown"; + parent::__construct($error->message ?? "Unknown error"); + $this->errorCode = $error->type ?? "unknown_error"; $this->statusCode = $statusCode; $this->requestId = $requestId; $this->data = $error->data ?? null; } + /** + * The Seam error type, e.g. `device_not_found`. + */ public function getErrorCode(): string { return $this->errorCode; @@ -32,7 +37,10 @@ public function getStatusCode(): int return $this->statusCode; } - public function getRequestId(): string + /** + * The `seam-request-id` response header, or null when absent. + */ + public function getRequestId(): ?string { return $this->requestId; } diff --git a/src/Exceptions/HttpInvalidInputError.php b/src/Exceptions/HttpInvalidInputError.php index 38cd8d74..fc20f172 100644 --- a/src/Exceptions/HttpInvalidInputError.php +++ b/src/Exceptions/HttpInvalidInputError.php @@ -1,7 +1,10 @@ errorCode = "invalid_input"; $this->validationErrors = $error->validation_errors ?? (object) []; } + /** + * The validation messages for a request parameter, or an empty array when + * that parameter has none. + * + * @return string[] + */ public function getValidationErrorMessages(string $paramName): array { return $this->validationErrors->{$paramName}->_errors ?? []; diff --git a/src/Exceptions/HttpUnauthorizedError.php b/src/Exceptions/HttpUnauthorizedError.php index 921ee2eb..2b43bffd 100644 --- a/src/Exceptions/HttpUnauthorizedError.php +++ b/src/Exceptions/HttpUnauthorizedError.php @@ -1,15 +1,21 @@ "unauthorized", - "message" => "Unauthorized", - ]; - parent::__construct($error, 401, $requestId); + parent::__construct( + (object) [ + "type" => "unauthorized", + "message" => "Unauthorized", + ], + 401, + $requestId, + ); } } diff --git a/src/Exceptions/InvalidOptionsError.php b/src/Exceptions/InvalidOptionsError.php new file mode 100644 index 00000000..65781860 --- /dev/null +++ b/src/Exceptions/InvalidOptionsError.php @@ -0,0 +1,16 @@ +status === "success") { + return $action_attempt; + } + + if ($action_attempt->status === "error") { + throw new ActionAttemptFailedError($action_attempt); + } + + if (self::now() + $polling_interval > $deadline) { + throw new ActionAttemptTimeoutError($action_attempt, $timeout); + } + + usleep((int) ($polling_interval * 1000000.0)); + + $action_attempt = self::get_action_attempt( + $client, + $action_attempt->action_attempt_id, + ); + } + } + + private static function get_action_attempt( + SeamHttpClient $client, + string $action_attempt_id, + ): ActionAttempt { + $res = $client->request( + "POST", + "/action_attempts/get", + json: (object) ["action_attempt_id" => $action_attempt_id], + ); + + $action_attempt = ActionAttempt::from_json( + $res->action_attempt ?? null, + ); + + if ($action_attempt === null) { + throw new \UnexpectedValueException( + "Seam returned no action attempt for {$action_attempt_id}", + ); + } + + return $action_attempt; + } + + private static function now(): float + { + return microtime(true); + } +} diff --git a/src/Http/SeamHttpClient.php b/src/Http/SeamHttpClient.php new file mode 100644 index 00000000..b9deb34c --- /dev/null +++ b/src/Http/SeamHttpClient.php @@ -0,0 +1,287 @@ + $auth_headers + * @param array $guzzle_options + */ + public function __construct( + string $endpoint, + array $auth_headers, + array $guzzle_options = [], + ?int $retries = null, + ?ClientInterface $client = null, + ) { + $this->client = + $client ?? + new GuzzleClient( + self::create_config( + $endpoint, + $auth_headers, + $guzzle_options, + $retries ?? self::DEFAULT_RETRIES, + ), + ); + } + + /** + * Wraps an already configured Guzzle client. The client carries its own + * endpoint and authorization, so none are resolved here. + */ + public static function from_client(ClientInterface $client): self + { + return new self("", [], [], null, $client); + } + + public function get_client(): ClientInterface + { + return $this->client; + } + + /** + * @param array $auth_headers + * @param array $guzzle_options + * @return array + */ + private static function create_config( + string $endpoint, + array $auth_headers, + array $guzzle_options, + int $retries, + ): array { + // The middleware has to be on the handler stack the client is built + // with: a stack pushed onto after construction does not apply. + $handler = $guzzle_options["handler"] ?? HandlerStack::create(); + + if ($handler instanceof HandlerStack) { + $handler->push( + GuzzleRetryMiddleware::factory([ + "retry_enabled" => $retries > 0, + "max_retry_attempts" => $retries, + // A connection failure never reached the server, so it is + // safe to retry whatever the method. Status codes are + // opted into per request, see request(). + "retry_on_timeout" => true, + "retry_on_status" => [], + ]), + "seam_retry", + ); + } + + $headers = array_merge( + $auth_headers, + $guzzle_options["headers"] ?? [], + self::sdk_headers(), + ); + + return array_merge( + [ + "base_uri" => $endpoint, + "timeout" => self::DEFAULT_TIMEOUT, + ], + $guzzle_options, + [ + "handler" => $handler, + "headers" => $headers, + // Error mapping happens in request(); letting Guzzle throw + // first would bypass it entirely. + "http_errors" => false, + ], + ); + } + + /** + * @return array + */ + private static function sdk_headers(): array + { + $version = PackageVersion::get(); + + return [ + "User-Agent" => "seam-php/" . $version, + "seam-sdk-name" => "seamapi/php", + "seam-sdk-version" => $version, + "seam-lts-version" => self::LTS_VERSION, + ]; + } + + /** + * @param array|object|null $json + * @param array|null $query + */ + public function request( + string $method, + string $path, + mixed $json = null, + ?array $query = null, + ): mixed { + $options = array_filter( + [ + "json" => $json, + "query" => $query, + ], + fn($option) => $option !== null, + ); + + if (in_array(strtoupper($method), self::IDEMPOTENT_METHODS, true)) { + $options["retry_on_status"] = self::RETRYABLE_STATUS_CODES; + } + + $response = $this->client->request($method, $path, $options); + $status_code = $response->getStatusCode(); + + if ($status_code < 200 || $status_code >= 300) { + $this->handle_error_response( + new Request($method, $path), + $response, + ); + } + + return self::decode_body($response); + } + + private function handle_error_response( + RequestInterface $request, + ResponseInterface $response, + ): void { + $status_code = $response->getStatusCode(); + $request_id = self::get_request_id($response); + + if ($status_code === 401) { + throw new HttpUnauthorizedError($request_id); + } + + if (!self::is_api_error_response($response)) { + // Not a Seam error response, so there is nothing to map onto a + // Seam exception. The other Seam SDKs surface the underlying + // transport error here too. + throw BadResponseException::create($request, $response); + } + + $error = self::decode_body($response)->error; + + if (($error->type ?? null) === "invalid_input") { + throw new HttpInvalidInputError($error, $status_code, $request_id); + } + + throw new HttpApiError($error, $status_code, $request_id); + } + + /** + * True when the response body is a Seam error envelope, i.e. JSON holding + * an `error` object with a string `type` and `message`. + */ + private static function is_api_error_response( + ResponseInterface $response, + ): bool { + if ( + !str_starts_with( + $response->getHeaderLine("content-type"), + "application/json", + ) + ) { + return false; + } + + $body = self::decode_body($response); + + if (!is_object($body)) { + return false; + } + + $error = $body->error ?? null; + + if (!is_object($error)) { + return false; + } + + return is_string($error->type ?? null) && + is_string($error->message ?? null); + } + + private static function decode_body(ResponseInterface $response): mixed + { + $body = $response->getBody(); + $body->rewind(); + $contents = $body->getContents(); + + if ($contents === "") { + return null; + } + + try { + return Utils::jsonDecode($contents); + } catch (\InvalidArgumentException) { + return null; + } + } + + private static function get_request_id(ResponseInterface $response): ?string + { + return $response->hasHeader("seam-request-id") + ? $response->getHeaderLine("seam-request-id") + : null; + } +} diff --git a/src/Options.php b/src/Options.php new file mode 100644 index 00000000..7f4b7151 --- /dev/null +++ b/src/Options.php @@ -0,0 +1,103 @@ +has_next_page ?? false), + next_page_cursor: $json->next_page_cursor ?? null, + next_page_url: $json->next_page_url ?? null, + ); + } +} diff --git a/src/Paginator.php b/src/Paginator.php index a913cf80..5b86e6db 100644 --- a/src/Paginator.php +++ b/src/Paginator.php @@ -2,11 +2,17 @@ namespace Seam; +/** + * Fetches and walks the pages of a list endpoint. + * + * Create one with `Seam::createPaginator`, passing a callable that invokes the + * list method with a params array. + */ class Paginator { private $request; - private $params; - private $pagination_cache = []; + private array $params; + private array $pagination_cache = []; private const FIRST_PAGE = "FIRST_PAGE"; public function __construct(callable $request, array $params = []) @@ -15,46 +21,55 @@ public function __construct(callable $request, array $params = []) $this->params = $params; } + /** + * @return array{0: array, 1: Pagination} + */ public function firstPage(): array { - $request = $this->request; - $params = $this->params; - - $params["on_response"] = fn($response) => $this->cachePagination( - $response, - self::FIRST_PAGE, - ); - - $data = $request($params); - - return [$data, $this->pagination_cache[self::FIRST_PAGE]]; + return $this->fetchPage(self::FIRST_PAGE); } - public function nextPage(string $next_page_cursor): array + /** + * @return array{0: array, 1: Pagination} + */ + public function nextPage(?string $next_page_cursor): array { - if ($next_page_cursor === null) { + if ($next_page_cursor === null || $next_page_cursor === "") { throw new \InvalidArgumentException( - "Cannot get the next page with a null next_page_cursor", + "Cannot get the next page without a next_page_cursor", ); } + return $this->fetchPage($next_page_cursor); + } + + /** + * @return array{0: array, 1: Pagination} + */ + private function fetchPage(string $cursor): array + { $request = $this->request; $params = $this->params; - $params["page_cursor"] = $next_page_cursor; + if ($cursor !== self::FIRST_PAGE) { + $params["page_cursor"] = $cursor; + } + $params["on_response"] = fn($response) => $this->cachePagination( $response, - $next_page_cursor, + $cursor, ); $data = $request($params); - return [$data, $this->pagination_cache[$next_page_cursor]]; + return [$data, $this->pagination_cache[$cursor] ?? new Pagination()]; } - private function cachePagination($response, $next_page_cursor) + private function cachePagination($response, string $cursor): void { - $this->pagination_cache[$next_page_cursor] = $response->pagination; + $this->pagination_cache[$cursor] = Pagination::from_json( + $response->pagination ?? null, + ); } public function flattenToArray(): array diff --git a/src/Routes/AccessCodesClient.php b/src/Routes/AccessCodesClient.php index 1c53b718..a3465a8d 100644 --- a/src/Routes/AccessCodesClient.php +++ b/src/Routes/AccessCodesClient.php @@ -2,19 +2,28 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\AccessCode; -use Seam\SeamClient; class AccessCodesClient { - private SeamClient $seam; + private SeamHttpClient $client; + + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; public AccessCodesSimulateClient $simulate; public AccessCodesUnmanagedClient $unmanaged; - public function __construct(SeamClient $seam) + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; - $this->simulate = new AccessCodesSimulateClient($seam); - $this->unmanaged = new AccessCodesUnmanagedClient($seam); + $this->client = $client; + $this->defaults = $defaults; + $this->simulate = new AccessCodesSimulateClient($client, $defaults); + $this->unmanaged = new AccessCodesUnmanagedClient($client, $defaults); } /** @@ -64,9 +73,7 @@ public function create( ): AccessCode { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; if ($allow_external_modification !== null) { $request_payload[ "allow_external_modification" @@ -127,7 +134,7 @@ public function create( ] = $use_offline_access_code; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_codes/create", json: (object) $request_payload, @@ -185,9 +192,7 @@ public function create_multiple( ): array { $request_payload = []; - if ($device_ids !== null) { - $request_payload["device_ids"] = $device_ids; - } + $request_payload["device_ids"] = $device_ids; if ($allow_external_modification !== null) { $request_payload[ "allow_external_modification" @@ -234,7 +239,7 @@ public function create_multiple( ] = $use_backup_access_code_pool; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_codes/create_multiple", json: (object) $request_payload, @@ -259,14 +264,12 @@ public function delete( ): void { $request_payload = []; - if ($access_code_id !== null) { - $request_payload["access_code_id"] = $access_code_id; - } + $request_payload["access_code_id"] = $access_code_id; if ($device_id !== null) { $request_payload["device_id"] = $device_id; } - $this->seam->request( + $this->client->request( "POST", "/access_codes/delete", json: (object) $request_payload, @@ -283,11 +286,9 @@ public function generate_code(string $device_id): AccessCode { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_codes/generate_code", json: (object) $request_payload, @@ -323,7 +324,7 @@ public function get( $request_payload["device_id"] = $device_id; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_codes/get", json: (object) $request_payload, @@ -347,6 +348,7 @@ public function get( * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $search String for which to search. Filters returned access codes to include all records that satisfy a partial match using `name`, `code` or `access_code_id`. * @param string $user_identifier_key Your user ID for the user by which to filter access codes. + * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ public function list( @@ -395,7 +397,7 @@ public function list( $request_payload["user_identifier_key"] = $user_identifier_key; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_codes/list", json: (object) $request_payload, @@ -429,11 +431,9 @@ public function pull_backup_access_code(string $access_code_id): AccessCode { $request_payload = []; - if ($access_code_id !== null) { - $request_payload["access_code_id"] = $access_code_id; - } + $request_payload["access_code_id"] = $access_code_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_codes/pull_backup_access_code", json: (object) $request_payload, @@ -461,9 +461,7 @@ public function report_device_constraints( ): void { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; if ($max_code_length !== null) { $request_payload["max_code_length"] = $max_code_length; } @@ -476,7 +474,7 @@ public function report_device_constraints( ] = $supported_code_lengths; } - $this->seam->request( + $this->client->request( "POST", "/access_codes/report_device_constraints", json: (object) $request_payload, @@ -536,9 +534,7 @@ public function update( ): void { $request_payload = []; - if ($access_code_id !== null) { - $request_payload["access_code_id"] = $access_code_id; - } + $request_payload["access_code_id"] = $access_code_id; if ($allow_external_modification !== null) { $request_payload[ "allow_external_modification" @@ -605,7 +601,7 @@ public function update( ] = $use_offline_access_code; } - $this->seam->request( + $this->client->request( "POST", "/access_codes/update", json: (object) $request_payload, @@ -639,9 +635,7 @@ public function update_multiple( ): void { $request_payload = []; - if ($common_code_key !== null) { - $request_payload["common_code_key"] = $common_code_key; - } + $request_payload["common_code_key"] = $common_code_key; if ($ends_at !== null) { $request_payload["ends_at"] = $ends_at; } @@ -652,7 +646,7 @@ public function update_multiple( $request_payload["starts_at"] = $starts_at; } - $this->seam->request( + $this->client->request( "POST", "/access_codes/update_multiple", json: (object) $request_payload, diff --git a/src/Routes/AccessCodesSimulateClient.php b/src/Routes/AccessCodesSimulateClient.php index 7f1ded04..0750e822 100644 --- a/src/Routes/AccessCodesSimulateClient.php +++ b/src/Routes/AccessCodesSimulateClient.php @@ -2,16 +2,25 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\UnmanagedAccessCode; -use Seam\SeamClient; class AccessCodesSimulateClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -29,17 +38,11 @@ public function create_unmanaged_access_code( ): UnmanagedAccessCode { $request_payload = []; - if ($code !== null) { - $request_payload["code"] = $code; - } - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } - if ($name !== null) { - $request_payload["name"] = $name; - } - - $res = $this->seam->request( + $request_payload["code"] = $code; + $request_payload["device_id"] = $device_id; + $request_payload["name"] = $name; + + $res = $this->client->request( "POST", "/access_codes/simulate/create_unmanaged_access_code", json: (object) $request_payload, diff --git a/src/Routes/AccessCodesUnmanagedClient.php b/src/Routes/AccessCodesUnmanagedClient.php index 93ec0001..e18b0ab3 100644 --- a/src/Routes/AccessCodesUnmanagedClient.php +++ b/src/Routes/AccessCodesUnmanagedClient.php @@ -2,16 +2,25 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\UnmanagedAccessCode; -use Seam\SeamClient; class AccessCodesUnmanagedClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -35,9 +44,7 @@ public function convert_to_managed( ): void { $request_payload = []; - if ($access_code_id !== null) { - $request_payload["access_code_id"] = $access_code_id; - } + $request_payload["access_code_id"] = $access_code_id; if ($allow_external_modification !== null) { $request_payload[ "allow_external_modification" @@ -52,7 +59,7 @@ public function convert_to_managed( ] = $is_external_modification_allowed; } - $this->seam->request( + $this->client->request( "POST", "/access_codes/unmanaged/convert_to_managed", json: (object) $request_payload, @@ -69,11 +76,9 @@ public function delete(string $access_code_id): void { $request_payload = []; - if ($access_code_id !== null) { - $request_payload["access_code_id"] = $access_code_id; - } + $request_payload["access_code_id"] = $access_code_id; - $this->seam->request( + $this->client->request( "POST", "/access_codes/unmanaged/delete", json: (object) $request_payload, @@ -107,7 +112,7 @@ public function get( $request_payload["device_id"] = $device_id; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_codes/unmanaged/get", json: (object) $request_payload, @@ -124,6 +129,7 @@ public function get( * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $search String for which to search. Filters returned access codes to include all records that satisfy a partial match using `name`, `code` or `access_code_id`. * @param string $user_identifier_key Your user ID for the user by which to filter unmanaged access codes. + * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ public function list( @@ -136,9 +142,7 @@ public function list( ): array { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; if ($limit !== null) { $request_payload["limit"] = $limit; } @@ -152,7 +156,7 @@ public function list( $request_payload["user_identifier_key"] = $user_identifier_key; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_codes/unmanaged/list", json: (object) $request_payload, @@ -187,12 +191,8 @@ public function update( ): void { $request_payload = []; - if ($access_code_id !== null) { - $request_payload["access_code_id"] = $access_code_id; - } - if ($is_managed !== null) { - $request_payload["is_managed"] = $is_managed; - } + $request_payload["access_code_id"] = $access_code_id; + $request_payload["is_managed"] = $is_managed; if ($allow_external_modification !== null) { $request_payload[ "allow_external_modification" @@ -207,7 +207,7 @@ public function update( ] = $is_external_modification_allowed; } - $this->seam->request( + $this->client->request( "POST", "/access_codes/unmanaged/update", json: (object) $request_payload, diff --git a/src/Routes/AccessGrantsClient.php b/src/Routes/AccessGrantsClient.php index 42c99bd3..188412e5 100644 --- a/src/Routes/AccessGrantsClient.php +++ b/src/Routes/AccessGrantsClient.php @@ -2,18 +2,27 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\AccessGrant; use Seam\Resources\Batch; -use Seam\SeamClient; class AccessGrantsClient { - private SeamClient $seam; + private SeamHttpClient $client; + + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; public AccessGrantsUnmanagedClient $unmanaged; - public function __construct(SeamClient $seam) + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; - $this->unmanaged = new AccessGrantsUnmanagedClient($seam); + $this->client = $client; + $this->defaults = $defaults; + $this->unmanaged = new AccessGrantsUnmanagedClient($client, $defaults); } /** @@ -55,11 +64,9 @@ public function create( ): AccessGrant { $request_payload = []; - if ($requested_access_methods !== null) { - $request_payload[ - "requested_access_methods" - ] = $requested_access_methods; - } + $request_payload[ + "requested_access_methods" + ] = $requested_access_methods; if ($user_identity_id !== null) { $request_payload["user_identity_id"] = $user_identity_id; } @@ -105,7 +112,7 @@ public function create( $request_payload["starts_at"] = $starts_at; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_grants/create", json: (object) $request_payload, @@ -124,11 +131,9 @@ public function delete(string $access_grant_id): void { $request_payload = []; - if ($access_grant_id !== null) { - $request_payload["access_grant_id"] = $access_grant_id; - } + $request_payload["access_grant_id"] = $access_grant_id; - $this->seam->request( + $this->client->request( "POST", "/access_grants/delete", json: (object) $request_payload, @@ -155,7 +160,7 @@ public function get( $request_payload["access_grant_key"] = $access_grant_key; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_grants/get", json: (object) $request_payload, @@ -194,7 +199,7 @@ public function get_related( $request_payload["include"] = $include; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_grants/get_related", json: (object) $request_payload, @@ -219,6 +224,7 @@ public function get_related( * @param string $reservation_key Filter Access Grants by reservation_key. * @param string $space_id ID of the space by which you want to filter the list of Access Grants. * @param string $user_identity_id ID of user identity by which you want to filter the list of Access Grants. + * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ public function list( @@ -279,7 +285,7 @@ public function list( $request_payload["user_identity_id"] = $user_identity_id; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_grants/list", json: (object) $request_payload, @@ -308,16 +314,12 @@ public function request_access_methods( ): AccessGrant { $request_payload = []; - if ($access_grant_id !== null) { - $request_payload["access_grant_id"] = $access_grant_id; - } - if ($requested_access_methods !== null) { - $request_payload[ - "requested_access_methods" - ] = $requested_access_methods; - } + $request_payload["access_grant_id"] = $access_grant_id; + $request_payload[ + "requested_access_methods" + ] = $requested_access_methods; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_grants/request_access_methods", json: (object) $request_payload, @@ -361,7 +363,7 @@ public function update( $request_payload["starts_at"] = $starts_at; } - $this->seam->request( + $this->client->request( "POST", "/access_grants/update", json: (object) $request_payload, diff --git a/src/Routes/AccessGrantsUnmanagedClient.php b/src/Routes/AccessGrantsUnmanagedClient.php index 84c4c449..72955f56 100644 --- a/src/Routes/AccessGrantsUnmanagedClient.php +++ b/src/Routes/AccessGrantsUnmanagedClient.php @@ -2,16 +2,25 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\UnmanagedAccessGrant; -use Seam\SeamClient; class AccessGrantsUnmanagedClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -24,11 +33,9 @@ public function get(string $access_grant_id): UnmanagedAccessGrant { $request_payload = []; - if ($access_grant_id !== null) { - $request_payload["access_grant_id"] = $access_grant_id; - } + $request_payload["access_grant_id"] = $access_grant_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_grants/unmanaged/get", json: (object) $request_payload, @@ -46,6 +53,7 @@ public function get(string $access_grant_id): UnmanagedAccessGrant * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $reservation_key Filter unmanaged Access Grants by reservation_key. * @param string $user_identity_id ID of user identity by which you want to filter the list of unmanaged Access Grants. + * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ public function list( @@ -78,7 +86,7 @@ public function list( $request_payload["user_identity_id"] = $user_identity_id; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_grants/unmanaged/list", json: (object) $request_payload, @@ -113,17 +121,13 @@ public function update( ): void { $request_payload = []; - if ($access_grant_id !== null) { - $request_payload["access_grant_id"] = $access_grant_id; - } - if ($is_managed !== null) { - $request_payload["is_managed"] = $is_managed; - } + $request_payload["access_grant_id"] = $access_grant_id; + $request_payload["is_managed"] = $is_managed; if ($access_grant_key !== null) { $request_payload["access_grant_key"] = $access_grant_key; } - $this->seam->request( + $this->client->request( "POST", "/access_grants/unmanaged/update", json: (object) $request_payload, diff --git a/src/Routes/AccessMethodsClient.php b/src/Routes/AccessMethodsClient.php index 6e9d975d..09421e8e 100644 --- a/src/Routes/AccessMethodsClient.php +++ b/src/Routes/AccessMethodsClient.php @@ -2,19 +2,29 @@ namespace Seam\Routes; +use Seam\Http\ResolveActionAttempt; +use Seam\Http\SeamHttpClient; use Seam\Resources\AccessMethod; use Seam\Resources\ActionAttempt; use Seam\Resources\Batch; -use Seam\SeamClient; class AccessMethodsClient { - private SeamClient $seam; + private SeamHttpClient $client; + + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; public AccessMethodsUnmanagedClient $unmanaged; - public function __construct(SeamClient $seam) + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; - $this->unmanaged = new AccessMethodsUnmanagedClient($seam); + $this->client = $client; + $this->defaults = $defaults; + $this->unmanaged = new AccessMethodsUnmanagedClient($client, $defaults); } /** @@ -22,37 +32,31 @@ public function __construct(SeamClient $seam) * * @param string $access_method_id ID of the `access_method` to assign the credential to. * @param string $card_number Card number of the credential to assign. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function assign_card( string $access_method_id, string $card_number, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($access_method_id !== null) { - $request_payload["access_method_id"] = $access_method_id; - } - if ($card_number !== null) { - $request_payload["card_number"] = $card_number; - } + $request_payload["access_method_id"] = $access_method_id; + $request_payload["card_number"] = $card_number; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_methods/assign_card", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } /** @@ -80,7 +84,7 @@ public function delete( $request_payload["reservation_key"] = $reservation_key; } - $this->seam->request( + $this->client->request( "POST", "/access_methods/delete", json: (object) $request_payload, @@ -92,37 +96,31 @@ public function delete( * * @param string $access_method_id ID of the `access_method` to encode onto a card. * @param string $acs_encoder_id ID of the `acs_encoder` to use to encode the `access_method`. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function encode( string $access_method_id, string $acs_encoder_id, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($access_method_id !== null) { - $request_payload["access_method_id"] = $access_method_id; - } - if ($acs_encoder_id !== null) { - $request_payload["acs_encoder_id"] = $acs_encoder_id; - } + $request_payload["access_method_id"] = $access_method_id; + $request_payload["acs_encoder_id"] = $acs_encoder_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_methods/encode", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } /** @@ -135,11 +133,9 @@ public function get(string $access_method_id): AccessMethod { $request_payload = []; - if ($access_method_id !== null) { - $request_payload["access_method_id"] = $access_method_id; - } + $request_payload["access_method_id"] = $access_method_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_methods/get", json: (object) $request_payload, @@ -163,9 +159,7 @@ public function get_related( ): Batch { $request_payload = []; - if ($access_method_ids !== null) { - $request_payload["access_method_ids"] = $access_method_ids; - } + $request_payload["access_method_ids"] = $access_method_ids; if ($exclude !== null) { $request_payload["exclude"] = $exclude; } @@ -173,7 +167,7 @@ public function get_related( $request_payload["include"] = $include; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_methods/get_related", json: (object) $request_payload, @@ -193,6 +187,7 @@ public function get_related( * @param int $limit Maximum number of records to return per page. * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $space_id ID of the space by which to filter the returned access methods. Must be combined with `access_grant_id`, `access_grant_key`, or `acs_entrance_id`. + * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ public function list( @@ -233,7 +228,7 @@ public function list( $request_payload["space_id"] = $space_id; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_methods/list", json: (object) $request_payload, @@ -254,36 +249,30 @@ public function list( * * @param string $access_method_id ID of the cloud_key `access_method` to use for the unlock operation. * @param string $acs_entrance_id ID of the entrance to unlock. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function unlock_door( string $access_method_id, string $acs_entrance_id, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($access_method_id !== null) { - $request_payload["access_method_id"] = $access_method_id; - } - if ($acs_entrance_id !== null) { - $request_payload["acs_entrance_id"] = $acs_entrance_id; - } + $request_payload["access_method_id"] = $access_method_id; + $request_payload["acs_entrance_id"] = $acs_entrance_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_methods/unlock_door", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } } diff --git a/src/Routes/AccessMethodsUnmanagedClient.php b/src/Routes/AccessMethodsUnmanagedClient.php index 94f9eb15..4599a2b4 100644 --- a/src/Routes/AccessMethodsUnmanagedClient.php +++ b/src/Routes/AccessMethodsUnmanagedClient.php @@ -2,16 +2,25 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\UnmanagedAccessMethod; -use Seam\SeamClient; class AccessMethodsUnmanagedClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -24,11 +33,9 @@ public function get(string $access_method_id): UnmanagedAccessMethod { $request_payload = []; - if ($access_method_id !== null) { - $request_payload["access_method_id"] = $access_method_id; - } + $request_payload["access_method_id"] = $access_method_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_methods/unmanaged/get", json: (object) $request_payload, @@ -54,9 +61,7 @@ public function list( ): array { $request_payload = []; - if ($access_grant_id !== null) { - $request_payload["access_grant_id"] = $access_grant_id; - } + $request_payload["access_grant_id"] = $access_grant_id; if ($acs_entrance_id !== null) { $request_payload["acs_entrance_id"] = $acs_entrance_id; } @@ -67,7 +72,7 @@ public function list( $request_payload["space_id"] = $space_id; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/access_methods/unmanaged/list", json: (object) $request_payload, diff --git a/src/Routes/AcsAccessGroupsClient.php b/src/Routes/AcsAccessGroupsClient.php index f4878d23..8fea3af4 100644 --- a/src/Routes/AcsAccessGroupsClient.php +++ b/src/Routes/AcsAccessGroupsClient.php @@ -2,18 +2,27 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\AcsAccessGroup; use Seam\Resources\AcsEntrance; use Seam\Resources\AcsUser; -use Seam\SeamClient; class AcsAccessGroupsClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -31,9 +40,7 @@ public function add_user( ): void { $request_payload = []; - if ($acs_access_group_id !== null) { - $request_payload["acs_access_group_id"] = $acs_access_group_id; - } + $request_payload["acs_access_group_id"] = $acs_access_group_id; if ($acs_user_id !== null) { $request_payload["acs_user_id"] = $acs_user_id; } @@ -41,7 +48,7 @@ public function add_user( $request_payload["user_identity_id"] = $user_identity_id; } - $this->seam->request( + $this->client->request( "POST", "/acs/access_groups/add_user", json: (object) $request_payload, @@ -58,11 +65,9 @@ public function delete(string $acs_access_group_id): void { $request_payload = []; - if ($acs_access_group_id !== null) { - $request_payload["acs_access_group_id"] = $acs_access_group_id; - } + $request_payload["acs_access_group_id"] = $acs_access_group_id; - $this->seam->request( + $this->client->request( "POST", "/acs/access_groups/delete", json: (object) $request_payload, @@ -79,11 +84,9 @@ public function get(string $acs_access_group_id): AcsAccessGroup { $request_payload = []; - if ($acs_access_group_id !== null) { - $request_payload["acs_access_group_id"] = $acs_access_group_id; - } + $request_payload["acs_access_group_id"] = $acs_access_group_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/access_groups/get", json: (object) $request_payload, @@ -122,7 +125,7 @@ public function list( $request_payload["user_identity_id"] = $user_identity_id; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/access_groups/list", json: (object) $request_payload, @@ -145,11 +148,9 @@ public function list_accessible_entrances( ): array { $request_payload = []; - if ($acs_access_group_id !== null) { - $request_payload["acs_access_group_id"] = $acs_access_group_id; - } + $request_payload["acs_access_group_id"] = $acs_access_group_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/access_groups/list_accessible_entrances", json: (object) $request_payload, @@ -171,11 +172,9 @@ public function list_users(string $acs_access_group_id): array { $request_payload = []; - if ($acs_access_group_id !== null) { - $request_payload["acs_access_group_id"] = $acs_access_group_id; - } + $request_payload["acs_access_group_id"] = $acs_access_group_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/access_groups/list_users", json: (object) $request_payload, @@ -199,9 +198,7 @@ public function remove_user( ): void { $request_payload = []; - if ($acs_access_group_id !== null) { - $request_payload["acs_access_group_id"] = $acs_access_group_id; - } + $request_payload["acs_access_group_id"] = $acs_access_group_id; if ($acs_user_id !== null) { $request_payload["acs_user_id"] = $acs_user_id; } @@ -209,7 +206,7 @@ public function remove_user( $request_payload["user_identity_id"] = $user_identity_id; } - $this->seam->request( + $this->client->request( "POST", "/acs/access_groups/remove_user", json: (object) $request_payload, diff --git a/src/Routes/AcsClient.php b/src/Routes/AcsClient.php index e6454811..1d5f11e0 100644 --- a/src/Routes/AcsClient.php +++ b/src/Routes/AcsClient.php @@ -2,25 +2,34 @@ namespace Seam\Routes; -use Seam\SeamClient; +use Seam\Http\SeamHttpClient; class AcsClient { - private SeamClient $seam; + private SeamHttpClient $client; + + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; public AcsAccessGroupsClient $access_groups; public AcsCredentialsClient $credentials; public AcsEncodersClient $encoders; public AcsEntrancesClient $entrances; public AcsSystemsClient $systems; public AcsUsersClient $users; - public function __construct(SeamClient $seam) + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; - $this->access_groups = new AcsAccessGroupsClient($seam); - $this->credentials = new AcsCredentialsClient($seam); - $this->encoders = new AcsEncodersClient($seam); - $this->entrances = new AcsEntrancesClient($seam); - $this->systems = new AcsSystemsClient($seam); - $this->users = new AcsUsersClient($seam); + $this->client = $client; + $this->defaults = $defaults; + $this->access_groups = new AcsAccessGroupsClient($client, $defaults); + $this->credentials = new AcsCredentialsClient($client, $defaults); + $this->encoders = new AcsEncodersClient($client, $defaults); + $this->entrances = new AcsEntrancesClient($client, $defaults); + $this->systems = new AcsSystemsClient($client, $defaults); + $this->users = new AcsUsersClient($client, $defaults); } } diff --git a/src/Routes/AcsCredentialsClient.php b/src/Routes/AcsCredentialsClient.php index b4151455..43b41e38 100644 --- a/src/Routes/AcsCredentialsClient.php +++ b/src/Routes/AcsCredentialsClient.php @@ -2,17 +2,26 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\AcsCredential; use Seam\Resources\AcsEntrance; -use Seam\SeamClient; class AcsCredentialsClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -30,9 +39,7 @@ public function assign( ): void { $request_payload = []; - if ($acs_credential_id !== null) { - $request_payload["acs_credential_id"] = $acs_credential_id; - } + $request_payload["acs_credential_id"] = $acs_credential_id; if ($acs_user_id !== null) { $request_payload["acs_user_id"] = $acs_user_id; } @@ -40,7 +47,7 @@ public function assign( $request_payload["user_identity_id"] = $user_identity_id; } - $this->seam->request( + $this->client->request( "POST", "/acs/credentials/assign", json: (object) $request_payload, @@ -82,9 +89,7 @@ public function create( ): AcsCredential { $request_payload = []; - if ($access_method !== null) { - $request_payload["access_method"] = $access_method; - } + $request_payload["access_method"] = $access_method; if ($acs_system_id !== null) { $request_payload["acs_system_id"] = $acs_system_id; } @@ -130,7 +135,7 @@ public function create( $request_payload["visionline_metadata"] = $visionline_metadata; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/credentials/create", json: (object) $request_payload, @@ -149,11 +154,9 @@ public function delete(string $acs_credential_id): void { $request_payload = []; - if ($acs_credential_id !== null) { - $request_payload["acs_credential_id"] = $acs_credential_id; - } + $request_payload["acs_credential_id"] = $acs_credential_id; - $this->seam->request( + $this->client->request( "POST", "/acs/credentials/delete", json: (object) $request_payload, @@ -170,11 +173,9 @@ public function get(string $acs_credential_id): AcsCredential { $request_payload = []; - if ($acs_credential_id !== null) { - $request_payload["acs_credential_id"] = $acs_credential_id; - } + $request_payload["acs_credential_id"] = $acs_credential_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/credentials/get", json: (object) $request_payload, @@ -194,6 +195,7 @@ public function get(string $acs_credential_id): AcsCredential * @param float $limit Number of credentials to return. * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $search String for which to search. Filters returned credentials to include all records that satisfy a partial match using `display_name`, `code`, `card_number`, `acs_user_id` or `acs_credential_id`. + * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ public function list( @@ -236,7 +238,7 @@ public function list( $request_payload["search"] = $search; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/credentials/list", json: (object) $request_payload, @@ -262,11 +264,9 @@ public function list_accessible_entrances(string $acs_credential_id): array { $request_payload = []; - if ($acs_credential_id !== null) { - $request_payload["acs_credential_id"] = $acs_credential_id; - } + $request_payload["acs_credential_id"] = $acs_credential_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/credentials/list_accessible_entrances", json: (object) $request_payload, @@ -293,9 +293,7 @@ public function unassign( ): void { $request_payload = []; - if ($acs_credential_id !== null) { - $request_payload["acs_credential_id"] = $acs_credential_id; - } + $request_payload["acs_credential_id"] = $acs_credential_id; if ($acs_user_id !== null) { $request_payload["acs_user_id"] = $acs_user_id; } @@ -303,7 +301,7 @@ public function unassign( $request_payload["user_identity_id"] = $user_identity_id; } - $this->seam->request( + $this->client->request( "POST", "/acs/credentials/unassign", json: (object) $request_payload, @@ -325,9 +323,7 @@ public function update( ): void { $request_payload = []; - if ($acs_credential_id !== null) { - $request_payload["acs_credential_id"] = $acs_credential_id; - } + $request_payload["acs_credential_id"] = $acs_credential_id; if ($code !== null) { $request_payload["code"] = $code; } @@ -335,7 +331,7 @@ public function update( $request_payload["ends_at"] = $ends_at; } - $this->seam->request( + $this->client->request( "POST", "/acs/credentials/update", json: (object) $request_payload, diff --git a/src/Routes/AcsEncodersClient.php b/src/Routes/AcsEncodersClient.php index d277fdb9..dd12962a 100644 --- a/src/Routes/AcsEncodersClient.php +++ b/src/Routes/AcsEncodersClient.php @@ -2,18 +2,28 @@ namespace Seam\Routes; +use Seam\Http\ResolveActionAttempt; +use Seam\Http\SeamHttpClient; use Seam\Resources\AcsEncoder; use Seam\Resources\ActionAttempt; -use Seam\SeamClient; class AcsEncodersClient { - private SeamClient $seam; + private SeamHttpClient $client; + + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; public AcsEncodersSimulateClient $simulate; - public function __construct(SeamClient $seam) + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; - $this->simulate = new AcsEncodersSimulateClient($seam); + $this->client = $client; + $this->defaults = $defaults; + $this->simulate = new AcsEncodersSimulateClient($client, $defaults); } /** @@ -22,19 +32,18 @@ public function __construct(SeamClient $seam) * @param string $acs_encoder_id ID of the `acs_encoder` to use to encode the `acs_credential`. * @param string $access_method_id ID of the `access_method` to encode onto a card. * @param string $acs_credential_id ID of the `acs_credential` to encode onto a card. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function encode_credential( string $acs_encoder_id, ?string $access_method_id = null, ?string $acs_credential_id = null, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($acs_encoder_id !== null) { - $request_payload["acs_encoder_id"] = $acs_encoder_id; - } + $request_payload["acs_encoder_id"] = $acs_encoder_id; if ($access_method_id !== null) { $request_payload["access_method_id"] = $access_method_id; } @@ -42,21 +51,18 @@ public function encode_credential( $request_payload["acs_credential_id"] = $acs_credential_id; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/encoders/encode_credential", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } /** @@ -69,11 +75,9 @@ public function get(string $acs_encoder_id): AcsEncoder { $request_payload = []; - if ($acs_encoder_id !== null) { - $request_payload["acs_encoder_id"] = $acs_encoder_id; - } + $request_payload["acs_encoder_id"] = $acs_encoder_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/encoders/get", json: (object) $request_payload, @@ -90,6 +94,7 @@ public function get(string $acs_encoder_id): AcsEncoder * @param array $acs_encoder_ids IDs of the encoders that you want to retrieve. * @param float $limit Number of encoders to return. * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ public function list( @@ -118,7 +123,7 @@ public function list( $request_payload["page_cursor"] = $page_cursor; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/encoders/list", json: (object) $request_payload, @@ -139,37 +144,33 @@ public function list( * * @param string $acs_encoder_id ID of the encoder to use for the scan. * @param mixed $salto_ks_metadata Salto KS-specific metadata for the scan action. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function scan_credential( string $acs_encoder_id, mixed $salto_ks_metadata = null, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($acs_encoder_id !== null) { - $request_payload["acs_encoder_id"] = $acs_encoder_id; - } + $request_payload["acs_encoder_id"] = $acs_encoder_id; if ($salto_ks_metadata !== null) { $request_payload["salto_ks_metadata"] = $salto_ks_metadata; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/encoders/scan_credential", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } /** @@ -179,6 +180,7 @@ public function scan_credential( * @param string $acs_user_id ID of the `acs_user` to assign the scanned credential to. * @param mixed $salto_ks_metadata Salto KS-specific metadata for the scan action. * @param string $user_identity_id ID of the `user_identity` to assign the scanned credential to. If the ACS system contains an ACS user linked to this user identity, it is used. Otherwise, one is created. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function scan_to_assign_credential( @@ -186,13 +188,11 @@ public function scan_to_assign_credential( ?string $acs_user_id = null, mixed $salto_ks_metadata = null, ?string $user_identity_id = null, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($acs_encoder_id !== null) { - $request_payload["acs_encoder_id"] = $acs_encoder_id; - } + $request_payload["acs_encoder_id"] = $acs_encoder_id; if ($acs_user_id !== null) { $request_payload["acs_user_id"] = $acs_user_id; } @@ -203,20 +203,17 @@ public function scan_to_assign_credential( $request_payload["user_identity_id"] = $user_identity_id; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/encoders/scan_to_assign_credential", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } } diff --git a/src/Routes/AcsEncodersSimulateClient.php b/src/Routes/AcsEncodersSimulateClient.php index 7fb9c2b4..54ce9aaf 100644 --- a/src/Routes/AcsEncodersSimulateClient.php +++ b/src/Routes/AcsEncodersSimulateClient.php @@ -2,15 +2,24 @@ namespace Seam\Routes; -use Seam\SeamClient; +use Seam\Http\SeamHttpClient; class AcsEncodersSimulateClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -28,9 +37,7 @@ public function next_credential_encode_will_fail( ): void { $request_payload = []; - if ($acs_encoder_id !== null) { - $request_payload["acs_encoder_id"] = $acs_encoder_id; - } + $request_payload["acs_encoder_id"] = $acs_encoder_id; if ($error_code !== null) { $request_payload["error_code"] = $error_code; } @@ -38,7 +45,7 @@ public function next_credential_encode_will_fail( $request_payload["acs_credential_id"] = $acs_credential_id; } - $this->seam->request( + $this->client->request( "POST", "/acs/encoders/simulate/next_credential_encode_will_fail", json: (object) $request_payload, @@ -58,14 +65,12 @@ public function next_credential_encode_will_succeed( ): void { $request_payload = []; - if ($acs_encoder_id !== null) { - $request_payload["acs_encoder_id"] = $acs_encoder_id; - } + $request_payload["acs_encoder_id"] = $acs_encoder_id; if ($scenario !== null) { $request_payload["scenario"] = $scenario; } - $this->seam->request( + $this->client->request( "POST", "/acs/encoders/simulate/next_credential_encode_will_succeed", json: (object) $request_payload, @@ -87,9 +92,7 @@ public function next_credential_scan_will_fail( ): void { $request_payload = []; - if ($acs_encoder_id !== null) { - $request_payload["acs_encoder_id"] = $acs_encoder_id; - } + $request_payload["acs_encoder_id"] = $acs_encoder_id; if ($error_code !== null) { $request_payload["error_code"] = $error_code; } @@ -99,7 +102,7 @@ public function next_credential_scan_will_fail( ] = $acs_credential_id_on_seam; } - $this->seam->request( + $this->client->request( "POST", "/acs/encoders/simulate/next_credential_scan_will_fail", json: (object) $request_payload, @@ -121,9 +124,7 @@ public function next_credential_scan_will_succeed( ): void { $request_payload = []; - if ($acs_encoder_id !== null) { - $request_payload["acs_encoder_id"] = $acs_encoder_id; - } + $request_payload["acs_encoder_id"] = $acs_encoder_id; if ($acs_credential_id_on_seam !== null) { $request_payload[ "acs_credential_id_on_seam" @@ -133,7 +134,7 @@ public function next_credential_scan_will_succeed( $request_payload["scenario"] = $scenario; } - $this->seam->request( + $this->client->request( "POST", "/acs/encoders/simulate/next_credential_scan_will_succeed", json: (object) $request_payload, diff --git a/src/Routes/AcsEntrancesClient.php b/src/Routes/AcsEntrancesClient.php index 583d6e5e..270cfb24 100644 --- a/src/Routes/AcsEntrancesClient.php +++ b/src/Routes/AcsEntrancesClient.php @@ -2,18 +2,28 @@ namespace Seam\Routes; +use Seam\Http\ResolveActionAttempt; +use Seam\Http\SeamHttpClient; use Seam\Resources\AcsCredential; use Seam\Resources\AcsEntrance; use Seam\Resources\ActionAttempt; -use Seam\SeamClient; class AcsEntrancesClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -26,11 +36,9 @@ public function get(string $acs_entrance_id): AcsEntrance { $request_payload = []; - if ($acs_entrance_id !== null) { - $request_payload["acs_entrance_id"] = $acs_entrance_id; - } + $request_payload["acs_entrance_id"] = $acs_entrance_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/entrances/get", json: (object) $request_payload, @@ -54,9 +62,7 @@ public function grant_access( ): void { $request_payload = []; - if ($acs_entrance_id !== null) { - $request_payload["acs_entrance_id"] = $acs_entrance_id; - } + $request_payload["acs_entrance_id"] = $acs_entrance_id; if ($acs_user_id !== null) { $request_payload["acs_user_id"] = $acs_user_id; } @@ -64,7 +70,7 @@ public function grant_access( $request_payload["user_identity_id"] = $user_identity_id; } - $this->seam->request( + $this->client->request( "POST", "/acs/entrances/grant_access", json: (object) $request_payload, @@ -85,6 +91,7 @@ public function grant_access( * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $search String for which to search. Filters returned entrances to include all records that satisfy a partial match using `display_name`. * @param string $space_id ID of the space for which you want to list entrances. + * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ public function list( @@ -137,7 +144,7 @@ public function list( $request_payload["space_id"] = $space_id; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/entrances/list", json: (object) $request_payload, @@ -166,14 +173,12 @@ public function list_credentials_with_access( ): array { $request_payload = []; - if ($acs_entrance_id !== null) { - $request_payload["acs_entrance_id"] = $acs_entrance_id; - } + $request_payload["acs_entrance_id"] = $acs_entrance_id; if ($include_if !== null) { $request_payload["include_if"] = $include_if; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/entrances/list_credentials_with_access", json: (object) $request_payload, @@ -190,36 +195,30 @@ public function list_credentials_with_access( * * @param string $acs_credential_id ID of the cloud_key credential to use for the unlock operation. * @param string $acs_entrance_id ID of the entrance to unlock. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function unlock( string $acs_credential_id, string $acs_entrance_id, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($acs_credential_id !== null) { - $request_payload["acs_credential_id"] = $acs_credential_id; - } - if ($acs_entrance_id !== null) { - $request_payload["acs_entrance_id"] = $acs_entrance_id; - } + $request_payload["acs_credential_id"] = $acs_credential_id; + $request_payload["acs_entrance_id"] = $acs_entrance_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/entrances/unlock", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } } diff --git a/src/Routes/AcsSystemsClient.php b/src/Routes/AcsSystemsClient.php index 9278fd9f..cc519bf5 100644 --- a/src/Routes/AcsSystemsClient.php +++ b/src/Routes/AcsSystemsClient.php @@ -2,16 +2,25 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\AcsSystem; -use Seam\SeamClient; class AcsSystemsClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -24,11 +33,9 @@ public function get(string $acs_system_id): AcsSystem { $request_payload = []; - if ($acs_system_id !== null) { - $request_payload["acs_system_id"] = $acs_system_id; - } + $request_payload["acs_system_id"] = $acs_system_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/systems/get", json: (object) $request_payload, @@ -64,7 +71,7 @@ public function list( $request_payload["search"] = $search; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/systems/list", json: (object) $request_payload, @@ -86,11 +93,9 @@ public function list_compatible_credential_manager_acs_systems( ): array { $request_payload = []; - if ($acs_system_id !== null) { - $request_payload["acs_system_id"] = $acs_system_id; - } + $request_payload["acs_system_id"] = $acs_system_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/systems/list_compatible_credential_manager_acs_systems", json: (object) $request_payload, @@ -114,9 +119,7 @@ public function report_devices( ): void { $request_payload = []; - if ($acs_system_id !== null) { - $request_payload["acs_system_id"] = $acs_system_id; - } + $request_payload["acs_system_id"] = $acs_system_id; if ($acs_encoders !== null) { $request_payload["acs_encoders"] = $acs_encoders; } @@ -124,7 +127,7 @@ public function report_devices( $request_payload["acs_entrances"] = $acs_entrances; } - $this->seam->request( + $this->client->request( "POST", "/acs/systems/report_devices", json: (object) $request_payload, diff --git a/src/Routes/AcsUsersClient.php b/src/Routes/AcsUsersClient.php index 729709e1..422a0502 100644 --- a/src/Routes/AcsUsersClient.php +++ b/src/Routes/AcsUsersClient.php @@ -2,17 +2,26 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\AcsEntrance; use Seam\Resources\AcsUser; -use Seam\SeamClient; class AcsUsersClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -28,14 +37,10 @@ public function add_to_access_group( ): void { $request_payload = []; - if ($acs_access_group_id !== null) { - $request_payload["acs_access_group_id"] = $acs_access_group_id; - } - if ($acs_user_id !== null) { - $request_payload["acs_user_id"] = $acs_user_id; - } + $request_payload["acs_access_group_id"] = $acs_access_group_id; + $request_payload["acs_user_id"] = $acs_user_id; - $this->seam->request( + $this->client->request( "POST", "/acs/users/add_to_access_group", json: (object) $request_payload, @@ -67,12 +72,8 @@ public function create( ): AcsUser { $request_payload = []; - if ($acs_system_id !== null) { - $request_payload["acs_system_id"] = $acs_system_id; - } - if ($full_name !== null) { - $request_payload["full_name"] = $full_name; - } + $request_payload["acs_system_id"] = $acs_system_id; + $request_payload["full_name"] = $full_name; if ($access_schedule !== null) { $request_payload["access_schedule"] = $access_schedule; } @@ -92,7 +93,7 @@ public function create( $request_payload["user_identity_id"] = $user_identity_id; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/users/create", json: (object) $request_payload, @@ -126,7 +127,7 @@ public function delete( $request_payload["user_identity_id"] = $user_identity_id; } - $this->seam->request( + $this->client->request( "POST", "/acs/users/delete", json: (object) $request_payload, @@ -158,7 +159,7 @@ public function get( $request_payload["user_identity_id"] = $user_identity_id; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/users/get", json: (object) $request_payload, @@ -178,6 +179,7 @@ public function get( * @param string $user_identity_email_address Email address of the user identity for which you want to retrieve all access system users. * @param string $user_identity_id ID of the user identity for which you want to retrieve all access system users. * @param string $user_identity_phone_number Phone number of the user identity for which you want to retrieve all access system users, in [E.164 format](https://www.itu.int/rec/T-REC-E.164/en) (for example, `+15555550100`). + * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ public function list( @@ -222,7 +224,7 @@ public function list( ] = $user_identity_phone_number; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/users/list", json: (object) $request_payload, @@ -260,7 +262,7 @@ public function list_accessible_entrances( $request_payload["user_identity_id"] = $user_identity_id; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/acs/users/list_accessible_entrances", json: (object) $request_payload, @@ -287,9 +289,7 @@ public function remove_from_access_group( ): void { $request_payload = []; - if ($acs_access_group_id !== null) { - $request_payload["acs_access_group_id"] = $acs_access_group_id; - } + $request_payload["acs_access_group_id"] = $acs_access_group_id; if ($acs_user_id !== null) { $request_payload["acs_user_id"] = $acs_user_id; } @@ -297,7 +297,7 @@ public function remove_from_access_group( $request_payload["user_identity_id"] = $user_identity_id; } - $this->seam->request( + $this->client->request( "POST", "/acs/users/remove_from_access_group", json: (object) $request_payload, @@ -329,7 +329,7 @@ public function revoke_access_to_all_entrances( $request_payload["user_identity_id"] = $user_identity_id; } - $this->seam->request( + $this->client->request( "POST", "/acs/users/revoke_access_to_all_entrances", json: (object) $request_payload, @@ -361,7 +361,7 @@ public function suspend( $request_payload["user_identity_id"] = $user_identity_id; } - $this->seam->request( + $this->client->request( "POST", "/acs/users/suspend", json: (object) $request_payload, @@ -393,7 +393,7 @@ public function unsuspend( $request_payload["user_identity_id"] = $user_identity_id; } - $this->seam->request( + $this->client->request( "POST", "/acs/users/unsuspend", json: (object) $request_payload, @@ -455,7 +455,7 @@ public function update( $request_payload["user_identity_id"] = $user_identity_id; } - $this->seam->request( + $this->client->request( "POST", "/acs/users/update", json: (object) $request_payload, diff --git a/src/Routes/ActionAttemptsClient.php b/src/Routes/ActionAttemptsClient.php index becd80db..971a286e 100644 --- a/src/Routes/ActionAttemptsClient.php +++ b/src/Routes/ActionAttemptsClient.php @@ -2,41 +2,55 @@ namespace Seam\Routes; -use Seam\ActionAttemptFailedError; -use Seam\ActionAttemptTimeoutError; +use Seam\Http\ResolveActionAttempt; +use Seam\Http\SeamHttpClient; use Seam\Resources\ActionAttempt; -use Seam\SeamClient; class ActionAttemptsClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** * Returns a specified [action attempt](https://docs.seam.co/core-concepts/action-attempts). * * @param string $action_attempt_id ID of the action attempt that you want to get. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ - public function get(string $action_attempt_id): ActionAttempt - { + public function get( + string $action_attempt_id, + bool|array|null $wait_for_action_attempt = null, + ): ActionAttempt { $request_payload = []; - if ($action_attempt_id !== null) { - $request_payload["action_attempt_id"] = $action_attempt_id; - } + $request_payload["action_attempt_id"] = $action_attempt_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/action_attempts/get", json: (object) $request_payload, ); - return ActionAttempt::from_json($res->action_attempt); + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], + ); } /** @@ -46,6 +60,8 @@ public function get(string $action_attempt_id): ActionAttempt * @param string $device_id ID of the device to filter action attempts by. * @param int $limit Maximum number of records to return per page. * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. + * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ public function list( @@ -53,6 +69,7 @@ public function list( ?string $device_id = null, ?int $limit = null, ?string $page_cursor = null, + bool|array|null $wait_for_action_attempt = null, ?callable $on_response = null, ): array { $request_payload = []; @@ -70,45 +87,17 @@ public function list( $request_payload["page_cursor"] = $page_cursor; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/action_attempts/list", json: (object) $request_payload, ); - if ($on_response !== null) { - $on_response($res); - } - - return array_map( - fn($r) => ActionAttempt::from_json($r), - $res->action_attempts, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempts), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); } - public function poll_until_ready( - string $action_attempt_id, - float $timeout = 20.0, - ): ActionAttempt { - $seam = $this->seam; - $time_waiting = 0.0; - $polling_interval = 0.4; - $action_attempt = $seam->action_attempts->get($action_attempt_id); - - while ($action_attempt->status == "pending") { - $action_attempt = $seam->action_attempts->get( - $action_attempt->action_attempt_id, - ); - if ($time_waiting > $timeout) { - throw new ActionAttemptTimeoutError($action_attempt, $timeout); - } - $time_waiting += $polling_interval; - usleep($polling_interval * 1000000); - } - - if ($action_attempt->status == "error") { - throw new ActionAttemptFailedError($action_attempt); - } - - return $action_attempt; - } } diff --git a/src/Routes/ClientSessionsClient.php b/src/Routes/ClientSessionsClient.php index ebb651de..93b81d8c 100644 --- a/src/Routes/ClientSessionsClient.php +++ b/src/Routes/ClientSessionsClient.php @@ -2,16 +2,25 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\ClientSession; -use Seam\SeamClient; class ClientSessionsClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -64,7 +73,7 @@ public function create( $request_payload["user_identity_ids"] = $user_identity_ids; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/client_sessions/create", json: (object) $request_payload, @@ -83,11 +92,9 @@ public function delete(string $client_session_id): void { $request_payload = []; - if ($client_session_id !== null) { - $request_payload["client_session_id"] = $client_session_id; - } + $request_payload["client_session_id"] = $client_session_id; - $this->seam->request( + $this->client->request( "POST", "/client_sessions/delete", json: (object) $request_payload, @@ -114,7 +121,7 @@ public function get( $request_payload["user_identifier_key"] = $user_identifier_key; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/client_sessions/get", json: (object) $request_payload, @@ -163,7 +170,7 @@ public function get_or_create( $request_payload["user_identity_ids"] = $user_identity_ids; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/client_sessions/get_or_create", json: (object) $request_payload, @@ -212,7 +219,7 @@ public function grant_access( $request_payload["user_identity_ids"] = $user_identity_ids; } - $this->seam->request( + $this->client->request( "POST", "/client_sessions/grant_access", json: (object) $request_payload, @@ -256,7 +263,7 @@ public function list( ] = $without_user_identifier_key; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/client_sessions/list", json: (object) $request_payload, @@ -280,11 +287,9 @@ public function revoke(string $client_session_id): void { $request_payload = []; - if ($client_session_id !== null) { - $request_payload["client_session_id"] = $client_session_id; - } + $request_payload["client_session_id"] = $client_session_id; - $this->seam->request( + $this->client->request( "POST", "/client_sessions/revoke", json: (object) $request_payload, diff --git a/src/Routes/ConnectWebviewsClient.php b/src/Routes/ConnectWebviewsClient.php index 4bd32a3e..760d8edb 100644 --- a/src/Routes/ConnectWebviewsClient.php +++ b/src/Routes/ConnectWebviewsClient.php @@ -2,16 +2,25 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\ConnectWebview; -use Seam\SeamClient; class ConnectWebviewsClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -86,7 +95,7 @@ public function create( ] = $wait_for_device_creation; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/connect_webviews/create", json: (object) $request_payload, @@ -107,11 +116,9 @@ public function delete(string $connect_webview_id): void { $request_payload = []; - if ($connect_webview_id !== null) { - $request_payload["connect_webview_id"] = $connect_webview_id; - } + $request_payload["connect_webview_id"] = $connect_webview_id; - $this->seam->request( + $this->client->request( "POST", "/connect_webviews/delete", json: (object) $request_payload, @@ -130,11 +137,9 @@ public function get(string $connect_webview_id): ConnectWebview { $request_payload = []; - if ($connect_webview_id !== null) { - $request_payload["connect_webview_id"] = $connect_webview_id; - } + $request_payload["connect_webview_id"] = $connect_webview_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/connect_webviews/get", json: (object) $request_payload, @@ -152,6 +157,7 @@ public function get(string $connect_webview_id): ConnectWebview * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $search String for which to search. Filters returned Connect Webviews to include all records that satisfy a partial match using `connect_webview_id`, `accepted_providers`, `custom_metadata`, or `customer_key`. * @param string $user_identifier_key Your user ID for the user by which you want to filter Connect Webviews. + * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ public function list( @@ -184,7 +190,7 @@ public function list( $request_payload["user_identifier_key"] = $user_identifier_key; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/connect_webviews/list", json: (object) $request_payload, diff --git a/src/Routes/ConnectedAccountsClient.php b/src/Routes/ConnectedAccountsClient.php index d3873aa0..9725597a 100644 --- a/src/Routes/ConnectedAccountsClient.php +++ b/src/Routes/ConnectedAccountsClient.php @@ -2,17 +2,29 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\ConnectedAccount; -use Seam\SeamClient; class ConnectedAccountsClient { - private SeamClient $seam; + private SeamHttpClient $client; + + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; public ConnectedAccountsSimulateClient $simulate; - public function __construct(SeamClient $seam) + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; - $this->simulate = new ConnectedAccountsSimulateClient($seam); + $this->client = $client; + $this->defaults = $defaults; + $this->simulate = new ConnectedAccountsSimulateClient( + $client, + $defaults, + ); } /** @@ -29,11 +41,9 @@ public function delete(string $connected_account_id): void { $request_payload = []; - if ($connected_account_id !== null) { - $request_payload["connected_account_id"] = $connected_account_id; - } + $request_payload["connected_account_id"] = $connected_account_id; - $this->seam->request( + $this->client->request( "POST", "/connected_accounts/delete", json: (object) $request_payload, @@ -60,7 +70,7 @@ public function get( $request_payload["email"] = $email; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/connected_accounts/get", json: (object) $request_payload, @@ -79,6 +89,7 @@ public function get( * @param string $search String for which to search. Filters returned connected accounts to include all records that satisfy a partial match using `connected_account_id`, `account_type`, `customer_key`, `custom_metadata`, `user_identifier.username`, `user_identifier.email` or `user_identifier.phone`. * @param string $space_id ID of the space by which you want to filter connected accounts. * @param string $user_identifier_key Your user ID for the user by which you want to filter connected accounts. + * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ public function list( @@ -115,7 +126,7 @@ public function list( $request_payload["user_identifier_key"] = $user_identifier_key; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/connected_accounts/list", json: (object) $request_payload, @@ -141,11 +152,9 @@ public function sync(string $connected_account_id): void { $request_payload = []; - if ($connected_account_id !== null) { - $request_payload["connected_account_id"] = $connected_account_id; - } + $request_payload["connected_account_id"] = $connected_account_id; - $this->seam->request( + $this->client->request( "POST", "/connected_accounts/sync", json: (object) $request_payload, @@ -173,9 +182,7 @@ public function update( ): void { $request_payload = []; - if ($connected_account_id !== null) { - $request_payload["connected_account_id"] = $connected_account_id; - } + $request_payload["connected_account_id"] = $connected_account_id; if ($accepted_capabilities !== null) { $request_payload["accepted_capabilities"] = $accepted_capabilities; } @@ -194,7 +201,7 @@ public function update( $request_payload["display_name"] = $display_name; } - $this->seam->request( + $this->client->request( "POST", "/connected_accounts/update", json: (object) $request_payload, diff --git a/src/Routes/ConnectedAccountsSimulateClient.php b/src/Routes/ConnectedAccountsSimulateClient.php index d3a913df..4eef7cb8 100644 --- a/src/Routes/ConnectedAccountsSimulateClient.php +++ b/src/Routes/ConnectedAccountsSimulateClient.php @@ -2,15 +2,24 @@ namespace Seam\Routes; -use Seam\SeamClient; +use Seam\Http\SeamHttpClient; class ConnectedAccountsSimulateClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -23,11 +32,9 @@ public function disconnect(string $connected_account_id): void { $request_payload = []; - if ($connected_account_id !== null) { - $request_payload["connected_account_id"] = $connected_account_id; - } + $request_payload["connected_account_id"] = $connected_account_id; - $this->seam->request( + $this->client->request( "POST", "/connected_accounts/simulate/disconnect", json: (object) $request_payload, diff --git a/src/Routes/CustomersClient.php b/src/Routes/CustomersClient.php index f8c97a1f..3fe55788 100644 --- a/src/Routes/CustomersClient.php +++ b/src/Routes/CustomersClient.php @@ -2,16 +2,25 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\CustomerPortal; -use Seam\SeamClient; class CustomersClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -83,7 +92,7 @@ public function create_portal( $request_payload["customer_data"] = $customer_data; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/customers/create_portal", json: (object) $request_payload, @@ -198,7 +207,7 @@ public function delete_data( $request_payload["user_keys"] = $user_keys; } - $this->seam->request( + $this->client->request( "POST", "/customers/delete_data", json: (object) $request_payload, @@ -254,9 +263,7 @@ public function push_data( ): void { $request_payload = []; - if ($customer_key !== null) { - $request_payload["customer_key"] = $customer_key; - } + $request_payload["customer_key"] = $customer_key; if ($access_grants !== null) { $request_payload["access_grants"] = $access_grants; } @@ -315,7 +322,7 @@ public function push_data( $request_payload["users"] = $users; } - $this->seam->request( + $this->client->request( "POST", "/customers/push_data", json: (object) $request_payload, diff --git a/src/Routes/DevicesClient.php b/src/Routes/DevicesClient.php index 9eb2f20c..61ed5303 100644 --- a/src/Routes/DevicesClient.php +++ b/src/Routes/DevicesClient.php @@ -2,20 +2,29 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\Device; use Seam\Resources\DeviceProvider; -use Seam\SeamClient; class DevicesClient { - private SeamClient $seam; + private SeamHttpClient $client; + + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; public DevicesSimulateClient $simulate; public DevicesUnmanagedClient $unmanaged; - public function __construct(SeamClient $seam) + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; - $this->simulate = new DevicesSimulateClient($seam); - $this->unmanaged = new DevicesUnmanagedClient($seam); + $this->client = $client; + $this->defaults = $defaults; + $this->simulate = new DevicesSimulateClient($client, $defaults); + $this->unmanaged = new DevicesUnmanagedClient($client, $defaults); } /** @@ -38,7 +47,7 @@ public function get(?string $device_id = null, ?string $name = null): Device $request_payload["name"] = $name; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/devices/get", json: (object) $request_payload, @@ -66,6 +75,7 @@ public function get(?string $device_id = null, ?string $name = null): Device * @param string $space_id ID of the space for which you want to list devices. * @param string $unstable_location_id * @param string $user_identifier_key Your own internal user ID for the user for which you want to list devices. + * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ public function list( @@ -138,7 +148,7 @@ public function list( $request_payload["user_identifier_key"] = $user_identifier_key; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/devices/list", json: (object) $request_payload, @@ -170,7 +180,7 @@ public function list_device_providers( $request_payload["provider_category"] = $provider_category; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/devices/list_device_providers", json: (object) $request_payload, @@ -192,11 +202,9 @@ public function report_provider_metadata(array $devices): void { $request_payload = []; - if ($devices !== null) { - $request_payload["devices"] = $devices; - } + $request_payload["devices"] = $devices; - $this->seam->request( + $this->client->request( "POST", "/devices/report_provider_metadata", json: (object) $request_payload, @@ -226,9 +234,7 @@ public function update( ): void { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; if ($backup_access_code_pool_enabled !== null) { $request_payload[ "backup_access_code_pool_enabled" @@ -247,7 +253,7 @@ public function update( $request_payload["properties"] = $properties; } - $this->seam->request( + $this->client->request( "POST", "/devices/update", json: (object) $request_payload, diff --git a/src/Routes/DevicesSimulateClient.php b/src/Routes/DevicesSimulateClient.php index 9fb351b3..2a14d57d 100644 --- a/src/Routes/DevicesSimulateClient.php +++ b/src/Routes/DevicesSimulateClient.php @@ -2,15 +2,24 @@ namespace Seam\Routes; -use Seam\SeamClient; +use Seam\Http\SeamHttpClient; class DevicesSimulateClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -23,11 +32,9 @@ public function connect(string $device_id): void { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; - $this->seam->request( + $this->client->request( "POST", "/devices/simulate/connect", json: (object) $request_payload, @@ -47,11 +54,9 @@ public function connect_to_hub(string $device_id): void { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; - $this->seam->request( + $this->client->request( "POST", "/devices/simulate/connect_to_hub", json: (object) $request_payload, @@ -68,11 +73,9 @@ public function disconnect(string $device_id): void { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; - $this->seam->request( + $this->client->request( "POST", "/devices/simulate/disconnect", json: (object) $request_payload, @@ -93,11 +96,9 @@ public function disconnect_from_hub(string $device_id): void { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; - $this->seam->request( + $this->client->request( "POST", "/devices/simulate/disconnect_from_hub", json: (object) $request_payload, @@ -117,14 +118,10 @@ public function paid_subscription(string $device_id, bool $is_expired): void { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } - if ($is_expired !== null) { - $request_payload["is_expired"] = $is_expired; - } + $request_payload["device_id"] = $device_id; + $request_payload["is_expired"] = $is_expired; - $this->seam->request( + $this->client->request( "POST", "/devices/simulate/paid_subscription", json: (object) $request_payload, @@ -141,11 +138,9 @@ public function remove(string $device_id): void { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; - $this->seam->request( + $this->client->request( "POST", "/devices/simulate/remove", json: (object) $request_payload, diff --git a/src/Routes/DevicesUnmanagedClient.php b/src/Routes/DevicesUnmanagedClient.php index 171090df..541be58e 100644 --- a/src/Routes/DevicesUnmanagedClient.php +++ b/src/Routes/DevicesUnmanagedClient.php @@ -2,16 +2,25 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\UnmanagedDevice; -use Seam\SeamClient; class DevicesUnmanagedClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -38,7 +47,7 @@ public function get( $request_payload["name"] = $name; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/devices/unmanaged/get", json: (object) $request_payload, @@ -68,6 +77,7 @@ public function get( * @param string $space_id ID of the space for which you want to list devices. * @param string $unstable_location_id * @param string $user_identifier_key Your own internal user ID for the user for which you want to list devices. + * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ public function list( @@ -140,7 +150,7 @@ public function list( $request_payload["user_identifier_key"] = $user_identifier_key; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/devices/unmanaged/list", json: (object) $request_payload, @@ -173,9 +183,7 @@ public function update( ): void { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; if ($custom_metadata !== null) { $request_payload["custom_metadata"] = $custom_metadata; } @@ -183,7 +191,7 @@ public function update( $request_payload["is_managed"] = $is_managed; } - $this->seam->request( + $this->client->request( "POST", "/devices/unmanaged/update", json: (object) $request_payload, diff --git a/src/Routes/EventsClient.php b/src/Routes/EventsClient.php index a1c37ba1..f2f1f65c 100644 --- a/src/Routes/EventsClient.php +++ b/src/Routes/EventsClient.php @@ -2,16 +2,25 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\Event; -use Seam\SeamClient; class EventsClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -39,7 +48,7 @@ public function get( $request_payload["event_type"] = $event_type; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/events/get", json: (object) $request_payload, @@ -198,7 +207,7 @@ public function list( $request_payload["user_identity_id"] = $user_identity_id; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/events/list", json: (object) $request_payload, diff --git a/src/Routes/InstantKeysClient.php b/src/Routes/InstantKeysClient.php index 34815729..e91f4f30 100644 --- a/src/Routes/InstantKeysClient.php +++ b/src/Routes/InstantKeysClient.php @@ -2,16 +2,25 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\InstantKey; -use Seam\SeamClient; class InstantKeysClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -24,11 +33,9 @@ public function delete(string $instant_key_id): void { $request_payload = []; - if ($instant_key_id !== null) { - $request_payload["instant_key_id"] = $instant_key_id; - } + $request_payload["instant_key_id"] = $instant_key_id; - $this->seam->request( + $this->client->request( "POST", "/instant_keys/delete", json: (object) $request_payload, @@ -55,7 +62,7 @@ public function get( $request_payload["instant_key_url"] = $instant_key_url; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/instant_keys/get", json: (object) $request_payload, @@ -78,7 +85,7 @@ public function list(?string $user_identity_id = null): array $request_payload["user_identity_id"] = $user_identity_id; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/instant_keys/list", json: (object) $request_payload, diff --git a/src/Routes/LocksClient.php b/src/Routes/LocksClient.php index 00878fd9..d54afadf 100644 --- a/src/Routes/LocksClient.php +++ b/src/Routes/LocksClient.php @@ -2,18 +2,28 @@ namespace Seam\Routes; +use Seam\Http\ResolveActionAttempt; +use Seam\Http\SeamHttpClient; use Seam\Resources\ActionAttempt; use Seam\Resources\Device; -use Seam\SeamClient; class LocksClient { - private SeamClient $seam; + private SeamHttpClient $client; + + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; public LocksSimulateClient $simulate; - public function __construct(SeamClient $seam) + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; - $this->simulate = new LocksSimulateClient($seam); + $this->client = $client; + $this->defaults = $defaults; + $this->simulate = new LocksSimulateClient($client, $defaults); } /** @@ -22,43 +32,37 @@ public function __construct(SeamClient $seam) * @param bool $auto_lock_enabled Whether to enable or disable auto-lock. * @param string $device_id ID of the lock for which you want to configure the auto-lock. * @param float $auto_lock_delay_seconds Delay in seconds before the lock automatically locks. Required when enabling auto-lock. Must be between 1 and 60. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function configure_auto_lock( bool $auto_lock_enabled, string $device_id, ?float $auto_lock_delay_seconds = null, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($auto_lock_enabled !== null) { - $request_payload["auto_lock_enabled"] = $auto_lock_enabled; - } - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["auto_lock_enabled"] = $auto_lock_enabled; + $request_payload["device_id"] = $device_id; if ($auto_lock_delay_seconds !== null) { $request_payload[ "auto_lock_delay_seconds" ] = $auto_lock_delay_seconds; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/locks/configure_auto_lock", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } /** @@ -80,7 +84,7 @@ public function get(?string $device_id = null, ?string $name = null): Device $request_payload["name"] = $name; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/locks/get", json: (object) $request_payload, @@ -108,6 +112,7 @@ public function get(?string $device_id = null, ?string $name = null): Device * @param string $space_id ID of the space for which you want to list devices. * @param string $unstable_location_id * @param string $user_identifier_key Your own internal user ID for the user for which you want to list devices. + * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ public function list( @@ -180,7 +185,7 @@ public function list( $request_payload["user_identifier_key"] = $user_identifier_key; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/locks/list", json: (object) $request_payload, @@ -197,65 +202,57 @@ public function list( * Locks a [lock](https://docs.seam.co/low-level-apis/smart-locks). See also [Locking and Unlocking Smart Locks](https://docs.seam.co/low-level-apis/smart-locks/lock-and-unlock). * * @param string $device_id ID of the lock that you want to lock. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function lock_door( string $device_id, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/locks/lock_door", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } /** * Unlocks a [lock](https://docs.seam.co/low-level-apis/smart-locks). See also [Locking and Unlocking Smart Locks](https://docs.seam.co/low-level-apis/smart-locks/lock-and-unlock). * * @param string $device_id ID of the lock that you want to unlock. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function unlock_door( string $device_id, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/locks/unlock_door", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } } diff --git a/src/Routes/LocksSimulateClient.php b/src/Routes/LocksSimulateClient.php index 500448d8..ca91968c 100644 --- a/src/Routes/LocksSimulateClient.php +++ b/src/Routes/LocksSimulateClient.php @@ -2,16 +2,26 @@ namespace Seam\Routes; +use Seam\Http\ResolveActionAttempt; +use Seam\Http\SeamHttpClient; use Seam\Resources\ActionAttempt; -use Seam\SeamClient; class LocksSimulateClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -19,69 +29,59 @@ public function __construct(SeamClient $seam) * * @param string $code Code that you want to simulate entering on a keypad. * @param string $device_id ID of the device for which you want to simulate a keypad code entry. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function keypad_code_entry( string $code, string $device_id, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($code !== null) { - $request_payload["code"] = $code; - } - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["code"] = $code; + $request_payload["device_id"] = $device_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/locks/simulate/keypad_code_entry", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } /** * Simulates a manual lock action using a keypad. You can only perform this action for [August](https://docs.seam.co/device-and-system-integration-guides/august-locks) devices within [sandbox workspaces](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces). * * @param string $device_id ID of the device for which you want to simulate a manual lock action using a keypad. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function manual_lock_via_keypad( string $device_id, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/locks/simulate/manual_lock_via_keypad", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } } diff --git a/src/Routes/NoiseSensorsClient.php b/src/Routes/NoiseSensorsClient.php index d34a85b9..3f199835 100644 --- a/src/Routes/NoiseSensorsClient.php +++ b/src/Routes/NoiseSensorsClient.php @@ -2,19 +2,31 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\Device; -use Seam\SeamClient; class NoiseSensorsClient { - private SeamClient $seam; + private SeamHttpClient $client; + + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; public NoiseSensorsNoiseThresholdsClient $noise_thresholds; public NoiseSensorsSimulateClient $simulate; - public function __construct(SeamClient $seam) + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; - $this->noise_thresholds = new NoiseSensorsNoiseThresholdsClient($seam); - $this->simulate = new NoiseSensorsSimulateClient($seam); + $this->client = $client; + $this->defaults = $defaults; + $this->noise_thresholds = new NoiseSensorsNoiseThresholdsClient( + $client, + $defaults, + ); + $this->simulate = new NoiseSensorsSimulateClient($client, $defaults); } /** @@ -36,6 +48,7 @@ public function __construct(SeamClient $seam) * @param string $space_id ID of the space for which you want to list devices. * @param string $unstable_location_id * @param string $user_identifier_key Your own internal user ID for the user for which you want to list devices. + * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ public function list( @@ -108,7 +121,7 @@ public function list( $request_payload["user_identifier_key"] = $user_identifier_key; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/noise_sensors/list", json: (object) $request_payload, diff --git a/src/Routes/NoiseSensorsNoiseThresholdsClient.php b/src/Routes/NoiseSensorsNoiseThresholdsClient.php index 80492126..af7ad0de 100644 --- a/src/Routes/NoiseSensorsNoiseThresholdsClient.php +++ b/src/Routes/NoiseSensorsNoiseThresholdsClient.php @@ -2,16 +2,25 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\NoiseThreshold; -use Seam\SeamClient; class NoiseSensorsNoiseThresholdsClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -35,15 +44,9 @@ public function create( ): NoiseThreshold { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } - if ($ends_daily_at !== null) { - $request_payload["ends_daily_at"] = $ends_daily_at; - } - if ($starts_daily_at !== null) { - $request_payload["starts_daily_at"] = $starts_daily_at; - } + $request_payload["device_id"] = $device_id; + $request_payload["ends_daily_at"] = $ends_daily_at; + $request_payload["starts_daily_at"] = $starts_daily_at; if ($name !== null) { $request_payload["name"] = $name; } @@ -56,7 +59,7 @@ public function create( $request_payload["noise_threshold_nrs"] = $noise_threshold_nrs; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/noise_sensors/noise_thresholds/create", json: (object) $request_payload, @@ -76,14 +79,10 @@ public function delete(string $device_id, string $noise_threshold_id): void { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } - if ($noise_threshold_id !== null) { - $request_payload["noise_threshold_id"] = $noise_threshold_id; - } + $request_payload["device_id"] = $device_id; + $request_payload["noise_threshold_id"] = $noise_threshold_id; - $this->seam->request( + $this->client->request( "POST", "/noise_sensors/noise_thresholds/delete", json: (object) $request_payload, @@ -100,11 +99,9 @@ public function get(string $noise_threshold_id): NoiseThreshold { $request_payload = []; - if ($noise_threshold_id !== null) { - $request_payload["noise_threshold_id"] = $noise_threshold_id; - } + $request_payload["noise_threshold_id"] = $noise_threshold_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/noise_sensors/noise_thresholds/get", json: (object) $request_payload, @@ -123,11 +120,9 @@ public function list(string $device_id): array { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/noise_sensors/noise_thresholds/list", json: (object) $request_payload, @@ -162,12 +157,8 @@ public function update( ): void { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } - if ($noise_threshold_id !== null) { - $request_payload["noise_threshold_id"] = $noise_threshold_id; - } + $request_payload["device_id"] = $device_id; + $request_payload["noise_threshold_id"] = $noise_threshold_id; if ($ends_daily_at !== null) { $request_payload["ends_daily_at"] = $ends_daily_at; } @@ -186,7 +177,7 @@ public function update( $request_payload["starts_daily_at"] = $starts_daily_at; } - $this->seam->request( + $this->client->request( "POST", "/noise_sensors/noise_thresholds/update", json: (object) $request_payload, diff --git a/src/Routes/NoiseSensorsSimulateClient.php b/src/Routes/NoiseSensorsSimulateClient.php index ac6c5891..7388b50b 100644 --- a/src/Routes/NoiseSensorsSimulateClient.php +++ b/src/Routes/NoiseSensorsSimulateClient.php @@ -2,15 +2,24 @@ namespace Seam\Routes; -use Seam\SeamClient; +use Seam\Http\SeamHttpClient; class NoiseSensorsSimulateClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -23,11 +32,9 @@ public function trigger_noise_threshold(string $device_id): void { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; - $this->seam->request( + $this->client->request( "POST", "/noise_sensors/simulate/trigger_noise_threshold", json: (object) $request_payload, diff --git a/src/Routes/PhonesClient.php b/src/Routes/PhonesClient.php index 922b3f64..b97d5243 100644 --- a/src/Routes/PhonesClient.php +++ b/src/Routes/PhonesClient.php @@ -2,17 +2,26 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\Phone; -use Seam\SeamClient; class PhonesClient { - private SeamClient $seam; + private SeamHttpClient $client; + + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; public PhonesSimulateClient $simulate; - public function __construct(SeamClient $seam) + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; - $this->simulate = new PhonesSimulateClient($seam); + $this->client = $client; + $this->defaults = $defaults; + $this->simulate = new PhonesSimulateClient($client, $defaults); } /** @@ -25,11 +34,9 @@ public function deactivate(string $device_id): void { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; - $this->seam->request( + $this->client->request( "POST", "/phones/deactivate", json: (object) $request_payload, @@ -46,11 +53,9 @@ public function get(string $device_id): Phone { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/phones/get", json: (object) $request_payload, @@ -81,7 +86,7 @@ public function list( ] = $owner_user_identity_id; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/phones/list", json: (object) $request_payload, diff --git a/src/Routes/PhonesSimulateClient.php b/src/Routes/PhonesSimulateClient.php index e33ee226..85c5e1ff 100644 --- a/src/Routes/PhonesSimulateClient.php +++ b/src/Routes/PhonesSimulateClient.php @@ -2,16 +2,25 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\Phone; -use Seam\SeamClient; class PhonesSimulateClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -31,9 +40,7 @@ public function create_sandbox_phone( ): Phone { $request_payload = []; - if ($user_identity_id !== null) { - $request_payload["user_identity_id"] = $user_identity_id; - } + $request_payload["user_identity_id"] = $user_identity_id; if ($assa_abloy_metadata !== null) { $request_payload["assa_abloy_metadata"] = $assa_abloy_metadata; } @@ -46,7 +53,7 @@ public function create_sandbox_phone( $request_payload["phone_metadata"] = $phone_metadata; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/phones/simulate/create_sandbox_phone", json: (object) $request_payload, diff --git a/src/Routes/SpacesClient.php b/src/Routes/SpacesClient.php index b90f93b9..46db212d 100644 --- a/src/Routes/SpacesClient.php +++ b/src/Routes/SpacesClient.php @@ -2,17 +2,26 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\Batch; use Seam\Resources\Space; -use Seam\SeamClient; class SpacesClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -28,14 +37,10 @@ public function add_acs_entrances( ): void { $request_payload = []; - if ($acs_entrance_ids !== null) { - $request_payload["acs_entrance_ids"] = $acs_entrance_ids; - } - if ($space_id !== null) { - $request_payload["space_id"] = $space_id; - } + $request_payload["acs_entrance_ids"] = $acs_entrance_ids; + $request_payload["space_id"] = $space_id; - $this->seam->request( + $this->client->request( "POST", "/spaces/add_acs_entrances", json: (object) $request_payload, @@ -55,14 +60,10 @@ public function add_connected_account( ): void { $request_payload = []; - if ($connected_account_id !== null) { - $request_payload["connected_account_id"] = $connected_account_id; - } - if ($space_id !== null) { - $request_payload["space_id"] = $space_id; - } + $request_payload["connected_account_id"] = $connected_account_id; + $request_payload["space_id"] = $space_id; - $this->seam->request( + $this->client->request( "POST", "/spaces/add_connected_account", json: (object) $request_payload, @@ -80,14 +81,10 @@ public function add_devices(array $device_ids, string $space_id): void { $request_payload = []; - if ($device_ids !== null) { - $request_payload["device_ids"] = $device_ids; - } - if ($space_id !== null) { - $request_payload["space_id"] = $space_id; - } + $request_payload["device_ids"] = $device_ids; + $request_payload["space_id"] = $space_id; - $this->seam->request( + $this->client->request( "POST", "/spaces/add_devices", json: (object) $request_payload, @@ -117,9 +114,7 @@ public function create( ): Space { $request_payload = []; - if ($name !== null) { - $request_payload["name"] = $name; - } + $request_payload["name"] = $name; if ($acs_entrance_ids !== null) { $request_payload["acs_entrance_ids"] = $acs_entrance_ids; } @@ -139,7 +134,7 @@ public function create( $request_payload["space_key"] = $space_key; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/spaces/create", json: (object) $request_payload, @@ -158,11 +153,9 @@ public function delete(string $space_id): void { $request_payload = []; - if ($space_id !== null) { - $request_payload["space_id"] = $space_id; - } + $request_payload["space_id"] = $space_id; - $this->seam->request( + $this->client->request( "POST", "/spaces/delete", json: (object) $request_payload, @@ -189,7 +182,7 @@ public function get( $request_payload["space_key"] = $space_key; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/spaces/get", json: (object) $request_payload, @@ -228,7 +221,7 @@ public function get_related( $request_payload["space_keys"] = $space_keys; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/spaces/get_related", json: (object) $request_payload, @@ -245,6 +238,7 @@ public function get_related( * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $search String for which to search. Filters returned spaces to include all records that satisfy a partial match using `name`, `space_key`, or `customer_key`. * @param string $space_key Filter spaces by space_key. + * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ public function list( @@ -273,7 +267,7 @@ public function list( $request_payload["space_key"] = $space_key; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/spaces/list", json: (object) $request_payload, @@ -299,14 +293,10 @@ public function remove_acs_entrances( ): void { $request_payload = []; - if ($acs_entrance_ids !== null) { - $request_payload["acs_entrance_ids"] = $acs_entrance_ids; - } - if ($space_id !== null) { - $request_payload["space_id"] = $space_id; - } + $request_payload["acs_entrance_ids"] = $acs_entrance_ids; + $request_payload["space_id"] = $space_id; - $this->seam->request( + $this->client->request( "POST", "/spaces/remove_acs_entrances", json: (object) $request_payload, @@ -326,14 +316,10 @@ public function remove_connected_account( ): void { $request_payload = []; - if ($connected_account_id !== null) { - $request_payload["connected_account_id"] = $connected_account_id; - } - if ($space_id !== null) { - $request_payload["space_id"] = $space_id; - } + $request_payload["connected_account_id"] = $connected_account_id; + $request_payload["space_id"] = $space_id; - $this->seam->request( + $this->client->request( "POST", "/spaces/remove_connected_account", json: (object) $request_payload, @@ -351,14 +337,10 @@ public function remove_devices(array $device_ids, string $space_id): void { $request_payload = []; - if ($device_ids !== null) { - $request_payload["device_ids"] = $device_ids; - } - if ($space_id !== null) { - $request_payload["space_id"] = $space_id; - } + $request_payload["device_ids"] = $device_ids; + $request_payload["space_id"] = $space_id; - $this->seam->request( + $this->client->request( "POST", "/spaces/remove_devices", json: (object) $request_payload, @@ -405,7 +387,7 @@ public function update( $request_payload["space_key"] = $space_key; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/spaces/update", json: (object) $request_payload, diff --git a/src/Routes/ThermostatsClient.php b/src/Routes/ThermostatsClient.php index 650aa7d6..7d7c4b4d 100644 --- a/src/Routes/ThermostatsClient.php +++ b/src/Routes/ThermostatsClient.php @@ -2,22 +2,35 @@ namespace Seam\Routes; +use Seam\Http\ResolveActionAttempt; +use Seam\Http\SeamHttpClient; use Seam\Resources\ActionAttempt; use Seam\Resources\Device; -use Seam\SeamClient; class ThermostatsClient { - private SeamClient $seam; + private SeamHttpClient $client; + + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; public ThermostatsDailyProgramsClient $daily_programs; public ThermostatsSchedulesClient $schedules; public ThermostatsSimulateClient $simulate; - public function __construct(SeamClient $seam) + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; - $this->daily_programs = new ThermostatsDailyProgramsClient($seam); - $this->schedules = new ThermostatsSchedulesClient($seam); - $this->simulate = new ThermostatsSimulateClient($seam); + $this->client = $client; + $this->defaults = $defaults; + $this->daily_programs = new ThermostatsDailyProgramsClient( + $client, + $defaults, + ); + $this->schedules = new ThermostatsSchedulesClient($client, $defaults); + $this->simulate = new ThermostatsSimulateClient($client, $defaults); } /** @@ -25,37 +38,31 @@ public function __construct(SeamClient $seam) * * @param string $climate_preset_key Climate preset key of the climate preset that you want to activate. * @param string $device_id ID of the thermostat device for which you want to activate a climate preset. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function activate_climate_preset( string $climate_preset_key, string $device_id, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($climate_preset_key !== null) { - $request_payload["climate_preset_key"] = $climate_preset_key; - } - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["climate_preset_key"] = $climate_preset_key; + $request_payload["device_id"] = $device_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/thermostats/activate_climate_preset", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } /** @@ -64,19 +71,18 @@ public function activate_climate_preset( * @param string $device_id ID of the thermostat device that you want to set to cool mode. * @param float $cooling_set_point_celsius [Cooling set point](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points) in °C that you want to set for the thermostat. You must set one of the `cooling_set_point` parameters. * @param float $cooling_set_point_fahrenheit [Cooling set point](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points) in °F that you want to set for the thermostat. You must set one of the `cooling_set_point` parameters. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function cool( string $device_id, ?float $cooling_set_point_celsius = null, ?float $cooling_set_point_fahrenheit = null, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; if ($cooling_set_point_celsius !== null) { $request_payload[ "cooling_set_point_celsius" @@ -88,21 +94,18 @@ public function cool( ] = $cooling_set_point_fahrenheit; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/thermostats/cool", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } /** @@ -138,12 +141,8 @@ public function create_climate_preset( ): void { $request_payload = []; - if ($climate_preset_key !== null) { - $request_payload["climate_preset_key"] = $climate_preset_key; - } - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["climate_preset_key"] = $climate_preset_key; + $request_payload["device_id"] = $device_id; if ($climate_preset_mode !== null) { $request_payload["climate_preset_mode"] = $climate_preset_mode; } @@ -185,7 +184,7 @@ public function create_climate_preset( $request_payload["name"] = $name; } - $this->seam->request( + $this->client->request( "POST", "/thermostats/create_climate_preset", json: (object) $request_payload, @@ -205,14 +204,10 @@ public function delete_climate_preset( ): void { $request_payload = []; - if ($climate_preset_key !== null) { - $request_payload["climate_preset_key"] = $climate_preset_key; - } - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["climate_preset_key"] = $climate_preset_key; + $request_payload["device_id"] = $device_id; - $this->seam->request( + $this->client->request( "POST", "/thermostats/delete_climate_preset", json: (object) $request_payload, @@ -225,19 +220,18 @@ public function delete_climate_preset( * @param string $device_id ID of the thermostat device that you want to set to heat mode. * @param float $heating_set_point_celsius [Heating set point](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points) in °C that you want to set for the thermostat. You must set one of the `heating_set_point` parameters. * @param float $heating_set_point_fahrenheit [Heating set point](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points) in °F that you want to set for the thermostat. You must set one of the `heating_set_point` parameters. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function heat( string $device_id, ?float $heating_set_point_celsius = null, ?float $heating_set_point_fahrenheit = null, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; if ($heating_set_point_celsius !== null) { $request_payload[ "heating_set_point_celsius" @@ -249,21 +243,18 @@ public function heat( ] = $heating_set_point_fahrenheit; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/thermostats/heat", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } /** @@ -274,6 +265,7 @@ public function heat( * @param float $cooling_set_point_fahrenheit [Cooling set point](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points) in °F that you want to set for the thermostat. You must set one of the `cooling_set_point` parameters. * @param float $heating_set_point_celsius [Heating set point](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points) in °C that you want to set for the thermostat. You must set one of the `heating_set_point` parameters. * @param float $heating_set_point_fahrenheit [Heating set point](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points) in °F that you want to set for the thermostat. You must set one of the `heating_set_point` parameters. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function heat_cool( @@ -282,13 +274,11 @@ public function heat_cool( ?float $cooling_set_point_fahrenheit = null, ?float $heating_set_point_celsius = null, ?float $heating_set_point_fahrenheit = null, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; if ($cooling_set_point_celsius !== null) { $request_payload[ "cooling_set_point_celsius" @@ -310,21 +300,18 @@ public function heat_cool( ] = $heating_set_point_fahrenheit; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/thermostats/heat_cool", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } /** @@ -346,6 +333,7 @@ public function heat_cool( * @param string $space_id ID of the space for which you want to list devices. * @param string $unstable_location_id * @param string $user_identifier_key Your own internal user ID for the user for which you want to list devices. + * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ public function list( @@ -418,7 +406,7 @@ public function list( $request_payload["user_identifier_key"] = $user_identifier_key; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/thermostats/list", json: (object) $request_payload, @@ -435,33 +423,29 @@ public function list( * Sets a specified [thermostat](https://docs.seam.co/capability-guides/thermostats) to ["off" mode](https://docs.seam.co/capability-guides/thermostats/configure-current-climate-settings). * * @param string $device_id ID of the thermostat device that you want to set to off mode. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function off( string $device_id, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/thermostats/off", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } /** @@ -477,14 +461,10 @@ public function set_fallback_climate_preset( ): void { $request_payload = []; - if ($climate_preset_key !== null) { - $request_payload["climate_preset_key"] = $climate_preset_key; - } - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["climate_preset_key"] = $climate_preset_key; + $request_payload["device_id"] = $device_id; - $this->seam->request( + $this->client->request( "POST", "/thermostats/set_fallback_climate_preset", json: (object) $request_payload, @@ -497,19 +477,18 @@ public function set_fallback_climate_preset( * @param string $device_id ID of the thermostat device for which you want to set the fan mode. * @param string $fan_mode Fan mode setting for the thermostat, such as `auto`, `on`, or `circulate`. * @param string $fan_mode_setting [Fan mode setting](https://docs.seam.co/capability-guides/thermostats/configure-current-climate-settings#fan-mode-settings) that you want to set for the thermostat. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function set_fan_mode( string $device_id, ?string $fan_mode = null, ?string $fan_mode_setting = null, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; if ($fan_mode !== null) { $request_payload["fan_mode"] = $fan_mode; } @@ -517,21 +496,18 @@ public function set_fan_mode( $request_payload["fan_mode_setting"] = $fan_mode_setting; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/thermostats/set_fan_mode", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } /** @@ -543,6 +519,7 @@ public function set_fan_mode( * @param float $cooling_set_point_fahrenheit [Cooling set point](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points) in °F that you want to set for the thermostat. You must set one of the `cooling_set_point` parameters. * @param float $heating_set_point_celsius [Heating set point](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points) in °C that you want to set for the thermostat. You must set one of the `heating_set_point` parameters. * @param float $heating_set_point_fahrenheit [Heating set point](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points) in °F that you want to set for the thermostat. You must set one of the `heating_set_point` parameters. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function set_hvac_mode( @@ -552,16 +529,12 @@ public function set_hvac_mode( ?float $cooling_set_point_fahrenheit = null, ?float $heating_set_point_celsius = null, ?float $heating_set_point_fahrenheit = null, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } - if ($hvac_mode_setting !== null) { - $request_payload["hvac_mode_setting"] = $hvac_mode_setting; - } + $request_payload["device_id"] = $device_id; + $request_payload["hvac_mode_setting"] = $hvac_mode_setting; if ($cooling_set_point_celsius !== null) { $request_payload[ "cooling_set_point_celsius" @@ -583,21 +556,18 @@ public function set_hvac_mode( ] = $heating_set_point_fahrenheit; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/thermostats/set_hvac_mode", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } /** @@ -619,9 +589,7 @@ public function set_temperature_threshold( ): void { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; if ($lower_limit_celsius !== null) { $request_payload["lower_limit_celsius"] = $lower_limit_celsius; } @@ -639,7 +607,7 @@ public function set_temperature_threshold( ] = $upper_limit_fahrenheit; } - $this->seam->request( + $this->client->request( "POST", "/thermostats/set_temperature_threshold", json: (object) $request_payload, @@ -679,12 +647,8 @@ public function update_climate_preset( ): void { $request_payload = []; - if ($climate_preset_key !== null) { - $request_payload["climate_preset_key"] = $climate_preset_key; - } - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["climate_preset_key"] = $climate_preset_key; + $request_payload["device_id"] = $device_id; if ($climate_preset_mode !== null) { $request_payload["climate_preset_mode"] = $climate_preset_mode; } @@ -726,7 +690,7 @@ public function update_climate_preset( $request_payload["name"] = $name; } - $this->seam->request( + $this->client->request( "POST", "/thermostats/update_climate_preset", json: (object) $request_payload, @@ -744,6 +708,7 @@ public function update_climate_preset( * @param string $thursday_program_id ID of the thermostat daily program to run on Thursdays. * @param string $tuesday_program_id ID of the thermostat daily program to run on Tuesdays. * @param string $wednesday_program_id ID of the thermostat daily program to run on Wednesdays. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function update_weekly_program( @@ -755,13 +720,11 @@ public function update_weekly_program( ?string $thursday_program_id = null, ?string $tuesday_program_id = null, ?string $wednesday_program_id = null, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; if ($friday_program_id !== null) { $request_payload["friday_program_id"] = $friday_program_id; } @@ -784,20 +747,17 @@ public function update_weekly_program( $request_payload["wednesday_program_id"] = $wednesday_program_id; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/thermostats/update_weekly_program", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } } diff --git a/src/Routes/ThermostatsDailyProgramsClient.php b/src/Routes/ThermostatsDailyProgramsClient.php index 6d786342..19f8196f 100644 --- a/src/Routes/ThermostatsDailyProgramsClient.php +++ b/src/Routes/ThermostatsDailyProgramsClient.php @@ -2,17 +2,27 @@ namespace Seam\Routes; +use Seam\Http\ResolveActionAttempt; +use Seam\Http\SeamHttpClient; use Seam\Resources\ActionAttempt; use Seam\Resources\ThermostatDailyProgram; -use Seam\SeamClient; class ThermostatsDailyProgramsClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -30,17 +40,11 @@ public function create( ): ThermostatDailyProgram { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } - if ($name !== null) { - $request_payload["name"] = $name; - } - if ($periods !== null) { - $request_payload["periods"] = $periods; - } - - $res = $this->seam->request( + $request_payload["device_id"] = $device_id; + $request_payload["name"] = $name; + $request_payload["periods"] = $periods; + + $res = $this->client->request( "POST", "/thermostats/daily_programs/create", json: (object) $request_payload, @@ -61,13 +65,11 @@ public function delete(string $thermostat_daily_program_id): void { $request_payload = []; - if ($thermostat_daily_program_id !== null) { - $request_payload[ - "thermostat_daily_program_id" - ] = $thermostat_daily_program_id; - } + $request_payload[ + "thermostat_daily_program_id" + ] = $thermostat_daily_program_id; - $this->seam->request( + $this->client->request( "POST", "/thermostats/daily_programs/delete", json: (object) $request_payload, @@ -80,42 +82,34 @@ public function delete(string $thermostat_daily_program_id): void * @param string $name Name of the thermostat daily program that you want to update. * @param array $periods Array of thermostat daily program periods. The periods that you specify overwrite any existing periods for the daily program. * @param string $thermostat_daily_program_id ID of the thermostat daily program that you want to update. + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function update( string $name, array $periods, string $thermostat_daily_program_id, - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { $request_payload = []; - if ($name !== null) { - $request_payload["name"] = $name; - } - if ($periods !== null) { - $request_payload["periods"] = $periods; - } - if ($thermostat_daily_program_id !== null) { - $request_payload[ - "thermostat_daily_program_id" - ] = $thermostat_daily_program_id; - } - - $res = $this->seam->request( + $request_payload["name"] = $name; + $request_payload["periods"] = $periods; + $request_payload[ + "thermostat_daily_program_id" + ] = $thermostat_daily_program_id; + + $res = $this->client->request( "POST", "/thermostats/daily_programs/update", json: (object) $request_payload, ); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } } diff --git a/src/Routes/ThermostatsSchedulesClient.php b/src/Routes/ThermostatsSchedulesClient.php index fbc504b3..51864c69 100644 --- a/src/Routes/ThermostatsSchedulesClient.php +++ b/src/Routes/ThermostatsSchedulesClient.php @@ -2,16 +2,25 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\ThermostatSchedule; -use Seam\SeamClient; class ThermostatsSchedulesClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -37,18 +46,10 @@ public function create( ): ThermostatSchedule { $request_payload = []; - if ($climate_preset_key !== null) { - $request_payload["climate_preset_key"] = $climate_preset_key; - } - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } - if ($ends_at !== null) { - $request_payload["ends_at"] = $ends_at; - } - if ($starts_at !== null) { - $request_payload["starts_at"] = $starts_at; - } + $request_payload["climate_preset_key"] = $climate_preset_key; + $request_payload["device_id"] = $device_id; + $request_payload["ends_at"] = $ends_at; + $request_payload["starts_at"] = $starts_at; if ($is_override_allowed !== null) { $request_payload["is_override_allowed"] = $is_override_allowed; } @@ -61,7 +62,7 @@ public function create( $request_payload["name"] = $name; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/thermostats/schedules/create", json: (object) $request_payload, @@ -80,13 +81,9 @@ public function delete(string $thermostat_schedule_id): void { $request_payload = []; - if ($thermostat_schedule_id !== null) { - $request_payload[ - "thermostat_schedule_id" - ] = $thermostat_schedule_id; - } + $request_payload["thermostat_schedule_id"] = $thermostat_schedule_id; - $this->seam->request( + $this->client->request( "POST", "/thermostats/schedules/delete", json: (object) $request_payload, @@ -103,13 +100,9 @@ public function get(string $thermostat_schedule_id): ThermostatSchedule { $request_payload = []; - if ($thermostat_schedule_id !== null) { - $request_payload[ - "thermostat_schedule_id" - ] = $thermostat_schedule_id; - } + $request_payload["thermostat_schedule_id"] = $thermostat_schedule_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/thermostats/schedules/get", json: (object) $request_payload, @@ -131,14 +124,12 @@ public function list( ): array { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; if ($user_identifier_key !== null) { $request_payload["user_identifier_key"] = $user_identifier_key; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/thermostats/schedules/list", json: (object) $request_payload, @@ -173,11 +164,7 @@ public function update( ): void { $request_payload = []; - if ($thermostat_schedule_id !== null) { - $request_payload[ - "thermostat_schedule_id" - ] = $thermostat_schedule_id; - } + $request_payload["thermostat_schedule_id"] = $thermostat_schedule_id; if ($climate_preset_key !== null) { $request_payload["climate_preset_key"] = $climate_preset_key; } @@ -199,7 +186,7 @@ public function update( $request_payload["starts_at"] = $starts_at; } - $this->seam->request( + $this->client->request( "POST", "/thermostats/schedules/update", json: (object) $request_payload, diff --git a/src/Routes/ThermostatsSimulateClient.php b/src/Routes/ThermostatsSimulateClient.php index e3cf413f..9b00648f 100644 --- a/src/Routes/ThermostatsSimulateClient.php +++ b/src/Routes/ThermostatsSimulateClient.php @@ -2,15 +2,24 @@ namespace Seam\Routes; -use Seam\SeamClient; +use Seam\Http\SeamHttpClient; class ThermostatsSimulateClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -34,12 +43,8 @@ public function hvac_mode_adjusted( ): void { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } - if ($hvac_mode !== null) { - $request_payload["hvac_mode"] = $hvac_mode; - } + $request_payload["device_id"] = $device_id; + $request_payload["hvac_mode"] = $hvac_mode; if ($cooling_set_point_celsius !== null) { $request_payload[ "cooling_set_point_celsius" @@ -61,7 +66,7 @@ public function hvac_mode_adjusted( ] = $heating_set_point_fahrenheit; } - $this->seam->request( + $this->client->request( "POST", "/thermostats/simulate/hvac_mode_adjusted", json: (object) $request_payload, @@ -83,9 +88,7 @@ public function temperature_reached( ): void { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } + $request_payload["device_id"] = $device_id; if ($temperature_celsius !== null) { $request_payload["temperature_celsius"] = $temperature_celsius; } @@ -95,7 +98,7 @@ public function temperature_reached( ] = $temperature_fahrenheit; } - $this->seam->request( + $this->client->request( "POST", "/thermostats/simulate/temperature_reached", json: (object) $request_payload, diff --git a/src/Routes/UserIdentitiesClient.php b/src/Routes/UserIdentitiesClient.php index 4dca58e2..e70ad961 100644 --- a/src/Routes/UserIdentitiesClient.php +++ b/src/Routes/UserIdentitiesClient.php @@ -2,22 +2,34 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\AcsEntrance; use Seam\Resources\AcsSystem; use Seam\Resources\AcsUser; use Seam\Resources\Device; use Seam\Resources\InstantKey; use Seam\Resources\UserIdentity; -use Seam\SeamClient; class UserIdentitiesClient { - private SeamClient $seam; + private SeamHttpClient $client; + + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; public UserIdentitiesUnmanagedClient $unmanaged; - public function __construct(SeamClient $seam) + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; - $this->unmanaged = new UserIdentitiesUnmanagedClient($seam); + $this->client = $client; + $this->defaults = $defaults; + $this->unmanaged = new UserIdentitiesUnmanagedClient( + $client, + $defaults, + ); } /** @@ -39,9 +51,7 @@ public function add_acs_user( ): void { $request_payload = []; - if ($acs_user_id !== null) { - $request_payload["acs_user_id"] = $acs_user_id; - } + $request_payload["acs_user_id"] = $acs_user_id; if ($user_identity_id !== null) { $request_payload["user_identity_id"] = $user_identity_id; } @@ -49,7 +59,7 @@ public function add_acs_user( $request_payload["user_identity_key"] = $user_identity_key; } - $this->seam->request( + $this->client->request( "POST", "/user_identities/add_acs_user", json: (object) $request_payload, @@ -91,7 +101,7 @@ public function create( $request_payload["user_identity_key"] = $user_identity_key; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/user_identities/create", json: (object) $request_payload, @@ -110,11 +120,9 @@ public function delete(string $user_identity_id): void { $request_payload = []; - if ($user_identity_id !== null) { - $request_payload["user_identity_id"] = $user_identity_id; - } + $request_payload["user_identity_id"] = $user_identity_id; - $this->seam->request( + $this->client->request( "POST", "/user_identities/delete", json: (object) $request_payload, @@ -136,9 +144,7 @@ public function generate_instant_key( ): InstantKey { $request_payload = []; - if ($user_identity_id !== null) { - $request_payload["user_identity_id"] = $user_identity_id; - } + $request_payload["user_identity_id"] = $user_identity_id; if ($customization_profile_id !== null) { $request_payload[ "customization_profile_id" @@ -148,7 +154,7 @@ public function generate_instant_key( $request_payload["max_use_count"] = $max_use_count; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/user_identities/generate_instant_key", json: (object) $request_payload, @@ -177,7 +183,7 @@ public function get( $request_payload["user_identity_key"] = $user_identity_key; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/user_identities/get", json: (object) $request_payload, @@ -199,14 +205,10 @@ public function grant_access_to_device( ): void { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } - if ($user_identity_id !== null) { - $request_payload["user_identity_id"] = $user_identity_id; - } + $request_payload["device_id"] = $device_id; + $request_payload["user_identity_id"] = $user_identity_id; - $this->seam->request( + $this->client->request( "POST", "/user_identities/grant_access_to_device", json: (object) $request_payload, @@ -222,6 +224,7 @@ public function grant_access_to_device( * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $search String for which to search. Filters returned user identities to include all records that satisfy a partial match using `full_name`, `phone_number`, `email_address` or `user_identity_id`. * @param array $user_identity_ids Array of user identity IDs by which to filter the list of user identities. + * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ public function list( @@ -256,7 +259,7 @@ public function list( $request_payload["user_identity_ids"] = $user_identity_ids; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/user_identities/list", json: (object) $request_payload, @@ -282,11 +285,9 @@ public function list_accessible_devices(string $user_identity_id): array { $request_payload = []; - if ($user_identity_id !== null) { - $request_payload["user_identity_id"] = $user_identity_id; - } + $request_payload["user_identity_id"] = $user_identity_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/user_identities/list_accessible_devices", json: (object) $request_payload, @@ -305,11 +306,9 @@ public function list_accessible_entrances(string $user_identity_id): array { $request_payload = []; - if ($user_identity_id !== null) { - $request_payload["user_identity_id"] = $user_identity_id; - } + $request_payload["user_identity_id"] = $user_identity_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/user_identities/list_accessible_entrances", json: (object) $request_payload, @@ -331,11 +330,9 @@ public function list_acs_systems(string $user_identity_id): array { $request_payload = []; - if ($user_identity_id !== null) { - $request_payload["user_identity_id"] = $user_identity_id; - } + $request_payload["user_identity_id"] = $user_identity_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/user_identities/list_acs_systems", json: (object) $request_payload, @@ -354,11 +351,9 @@ public function list_acs_users(string $user_identity_id): array { $request_payload = []; - if ($user_identity_id !== null) { - $request_payload["user_identity_id"] = $user_identity_id; - } + $request_payload["user_identity_id"] = $user_identity_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/user_identities/list_acs_users", json: (object) $request_payload, @@ -380,14 +375,10 @@ public function remove_acs_user( ): void { $request_payload = []; - if ($acs_user_id !== null) { - $request_payload["acs_user_id"] = $acs_user_id; - } - if ($user_identity_id !== null) { - $request_payload["user_identity_id"] = $user_identity_id; - } + $request_payload["acs_user_id"] = $acs_user_id; + $request_payload["user_identity_id"] = $user_identity_id; - $this->seam->request( + $this->client->request( "POST", "/user_identities/remove_acs_user", json: (object) $request_payload, @@ -407,14 +398,10 @@ public function revoke_access_to_device( ): void { $request_payload = []; - if ($device_id !== null) { - $request_payload["device_id"] = $device_id; - } - if ($user_identity_id !== null) { - $request_payload["user_identity_id"] = $user_identity_id; - } + $request_payload["device_id"] = $device_id; + $request_payload["user_identity_id"] = $user_identity_id; - $this->seam->request( + $this->client->request( "POST", "/user_identities/revoke_access_to_device", json: (object) $request_payload, @@ -440,9 +427,7 @@ public function update( ): void { $request_payload = []; - if ($user_identity_id !== null) { - $request_payload["user_identity_id"] = $user_identity_id; - } + $request_payload["user_identity_id"] = $user_identity_id; if ($email_address !== null) { $request_payload["email_address"] = $email_address; } @@ -456,7 +441,7 @@ public function update( $request_payload["user_identity_key"] = $user_identity_key; } - $this->seam->request( + $this->client->request( "POST", "/user_identities/update", json: (object) $request_payload, diff --git a/src/Routes/UserIdentitiesUnmanagedClient.php b/src/Routes/UserIdentitiesUnmanagedClient.php index e8f40773..6683b8a2 100644 --- a/src/Routes/UserIdentitiesUnmanagedClient.php +++ b/src/Routes/UserIdentitiesUnmanagedClient.php @@ -2,16 +2,25 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\UnmanagedUserIdentity; -use Seam\SeamClient; class UserIdentitiesUnmanagedClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -24,11 +33,9 @@ public function get(string $user_identity_id): UnmanagedUserIdentity { $request_payload = []; - if ($user_identity_id !== null) { - $request_payload["user_identity_id"] = $user_identity_id; - } + $request_payload["user_identity_id"] = $user_identity_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/user_identities/unmanaged/get", json: (object) $request_payload, @@ -44,6 +51,7 @@ public function get(string $user_identity_id): UnmanagedUserIdentity * @param int $limit Maximum number of records to return per page. * @param string $page_cursor Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. * @param string $search String for which to search. Filters returned unmanaged user identities to include all records that satisfy a partial match using `full_name`, `phone_number`, `email_address`, `user_identity_id` or `acs_system_id`. + * @param callable|null $on_response Called with the raw response envelope, used by the paginator to read the pagination metadata. * @return array OK */ public function list( @@ -68,7 +76,7 @@ public function list( $request_payload["search"] = $search; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/user_identities/unmanaged/list", json: (object) $request_payload, @@ -101,17 +109,13 @@ public function update( ): void { $request_payload = []; - if ($is_managed !== null) { - $request_payload["is_managed"] = $is_managed; - } - if ($user_identity_id !== null) { - $request_payload["user_identity_id"] = $user_identity_id; - } + $request_payload["is_managed"] = $is_managed; + $request_payload["user_identity_id"] = $user_identity_id; if ($user_identity_key !== null) { $request_payload["user_identity_key"] = $user_identity_key; } - $this->seam->request( + $this->client->request( "POST", "/user_identities/unmanaged/update", json: (object) $request_payload, diff --git a/src/Routes/WebhooksClient.php b/src/Routes/WebhooksClient.php index 67fd844c..4d95a1b7 100644 --- a/src/Routes/WebhooksClient.php +++ b/src/Routes/WebhooksClient.php @@ -2,16 +2,25 @@ namespace Seam\Routes; +use Seam\Http\SeamHttpClient; use Seam\Resources\Webhook; -use Seam\SeamClient; class WebhooksClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -25,14 +34,12 @@ public function create(string $url, ?array $event_types = null): Webhook { $request_payload = []; - if ($url !== null) { - $request_payload["url"] = $url; - } + $request_payload["url"] = $url; if ($event_types !== null) { $request_payload["event_types"] = $event_types; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/webhooks/create", json: (object) $request_payload, @@ -51,11 +58,9 @@ public function delete(string $webhook_id): void { $request_payload = []; - if ($webhook_id !== null) { - $request_payload["webhook_id"] = $webhook_id; - } + $request_payload["webhook_id"] = $webhook_id; - $this->seam->request( + $this->client->request( "POST", "/webhooks/delete", json: (object) $request_payload, @@ -72,11 +77,9 @@ public function get(string $webhook_id): Webhook { $request_payload = []; - if ($webhook_id !== null) { - $request_payload["webhook_id"] = $webhook_id; - } + $request_payload["webhook_id"] = $webhook_id; - $res = $this->seam->request( + $res = $this->client->request( "POST", "/webhooks/get", json: (object) $request_payload, @@ -92,7 +95,7 @@ public function get(string $webhook_id): Webhook */ public function list(): array { - $res = $this->seam->request("POST", "/webhooks/list"); + $res = $this->client->request("POST", "/webhooks/list"); return array_map(fn($r) => Webhook::from_json($r), $res->webhooks); } @@ -108,14 +111,10 @@ public function update(array $event_types, string $webhook_id): void { $request_payload = []; - if ($event_types !== null) { - $request_payload["event_types"] = $event_types; - } - if ($webhook_id !== null) { - $request_payload["webhook_id"] = $webhook_id; - } + $request_payload["event_types"] = $event_types; + $request_payload["webhook_id"] = $webhook_id; - $this->seam->request( + $this->client->request( "POST", "/webhooks/update", json: (object) $request_payload, diff --git a/src/Routes/WorkspacesClient.php b/src/Routes/WorkspacesClient.php index 630f4755..42a52ec5 100644 --- a/src/Routes/WorkspacesClient.php +++ b/src/Routes/WorkspacesClient.php @@ -2,17 +2,27 @@ namespace Seam\Routes; +use Seam\Http\ResolveActionAttempt; +use Seam\Http\SeamHttpClient; use Seam\Resources\ActionAttempt; use Seam\Resources\Workspace; -use Seam\SeamClient; class WorkspacesClient { - private SeamClient $seam; + private SeamHttpClient $client; - public function __construct(SeamClient $seam) + /** + * @var array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} + */ + private array $defaults; + + /** + * @param array{wait_for_action_attempt: bool|array{timeout?: float, polling_interval?: float}} $defaults + */ + public function __construct(SeamHttpClient $client, array $defaults) { - $this->seam = $seam; + $this->client = $client; + $this->defaults = $defaults; } /** @@ -44,9 +54,7 @@ public function create( ): Workspace { $request_payload = []; - if ($name !== null) { - $request_payload["name"] = $name; - } + $request_payload["name"] = $name; if ($company_name !== null) { $request_payload["company_name"] = $company_name; } @@ -83,7 +91,7 @@ public function create( ] = $webview_success_message; } - $res = $this->seam->request( + $res = $this->client->request( "POST", "/workspaces/create", json: (object) $request_payload, @@ -99,7 +107,7 @@ public function create( */ public function get(): Workspace { - $res = $this->seam->request("POST", "/workspaces/get"); + $res = $this->client->request("POST", "/workspaces/get"); return Workspace::from_json($res->workspace); } @@ -111,7 +119,7 @@ public function get(): Workspace */ public function list(): array { - $res = $this->seam->request("POST", "/workspaces/list"); + $res = $this->client->request("POST", "/workspaces/list"); return array_map(fn($r) => Workspace::from_json($r), $res->workspaces); } @@ -119,22 +127,20 @@ public function list(): array /** * Resets the [sandbox workspace](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces) associated with the authentication value. Note that this endpoint is only available for sandbox workspaces. * + * @param bool|array|null $wait_for_action_attempt Whether to wait for the action attempt to finish, optionally with timeout and polling_interval in seconds. Defaults to the value set on the client. * @return ActionAttempt OK */ public function reset_sandbox( - bool $wait_for_action_attempt = true, + bool|array|null $wait_for_action_attempt = null, ): ActionAttempt { - $res = $this->seam->request("POST", "/workspaces/reset_sandbox"); + $res = $this->client->request("POST", "/workspaces/reset_sandbox"); - if (!$wait_for_action_attempt) { - return ActionAttempt::from_json($res->action_attempt); - } - - $action_attempt = $this->seam->action_attempts->poll_until_ready( - $res->action_attempt->action_attempt_id, + return ResolveActionAttempt::resolve_action_attempt( + ActionAttempt::from_json($res->action_attempt), + $this->client, + $wait_for_action_attempt ?? + $this->defaults["wait_for_action_attempt"], ); - - return $action_attempt; } /** @@ -181,7 +187,7 @@ public function update( $request_payload["organization_id"] = $organization_id; } - $this->seam->request( + $this->client->request( "POST", "/workspaces/update", json: (object) $request_payload, diff --git a/src/Seam.php b/src/Seam.php new file mode 100644 index 00000000..588c7617 --- /dev/null +++ b/src/Seam.php @@ -0,0 +1,256 @@ + $guzzle_options Options merged into the underlying Guzzle client, e.g. timeout or headers. + * @param int|null $retries How many times to retry a failed request. Defaults to 2; pass 0 to disable. + * @param ClientInterface|null $client A preconfigured Guzzle client. It carries its own endpoint and authorization, so no other authentication option may be given with it. + */ + public function __construct( + ?string $api_key = null, + ?string $personal_access_token = null, + ?string $workspace_id = null, + ?string $endpoint = null, + bool|array|null $wait_for_action_attempt = null, + array $guzzle_options = [], + ?int $retries = null, + ?ClientInterface $client = null, + ) { + $this->defaults = [ + "wait_for_action_attempt" => $wait_for_action_attempt ?? true, + ]; + + // A client carries its own endpoint and authorization, so the + // authentication options are only read when one has to be built. + $this->client = + $client !== null + ? SeamHttpClient::from_client($client) + : new SeamHttpClient( + Options::get_endpoint($endpoint), + Auth::get_auth_headers( + $api_key, + $personal_access_token, + $workspace_id, + ), + $guzzle_options, + $retries, + ); + + $this->access_codes = new AccessCodesClient( + $this->client, + $this->defaults, + ); + $this->access_grants = new AccessGrantsClient( + $this->client, + $this->defaults, + ); + $this->access_methods = new AccessMethodsClient( + $this->client, + $this->defaults, + ); + $this->acs = new AcsClient($this->client, $this->defaults); + $this->action_attempts = new ActionAttemptsClient( + $this->client, + $this->defaults, + ); + $this->client_sessions = new ClientSessionsClient( + $this->client, + $this->defaults, + ); + $this->connect_webviews = new ConnectWebviewsClient( + $this->client, + $this->defaults, + ); + $this->connected_accounts = new ConnectedAccountsClient( + $this->client, + $this->defaults, + ); + $this->customers = new CustomersClient($this->client, $this->defaults); + $this->devices = new DevicesClient($this->client, $this->defaults); + $this->events = new EventsClient($this->client, $this->defaults); + $this->instant_keys = new InstantKeysClient( + $this->client, + $this->defaults, + ); + $this->locks = new LocksClient($this->client, $this->defaults); + $this->noise_sensors = new NoiseSensorsClient( + $this->client, + $this->defaults, + ); + $this->phones = new PhonesClient($this->client, $this->defaults); + $this->spaces = new SpacesClient($this->client, $this->defaults); + $this->thermostats = new ThermostatsClient( + $this->client, + $this->defaults, + ); + $this->user_identities = new UserIdentitiesClient( + $this->client, + $this->defaults, + ); + $this->webhooks = new WebhooksClient($this->client, $this->defaults); + $this->workspaces = new WorkspacesClient( + $this->client, + $this->defaults, + ); + } + + /** + * Creates a client authorized with an API key. + */ + public static function from_api_key( + string $api_key, + ?string $endpoint = null, + bool|array|null $wait_for_action_attempt = null, + array $guzzle_options = [], + ?int $retries = null, + ): static { + return new static( + api_key: $api_key, + endpoint: $endpoint, + wait_for_action_attempt: $wait_for_action_attempt, + guzzle_options: $guzzle_options, + retries: $retries, + ); + } + + /** + * Creates a client authorized with a personal access token, scoped to the + * given workspace. + */ + public static function from_personal_access_token( + string $personal_access_token, + string $workspace_id, + ?string $endpoint = null, + bool|array|null $wait_for_action_attempt = null, + array $guzzle_options = [], + ?int $retries = null, + ): static { + return new static( + personal_access_token: $personal_access_token, + workspace_id: $workspace_id, + endpoint: $endpoint, + wait_for_action_attempt: $wait_for_action_attempt, + retries: $retries, + guzzle_options: $guzzle_options, + ); + } + + /** + * Creates a client from a preconfigured Guzzle client. + */ + public static function from_client( + ClientInterface $client, + bool|array|null $wait_for_action_attempt = null, + ): static { + return new static( + client: $client, + wait_for_action_attempt: $wait_for_action_attempt, + ); + } + + public function lts_version(): string + { + return self::LTS_VERSION; + } + + /** + * Makes a request against the Seam API with this client's authorization. + * + * @param array|object|null $json + * @param array|null $query + */ + public function request( + string $method, + string $path, + mixed $json = null, + ?array $query = null, + ): mixed { + return $this->client->request($method, $path, $json, $query); + } + + /** + * Creates a paginator for a list endpoint. + * + * @param callable $request Invokes the list method with a params array, e.g. fn($params) => $seam->devices->list(...$params) + * @param array $params + */ + public function createPaginator( + callable $request, + array $params = [], + ): Paginator { + return new Paginator($request, $params); + } +} diff --git a/src/SeamClient.php b/src/SeamClient.php index f2384ab2..c43d7e1b 100644 --- a/src/SeamClient.php +++ b/src/SeamClient.php @@ -2,154 +2,8 @@ namespace Seam; -use Seam\Routes\AccessCodesClient; -use Seam\Routes\AccessGrantsClient; -use Seam\Routes\AccessMethodsClient; -use Seam\Routes\AcsClient; -use Seam\Routes\ActionAttemptsClient; -use Seam\Routes\ClientSessionsClient; -use Seam\Routes\ConnectedAccountsClient; -use Seam\Routes\ConnectWebviewsClient; -use Seam\Routes\CustomersClient; -use Seam\Routes\DevicesClient; -use Seam\Routes\EventsClient; -use Seam\Routes\InstantKeysClient; -use Seam\Routes\LocksClient; -use Seam\Routes\NoiseSensorsClient; -use Seam\Routes\PhonesClient; -use Seam\Routes\SpacesClient; -use Seam\Routes\ThermostatsClient; -use Seam\Routes\UserIdentitiesClient; -use Seam\Routes\WebhooksClient; -use Seam\Routes\WorkspacesClient; -use Seam\Utils\PackageVersion; - -use GuzzleHttp\Client as HTTPClient; -use \Exception as Exception; -use Seam\HttpApiError; -use Seam\HttpUnauthorizedError; -use Seam\HttpInvalidInputError; - -define("LTS_VERSION", "1.0.0"); - -class SeamClient -{ - public AccessCodesClient $access_codes; - public AccessGrantsClient $access_grants; - public AccessMethodsClient $access_methods; - public AcsClient $acs; - public ActionAttemptsClient $action_attempts; - public ClientSessionsClient $client_sessions; - public ConnectWebviewsClient $connect_webviews; - public ConnectedAccountsClient $connected_accounts; - public CustomersClient $customers; - public DevicesClient $devices; - public EventsClient $events; - public InstantKeysClient $instant_keys; - public LocksClient $locks; - public NoiseSensorsClient $noise_sensors; - public PhonesClient $phones; - public SpacesClient $spaces; - public ThermostatsClient $thermostats; - public UserIdentitiesClient $user_identities; - public WebhooksClient $webhooks; - public WorkspacesClient $workspaces; - - public string $api_key; - public HTTPClient $client; - public string $ltsVersion = LTS_VERSION; - - public function __construct( - $api_key = null, - $endpoint = "https://connect.getseam.com", - $throw_http_errors = false, - ) { - $this->api_key = $api_key ?: (getenv("SEAM_API_KEY") ?: null); - $seam_sdk_version = PackageVersion::get(); - $this->client = new HTTPClient([ - "base_uri" => $endpoint, - "timeout" => 60.0, - "headers" => [ - "Authorization" => "Bearer " . $this->api_key, - "User-Agent" => "Seam PHP Client " . $seam_sdk_version, - "seam-sdk-name" => "seamapi/php", - "seam-sdk-version" => $seam_sdk_version, - "seam-lts-version" => $this->ltsVersion, - ], - "http_errors" => $throw_http_errors, - ]); - $this->access_codes = new AccessCodesClient($this); - $this->access_grants = new AccessGrantsClient($this); - $this->access_methods = new AccessMethodsClient($this); - $this->acs = new AcsClient($this); - $this->action_attempts = new ActionAttemptsClient($this); - $this->client_sessions = new ClientSessionsClient($this); - $this->connect_webviews = new ConnectWebviewsClient($this); - $this->connected_accounts = new ConnectedAccountsClient($this); - $this->customers = new CustomersClient($this); - $this->devices = new DevicesClient($this); - $this->events = new EventsClient($this); - $this->instant_keys = new InstantKeysClient($this); - $this->locks = new LocksClient($this); - $this->noise_sensors = new NoiseSensorsClient($this); - $this->phones = new PhonesClient($this); - $this->spaces = new SpacesClient($this); - $this->thermostats = new ThermostatsClient($this); - $this->user_identities = new UserIdentitiesClient($this); - $this->webhooks = new WebhooksClient($this); - $this->workspaces = new WorkspacesClient($this); - } - - public function request($method, $path, $json = null, $query = null) - { - $options = [ - "json" => $json, - "query" => $query, - ]; - $options = array_filter($options, fn($option) => $option !== null); - - $response = $this->client->request($method, $path, $options); - $status_code = $response->getStatusCode(); - $request_id = $response->getHeaderLine("seam-request-id"); - - $res_json = null; - try { - $res_json = json_decode($response->getBody()); - } catch (Exception $ignoreError) { - } - - if ($status_code >= 400) { - if ($status_code === 401) { - throw new HttpUnauthorizedError($request_id); - } - - if (($res_json->error ?? null) != null) { - if ($res_json->error->type === "invalid_input") { - throw new HttpInvalidInputError( - $res_json->error, - $status_code, - $request_id, - ); - } - - throw new HttpApiError( - $res_json->error, - $status_code, - $request_id, - ); - } - - throw \GuzzleHttp\Exception\RequestException::create( - new \GuzzleHttp\Psr7\Request($method, $path), - $response, - ); - } - - return $res_json; - } - - public function createPaginator($request, $params = []) - { - return new Paginator($request, $params); - } -} +/** + * @deprecated Use Seam\Seam instead. This alias will be removed in a future + * major version. + */ +class SeamClient extends Seam {} diff --git a/src/SeamMultiWorkspace.php b/src/SeamMultiWorkspace.php new file mode 100644 index 00000000..315ffd29 --- /dev/null +++ b/src/SeamMultiWorkspace.php @@ -0,0 +1,91 @@ + $guzzle_options + */ + public function __construct( + ?string $personal_access_token = null, + ?string $endpoint = null, + array $guzzle_options = [], + ?int $retries = null, + ?ClientInterface $client = null, + ) { + // A client carries its own endpoint and authorization, so the + // authentication options are only read when one has to be built. + if ($client !== null) { + $this->client = SeamHttpClient::from_client($client); + } else { + if ($personal_access_token === null) { + throw new Exceptions\InvalidOptionsError( + "Must specify a personal_access_token", + ); + } + + $this->client = new SeamHttpClient( + Options::get_endpoint($endpoint), + Auth::get_auth_headers_for_multi_workspace_personal_access_token( + $personal_access_token, + ), + $guzzle_options, + $retries, + ); + } + + $this->workspaces = new WorkspacesProxy( + new WorkspacesClient($this->client, [ + "wait_for_action_attempt" => false, + ]), + ); + } + + /** + * Creates a client authorized with a personal access token, not scoped to + * any workspace. + */ + public static function from_personal_access_token( + string $personal_access_token, + ?string $endpoint = null, + array $guzzle_options = [], + ?int $retries = null, + ): static { + return new static( + personal_access_token: $personal_access_token, + endpoint: $endpoint, + guzzle_options: $guzzle_options, + retries: $retries, + ); + } + + /** + * Creates a client from a preconfigured Guzzle client. + */ + public static function from_client(ClientInterface $client): static + { + return new static(client: $client); + } + + public function lts_version(): string + { + return self::LTS_VERSION; + } +} diff --git a/src/SeamWebhook.php b/src/SeamWebhook.php new file mode 100644 index 00000000..9ab920af --- /dev/null +++ b/src/SeamWebhook.php @@ -0,0 +1,53 @@ +webhook = new Webhook($secret); + } + + /** + * Verifies an incoming webhook request and returns the event it carries. + * + * @param string $payload The raw HTTP request body. + * @param array $headers The HTTP request headers. + * + * @throws \Svix\Exception\WebhookVerificationException When the signature does not match. + */ + public function verify(string $payload, array $headers): Event + { + $normalized_headers = []; + foreach ($headers as $name => $value) { + $normalized_headers[strtolower($name)] = $value; + } + + $this->webhook->verify($payload, $normalized_headers); + + $event = Event::from_json(json_decode($payload)); + + if ($event === null) { + throw new WebhookVerificationException( + "The verified webhook payload did not contain an event", + ); + } + + return $event; + } +} diff --git a/src/Token.php b/src/Token.php new file mode 100644 index 00000000..90349502 --- /dev/null +++ b/src/Token.php @@ -0,0 +1,67 @@ +workspaces = $workspaces; + } + + /** + * @return Workspace[] + */ + public function list(): array + { + return $this->workspaces->list(); + } + + public function create( + string $name, + ?string $company_name = null, + ?string $connect_partner_name = null, + mixed $connect_webview_customization = null, + ?bool $is_sandbox = null, + ?string $organization_id = null, + ?string $webview_logo_shape = null, + ?string $webview_primary_button_color = null, + ?string $webview_primary_button_text_color = null, + ?string $webview_success_message = null, + ): Workspace { + return $this->workspaces->create( + $name, + $company_name, + $connect_partner_name, + $connect_webview_customization, + $is_sandbox, + $organization_id, + $webview_logo_shape, + $webview_primary_button_color, + $webview_primary_button_text_color, + $webview_success_message, + ); + } +} diff --git a/tests/ApiKeyTest.php b/tests/ApiKeyTest.php new file mode 100644 index 00000000..b1b8e9bb --- /dev/null +++ b/tests/ApiKeyTest.php @@ -0,0 +1,89 @@ +seed["seam_apikey1_token"], + endpoint: $this->endpoint, + ); + + $device = $seam->devices->get($this->seed["august_device_1"]); + + $this->assertSame($this->seed["august_device_1"], $device->device_id); + $this->assertSame( + $this->seed["seed_workspace_1"], + $device->workspace_id, + ); + } + + public function testConstructorReturnsAnAuthorizedClient(): void + { + $seam = new Seam( + api_key: $this->seed["seam_apikey1_token"], + endpoint: $this->endpoint, + ); + + $device = $seam->devices->get($this->seed["august_device_1"]); + + $this->assertSame($this->seed["august_device_1"], $device->device_id); + } + + public function testInvalidApiKeyIsRejectedByTheServer(): void + { + $seam = new Seam( + api_key: "seam_invalid_api_key", + endpoint: $this->endpoint, + ); + + $this->expectException(HttpUnauthorizedError::class); + + $seam->devices->list(); + } + + /** + * @dataProvider unusableTokens + */ + public function testApiKeyFormatIsChecked( + string $token, + string $expected_message, + ): void { + $this->expectException(InvalidTokenError::class); + $this->expectExceptionMessage($expected_message); + + new Seam(api_key: $token, endpoint: $this->endpoint); + } + + public static function unusableTokens(): array + { + return [ + "client session token" => [ + "seam_cst_1234", + "A Client Session Token cannot be used as an api_key", + ], + "jwt" => ["ey_some_jwt", "A JWT cannot be used as an api_key"], + "access token" => [ + "seam_at_1234", + "An Access Token cannot be used as an api_key", + ], + "publishable key" => [ + "seam_pk_1234", + "A Publishable Key cannot be used as an api_key", + ], + "unknown format" => [ + "some-random-token", + "Unknown or invalid api_key format", + ], + ]; + } +} diff --git a/tests/ClientTest.php b/tests/ClientTest.php new file mode 100644 index 00000000..739c263b --- /dev/null +++ b/tests/ClientTest.php @@ -0,0 +1,168 @@ +seam(); + + $res = $seam->client->request( + "POST", + "/devices/get", + (object) [ + "device_id" => $this->seed["august_device_1"], + ], + ); + + $this->assertSame( + $this->seed["august_device_1"], + $res->device->device_id, + ); + $this->assertSame( + $this->seed["seed_workspace_1"], + $res->device->workspace_id, + ); + } + + public function testRequestDelegatesToTheClient(): void + { + $seam = $this->seam(); + + $res = $seam->request( + "POST", + "/devices/get", + (object) [ + "device_id" => $this->seed["august_device_1"], + ], + ); + + $this->assertSame( + $this->seed["august_device_1"], + $res->device->device_id, + ); + } + + /** + * A client carries its own endpoint and authorization, so it has to work + * on its own without any authentication option beside it. + */ + public function testFromClientNeedsNoCredentials(): void + { + $authorized = $this->seam(); + + $seam = Seam::from_client($authorized->client->get_client()); + + $device = $seam->devices->get($this->seed["august_device_1"]); + + $this->assertSame($this->seed["august_device_1"], $device->device_id); + $this->assertSame( + $this->seed["seed_workspace_1"], + $device->workspace_id, + ); + } + + public function testClientOptionReusesAnotherInstancesClient(): void + { + $seam = new Seam(client: $this->seam()->client->get_client()); + + $device = $seam->devices->get($this->seed["august_device_1"]); + + $this->assertSame($this->seed["august_device_1"], $device->device_id); + } + + public function testGuzzleOptionsAreMergedIntoTheClient(): void + { + $seam = $this->seam( + guzzle_options: [ + "headers" => ["Custom-Header" => "Test-Value"], + "timeout" => 30, + ], + ); + + $config = $seam->client->get_client()->getConfig(); + + $this->assertSame(30, $config["timeout"]); + $this->assertSame("Test-Value", $config["headers"]["Custom-Header"]); + // The custom headers must not displace the authorization or the SDK + // headers. + $this->assertSame( + "Bearer " . $this->seed["seam_apikey1_token"], + $config["headers"]["authorization"], + ); + $this->assertSame("seamapi/php", $config["headers"]["seam-sdk-name"]); + } + + public function testGuzzleOptionsStillAuthorizeRequests(): void + { + $seam = $this->seam( + guzzle_options: ["headers" => ["Custom-Header" => "Test-Value"]], + ); + + $device = $seam->devices->get($this->seed["august_device_1"]); + + $this->assertSame($this->seed["august_device_1"], $device->device_id); + } + + public function testTimeoutDefaultsToSixtySeconds(): void + { + $config = $this->seam()->client->get_client()->getConfig(); + + $this->assertSame(SeamHttpClient::DEFAULT_TIMEOUT, $config["timeout"]); + } + + /** + * Every constructor and factory has to agree on the default, otherwise + * the same call waits or does not depending on how the client was built. + */ + public function testWaitForActionAttemptDefaultsToTrueEverywhere(): void + { + $api_key = $this->seed["seam_apikey1_token"]; + + $clients = [ + "constructor" => new Seam( + api_key: $api_key, + endpoint: $this->endpoint, + ), + "from_api_key" => Seam::from_api_key( + $api_key, + endpoint: $this->endpoint, + ), + "from_personal_access_token" => Seam::from_personal_access_token( + $this->seed["seam_at1_token"], + $this->seed["seed_workspace_1"], + endpoint: $this->endpoint, + ), + "from_client" => Seam::from_client( + $this->seam()->client->get_client(), + ), + ]; + + foreach ($clients as $name => $seam) { + $this->assertTrue( + $seam->defaults["wait_for_action_attempt"], + "{$name} should wait for action attempts by default", + ); + } + } + + public function testWaitForActionAttemptDefaultCanBeDisabled(): void + { + $seam = $this->seam(wait_for_action_attempt: false); + + $this->assertFalse($seam->defaults["wait_for_action_attempt"]); + } + + public function testLtsVersionIsExposed(): void + { + $this->assertSame("1.0.0", Seam::LTS_VERSION); + $this->assertSame("1.0.0", $this->seam()->lts_version()); + } +} diff --git a/tests/EnvTest.php b/tests/EnvTest.php new file mode 100644 index 00000000..59d4886c --- /dev/null +++ b/tests/EnvTest.php @@ -0,0 +1,143 @@ + */ + private array $saved_env = []; + + protected function setUp(): void + { + parent::setUp(); + + foreach (self::VARIABLES as $name) { + $this->saved_env[$name] = getenv($name); + putenv($name); + } + } + + protected function tearDown(): void + { + foreach ($this->saved_env as $name => $value) { + if ($value === false) { + putenv($name); + } else { + putenv("{$name}={$value}"); + } + } + + parent::tearDown(); + } + + public function testReadsTheApiKeyFromTheEnvironment(): void + { + putenv("SEAM_API_KEY=" . $this->seed["seam_apikey1_token"]); + + $seam = new Seam(endpoint: $this->endpoint); + + $device = $seam->devices->get($this->seed["august_device_1"]); + + $this->assertSame($this->seed["august_device_1"], $device->device_id); + } + + public function testReadsTheEndpointFromTheEnvironment(): void + { + putenv("SEAM_ENDPOINT=" . $this->endpoint); + + $seam = new Seam(api_key: $this->seed["seam_apikey1_token"]); + + $device = $seam->devices->get($this->seed["august_device_1"]); + + $this->assertSame($this->seed["august_device_1"], $device->device_id); + } + + public function testFallsBackToTheDefaultEndpoint(): void + { + $this->assertSame(Options::DEFAULT_ENDPOINT, Options::get_endpoint()); + } + + public function testEndpointOptionWinsOverTheEnvironment(): void + { + putenv("SEAM_ENDPOINT=https://from-the-environment.example.com"); + + $this->assertSame( + "https://from-the-option.example.com", + Options::get_endpoint("https://from-the-option.example.com"), + ); + } + + public function testSeamEndpointWinsOverTheDeprecatedSeamApiUrl(): void + { + putenv("SEAM_ENDPOINT=https://endpoint.example.com"); + putenv("SEAM_API_URL=https://api-url.example.com"); + + // Both the deprecation and the precedence notice are raised. + $endpoint = @Options::get_endpoint(); + + $this->assertSame("https://endpoint.example.com", $endpoint); + } + + public function testDeprecatedSeamApiUrlIsStillHonored(): void + { + putenv("SEAM_API_URL=https://api-url.example.com"); + + $this->assertSame( + "https://api-url.example.com", + @Options::get_endpoint(), + ); + } + + public function testDeprecatedSeamApiUrlWarns(): void + { + putenv("SEAM_API_URL=https://api-url.example.com"); + + set_error_handler( + static fn( + int $severity, + string $message, + ) => throw new \ErrorException($message), + E_USER_WARNING, + ); + + try { + $this->expectException(\ErrorException::class); + $this->expectExceptionMessage("SEAM_API_URL"); + Options::get_endpoint(); + } finally { + restore_error_handler(); + } + } + + public function testFailsWhenNoCredentialsAreAvailable(): void + { + $this->expectException(InvalidOptionsError::class); + $this->expectExceptionMessage("SEAM_API_KEY is not set"); + + new Seam(endpoint: $this->endpoint); + } + + public function testApiKeyEnvironmentVariableIsIgnoredForAPersonalAccessToken(): void + { + putenv("SEAM_API_KEY=" . $this->seed["seam_apikey1_token"]); + + $seam = new Seam( + personal_access_token: $this->seed["seam_at1_token"], + workspace_id: $this->seed["seed_workspace_1"], + endpoint: $this->endpoint, + ); + + $device = $seam->devices->get($this->seed["august_device_1"]); + + $this->assertSame($this->seed["august_device_1"], $device->device_id); + } +} diff --git a/tests/HeadersTest.php b/tests/HeadersTest.php new file mode 100644 index 00000000..3f8cf4c7 --- /dev/null +++ b/tests/HeadersTest.php @@ -0,0 +1,141 @@ + ["device_id" => "d1"]]), + ]); + + $seam = Seam::from_api_key( + "seam_apikey_token", + endpoint: "https://example.com", + guzzle_options: $recorder->guzzle_options(), + ); + + $device = $seam->devices->get("d1"); + + $this->assertSame("d1", $device->device_id); + + $request = $recorder->request(); + + $this->assertSame("/devices/get", $request->getUri()->getPath()); + $this->assertEquals((object) ["device_id" => "d1"], $recorder->body()); + + $this->assertSame( + "Bearer seam_apikey_token", + $request->getHeaderLine("authorization"), + ); + $this->assertSame( + "seamapi/php", + $request->getHeaderLine("seam-sdk-name"), + ); + $this->assertSame( + PackageVersion::get(), + $request->getHeaderLine("seam-sdk-version"), + ); + $this->assertSame( + SeamHttpClient::LTS_VERSION, + $request->getHeaderLine("seam-lts-version"), + ); + $this->assertSame( + "seam-php/" . PackageVersion::get(), + $request->getHeaderLine("User-Agent"), + ); + } + + public function testSendsWorkspaceHeaderWithAPersonalAccessToken(): void + { + $recorder = new RecordingClient([ + RecordingClient::json(200, ["device" => ["device_id" => "d1"]]), + ]); + + $seam = Seam::from_personal_access_token( + "seam_at_token", + "workspace-1", + endpoint: "https://example.com", + guzzle_options: $recorder->guzzle_options(), + ); + + $seam->devices->get("d1"); + + $request = $recorder->request(); + + $this->assertSame( + "Bearer seam_at_token", + $request->getHeaderLine("authorization"), + ); + $this->assertSame( + "workspace-1", + $request->getHeaderLine("seam-workspace"), + ); + } + + public function testCustomHeadersAreSentAlongsideTheSdkHeaders(): void + { + $recorder = new RecordingClient([ + RecordingClient::json(200, ["device" => ["device_id" => "d1"]]), + ]); + + $seam = Seam::from_api_key( + "seam_apikey_token", + endpoint: "https://example.com", + guzzle_options: array_merge($recorder->guzzle_options(), [ + "headers" => ["Custom-Header" => "Test-Value"], + ]), + ); + + $seam->devices->get("d1"); + + $request = $recorder->request(); + + $this->assertSame( + "Test-Value", + $request->getHeaderLine("Custom-Header"), + ); + $this->assertSame( + "seamapi/php", + $request->getHeaderLine("seam-sdk-name"), + ); + } + + /** + * The SDK headers identify the SDK, so a caller cannot displace them. + */ + public function testSdkHeadersCannotBeOverridden(): void + { + $recorder = new RecordingClient([ + RecordingClient::json(200, ["device" => ["device_id" => "d1"]]), + ]); + + $seam = Seam::from_api_key( + "seam_apikey_token", + endpoint: "https://example.com", + guzzle_options: array_merge($recorder->guzzle_options(), [ + "headers" => ["seam-sdk-name" => "not-the-sdk"], + ]), + ); + + $seam->devices->get("d1"); + + $this->assertSame( + "seamapi/php", + $recorder->request()->getHeaderLine("seam-sdk-name"), + ); + } +} diff --git a/tests/HttpErrorTest.php b/tests/HttpErrorTest.php index 648eb882..72a029cb 100644 --- a/tests/HttpErrorTest.php +++ b/tests/HttpErrorTest.php @@ -2,29 +2,118 @@ declare(strict_types=1); -use PHPUnit\Framework\TestCase; +namespace Tests; -final class HttpErrorTest extends TestCase +use Seam\Exceptions\HttpApiError; +use Seam\Exceptions\HttpInvalidInputError; +use Seam\Exceptions\HttpUnauthorizedError; +use Seam\Seam; +use Tests\Support\FakeSeamConnectTestCase; + +final class HttpErrorTest extends FakeSeamConnectTestCase { - public function testNonSeamError(): void + public function testThrowsUnauthorizedError(): void { - $seam = new \Seam\SeamClient( - "seam_apikey1_token", - "https://nonexistent.example.com", + $seam = new Seam( + api_key: "seam_invalid_api_key", + endpoint: $this->endpoint, ); try { $seam->devices->list(); - $this->fail("Expected GuzzleHttp ConnectException"); - } catch (\GuzzleHttp\Exception\ConnectException $e) { - $this->assertInstanceOf( - \GuzzleHttp\Exception\ConnectException::class, - $e, + $this->fail("Expected HttpUnauthorizedError"); + } catch (HttpUnauthorizedError $error) { + $this->assertSame(401, $error->getStatusCode()); + $this->assertSame("unauthorized", $error->getErrorCode()); + $this->assertStringStartsWith( + "request", + (string) $error->getRequestId(), + ); + } + } + + public function testThrowsApiErrorOnStandardErrorResponse(): void + { + try { + $this->seam()->devices->get("unknown-device"); + $this->fail("Expected HttpApiError"); + } catch (HttpApiError $error) { + $this->assertSame(404, $error->getStatusCode()); + $this->assertSame("device_not_found", $error->getErrorCode()); + $this->assertStringStartsWith( + "request", + (string) $error->getRequestId(), + ); + } + } + + public function testThrowsInvalidInputErrorWithValidationMessages(): void + { + try { + $this->seam()->client->request( + "POST", + "/devices/list", + (object) [ + "device_ids" => 4242, + ], + ); + $this->fail("Expected HttpInvalidInputError"); + } catch (HttpInvalidInputError $error) { + $this->assertSame(400, $error->getStatusCode()); + $this->assertSame("invalid_input", $error->getErrorCode()); + $this->assertStringStartsWith( + "request", + (string) $error->getRequestId(), + ); + $this->assertSame( + ["Expected array, received number"], + $error->getValidationErrorMessages("device_ids"), ); - $this->assertStringContainsString( - "Could not resolve host", - $e->getMessage(), + } + } + + public function testValidationMessagesAreEmptyForAnUnknownParam(): void + { + try { + $this->seam()->client->request( + "POST", + "/devices/list", + (object) [ + "device_ids" => 4242, + ], + ); + $this->fail("Expected HttpInvalidInputError"); + } catch (HttpInvalidInputError $error) { + $this->assertSame( + [], + $error->getValidationErrorMessages("non_existent_param"), ); } } + + /** + * A workspace outage answers with a 503 that is not a Seam error + * envelope, so it surfaces as the underlying transport error rather than + * a Seam exception, which is what the other Seam SDKs do too. + */ + public function testWorkspaceOutageSurfacesTheTransportError(): void + { + $seam = $this->seam(retries: 0); + + $seam->client->request( + "POST", + "/_fake/simulate_workspace_outage", + (object) [ + "workspace_id" => $this->seed["seed_workspace_1"], + "routes" => ["/devices/list"], + ], + ); + + try { + $seam->devices->list(); + $this->fail("Expected a Guzzle BadResponseException"); + } catch (\GuzzleHttp\Exception\BadResponseException $error) { + $this->assertSame(503, $error->getResponse()->getStatusCode()); + } + } } diff --git a/tests/MalformedResponseTest.php b/tests/MalformedResponseTest.php new file mode 100644 index 00000000..b9df3dd0 --- /dev/null +++ b/tests/MalformedResponseTest.php @@ -0,0 +1,112 @@ +guzzle_options(), + retries: 0, + ); + } + + /** + * @dataProvider nonSeamErrorResponses + */ + public function testNonSeamErrorResponsesSurfaceTheTransportError( + Response $response, + ): void { + $seam = $this->seam(new RecordingClient([$response])); + + try { + $seam->devices->list(); + $this->fail("Expected a Guzzle BadResponseException"); + } catch (HttpApiError $error) { + $this->fail( + "Expected a transport error, got " . + $error::class . + ": " . + $error->getMessage(), + ); + } catch (BadResponseException $error) { + $this->assertSame(500, $error->getResponse()->getStatusCode()); + } + } + + public static function nonSeamErrorResponses(): array + { + return [ + "plain text body" => [ + RecordingClient::raw(500, "Internal Server Error"), + ], + "html body" => [ + RecordingClient::raw( + 500, + "Gateway", + "text/html", + ), + ], + "malformed json" => [ + RecordingClient::raw(500, "{invalid json", "application/json"), + ], + "json without an error object" => [ + RecordingClient::json(500, ["message" => "Some error"]), + ], + "error without a type and message" => [ + RecordingClient::json(500, ["error" => ["code" => 500]]), + ], + "json that is not an object" => [ + RecordingClient::json(500, [1, 2]), + ], + "error that is not an object" => [ + RecordingClient::json(500, ["error" => "boom"]), + ], + "empty body" => [RecordingClient::raw(500, "", "application/json")], + ]; + } + + /** + * A redirect is not a success, so it must not be handed back to the + * caller as though it were a resource. + */ + public function testRedirectIsNotTreatedAsSuccess(): void + { + $recorder = new RecordingClient([ + new Response(302, ["location" => "https://example.com/elsewhere"]), + ]); + + $seam = Seam::from_api_key( + "seam_apikey_token", + endpoint: "https://example.com", + guzzle_options: array_merge($recorder->guzzle_options(), [ + "allow_redirects" => false, + ]), + retries: 0, + ); + + // A 3xx is not a Seam error envelope either, so it surfaces as the + // transport error rather than being handed back as a resource. + $this->expectException(RequestException::class); + + $seam->devices->list(); + } +} diff --git a/tests/PackageVersionTest.php b/tests/PackageVersionTest.php index d45e970d..7b1d7f9a 100644 --- a/tests/PackageVersionTest.php +++ b/tests/PackageVersionTest.php @@ -28,8 +28,8 @@ public function testVersionMatchesPackageJson(): void public function testVersionIsUsedAsTheSdkVersionHeader(): void { - $seam = new \Seam\SeamClient("seam_apikey1_token"); - $headers = $seam->client->getConfig("headers"); + $seam = new \Seam\Seam("seam_apikey1_token"); + $headers = $seam->client->get_client()->getConfig("headers"); $this->assertSame(PackageVersion::get(), $headers["seam-sdk-version"]); } diff --git a/tests/PaginatorTest.php b/tests/PaginatorTest.php new file mode 100644 index 00000000..8442318a --- /dev/null +++ b/tests/PaginatorTest.php @@ -0,0 +1,123 @@ + 2]): Paginator + { + $seam = $this->seam(); + + return $seam->createPaginator( + fn($p) => $seam->connected_accounts->list(...$p), + $params, + ); + } + + public function testCreatePaginatorReturnsAPaginator(): void + { + $this->assertInstanceOf(Paginator::class, $this->paginator()); + } + + public function testFirstPageReturnsTheFirstPage(): void + { + [$accounts, $pagination] = $this->paginator()->firstPage(); + + $this->assertCount(2, $accounts); + $this->assertInstanceOf(Pagination::class, $pagination); + $this->assertTrue($pagination->has_next_page); + $this->assertNotNull($pagination->next_page_cursor); + } + + public function testNextPageReturnsTheNextPage(): void + { + $pages = $this->paginator(); + + [$first, $pagination] = $pages->firstPage(); + [$second] = $pages->nextPage($pagination->next_page_cursor); + + $this->assertNotEmpty($second); + + $first_ids = array_map( + fn($account) => $account->connected_account_id, + $first, + ); + $second_ids = array_map( + fn($account) => $account->connected_account_id, + $second, + ); + + $this->assertEmpty(array_intersect($first_ids, $second_ids)); + } + + public function testNextPageRequiresACursor(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("next_page_cursor"); + + $this->paginator()->nextPage(null); + } + + public function testNextPageRejectsAnEmptyCursor(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->paginator()->nextPage(""); + } + + public function testLastPageHasNoNextPage(): void + { + $pages = $this->paginator(["limit" => 100]); + + [, $pagination] = $pages->firstPage(); + + $this->assertFalse($pagination->has_next_page); + $this->assertNull($pagination->next_page_cursor); + } + + public function testFlattenToArrayReturnsEveryResource(): void + { + $all = $this->paginator()->flattenToArray(); + $expected = $this->seam()->connected_accounts->list(); + + $this->assertCount(count($expected), $all); + } + + public function testFlattenIteratesEveryResource(): void + { + $ids = []; + + foreach ($this->paginator()->flatten() as $account) { + $ids[] = $account->connected_account_id; + } + + $expected = $this->seam()->connected_accounts->list(); + + $this->assertCount(count($expected), $ids); + $this->assertSame(array_unique($ids), $ids); + } + + /** + * Not every list endpoint returns pagination metadata, and asking for a + * page of one that does not should not fail. + */ + public function testEndpointWithoutPaginationYieldsAnEmptyPagination(): void + { + $seam = $this->seam(); + + $pages = $seam->createPaginator(fn($p) => $seam->workspaces->list()); + + [$workspaces, $pagination] = $pages->firstPage(); + + $this->assertNotEmpty($workspaces); + $this->assertInstanceOf(Pagination::class, $pagination); + $this->assertFalse($pagination->has_next_page); + $this->assertNull($pagination->next_page_cursor); + } +} diff --git a/tests/PersonalAccessTokenTest.php b/tests/PersonalAccessTokenTest.php new file mode 100644 index 00000000..5abd2d81 --- /dev/null +++ b/tests/PersonalAccessTokenTest.php @@ -0,0 +1,140 @@ +seed["seam_at1_token"], + $this->seed["seed_workspace_1"], + endpoint: $this->endpoint, + ); + + $device = $seam->devices->get($this->seed["august_device_1"]); + + $this->assertSame($this->seed["august_device_1"], $device->device_id); + $this->assertSame( + $this->seed["seed_workspace_1"], + $device->workspace_id, + ); + } + + public function testConstructorReturnsAnAuthorizedClient(): void + { + $seam = new Seam( + personal_access_token: $this->seed["seam_at1_token"], + workspace_id: $this->seed["seed_workspace_1"], + endpoint: $this->endpoint, + ); + + $device = $seam->devices->get($this->seed["august_device_1"]); + + $this->assertSame($this->seed["august_device_1"], $device->device_id); + } + + public function testWorkspaceIdIsRequired(): void + { + $this->expectException(InvalidOptionsError::class); + $this->expectExceptionMessage( + "Must pass a workspace_id when using a personal_access_token", + ); + + new Seam( + personal_access_token: $this->seed["seam_at1_token"], + endpoint: $this->endpoint, + ); + } + + public function testApiKeyCannotBeCombinedWithAPersonalAccessToken(): void + { + $this->expectException(InvalidOptionsError::class); + + new Seam( + api_key: $this->seed["seam_apikey1_token"], + personal_access_token: $this->seed["seam_at1_token"], + workspace_id: $this->seed["seed_workspace_1"], + endpoint: $this->endpoint, + ); + } + + public function testPersonalAccessTokenFormatIsChecked(): void + { + $this->expectException(InvalidTokenError::class); + + new Seam( + personal_access_token: "seam_cst_1234", + workspace_id: $this->seed["seed_workspace_1"], + endpoint: $this->endpoint, + ); + } + + public function testMultiWorkspaceClientListsWorkspaces(): void + { + $seam = SeamMultiWorkspace::from_personal_access_token( + $this->seed["seam_at1_token"], + endpoint: $this->endpoint, + ); + + $workspaces = $seam->workspaces->list(); + + $workspace_ids = array_map( + fn($workspace) => $workspace->workspace_id, + $workspaces, + ); + + $this->assertContains($this->seed["seed_workspace_1"], $workspace_ids); + } + + public function testMultiWorkspaceConstructorListsWorkspaces(): void + { + $seam = new SeamMultiWorkspace( + personal_access_token: $this->seed["seam_at1_token"], + endpoint: $this->endpoint, + ); + + $this->assertNotEmpty($seam->workspaces->list()); + } + + public function testMultiWorkspaceClientCreatesAWorkspace(): void + { + $seam = SeamMultiWorkspace::from_personal_access_token( + $this->seed["seam_at1_token"], + endpoint: $this->endpoint, + ); + + $workspace = $seam->workspaces->create( + name: "Test Workspace", + connect_partner_name: "Test Partner", + is_sandbox: true, + ); + + $this->assertSame("Test Workspace", $workspace->name); + } + + public function testMultiWorkspaceClientRequiresAToken(): void + { + $this->expectException(InvalidOptionsError::class); + + new SeamMultiWorkspace(endpoint: $this->endpoint); + } + + public function testMultiWorkspaceClientChecksTheTokenFormat(): void + { + $this->expectException(InvalidTokenError::class); + + SeamMultiWorkspace::from_personal_access_token( + $this->seed["seam_apikey1_token"], + endpoint: $this->endpoint, + ); + } +} diff --git a/tests/RetryTest.php b/tests/RetryTest.php new file mode 100644 index 00000000..322697e7 --- /dev/null +++ b/tests/RetryTest.php @@ -0,0 +1,139 @@ + ["type" => "service_unavailable", "message" => "Down"], + ]); + } + + private static function devices(): \GuzzleHttp\Psr7\Response + { + return RecordingClient::json(200, ["devices" => []]); + } + + private function seam(RecordingClient $recorder, ?int $retries = null): Seam + { + return new Seam( + api_key: self::API_KEY, + endpoint: "https://example.com", + guzzle_options: $recorder->guzzle_options(), + retries: $retries, + ); + } + + /** + * Every Seam endpoint is a POST. Retrying one the server may already have + * processed could duplicate a write, so a status code never triggers a + * retry. The Ruby SDK asserts the same thing. + */ + public function testDoesNotRetryPostOnServiceUnavailable(): void + { + $recorder = RecordingClient::repeating(self::service_unavailable()); + + try { + $this->seam($recorder)->devices->list(); + $this->fail("Expected the 503 to surface"); + } catch (\Throwable) { + // The error mapping is covered in HttpErrorTest. + } + + $this->assertSame(1, $recorder->attempt_count()); + } + + /** + * A connection failure never reached the server, so retrying it cannot + * duplicate anything. + */ + public function testRetriesPostOnConnectionFailure(): void + { + $connect_error = new ConnectException( + "Could not resolve host", + new Request("POST", "/devices/list"), + ); + + $recorder = new RecordingClient([ + $connect_error, + $connect_error, + self::devices(), + ]); + + $devices = $this->seam($recorder)->devices->list(); + + $this->assertSame([], $devices); + $this->assertSame(3, $recorder->attempt_count()); + } + + public function testStopsRetryingOnceRetriesAreExhausted(): void + { + $connect_error = new ConnectException( + "Could not resolve host", + new Request("POST", "/devices/list"), + ); + + $recorder = RecordingClient::repeating_throwable($connect_error); + + $this->expectException(ConnectException::class); + + try { + $this->seam($recorder, retries: 1)->devices->list(); + } finally { + $this->assertSame(2, $recorder->attempt_count()); + } + } + + public function testDoesNotRetryWhenRetriesAreDisabled(): void + { + $connect_error = new ConnectException( + "Could not resolve host", + new Request("POST", "/devices/list"), + ); + + $recorder = RecordingClient::repeating_throwable($connect_error); + + try { + $this->seam($recorder, retries: 0)->devices->list(); + $this->fail("Expected the connection failure to surface"); + } catch (ConnectException) { + // Expected. + } + + $this->assertSame(1, $recorder->attempt_count()); + } + + /** + * The SDK itself only issues POSTs, but a caller reaching for the client + * directly with an idempotent method does get status based retries. + */ + public function testRetriesIdempotentRequestsOnServiceUnavailable(): void + { + $recorder = new RecordingClient([ + self::service_unavailable(), + self::service_unavailable(), + self::devices(), + ]); + + $res = $this->seam($recorder)->client->request("GET", "/devices/list"); + + $this->assertSame([], $res->devices); + $this->assertSame(3, $recorder->attempt_count()); + } +} diff --git a/tests/SeamWebhookTest.php b/tests/SeamWebhookTest.php new file mode 100644 index 00000000..be871513 --- /dev/null +++ b/tests/SeamWebhookTest.php @@ -0,0 +1,103 @@ + "8d7e0b26-5e6c-4a1f-9b3d-1b0f0e5a9c11", + "event_type" => "device.connected", + "workspace_id" => "398d80b7-3f96-47c2-b85a-6f8ba21d07be", + "device_id" => "054765c8-a2fc-4599-b486-14c19f462c45", + "created_at" => "2024-01-01T00:00:00.000Z", + "occurred_at" => "2024-01-01T00:00:00.000Z", + ]); + } + + /** + * @return array + */ + private function signed_headers(string $payload): array + { + $id = "msg_test"; + $timestamp = (string) time(); + + $signature = (new Webhook(self::SECRET))->sign( + $id, + $timestamp, + $payload, + ); + + return [ + "svix-id" => $id, + "svix-timestamp" => $timestamp, + "svix-signature" => $signature, + ]; + } + + public function testVerifyReturnsTheEvent(): void + { + $payload = $this->payload(); + + $event = (new SeamWebhook(self::SECRET))->verify( + $payload, + $this->signed_headers($payload), + ); + + $this->assertSame("device.connected", $event->event_type); + $this->assertSame( + "8d7e0b26-5e6c-4a1f-9b3d-1b0f0e5a9c11", + $event->event_id, + ); + } + + public function testVerifyAcceptsHeadersInAnyCase(): void + { + $payload = $this->payload(); + + $headers = []; + foreach ($this->signed_headers($payload) as $name => $value) { + $headers[strtoupper($name)] = $value; + } + + $event = (new SeamWebhook(self::SECRET))->verify($payload, $headers); + + $this->assertSame("device.connected", $event->event_type); + } + + public function testVerifyRejectsATamperedPayload(): void + { + $payload = $this->payload(); + $headers = $this->signed_headers($payload); + + $this->expectException(WebhookVerificationException::class); + + (new SeamWebhook(self::SECRET))->verify( + str_replace("device.connected", "device.disconnected", $payload), + $headers, + ); + } + + public function testVerifyRejectsTheWrongSecret(): void + { + $payload = $this->payload(); + $headers = $this->signed_headers($payload); + + $this->expectException(WebhookVerificationException::class); + + (new SeamWebhook( + "whsec_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + ))->verify($payload, $headers); + } +} diff --git a/tests/SerializationTest.php b/tests/SerializationTest.php new file mode 100644 index 00000000..83310710 --- /dev/null +++ b/tests/SerializationTest.php @@ -0,0 +1,55 @@ +seam()->devices->list(); + + $this->assertNotEmpty($devices); + } + + public function testNullArrayParameterIsNotSent(): void + { + $devices = $this->seam()->devices->list(device_ids: null); + + $this->assertCount(count($this->seam()->devices->list()), $devices); + } + + public function testEmptyArrayParameterIsSent(): void + { + $devices = $this->seam()->devices->list(device_ids: []); + + $this->assertCount(0, $devices); + } + + public function testPopulatedArrayParameterFiltersTheResults(): void + { + $device_ids = [ + $this->seed["august_device_1"], + $this->seed["ecobee_device_1"], + ]; + + $devices = $this->seam()->devices->list(device_ids: $device_ids); + + $this->assertCount(2, $devices); + + $returned_ids = array_map(fn($device) => $device->device_id, $devices); + + sort($returned_ids); + sort($device_ids); + + $this->assertSame($device_ids, $returned_ids); + } +} diff --git a/tests/Support/FakeSeamConnect.php b/tests/Support/FakeSeamConnect.php new file mode 100644 index 00000000..93976532 --- /dev/null +++ b/tests/Support/FakeSeamConnect.php @@ -0,0 +1,171 @@ + */ + private array $pipes = []; + + public static function start(): self + { + $fake = new self(); + $fake->run(); + + return $fake; + } + + public function endpoint(): string + { + return $this->endpoint; + } + + /** + * The ids and tokens of the seeded records. + */ + public function seed(): array + { + return $this->seed; + } + + private function run(): void + { + $binary = dirname(__DIR__, 2) . "/node_modules/.bin/fake-seam-connect"; + + if (!is_executable($binary)) { + throw new \RuntimeException( + "Could not find {$binary}, run npm install before the tests.", + ); + } + + $port = self::unused_port(); + $this->endpoint = "http://127.0.0.1:{$port}"; + + // The binary is spawned directly rather than through npm so the + // process handle is the server itself and stopping it does not leave + // an orphan behind. PORT goes to the child only, leaving the parent + // environment alone for the tests that read it. + $this->process = proc_open( + [$binary, "--seed"], + [ + 0 => ["file", "/dev/null", "r"], + 1 => ["file", "/dev/null", "w"], + 2 => ["file", "/dev/null", "w"], + ], + $this->pipes, + dirname(__DIR__, 2), + ["PORT" => (string) $port] + getenv(), + ); + + if (!is_resource($this->process)) { + throw new \RuntimeException("Could not start Fake Seam Connect."); + } + + $this->wait_for_health(); + $this->seed = $this->fetch_seed(); + } + + public function stop(): void + { + if (!is_resource($this->process)) { + return; + } + + proc_terminate($this->process, SIGTERM); + + $deadline = microtime(true) + self::SHUTDOWN_TIMEOUT; + while (microtime(true) < $deadline) { + if (!proc_get_status($this->process)["running"]) { + break; + } + usleep(self::POLL_INTERVAL); + } + + if (proc_get_status($this->process)["running"]) { + proc_terminate($this->process, SIGKILL); + } + + proc_close($this->process); + $this->process = null; + } + + private function wait_for_health(): void + { + $deadline = microtime(true) + self::STARTUP_TIMEOUT; + + while (microtime(true) < $deadline) { + if (!proc_get_status($this->process)["running"]) { + throw new \RuntimeException( + "Fake Seam Connect exited before becoming healthy.", + ); + } + + if ($this->get("/health") !== null) { + return; + } + + usleep(self::POLL_INTERVAL); + } + + throw new \RuntimeException( + "Fake Seam Connect did not become healthy within " . + self::STARTUP_TIMEOUT . + "s.", + ); + } + + private function fetch_seed(): array + { + $body = $this->get("/_fake/default_seed"); + + if ($body === null) { + throw new \RuntimeException( + "Could not read the seed from Fake Seam Connect.", + ); + } + + return json_decode($body, true); + } + + private function get(string $path): ?string + { + $context = stream_context_create([ + "http" => ["timeout" => 5, "ignore_errors" => true], + ]); + + $body = @file_get_contents($this->endpoint . $path, false, $context); + + return $body === false ? null : $body; + } + + private static function unused_port(): int + { + $socket = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr); + + if ($socket === false) { + throw new \RuntimeException( + "Could not find an unused port: {$errstr}", + ); + } + + $name = stream_socket_get_name($socket, false); + fclose($socket); + + return (int) substr($name, strrpos($name, ":") + 1); + } +} diff --git a/tests/Support/FakeSeamConnectTestCase.php b/tests/Support/FakeSeamConnectTestCase.php new file mode 100644 index 00000000..81f91400 --- /dev/null +++ b/tests/Support/FakeSeamConnectTestCase.php @@ -0,0 +1,45 @@ +fake = FakeSeamConnect::start(); + $this->endpoint = $this->fake->endpoint(); + $this->seed = $this->fake->seed(); + } + + protected function tearDown(): void + { + $this->fake->stop(); + } + + /** + * A client authorized against the fake with the seeded API key. + */ + protected function seam( + bool|array|null $wait_for_action_attempt = null, + array $guzzle_options = [], + ?int $retries = null, + ): Seam { + return new Seam( + api_key: $this->seed["seam_apikey1_token"], + endpoint: $this->endpoint, + wait_for_action_attempt: $wait_for_action_attempt, + guzzle_options: $guzzle_options, + retries: $retries, + ); + } +} diff --git a/tests/Support/RecordingClient.php b/tests/Support/RecordingClient.php new file mode 100644 index 00000000..d2494332 --- /dev/null +++ b/tests/Support/RecordingClient.php @@ -0,0 +1,107 @@ + */ + public array $transactions = []; + + private MockHandler $mock; + + /** + * @param array $responses Served in order; the last one repeats. + */ + public function __construct(private array $responses) + { + $this->mock = new MockHandler($responses); + } + + /** + * Builds the Guzzle options to hand to a Seam client so its requests land + * here instead of on the network. + * + * @return array + */ + public function guzzle_options(): array + { + $stack = HandlerStack::create($this->mock); + $stack->push(Middleware::history($this->transactions)); + + return ["handler" => $stack]; + } + + /** + * How many logical calls the SDK made. Retries are invisible here because + * the SDK pushes its retry middleware inside this recorder; use + * attempt_count() to count those. + */ + public function request_count(): int + { + return count($this->transactions); + } + + /** + * How many requests actually reached the handler, retries included. + */ + public function attempt_count(): int + { + return count($this->responses) - $this->mock->count(); + } + + public function request(int $index = 0): RequestInterface + { + return $this->transactions[$index]["request"]; + } + + public function body(int $index = 0): mixed + { + return json_decode((string) $this->request($index)->getBody()); + } + + /** + * A response whose body repeats for every request, so retries keep + * failing the same way. + */ + public static function repeating(Response $response, int $times = 20): self + { + return new self(array_fill(0, $times, $response)); + } + + public static function repeating_throwable( + \Throwable $error, + int $times = 20, + ): self { + return new self(array_fill(0, $times, $error)); + } + + public static function json(int $status, mixed $body): Response + { + return new Response( + $status, + ["content-type" => "application/json"], + json_encode($body), + ); + } + + public static function raw( + int $status, + string $body, + string $content_type = "text/plain", + ): Response { + return new Response($status, ["content-type" => $content_type], $body); + } +} diff --git a/tests/WaitForActionAttemptTest.php b/tests/WaitForActionAttemptTest.php new file mode 100644 index 00000000..d6bbe194 --- /dev/null +++ b/tests/WaitForActionAttemptTest.php @@ -0,0 +1,283 @@ +locks->unlock_door( + $this->seed["august_device_1"], + ); + + $this->assertSame("pending", $action_attempt->status); + + $this->set_status($seam, $action_attempt, "pending"); + + return $action_attempt; + } + + private function set_status( + Seam $seam, + ActionAttempt $action_attempt, + string $status, + ?array $error = null, + ): void { + $seam->client->request( + "POST", + "/_fake/update_action_attempt", + (object) array_filter([ + "action_attempt_id" => $action_attempt->action_attempt_id, + "status" => $status, + "error" => $error, + ]), + ); + } + + public function testWaitsByDefault(): void + { + $action_attempt = $this->seam()->locks->unlock_door( + $this->seed["august_device_1"], + ); + + $this->assertSame("success", $action_attempt->status); + } + + public function testClientDefaultCanDisableWaiting(): void + { + $seam = $this->seam(wait_for_action_attempt: false); + + $action_attempt = $seam->locks->unlock_door( + $this->seed["august_device_1"], + ); + + $this->assertSame("pending", $action_attempt->status); + } + + /** + * The options form of the client default has to wait just like `true` + * does; treating it as "no waiting" would hand back a pending attempt + * with no indication anything was skipped. + */ + public function testClientDefaultCanBeAnOptionsArray(): void + { + $seam = $this->seam( + wait_for_action_attempt: [ + "timeout" => 5.0, + "polling_interval" => 0.05, + ], + ); + + $action_attempt = $seam->locks->unlock_door( + $this->seed["august_device_1"], + ); + + $this->assertSame("success", $action_attempt->status); + } + + public function testPerCallOptionCanDisableWaiting(): void + { + $action_attempt = $this->seam()->locks->unlock_door( + $this->seed["august_device_1"], + wait_for_action_attempt: false, + ); + + $this->assertSame("pending", $action_attempt->status); + } + + public function testPerCallOptionCanEnableWaiting(): void + { + $seam = $this->seam(wait_for_action_attempt: false); + + $action_attempt = $seam->locks->unlock_door( + $this->seed["august_device_1"], + wait_for_action_attempt: true, + ); + + $this->assertSame("success", $action_attempt->status); + } + + public function testReturnsAnAlreadySuccessfulActionAttempt(): void + { + $seam = $this->seam(wait_for_action_attempt: false); + + $action_attempt = $seam->locks->unlock_door( + $this->seed["august_device_1"], + ); + $this->set_status($seam, $action_attempt, "success"); + + $resolved = $seam->action_attempts->get( + $action_attempt->action_attempt_id, + wait_for_action_attempt: true, + ); + + $this->assertSame("success", $resolved->status); + $this->assertSame( + $action_attempt->action_attempt_id, + $resolved->action_attempt_id, + ); + } + + /** + * Proves the resolver really re-reads the action attempt: it starts out + * pending and is moved to success by something outside this process, the + * way the JavaScript and Ruby suites do it. + */ + public function testWaitsForAnActionAttemptResolvedOutOfBand(): void + { + $seam = $this->seam(wait_for_action_attempt: false); + $action_attempt = $this->pending_action_attempt($seam); + + $resolver = $this->resolve_after( + $action_attempt->action_attempt_id, + 0.5, + ); + + try { + $resolved = $seam->action_attempts->get( + $action_attempt->action_attempt_id, + wait_for_action_attempt: [ + "timeout" => 15.0, + "polling_interval" => 0.1, + ], + ); + + $this->assertSame("success", $resolved->status); + } finally { + proc_close($resolver); + } + } + + /** + * @return resource + */ + private function resolve_after(string $action_attempt_id, float $delay) + { + $payload = json_encode([ + "action_attempt_id" => $action_attempt_id, + "status" => "success", + ]); + + $script = sprintf( + "usleep(%d); file_get_contents(%s, false, stream_context_create(%s));", + (int) ($delay * 1000000.0), + var_export($this->endpoint . "/_fake/update_action_attempt", true), + var_export( + [ + "http" => [ + "method" => "POST", + "header" => "Content-Type: application/json", + "content" => $payload, + "ignore_errors" => true, + ], + ], + true, + ), + ); + + $process = proc_open( + [PHP_BINARY, "-r", $script], + [ + 1 => ["file", "/dev/null", "w"], + 2 => ["file", "/dev/null", "w"], + ], + $pipes, + ); + + if (!is_resource($process)) { + $this->fail("Could not start the out of band resolver"); + } + + return $process; + } + + public function testThrowsWhenTheActionAttemptFails(): void + { + $seam = $this->seam(wait_for_action_attempt: false); + + $action_attempt = $seam->locks->unlock_door( + $this->seed["august_device_1"], + ); + $this->set_status($seam, $action_attempt, "error", [ + "type" => "foo", + "message" => "Failed", + ]); + + try { + $seam->action_attempts->get( + $action_attempt->action_attempt_id, + wait_for_action_attempt: true, + ); + $this->fail("Expected ActionAttemptFailedError"); + } catch (ActionAttemptFailedError $error) { + $this->assertSame("Failed", $error->getMessage()); + $this->assertSame("foo", $error->getErrorCode()); + $this->assertSame("error", $error->getActionAttempt()->status); + $this->assertSame( + $action_attempt->action_attempt_id, + $error->getActionAttempt()->action_attempt_id, + ); + $this->assertInstanceOf(ActionAttemptError::class, $error); + } + } + + public function testTimesOutWhileTheActionAttemptIsPending(): void + { + $seam = $this->seam(wait_for_action_attempt: false); + $action_attempt = $this->pending_action_attempt($seam); + + try { + $seam->action_attempts->get( + $action_attempt->action_attempt_id, + wait_for_action_attempt: [ + "timeout" => 0.2, + "polling_interval" => 5.0, + ], + ); + $this->fail("Expected ActionAttemptTimeoutError"); + } catch (ActionAttemptTimeoutError $error) { + $this->assertSame( + $action_attempt->action_attempt_id, + $error->getActionAttempt()->action_attempt_id, + ); + $this->assertStringContainsString( + "Timed out waiting for action attempt", + $error->getMessage(), + ); + } + } + + /** + * Resolving fetches the action attempt through the HTTP client rather + * than the route client, so enabling the option on the route that reads + * action attempts cannot recurse. + */ + public function testActionAttemptsGetDoesNotRecurse(): void + { + $seam = $this->seam(wait_for_action_attempt: false); + + $action_attempt = $seam->locks->unlock_door( + $this->seed["august_device_1"], + ); + $this->set_status($seam, $action_attempt, "success"); + + $resolved = $seam->action_attempts->get( + $action_attempt->action_attempt_id, + wait_for_action_attempt: [ + "timeout" => 1.0, + "polling_interval" => 0.05, + ], + ); + + $this->assertSame("success", $resolved->status); + } +} From ff3b40702f5d09bd034a888d3073a1bd0b3be083 Mon Sep 17 00:00:00 2001 From: Evan Date: Thu, 6 Aug 2026 16:40:54 +0000 Subject: [PATCH 2/2] refactor: keep the exception classes in the Seam namespace The core public API is small enough to read at a glance, so nesting part of it under Seam\Exceptions bought organization it does not need. Keeping the classes where 3.x had them also means existing catch blocks keep working. Sub-namespacing errors is the more common PHP convention, but the Python and JavaScript SDKs both export theirs at the package root, so this is closer to them as well. The new SeamException marker interface, InvalidOptionsError, and InvalidTokenError are all that changes for a caller upgrading from 3.x. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Fwgphpc9YhtxBbxuxppy8m --- README.md | 21 ++++++++++--------- src/{Exceptions => }/ActionAttemptError.php | 2 +- .../ActionAttemptFailedError.php | 2 +- .../ActionAttemptTimeoutError.php | 2 +- src/Auth.php | 3 --- src/Http/ResolveActionAttempt.php | 4 ++-- src/Http/SeamHttpClient.php | 6 +++--- src/{Exceptions => }/HttpApiError.php | 2 +- .../HttpInvalidInputError.php | 2 +- .../HttpUnauthorizedError.php | 2 +- src/{Exceptions => }/InvalidOptionsError.php | 2 +- src/{Exceptions => }/InvalidTokenError.php | 2 +- src/Options.php | 2 -- src/{Exceptions => }/SeamException.php | 2 +- src/SeamMultiWorkspace.php | 2 +- tests/ApiKeyTest.php | 4 ++-- tests/EnvTest.php | 2 +- tests/HttpErrorTest.php | 6 +++--- tests/MalformedResponseTest.php | 2 +- tests/PersonalAccessTokenTest.php | 4 ++-- tests/WaitForActionAttemptTest.php | 6 +++--- 21 files changed, 38 insertions(+), 42 deletions(-) rename src/{Exceptions => }/ActionAttemptError.php (95%) rename src/{Exceptions => }/ActionAttemptFailedError.php (96%) rename src/{Exceptions => }/ActionAttemptTimeoutError.php (94%) rename src/{Exceptions => }/HttpApiError.php (97%) rename src/{Exceptions => }/HttpInvalidInputError.php (96%) rename src/{Exceptions => }/HttpUnauthorizedError.php (93%) rename src/{Exceptions => }/InvalidOptionsError.php (93%) rename src/{Exceptions => }/InvalidTokenError.php (92%) rename src/{Exceptions => }/SeamException.php (87%) diff --git a/README.md b/README.md index 35ef6120..9ba25b03 100644 --- a/README.md +++ b/README.md @@ -108,18 +108,18 @@ By default the SDK waits for the action attempt to finish: - It polls up to a timeout at a polling interval. - It returns a fresh copy of the successful action attempt. -- It throws `Seam\Exceptions\ActionAttemptFailedError` if the action failed. -- It throws `Seam\Exceptions\ActionAttemptTimeoutError` if the timeout +- It throws `Seam\ActionAttemptFailedError` if the action failed. +- It throws `Seam\ActionAttemptTimeoutError` if the timeout elapses first. -Both errors extend `Seam\Exceptions\ActionAttemptError` and expose the action +Both errors extend `Seam\ActionAttemptError` and expose the action attempt with `getActionAttempt()`. [action attempt]: https://docs.seam.co/latest/core-concepts/action-attempts ```php -use Seam\Exceptions\ActionAttemptFailedError; -use Seam\Exceptions\ActionAttemptTimeoutError; +use Seam\ActionAttemptFailedError; +use Seam\ActionAttemptTimeoutError; try { $seam->locks->unlock_door($device_id); @@ -355,7 +355,7 @@ $seam = Seam\Seam::from_client($client); #### Errors -Every exception the SDK raises implements `Seam\Exceptions\SeamException`. +Every exception the SDK raises implements `Seam\SeamException`. | Error | Raised when | | --------------------------- | -------------------------------------------------- | @@ -373,8 +373,8 @@ BadResponseException` instead. Transport failures surface as the corresponding Guzzle exception. ```php -use Seam\Exceptions\HttpApiError; -use Seam\Exceptions\HttpInvalidInputError; +use Seam\HttpApiError; +use Seam\HttpInvalidInputError; try { $seam->devices->get($device_id); @@ -397,8 +397,9 @@ Version 4 brings this SDK in line with the Seam SDKs for other languages. - The constructor takes named options. `$endpoint` is no longer the second positional argument, and `$throw_http_errors` is gone; API errors always raise a Seam exception. -- Exceptions moved to the `Seam\Exceptions` namespace, e.g. - `Seam\HttpApiError` is now `Seam\Exceptions\HttpApiError`. +- The exception classes keep their `Seam\` namespace. They now all implement + a shared `Seam\SeamException` interface, and `Seam\InvalidOptionsError` and + `Seam\InvalidTokenError` are new. - `$seam->action_attempts->poll_until_ready()` was removed. Use `wait_for_action_attempt`, which now also accepts a `timeout` and `polling_interval`. The defaults changed from 20s/0.4s to 10s/1s. diff --git a/src/Exceptions/ActionAttemptError.php b/src/ActionAttemptError.php similarity index 95% rename from src/Exceptions/ActionAttemptError.php rename to src/ActionAttemptError.php index 7368770b..776ee6fe 100644 --- a/src/Exceptions/ActionAttemptError.php +++ b/src/ActionAttemptError.php @@ -1,6 +1,6 @@ client = SeamHttpClient::from_client($client); } else { if ($personal_access_token === null) { - throw new Exceptions\InvalidOptionsError( + throw new InvalidOptionsError( "Must specify a personal_access_token", ); } diff --git a/tests/ApiKeyTest.php b/tests/ApiKeyTest.php index b1b8e9bb..838fa24a 100644 --- a/tests/ApiKeyTest.php +++ b/tests/ApiKeyTest.php @@ -4,8 +4,8 @@ namespace Tests; -use Seam\Exceptions\HttpUnauthorizedError; -use Seam\Exceptions\InvalidTokenError; +use Seam\HttpUnauthorizedError; +use Seam\InvalidTokenError; use Seam\Seam; use Tests\Support\FakeSeamConnectTestCase; diff --git a/tests/EnvTest.php b/tests/EnvTest.php index 59d4886c..3c0f92a7 100644 --- a/tests/EnvTest.php +++ b/tests/EnvTest.php @@ -4,7 +4,7 @@ namespace Tests; -use Seam\Exceptions\InvalidOptionsError; +use Seam\InvalidOptionsError; use Seam\Options; use Seam\Seam; use Tests\Support\FakeSeamConnectTestCase; diff --git a/tests/HttpErrorTest.php b/tests/HttpErrorTest.php index 72a029cb..d445defc 100644 --- a/tests/HttpErrorTest.php +++ b/tests/HttpErrorTest.php @@ -4,9 +4,9 @@ namespace Tests; -use Seam\Exceptions\HttpApiError; -use Seam\Exceptions\HttpInvalidInputError; -use Seam\Exceptions\HttpUnauthorizedError; +use Seam\HttpApiError; +use Seam\HttpInvalidInputError; +use Seam\HttpUnauthorizedError; use Seam\Seam; use Tests\Support\FakeSeamConnectTestCase; diff --git a/tests/MalformedResponseTest.php b/tests/MalformedResponseTest.php index b9df3dd0..e132c10a 100644 --- a/tests/MalformedResponseTest.php +++ b/tests/MalformedResponseTest.php @@ -8,7 +8,7 @@ use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\TestCase; -use Seam\Exceptions\HttpApiError; +use Seam\HttpApiError; use Seam\Seam; use Tests\Support\RecordingClient; diff --git a/tests/PersonalAccessTokenTest.php b/tests/PersonalAccessTokenTest.php index 5abd2d81..bce47735 100644 --- a/tests/PersonalAccessTokenTest.php +++ b/tests/PersonalAccessTokenTest.php @@ -4,8 +4,8 @@ namespace Tests; -use Seam\Exceptions\InvalidOptionsError; -use Seam\Exceptions\InvalidTokenError; +use Seam\InvalidOptionsError; +use Seam\InvalidTokenError; use Seam\Seam; use Seam\SeamMultiWorkspace; use Tests\Support\FakeSeamConnectTestCase; diff --git a/tests/WaitForActionAttemptTest.php b/tests/WaitForActionAttemptTest.php index d6bbe194..32dae081 100644 --- a/tests/WaitForActionAttemptTest.php +++ b/tests/WaitForActionAttemptTest.php @@ -4,9 +4,9 @@ namespace Tests; -use Seam\Exceptions\ActionAttemptError; -use Seam\Exceptions\ActionAttemptFailedError; -use Seam\Exceptions\ActionAttemptTimeoutError; +use Seam\ActionAttemptError; +use Seam\ActionAttemptFailedError; +use Seam\ActionAttemptTimeoutError; use Seam\Resources\ActionAttempt; use Seam\Seam; use Tests\Support\FakeSeamConnectTestCase;