From 7318276d85dfff02708caf887379853bfcde0bd3 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Wed, 3 Jun 2026 18:29:00 -0300 Subject: [PATCH 01/18] Update workflow --- .github/assets/header-dark-mobile.svg | 1 + .github/assets/header-dark.svg | 1 + .github/assets/header-light-mobile.svg | 1 + .github/assets/header-light.svg | 1 + .github/workflows/branch-validations.yaml | 173 -- .github/workflows/release-drafter.yaml | 17 - .github/workflows/validate-branch.yaml | 16 + LICENSE | 21 + README.md | 99 +- composer.json | 61 +- composer.lock | 2998 +++++++++++++++---- phpcs.xml.dist | 15 +- phpstan.neon.dist | 2 +- phpunit.xml.dist | 2 +- src/.gitkeep | 0 src/ApiClient.php | 25 + src/ApiKey.php | 249 ++ src/Content/ArrayContentProvider.php | 30 + src/Content/ContentProvider.php | 20 + src/Content/ContentSource.php | 26 + src/Content/ExperienceMetadata.php | 82 + src/Content/ExperimentMetadata.php | 60 + src/Content/NullContentProvider.php | 19 + src/Content/SlotMetadata.php | 147 + src/ContentFetcher.php | 22 + src/Cookie.php | 155 + src/CookieConfiguration.php | 146 + src/CookieStorage.php | 207 ++ src/Croct.php | 247 ++ src/EvaluationOptions.php | 96 + src/Evaluator.php | 22 + src/Exception/ApiException.php | 58 + src/Exception/ConfigurationException.php | 14 + src/Exception/ContentException.php | 12 + src/Exception/CroctException.php | 12 + src/Exception/EvaluationException.php | 12 + src/Exception/MalformedTokenException.php | 12 + src/FetchOptions.php | 215 ++ src/FetchResponse.php | 62 + src/HttpContentFetcher.php | 90 + src/HttpEvaluator.php | 50 + src/HttpHeader.php | 41 + src/IdentityStore.php | 35 + src/InMemoryIdentityStore.php | 44 + src/Plug.php | 62 + src/PsrApiClient.php | 135 + src/RequestContext.php | 203 ++ src/Session.php | 161 + src/Token.php | 392 +++ src/Uuid.php | 80 + src/VaryingResponseObserver.php | 88 + tests/.gitkeep | 0 tests/ApiKeyTest.php | 184 ++ tests/Content/ArrayContentProviderTest.php | 29 + tests/Content/ExperienceMetadataTest.php | 85 + tests/Content/ExperimentMetadataTest.php | 64 + tests/Content/NullContentProviderTest.php | 21 + tests/Content/SlotMetadataTest.php | 93 + tests/CookieConfigurationTest.php | 85 + tests/CookieStorageTest.php | 178 ++ tests/CookieTest.php | 94 + tests/CroctTest.php | 250 ++ tests/EcKeyFactory.php | 80 + tests/EvaluationOptionsTest.php | 63 + tests/Exception/ApiExceptionTest.php | 45 + tests/FetchOptionsTest.php | 82 + tests/FetchResponseTest.php | 64 + tests/Fixtures/InstalledContentProvider.php | 18 + tests/HttpContentFetcherTest.php | 234 ++ tests/HttpEvaluatorTest.php | 146 + tests/InMemoryIdentityStoreTest.php | 54 + tests/PsrApiClientTest.php | 211 ++ tests/RequestContextTest.php | 114 + tests/SessionTest.php | 194 ++ tests/TokenTest.php | 288 ++ tests/UuidTest.php | 94 + tests/VaryingResponseObserverTest.php | 153 + 77 files changed, 8752 insertions(+), 910 deletions(-) create mode 100644 .github/assets/header-dark-mobile.svg create mode 100644 .github/assets/header-dark.svg create mode 100644 .github/assets/header-light-mobile.svg create mode 100644 .github/assets/header-light.svg delete mode 100644 .github/workflows/branch-validations.yaml delete mode 100644 .github/workflows/release-drafter.yaml create mode 100644 .github/workflows/validate-branch.yaml create mode 100644 LICENSE delete mode 100644 src/.gitkeep create mode 100644 src/ApiClient.php create mode 100644 src/ApiKey.php create mode 100644 src/Content/ArrayContentProvider.php create mode 100644 src/Content/ContentProvider.php create mode 100644 src/Content/ContentSource.php create mode 100644 src/Content/ExperienceMetadata.php create mode 100644 src/Content/ExperimentMetadata.php create mode 100644 src/Content/NullContentProvider.php create mode 100644 src/Content/SlotMetadata.php create mode 100644 src/ContentFetcher.php create mode 100644 src/Cookie.php create mode 100644 src/CookieConfiguration.php create mode 100644 src/CookieStorage.php create mode 100644 src/Croct.php create mode 100644 src/EvaluationOptions.php create mode 100644 src/Evaluator.php create mode 100644 src/Exception/ApiException.php create mode 100644 src/Exception/ConfigurationException.php create mode 100644 src/Exception/ContentException.php create mode 100644 src/Exception/CroctException.php create mode 100644 src/Exception/EvaluationException.php create mode 100644 src/Exception/MalformedTokenException.php create mode 100644 src/FetchOptions.php create mode 100644 src/FetchResponse.php create mode 100644 src/HttpContentFetcher.php create mode 100644 src/HttpEvaluator.php create mode 100644 src/HttpHeader.php create mode 100644 src/IdentityStore.php create mode 100644 src/InMemoryIdentityStore.php create mode 100644 src/Plug.php create mode 100644 src/PsrApiClient.php create mode 100644 src/RequestContext.php create mode 100644 src/Session.php create mode 100644 src/Token.php create mode 100644 src/Uuid.php create mode 100644 src/VaryingResponseObserver.php delete mode 100644 tests/.gitkeep create mode 100644 tests/ApiKeyTest.php create mode 100644 tests/Content/ArrayContentProviderTest.php create mode 100644 tests/Content/ExperienceMetadataTest.php create mode 100644 tests/Content/ExperimentMetadataTest.php create mode 100644 tests/Content/NullContentProviderTest.php create mode 100644 tests/Content/SlotMetadataTest.php create mode 100644 tests/CookieConfigurationTest.php create mode 100644 tests/CookieStorageTest.php create mode 100644 tests/CookieTest.php create mode 100644 tests/CroctTest.php create mode 100644 tests/EcKeyFactory.php create mode 100644 tests/EvaluationOptionsTest.php create mode 100644 tests/Exception/ApiExceptionTest.php create mode 100644 tests/FetchOptionsTest.php create mode 100644 tests/FetchResponseTest.php create mode 100644 tests/Fixtures/InstalledContentProvider.php create mode 100644 tests/HttpContentFetcherTest.php create mode 100644 tests/HttpEvaluatorTest.php create mode 100644 tests/InMemoryIdentityStoreTest.php create mode 100644 tests/PsrApiClientTest.php create mode 100644 tests/RequestContextTest.php create mode 100644 tests/SessionTest.php create mode 100644 tests/TokenTest.php create mode 100644 tests/UuidTest.php create mode 100644 tests/VaryingResponseObserverTest.php diff --git a/.github/assets/header-dark-mobile.svg b/.github/assets/header-dark-mobile.svg new file mode 100644 index 0000000..716e8f1 --- /dev/null +++ b/.github/assets/header-dark-mobile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.github/assets/header-dark.svg b/.github/assets/header-dark.svg new file mode 100644 index 0000000..4d8c197 --- /dev/null +++ b/.github/assets/header-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.github/assets/header-light-mobile.svg b/.github/assets/header-light-mobile.svg new file mode 100644 index 0000000..1471314 --- /dev/null +++ b/.github/assets/header-light-mobile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.github/assets/header-light.svg b/.github/assets/header-light.svg new file mode 100644 index 0000000..272dc4d --- /dev/null +++ b/.github/assets/header-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.github/workflows/branch-validations.yaml b/.github/workflows/branch-validations.yaml deleted file mode 100644 index 288aed1..0000000 --- a/.github/workflows/branch-validations.yaml +++ /dev/null @@ -1,173 +0,0 @@ -name: Validations - -on: - push: - tags-ignore: - - '**' - branches: - - master - pull_request: - types: - - synchronize - - opened - -jobs: - security: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: symfonycorp/security-checker-action@v5 - - composer: - runs-on: ubuntu-latest - container: - image: crocttech/php-base-image:php8.1-dev - steps: - - uses: actions/checkout@v4 - - - name: Composer cache - uses: actions/cache@v3 - with: - key: composer-${{ hashFiles('**/composer.lock') }} - path: ${{ github.workspace }}/.cache - - - name: Vendor cache - uses: actions/cache@v3 - with: - key: vendor-${{ hashFiles('**/composer.lock') }} - path: ${{ github.workspace }}/vendor - - - name: Tools cache - uses: actions/cache@v3 - with: - key: tools - path: ${{ github.workspace }}/.cache - - - name: Install dependencies - env: - COMPOSER_CACHE_DIR: ${{ github.workspace }}/.cache - run: |- - composer config --global --auth http-basic.croct.repo.repman.io token "${{ secrets.REPMAN_TOKEN }}" - composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader - - - name: Validate composer - env: - COMPOSER_CACHE_DIR: ${{ github.workspace }}/.cache - run: composer validate - - - name: Normalize composer - env: - COMPOSER_CACHE_DIR: ${{ github.workspace }}/.cache - run: composer normalize --dry-run - - phpunit: - runs-on: ubuntu-latest - container: - image: crocttech/php-base-image:php8.1-dev - steps: - - uses: actions/checkout@v4 - - - name: Composer cache - uses: actions/cache@v3 - with: - key: composer-${{ hashFiles('**/composer.lock') }} - path: ${{ github.workspace }}/.cache - - - name: Vendor cache - uses: actions/cache@v3 - with: - key: vendor-${{ hashFiles('**/composer.lock') }} - path: ${{ github.workspace }}/vendor - - - name: Tools cache - uses: actions/cache@v3 - with: - key: tools - path: ${{ github.workspace }}/.cache - - - name: Install dependencies - env: - COMPOSER_CACHE_DIR: ${{ github.workspace }}/.cache - run: |- - composer config --global --auth http-basic.croct.repo.repman.io token "${{ secrets.REPMAN_TOKEN }}" - composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader - - - name: phpunit - run: ./vendor/bin/phpunit --coverage-clover ./coverage.xml - - - uses: paambaati/codeclimate-action@v5.0.0 - env: - CC_TEST_REPORTER_ID: ${{ secrets.CODECLIMATE_TESTREPORTER_ID }} - with: - coverageCommand: sed -i "s~$(pwd)/~~" coverage.xml - coverageLocations: ./coverage.xml:clover - - phpstan: - runs-on: ubuntu-latest - container: - image: crocttech/php-base-image:php8.1-dev - steps: - - uses: actions/checkout@v4 - - - name: Composer cache - uses: actions/cache@v3 - with: - key: composer-${{ hashFiles('**/composer.lock') }} - path: ${{ github.workspace }}/.cache - - - name: Vendor cache - uses: actions/cache@v3 - with: - key: vendor-${{ hashFiles('**/composer.lock') }} - path: ${{ github.workspace }}/vendor - - - name: Tools cache - uses: actions/cache@v3 - with: - key: tools - path: ${{ github.workspace }}/.cache - - - name: Install dependencies - env: - COMPOSER_CACHE_DIR: ${{ github.workspace }}/.cache - run: |- - composer config --global --auth http-basic.croct.repo.repman.io token "${{ secrets.REPMAN_TOKEN }}" - composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader - - - name: phpstan - run: ./vendor/bin/phpstan analyse -l max --memory-limit=1G src tests - - phpcs: - runs-on: ubuntu-latest - container: - image: crocttech/php-base-image:php8.1-dev - steps: - - uses: actions/checkout@v4 - - - name: Composer cache - uses: actions/cache@v3 - with: - key: composer-${{ hashFiles('**/composer.lock') }} - path: ${{ github.workspace }}/.cache - - - name: Vendor cache - uses: actions/cache@v3 - with: - key: vendor-${{ hashFiles('**/composer.lock') }} - path: ${{ github.workspace }}/vendor - - - name: Tools cache - uses: actions/cache@v3 - with: - key: tools - path: ${{ github.workspace }}/.cache - - - name: Install dependencies - env: - COMPOSER_CACHE_DIR: ${{ github.workspace }}/.cache - run: |- - composer config --global --auth http-basic.croct.repo.repman.io token "${{ secrets.REPMAN_TOKEN }}" - composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader - - - name: phpcs - run: ./vendor/bin/phpcs -d memory_limit=1G diff --git a/.github/workflows/release-drafter.yaml b/.github/workflows/release-drafter.yaml deleted file mode 100644 index 9af0eca..0000000 --- a/.github/workflows/release-drafter.yaml +++ /dev/null @@ -1,17 +0,0 @@ -name: Release Drafter - -on: - push: - branches: - - master - tags-ignore: - - '**' - -jobs: - release-draft: - runs-on: ubuntu-latest - steps: - - name: Update release draft - uses: release-drafter/release-drafter@v5 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/validate-branch.yaml b/.github/workflows/validate-branch.yaml new file mode 100644 index 0000000..50d3a83 --- /dev/null +++ b/.github/workflows/validate-branch.yaml @@ -0,0 +1,16 @@ +name: Validate branch + +on: + push: + tags-ignore: + - '**' + branches: + - master + pull_request: + types: + - synchronize + - opened + +jobs: + validate: + uses: croct-tech/shared-public-configs/.github/workflows/php-validations.yml@master diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f26bb0b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Croct.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index db821ce..b0def41 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,56 @@

- - Croct - -
- PHP Project Title -
- A brief description about the project. + + + + + + + Croct PHP SDK + + +
+ Croct PHP SDK
+ Bring dynamic, personalized content natively into your applications.

+
+ 📘 Quick start → +
+

- Language - Build - License -
-
- 📦 Releases - · - 🐞 Report Bug - · - ✨ Request Feature + Version + PHP version

-# Instructions +## Introduction -Follow the steps below to create a new repository: - -1. Customize the repository - 1. Click on the _Use this template_ button at the top of this page - 2. Clone the repository locally - 3. Update the `README.md` and `composer.json` with the new package information -2. Setup Code Climate - 1. Add the project to [Croct's code climate organization](https://codeclimate.com/accounts/5e714648faaa9c00fb000081/dashboard) - 2. Go to **Repo Settings > Badges** and copy the maintainability and coverage badges to the `README.md` - 3. Go to **Repo Settings > Test coverage** and copy the "_TEST REPORTER ID_" - 4. On the Github repository page, go to **Settings > Secrets** and add a secret with name `CODECLIMATE_TESTREPORTER_ID` and the ID from the previous step as value -3. Setup Repman - 1. If you are a Repman admin, you need to generate a token for each member. Go to [**Organizations > Croct > Tokens > New Token**](https://app.repman.io/organization/croct/token/new) and click on Generate New Token button. - 2. If you are a member, you need to configure global authentication to access this organization's packages. With the token in hand, you can authorize Composer with the following command (replace `TOKEN_VALUE` with the actual token): - - ```sh - composer config --global --auth http-basic.croct.repo.repman.io token TOKEN_VALUE - ``` +Croct is a headless CMS that helps you manage content, run AB tests, and personalize experiences without the hassle of complex integrations. ## Installation -We recommend using the package manager [Composer](https://getcomposer.org) to install the package: +Run this command to install the SDK: ```sh -composer require croct/project-php +composer require croct/plug-php ``` -## Basic usage - -```php -use Croct\Project\Example; +See our [quick start guide](https://docs.croct.com/reference/sdk/php/installation) for more details. -$example = new Example(); -$example->displayBasicUsage(); -``` - -## Contributing +## Documentation -Contributions to the package are always welcome! +Visit our [official documentation](https://docs.croct.com/reference/sdk/php/installation). -- Report any bugs or issues on the [issue tracker](https://github.com/croct-tech/project-php/issues). -- For major changes, please [open an issue](https://github.com/croct-tech/project-php/issues) first to discuss what you would like to change. -- Please make sure to update tests as appropriate. +## Support -## Testing +Join our official [Slack channel](https://croct.link/community) to get help from the Croct team and other developers. -Before running the test suites, the development dependencies must be installed: +## Contribution -```sh -composer install -``` - -Then, to run all tests: - -```sh -composer test -``` +Contributions are always welcome! -## Copyright Notice +- Report any bugs or issues on the [issue tracker](https://github.com/croct-tech/plug-php/issues). +- For major changes, please [open an issue](https://github.com/croct-tech/plug-php/issues) first to discuss what you would like to change. +- Please make sure to update tests as appropriate. Run tests with `composer test`. -Copyright © 2015-2020 Croct Limited, All Rights Reserved. +## License -All information contained herein is, and remains the property of Croct Limited. The intellectual, design and technical concepts contained herein are proprietary to Croct Limited s and may be covered by U.S. and Foreign Patents, patents in process, and are protected by trade secret or copyright law. Dissemination of this information or reproduction of this material is strictly forbidden unless prior written permission is obtained from Croct Limited. +This library is licensed under the [MIT license](LICENSE). diff --git a/composer.json b/composer.json index c56dfeb..15f313a 100644 --- a/composer.json +++ b/composer.json @@ -1,33 +1,55 @@ { - "name": "croct/project-php", - "description": "A brief description about the project.", - "license": "proprietary", + "name": "croct/plug-php", + "description": "Server-side library to plug your PHP applications into Croct.", + "license": "MIT", "type": "library", "keywords": [ "croct", - "related", - "keyword" + "personalization", + "php", + "server-side" ], "authors": [ { "name": "Croct", - "email": "lib+project-php@croct.com", + "email": "lib+plug-php@croct.com", "homepage": "https://croct.com" } ], - "homepage": "https://github.com/croct-tech/project-php", + "homepage": "https://github.com/croct-tech/plug-php", + "support": { + "issues": "https://github.com/croct-tech/plug-php/issues", + "source": "https://github.com/croct-tech/plug-php" + }, "require": { - "php": "^8.0" + "php": "^8.2", + "ext-json": "*", + "ext-openssl": "*", + "composer-runtime-api": "^2.0", + "php-http/discovery": "^1.19", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "psr/log": "^2.0 || ^3.0" }, "require-dev": { - "croct/coding-standard": "^0.4", + "croct/coding-standard": "^0.4.5", "ergebnis/composer-normalize": "^2.28", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-deprecation-rules": "^1.0", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.4", - "phpunit/phpunit": "^10.0", - "squizlabs/php_codesniffer": "^3.7" + "guzzlehttp/guzzle": "^7.5", + "nyholm/psr7": "^1.8", + "php-http/mock-client": "^1.6", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.0", + "squizlabs/php_codesniffer": "^4.0" + }, + "suggest": { + "guzzlehttp/guzzle": "A PSR-18 HTTP client used to talk to the Croct API (any PSR-18 client works).", + "nyholm/psr7": "A lightweight PSR-7/PSR-17 implementation for building requests.", + "symfony/http-client": "An alternative PSR-18 HTTP client." }, "repositories": [ { @@ -39,19 +61,20 @@ "prefer-stable": true, "autoload": { "psr-4": { - "Croct\\Project\\": "src/" + "Croct\\Plug\\": "src/" } }, "autoload-dev": { "psr-4": { - "Croct\\Project\\Tests\\": "tests/" + "Croct\\Plug\\Tests\\": "tests/" } }, "config": { "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true, "ergebnis/composer-normalize": true, - "phpstan/extension-installer": false + "php-http/discovery": true, + "phpstan/extension-installer": true } }, "scripts": { @@ -60,7 +83,7 @@ "composer normalize --dry-run", "mkdir -p .cache", "phpcs", - "phpstan analyse", + "phpstan analyse --memory-limit=512M", "phpunit" ] } diff --git a/composer.lock b/composer.lock index b8f8f8d..fcc1e55 100644 --- a/composer.lock +++ b/composer.lock @@ -4,21 +4,377 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bbfd140bbbfb9af32d513ba24c2f0f0e", - "packages": [], + "content-hash": "992b9f692e92e9cc9abc6f49fb8a0abf", + "packages": [ + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "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" + } + ], "packages-dev": [ + { + "name": "clue/stream-filter", + "version": "v1.7.0", + "source": { + "type": "git", + "url": "https://github.com/clue/stream-filter.git", + "reference": "049509fef80032cb3f051595029ab75b49a3c2f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/stream-filter/zipball/049509fef80032cb3f051595029ab75b49a3c2f7", + "reference": "049509fef80032cb3f051595029ab75b49a3c2f7", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "Clue\\StreamFilter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "A simple and modern approach to stream filtering in PHP", + "homepage": "https://github.com/clue/stream-filter", + "keywords": [ + "bucket brigade", + "callback", + "filter", + "php_user_filter", + "stream", + "stream_filter_append", + "stream_filter_register" + ], + "support": { + "issues": "https://github.com/clue/stream-filter/issues", + "source": "https://github.com/clue/stream-filter/tree/v1.7.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2023-12-20T15:40:13+00:00" + }, { "name": "croct/coding-standard", - "version": "0.4.4", + "version": "0.4.5", "source": { "type": "git", "url": "git@github.com:croct-tech/coding-standard-php.git", - "reference": "7ee8241e988a67a4ba7f1dbbf8b9089967707218" + "reference": "cdd2d44ac4801137e52d21f27b3be22afe80144a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/croct-tech/coding-standard-php/zipball/7ee8241e988a67a4ba7f1dbbf8b9089967707218", - "reference": "7ee8241e988a67a4ba7f1dbbf8b9089967707218", + "url": "https://api.github.com/repos/croct-tech/coding-standard-php/zipball/cdd2d44ac4801137e52d21f27b3be22afe80144a", + "reference": "cdd2d44ac4801137e52d21f27b3be22afe80144a", "shasum": "", "mirrors": [ { @@ -28,18 +384,18 @@ ] }, "require": { - "php": "^8.1", - "slevomat/coding-standard": "^8.0", - "squizlabs/php_codesniffer": "^3.7" + "php": "^8.5", + "slevomat/coding-standard": "^8.28", + "squizlabs/php_codesniffer": "^4.0" }, "require-dev": { "ergebnis/composer-normalize": "^2.28", "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.7", - "phpstan/phpstan-deprecation-rules": "^1.0", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.2", - "phpunit/phpunit": "^10.0" + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^13.1" }, "type": "phpcodesniffer-standard", "autoload": { @@ -85,36 +441,36 @@ "standard" ], "support": { - "source": "https://github.com/croct-tech/coding-standard-php/tree/0.4.4", + "source": "https://github.com/croct-tech/coding-standard-php/tree/0.4.5", "issues": "https://github.com/croct-tech/coding-standard-php/issues" }, - "time": "2023-05-29T03:31:45+00:00" + "time": "2026-05-06T14:38:44+00:00" }, { "name": "dealerdirect/phpcodesniffer-composer-installer", - "version": "v1.0.0", + "version": "v1.2.1", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/composer-installer.git", - "reference": "4be43904336affa5c2f70744a348312336afd0da" + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/4be43904336affa5c2f70744a348312336afd0da", - "reference": "4be43904336affa5c2f70744a348312336afd0da", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd", "shasum": "" }, "require": { - "composer-plugin-api": "^1.0 || ^2.0", + "composer-plugin-api": "^2.2", "php": ">=5.4", - "squizlabs/php_codesniffer": "^2.0 || ^3.1.0 || ^4.0" + "squizlabs/php_codesniffer": "^3.1.0 || ^4.0" }, "require-dev": { - "composer/composer": "*", + "composer/composer": "^2.2", "ext-json": "*", "ext-zip": "*", - "php-parallel-lint/php-parallel-lint": "^1.3.1", - "phpcompatibility/php-compatibility": "^9.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev", "yoast/phpunit-polyfills": "^1.0" }, "type": "composer-plugin", @@ -133,9 +489,9 @@ "authors": [ { "name": "Franck Nijhof", - "email": "franck.nijhof@dealerdirect.com", - "homepage": "http://www.frenck.nl", - "role": "Developer / IT Manager" + "email": "opensource@frenck.dev", + "homepage": "https://frenck.dev", + "role": "Open source developer" }, { "name": "Contributors", @@ -143,7 +499,6 @@ } ], "description": "PHP_CodeSniffer Standards Composer Installer Plugin", - "homepage": "http://www.dealerdirect.com", "keywords": [ "PHPCodeSniffer", "PHP_CodeSniffer", @@ -164,55 +519,81 @@ ], "support": { "issues": "https://github.com/PHPCSStandards/composer-installer/issues", + "security": "https://github.com/PHPCSStandards/composer-installer/security/policy", "source": "https://github.com/PHPCSStandards/composer-installer" }, - "time": "2023-01-05T11:28:13+00:00" + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-05-06T08:26:05+00:00" }, { "name": "ergebnis/composer-normalize", - "version": "2.39.0", + "version": "2.52.0", "source": { "type": "git", "url": "https://github.com/ergebnis/composer-normalize.git", - "reference": "a878360bc8cb5cb440b9381f72b0aaa125f937c7" + "reference": "988f83f5e51a42cdd2337e5fcd935432f8dfa33c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/composer-normalize/zipball/a878360bc8cb5cb440b9381f72b0aaa125f937c7", - "reference": "a878360bc8cb5cb440b9381f72b0aaa125f937c7", + "url": "https://api.github.com/repos/ergebnis/composer-normalize/zipball/988f83f5e51a42cdd2337e5fcd935432f8dfa33c", + "reference": "988f83f5e51a42cdd2337e5fcd935432f8dfa33c", "shasum": "" }, "require": { "composer-plugin-api": "^2.0.0", - "ergebnis/json": "^1.1.0", - "ergebnis/json-normalizer": "^4.3.0", - "ergebnis/json-printer": "^3.4.0", + "ergebnis/json": "^1.4.0", + "ergebnis/json-normalizer": "^4.9.0", + "ergebnis/json-printer": "^3.7.0", "ext-json": "*", - "justinrainbow/json-schema": "^5.2.12", - "localheinz/diff": "^1.1.1", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "justinrainbow/json-schema": "^5.2.12 || ^6.0.0", + "localheinz/diff": "^1.3.0", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { - "composer/composer": "^2.6.5", - "ergebnis/license": "^2.2.0", - "ergebnis/php-cs-fixer-config": "~6.7.0", - "ergebnis/phpunit-slow-test-detector": "^2.3.0", - "fakerphp/faker": "^1.23.0", - "infection/infection": "~0.27.4", - "phpunit/phpunit": "^10.4.1", - "psalm/plugin-phpunit": "~0.18.4", - "rector/rector": "~0.18.5", - "symfony/filesystem": "^6.0.13", - "vimeo/psalm": "^5.15.0" + "composer/composer": "^2.9.8", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.62.1", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", + "fakerphp/faker": "^1.24.1", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.11", + "phpunit/phpunit": "^9.6.33", + "rector/rector": "^2.4.3", + "symfony/filesystem": "^5.4.41" }, "type": "composer-plugin", "extra": { "class": "Ergebnis\\Composer\\Normalize\\NormalizePlugin", + "branch-alias": { + "dev-main": "2.52-dev" + }, + "plugin-optional": true, "composer-normalize": { "indent-size": 2, "indent-style": "space" - }, - "plugin-optional": true + } }, "autoload": { "psr-4": { @@ -243,40 +624,48 @@ "security": "https://github.com/ergebnis/composer-normalize/blob/main/.github/SECURITY.md", "source": "https://github.com/ergebnis/composer-normalize" }, - "time": "2023-10-10T15:43:27+00:00" + "time": "2026-05-15T15:39:24+00:00" }, { "name": "ergebnis/json", - "version": "1.1.0", + "version": "1.6.0", "source": { "type": "git", "url": "https://github.com/ergebnis/json.git", - "reference": "9f2b9086c43b189d7044a5b6215a931fb6e9125d" + "reference": "7b56d2b5d9e897e75b43e2e753075a0904c921b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/json/zipball/9f2b9086c43b189d7044a5b6215a931fb6e9125d", - "reference": "9f2b9086c43b189d7044a5b6215a931fb6e9125d", + "url": "https://api.github.com/repos/ergebnis/json/zipball/7b56d2b5d9e897e75b43e2e753075a0904c921b1", + "reference": "7b56d2b5d9e897e75b43e2e753075a0904c921b1", "shasum": "" }, "require": { - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "ext-json": "*", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { - "ergebnis/composer-normalize": "^2.29.0", - "ergebnis/data-provider": "^3.0.0", - "ergebnis/license": "^2.2.0", - "ergebnis/php-cs-fixer-config": "^6.6.0", - "ergebnis/phpunit-slow-test-detector": "^2.3.0", - "fakerphp/faker": "^1.23.0", - "infection/infection": "~0.27.4", - "phpunit/phpunit": "^10.4.1", - "psalm/plugin-phpunit": "~0.18.4", - "rector/rector": "~0.18.5", - "vimeo/psalm": "^5.15.0" + "ergebnis/composer-normalize": "^2.44.0", + "ergebnis/data-provider": "^3.3.0", + "ergebnis/license": "^2.5.0", + "ergebnis/php-cs-fixer-config": "^6.37.0", + "ergebnis/phpstan-rules": "^2.11.0", + "ergebnis/phpunit-slow-test-detector": "^2.16.1", + "fakerphp/faker": "^1.24.0", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpstan/phpstan-strict-rules": "^2.0.6", + "phpunit/phpunit": "^9.6.24", + "rector/rector": "^2.1.4" }, "type": "library", "extra": { + "branch-alias": { + "dev-main": "1.7-dev" + }, "composer-normalize": { "indent-size": 2, "indent-style": "space" @@ -308,50 +697,61 @@ "security": "https://github.com/ergebnis/json/blob/main/.github/SECURITY.md", "source": "https://github.com/ergebnis/json" }, - "time": "2023-10-10T07:57:48+00:00" + "time": "2025-09-06T09:08:45+00:00" }, { "name": "ergebnis/json-normalizer", - "version": "4.3.0", + "version": "4.10.1", "source": { "type": "git", "url": "https://github.com/ergebnis/json-normalizer.git", - "reference": "716fa0a5dcc75fbcb2c1c2e0542b2f56732460bd" + "reference": "77961faf2c651c3f05977b53c6c68e8434febf62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/json-normalizer/zipball/716fa0a5dcc75fbcb2c1c2e0542b2f56732460bd", - "reference": "716fa0a5dcc75fbcb2c1c2e0542b2f56732460bd", + "url": "https://api.github.com/repos/ergebnis/json-normalizer/zipball/77961faf2c651c3f05977b53c6c68e8434febf62", + "reference": "77961faf2c651c3f05977b53c6c68e8434febf62", "shasum": "" }, "require": { - "ergebnis/json": "^1.1.0", - "ergebnis/json-pointer": "^3.2.0", - "ergebnis/json-printer": "^3.4.0", - "ergebnis/json-schema-validator": "^4.1.0", + "ergebnis/json": "^1.2.0", + "ergebnis/json-pointer": "^3.4.0", + "ergebnis/json-printer": "^3.5.0", + "ergebnis/json-schema-validator": "^4.2.0", "ext-json": "*", - "justinrainbow/json-schema": "^5.2.12", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "justinrainbow/json-schema": "^5.2.12 || ^6.0.0", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { - "composer/semver": "^3.4.0", - "ergebnis/data-provider": "^3.0.0", - "ergebnis/license": "^2.2.0", - "ergebnis/php-cs-fixer-config": "~6.7.0", - "ergebnis/phpunit-slow-test-detector": "^2.3.0", - "fakerphp/faker": "^1.23.0", - "infection/infection": "~0.27.4", - "phpunit/phpunit": "^10.4.1", - "psalm/plugin-phpunit": "~0.18.4", - "rector/rector": "~0.18.5", - "symfony/filesystem": "^6.3.1", - "symfony/finder": "^6.3.5", - "vimeo/psalm": "^5.15.0" + "composer/semver": "^3.4.3", + "ergebnis/composer-normalize": "^2.44.0", + "ergebnis/data-provider": "^3.3.0", + "ergebnis/license": "^2.5.0", + "ergebnis/php-cs-fixer-config": "^6.37.0", + "ergebnis/phpunit-slow-test-detector": "^2.16.1", + "fakerphp/faker": "^1.24.0", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.10", + "phpstan/phpstan-deprecation-rules": "^1.2.1", + "phpstan/phpstan-phpunit": "^1.4.0", + "phpstan/phpstan-strict-rules": "^1.6.1", + "phpunit/phpunit": "^9.6.19", + "rector/rector": "^1.2.10" }, "suggest": { "composer/semver": "If you want to use ComposerJsonNormalizer or VersionConstraintNormalizer" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.11-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, "autoload": { "psr-4": { "Ergebnis\\Json\\Normalizer\\": "src/" @@ -379,40 +779,48 @@ "security": "https://github.com/ergebnis/json-normalizer/blob/main/.github/SECURITY.md", "source": "https://github.com/ergebnis/json-normalizer" }, - "time": "2023-10-10T15:15:03+00:00" + "time": "2025-09-06T09:18:13+00:00" }, { "name": "ergebnis/json-pointer", - "version": "3.3.0", + "version": "3.8.0", "source": { "type": "git", "url": "https://github.com/ergebnis/json-pointer.git", - "reference": "8e517faefc06b7c761eaa041febef51a9375819a" + "reference": "b58c3c468a7ff109fdf9a255f17de29ecbe5276c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/json-pointer/zipball/8e517faefc06b7c761eaa041febef51a9375819a", - "reference": "8e517faefc06b7c761eaa041febef51a9375819a", + "url": "https://api.github.com/repos/ergebnis/json-pointer/zipball/b58c3c468a7ff109fdf9a255f17de29ecbe5276c", + "reference": "b58c3c468a7ff109fdf9a255f17de29ecbe5276c", "shasum": "" }, "require": { - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { - "ergebnis/composer-normalize": "^2.29.0", - "ergebnis/data-provider": "^3.0.0", - "ergebnis/license": "^2.2.0", - "ergebnis/php-cs-fixer-config": "~6.7.0", - "ergebnis/phpunit-slow-test-detector": "^2.3.0", - "fakerphp/faker": "^1.23.0", - "infection/infection": "~0.27.4", - "phpunit/phpunit": "^10.4.1", - "psalm/plugin-phpunit": "~0.18.4", - "rector/rector": "~0.18.5", - "vimeo/psalm": "^5.15.0" + "ergebnis/composer-normalize": "^2.50.0", + "ergebnis/data-provider": "^3.6.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.16.0", + "fakerphp/faker": "^1.24.1", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.46", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.0" }, "type": "library", "extra": { + "branch-alias": { + "dev-main": "3.8-dev" + }, "composer-normalize": { "indent-size": 2, "indent-style": "space" @@ -434,7 +842,7 @@ "homepage": "https://localheinz.com" } ], - "description": "Provides JSON pointer as a value object.", + "description": "Provides an abstraction of a JSON pointer.", "homepage": "https://github.com/ergebnis/json-pointer", "keywords": [ "RFC6901", @@ -446,39 +854,53 @@ "security": "https://github.com/ergebnis/json-pointer/blob/main/.github/SECURITY.md", "source": "https://github.com/ergebnis/json-pointer" }, - "time": "2023-10-10T14:41:06+00:00" + "time": "2026-04-07T14:52:13+00:00" }, { "name": "ergebnis/json-printer", - "version": "3.4.0", + "version": "3.8.1", "source": { "type": "git", "url": "https://github.com/ergebnis/json-printer.git", - "reference": "05841593d72499de4f7ce4034a237c77e470558f" + "reference": "211d73fc7ec6daf98568ee6ed6e6d133dee8503e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/json-printer/zipball/05841593d72499de4f7ce4034a237c77e470558f", - "reference": "05841593d72499de4f7ce4034a237c77e470558f", + "url": "https://api.github.com/repos/ergebnis/json-printer/zipball/211d73fc7ec6daf98568ee6ed6e6d133dee8503e", + "reference": "211d73fc7ec6daf98568ee6ed6e6d133dee8503e", "shasum": "" }, "require": { "ext-json": "*", "ext-mbstring": "*", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { - "ergebnis/license": "^2.2.0", - "ergebnis/php-cs-fixer-config": "^6.6.0", - "ergebnis/phpunit-slow-test-detector": "^2.3.0", - "fakerphp/faker": "^1.23.0", - "infection/infection": "~0.27.3", - "phpunit/phpunit": "^10.4.1", - "psalm/plugin-phpunit": "~0.18.4", - "rector/rector": "~0.18.5", - "vimeo/psalm": "^5.15.0" + "ergebnis/composer-normalize": "^2.44.0", + "ergebnis/data-provider": "^3.3.0", + "ergebnis/license": "^2.5.0", + "ergebnis/php-cs-fixer-config": "^6.37.0", + "ergebnis/phpunit-slow-test-detector": "^2.16.1", + "fakerphp/faker": "^1.24.0", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.10", + "phpstan/phpstan-deprecation-rules": "^1.2.1", + "phpstan/phpstan-phpunit": "^1.4.1", + "phpstan/phpstan-strict-rules": "^1.6.1", + "phpunit/phpunit": "^9.6.21", + "rector/rector": "^1.2.10" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.9-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, "autoload": { "psr-4": { "Ergebnis\\Json\\Printer\\": "src/" @@ -507,44 +929,50 @@ "security": "https://github.com/ergebnis/json-printer/blob/main/.github/SECURITY.md", "source": "https://github.com/ergebnis/json-printer" }, - "time": "2023-10-10T07:42:48+00:00" + "time": "2025-09-06T09:59:26+00:00" }, { "name": "ergebnis/json-schema-validator", - "version": "4.1.0", + "version": "4.5.1", "source": { "type": "git", "url": "https://github.com/ergebnis/json-schema-validator.git", - "reference": "d568ed85d1cdc2e49d650c2fc234dc2516f3f25b" + "reference": "b739527a480a9e3651360ad351ea77e7e9019df2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/json-schema-validator/zipball/d568ed85d1cdc2e49d650c2fc234dc2516f3f25b", - "reference": "d568ed85d1cdc2e49d650c2fc234dc2516f3f25b", + "url": "https://api.github.com/repos/ergebnis/json-schema-validator/zipball/b739527a480a9e3651360ad351ea77e7e9019df2", + "reference": "b739527a480a9e3651360ad351ea77e7e9019df2", "shasum": "" }, "require": { - "ergebnis/json": "^1.0.1", - "ergebnis/json-pointer": "^3.2.0", + "ergebnis/json": "^1.2.0", + "ergebnis/json-pointer": "^3.4.0", "ext-json": "*", - "justinrainbow/json-schema": "^5.2.12", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + "justinrainbow/json-schema": "^5.2.12 || ^6.0.0", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { - "ergebnis/composer-normalize": "^2.21.0", - "ergebnis/data-provider": "^3.0.0", - "ergebnis/license": "^2.2.0", - "ergebnis/php-cs-fixer-config": "~6.6.0", - "ergebnis/phpunit-slow-test-detector": "^2.3.0", - "fakerphp/faker": "^1.23.0", - "infection/infection": "~0.27.4", - "phpunit/phpunit": "^10.4.1", - "psalm/plugin-phpunit": "~0.18.4", - "rector/rector": "~0.18.5", - "vimeo/psalm": "^5.15.0" + "ergebnis/composer-normalize": "^2.44.0", + "ergebnis/data-provider": "^3.3.0", + "ergebnis/license": "^2.5.0", + "ergebnis/php-cs-fixer-config": "^6.37.0", + "ergebnis/phpunit-slow-test-detector": "^2.16.1", + "fakerphp/faker": "^1.24.0", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.10", + "phpstan/phpstan-deprecation-rules": "^1.2.1", + "phpstan/phpstan-phpunit": "^1.4.0", + "phpstan/phpstan-strict-rules": "^1.6.1", + "phpunit/phpunit": "^9.6.20", + "rector/rector": "^1.2.10" }, "type": "library", "extra": { + "branch-alias": { + "dev-main": "4.6-dev" + }, "composer-normalize": { "indent-size": 2, "indent-style": "space" @@ -578,37 +1006,369 @@ "security": "https://github.com/ergebnis/json-schema-validator/blob/main/.github/SECURITY.md", "source": "https://github.com/ergebnis/json-schema-validator" }, - "time": "2023-10-10T14:16:57+00:00" + "time": "2025-09-06T11:37:35+00:00" }, { - "name": "justinrainbow/json-schema", - "version": "v5.2.13", + "name": "guzzlehttp/guzzle", + "version": "7.10.6", "source": { "type": "git", - "url": "https://github.com/justinrainbow/json-schema.git", - "reference": "fbbe7e5d79f618997bc3332a6f49246036c45793" + "url": "https://github.com/guzzle/guzzle.git", + "reference": "e7412b3180912c01650cc66647f18c1d1cbe9b94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/fbbe7e5d79f618997bc3332a6f49246036c45793", - "reference": "fbbe7e5d79f618997bc3332a6f49246036c45793", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/e7412b3180912c01650cc66647f18c1d1cbe9b94", + "reference": "e7412b3180912c01650cc66647f18c1d1cbe9b94", "shasum": "" }, "require": { - "php": ">=5.3.3" + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", - "json-schema/json-schema-test-suite": "1.2.0", - "phpunit/phpunit": "^4.8.35" + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.4", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" }, - "bin": [ + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.6" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-06-01T13:06:22+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2", + "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.4.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-05-20T22:57:30+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.10.4", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "d2a1a094e396da8957e797489fddaf860c340cfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/d2a1a094e396da8957e797489fddaf860c340cfc", + "reference": "d2a1a094e396da8957e797489fddaf860c340cfc", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.10.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-05-29T12:59:07+00:00" + }, + { + "name": "justinrainbow/json-schema", + "version": "6.8.2", + "source": { + "type": "git", + "url": "https://github.com/jsonrainbow/json-schema.git", + "reference": "2c89ebb95ca9cedc9347f780333f7b25792dcb76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/2c89ebb95ca9cedc9347f780333f7b25792dcb76", + "reference": "2c89ebb95ca9cedc9347f780333f7b25792dcb76", + "shasum": "" + }, + "require": { + "ext-json": "*", + "marc-mabe/php-enum": "^4.4", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.3.0", + "json-schema/json-schema-test-suite": "dev-main", + "marc-mabe/php-enum-phpstan": "^2.0", + "phpspec/prophecy": "^1.19", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^8.5" + }, + "bin": [ "bin/validate-json" ], "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0.x-dev" + "dev-master": "6.x-dev" } }, "autoload": { @@ -639,36 +1399,36 @@ } ], "description": "A library to validate a json schema.", - "homepage": "https://github.com/justinrainbow/json-schema", + "homepage": "https://github.com/jsonrainbow/json-schema", "keywords": [ "json", "schema" ], "support": { - "issues": "https://github.com/justinrainbow/json-schema/issues", - "source": "https://github.com/justinrainbow/json-schema/tree/v5.2.13" + "issues": "https://github.com/jsonrainbow/json-schema/issues", + "source": "https://github.com/jsonrainbow/json-schema/tree/6.8.2" }, - "time": "2023-09-26T02:20:38+00:00" + "time": "2026-05-05T05:39:01+00:00" }, { "name": "localheinz/diff", - "version": "1.1.1", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/localheinz/diff.git", - "reference": "851bb20ea8358c86f677f5f111c4ab031b1c764c" + "reference": "33bd840935970cda6691c23fc7d94ae764c0734c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/localheinz/diff/zipball/851bb20ea8358c86f677f5f111c4ab031b1c764c", - "reference": "851bb20ea8358c86f677f5f111c4ab031b1c764c", + "url": "https://api.github.com/repos/localheinz/diff/zipball/33bd840935970cda6691c23fc7d94ae764c0734c", + "reference": "33bd840935970cda6691c23fc7d94ae764c0734c", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { - "phpunit/phpunit": "^7.5 || ^8.0", + "phpunit/phpunit": "^7.5.0 || ^8.5.23", "symfony/process": "^4.2 || ^5" }, "type": "library", @@ -700,268 +1460,779 @@ "unified diff" ], "support": { - "source": "https://github.com/localheinz/diff/tree/main" + "issues": "https://github.com/localheinz/diff/issues", + "source": "https://github.com/localheinz/diff/tree/1.3.0" + }, + "time": "2025-08-30T09:44:18+00:00" + }, + { + "name": "marc-mabe/php-enum", + "version": "v4.7.2", + "source": { + "type": "git", + "url": "https://github.com/marc-mabe/php-enum.git", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/marc-mabe/php-enum/zipball/bb426fcdd65c60fb3638ef741e8782508fda7eef", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef", + "shasum": "" + }, + "require": { + "ext-reflection": "*", + "php": "^7.1 | ^8.0" + }, + "require-dev": { + "phpbench/phpbench": "^0.16.10 || ^1.0.4", + "phpstan/phpstan": "^1.3.1", + "phpunit/phpunit": "^7.5.20 | ^8.5.22 | ^9.5.11", + "vimeo/psalm": "^4.17.0 | ^5.26.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-3.x": "3.2-dev", + "dev-master": "4.7-dev" + } + }, + "autoload": { + "psr-4": { + "MabeEnum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Marc Bennewitz", + "email": "dev@mabe.berlin", + "homepage": "https://mabe.berlin/", + "role": "Lead" + } + ], + "description": "Simple and fast implementation of enumerations with native PHP", + "homepage": "https://github.com/marc-mabe/php-enum", + "keywords": [ + "enum", + "enum-map", + "enum-set", + "enumeration", + "enumerator", + "enummap", + "enumset", + "map", + "set", + "type", + "type-hint", + "typehint" + ], + "support": { + "issues": "https://github.com/marc-mabe/php-enum/issues", + "source": "https://github.com/marc-mabe/php-enum/tree/v4.7.2" + }, + "time": "2025-09-14T11:18:39+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", "type": "github" } ], - "time": "2020-07-06T04:49:32+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": "php-http/client-common", + "version": "2.7.3", + "source": { + "type": "git", + "url": "https://github.com/php-http/client-common.git", + "reference": "dcc6de29c90dd74faab55f71b79d89409c4bf0c1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/client-common/zipball/dcc6de29c90dd74faab55f71b79d89409c4bf0c1", + "reference": "dcc6de29c90dd74faab55f71b79d89409c4bf0c1", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/httplug": "^2.0", + "php-http/message": "^1.6", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0 || ^2.0", + "symfony/options-resolver": "~4.0.15 || ~4.1.9 || ^4.2.1 || ^5.0 || ^6.0 || ^7.0 || ^8.0", + "symfony/polyfill-php80": "^1.17" + }, + "require-dev": { + "doctrine/instantiator": "^1.1", + "guzzlehttp/psr7": "^1.4", + "nyholm/psr7": "^1.2", + "phpunit/phpunit": "^7.5.20 || ^8.5.33 || ^9.6.7" + }, + "suggest": { + "ext-json": "To detect JSON responses with the ContentTypePlugin", + "ext-libxml": "To detect XML responses with the ContentTypePlugin", + "php-http/cache-plugin": "PSR-6 Cache plugin", + "php-http/logger-plugin": "PSR-3 Logger plugin", + "php-http/stopwatch-plugin": "Symfony Stopwatch plugin" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Client\\Common\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Common HTTP Client implementations and tools for HTTPlug", + "homepage": "http://httplug.io", + "keywords": [ + "client", + "common", + "http", + "httplug" + ], + "support": { + "issues": "https://github.com/php-http/client-common/issues", + "source": "https://github.com/php-http/client-common/tree/2.7.3" + }, + "time": "2025-11-29T19:12:34+00:00" + }, + { + "name": "php-http/httplug", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/httplug.git", + "reference": "5cad731844891a4c282f3f3e1b582c46839d22f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/httplug/zipball/5cad731844891a4c282f3f3e1b582c46839d22f4", + "reference": "5cad731844891a4c282f3f3e1b582c46839d22f4", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/promise": "^1.1", + "psr/http-client": "^1.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.1 || ^5.0 || ^6.0", + "phpspec/phpspec": "^5.1 || ^6.0 || ^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eric GELOEN", + "email": "geloen.eric@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "HTTPlug, the HTTP client abstraction for PHP", + "homepage": "http://httplug.io", + "keywords": [ + "client", + "http" + ], + "support": { + "issues": "https://github.com/php-http/httplug/issues", + "source": "https://github.com/php-http/httplug/tree/2.4.1" + }, + "time": "2024-09-23T11:39:58+00:00" }, { - "name": "myclabs/deep-copy", - "version": "1.11.1", + "name": "php-http/message", + "version": "1.16.2", "source": { "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c" + "url": "https://github.com/php-http/message.git", + "reference": "06dd5e8562f84e641bf929bfe699ee0f5ce8080a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "url": "https://api.github.com/repos/php-http/message/zipball/06dd5e8562f84e641bf929bfe699ee0f5ce8080a", + "reference": "06dd5e8562f84e641bf929bfe699ee0f5ce8080a", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "clue/stream-filter": "^1.5", + "php": "^7.2 || ^8.0", + "psr/http-message": "^1.1 || ^2.0" }, - "conflict": { - "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3,<3.2.2" + "provide": { + "php-http/message-factory-implementation": "1.0" }, "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" + "ergebnis/composer-normalize": "^2.6", + "ext-zlib": "*", + "guzzlehttp/psr7": "^1.0 || ^2.0", + "laminas/laminas-diactoros": "^2.0 || ^3.0", + "php-http/message-factory": "^1.0.2", + "phpspec/phpspec": "^5.1 || ^6.3 || ^7.1", + "slim/slim": "^3.0" + }, + "suggest": { + "ext-zlib": "Used with compressor/decompressor streams", + "guzzlehttp/psr7": "Used with Guzzle PSR-7 Factories", + "laminas/laminas-diactoros": "Used with Diactoros Factories", + "slim/slim": "Used with Slim Framework PSR-7 implementation" }, "type": "library", "autoload": { "files": [ - "src/DeepCopy/deep_copy.php" + "src/filters.php" ], "psr-4": { - "DeepCopy\\": "src/DeepCopy/" + "Http\\Message\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "Create deep copies (clones) of your objects", + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "HTTP Message related tools", + "homepage": "http://php-http.org", "keywords": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" + "http", + "message", + "psr-7" ], "support": { - "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.11.1" + "issues": "https://github.com/php-http/message/issues", + "source": "https://github.com/php-http/message/tree/1.16.2" }, - "funding": [ - { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" - } - ], - "time": "2023-03-08T13:26:56+00:00" + "time": "2024-10-02T11:34:13+00:00" }, { - "name": "nikic/php-parser", - "version": "v4.17.1", + "name": "php-http/mock-client", + "version": "1.6.1", "source": { "type": "git", - "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d" + "url": "https://github.com/php-http/mock-client.git", + "reference": "81f558234421f7da58ed015604a03808996017d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", - "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", + "url": "https://api.github.com/repos/php-http/mock-client/zipball/81f558234421f7da58ed015604a03808996017d0", + "reference": "81f558234421f7da58ed015604a03808996017d0", "shasum": "" }, "require": { - "ext-tokenizer": "*", - "php": ">=7.0" + "php": "^7.1 || ^8.0", + "php-http/client-common": "^2.0", + "php-http/discovery": "^1.16", + "php-http/httplug": "^2.0", + "psr/http-client": "^1.0", + "psr/http-factory-implementation": "^1.0", + "psr/http-message": "^1.0 || ^2.0", + "symfony/polyfill-php80": "^1.17" + }, + "provide": { + "php-http/async-client-implementation": "1.0", + "php-http/client-implementation": "1.0", + "psr/http-client-implementation": "1.0" }, "require-dev": { - "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3" }, - "bin": [ - "bin/php-parse" - ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.9-dev" - } - }, "autoload": { "psr-4": { - "PhpParser\\": "lib/PhpParser" + "Http\\Mock\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Nikita Popov" + "name": "David de Boer", + "email": "david@ddeboer.nl" } ], - "description": "A PHP parser written in PHP", + "description": "Mock HTTP client", + "homepage": "http://httplug.io", "keywords": [ - "parser", - "php" + "client", + "http", + "mock", + "psr7" ], "support": { - "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.17.1" + "issues": "https://github.com/php-http/mock-client/issues", + "source": "https://github.com/php-http/mock-client/tree/1.6.1" }, - "time": "2023-08-13T19:53:39+00:00" + "time": "2024-10-31T10:30:18+00:00" }, { - "name": "phar-io/manifest", - "version": "2.0.3", + "name": "php-http/promise", + "version": "1.3.1", "source": { "type": "git", - "url": "https://github.com/phar-io/manifest.git", - "reference": "97803eca37d319dfa7826cc2437fc020857acb53" + "url": "https://github.com/php-http/promise.git", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53", - "reference": "97803eca37d319dfa7826cc2437fc020857acb53", + "url": "https://api.github.com/repos/php-http/promise/zipball/fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-phar": "*", - "ext-xmlwriter": "*", - "phar-io/version": "^3.0.1", - "php": "^7.2 || ^8.0" + "php": "^7.1 || ^8.0" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.3.2 || ^6.3", + "phpspec/phpspec": "^5.1.2 || ^6.2 || ^7.4" }, + "type": "library", "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Http\\Promise\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" - }, - { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" + "name": "Joel Wurtz", + "email": "joel.wurtz@gmail.com" }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" } ], - "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "description": "Promise used for asynchronous HTTP requests", + "homepage": "http://httplug.io", + "keywords": [ + "promise" + ], "support": { - "issues": "https://github.com/phar-io/manifest/issues", - "source": "https://github.com/phar-io/manifest/tree/2.0.3" + "issues": "https://github.com/php-http/promise/issues", + "source": "https://github.com/php-http/promise/tree/1.3.1" }, - "time": "2021-07-20T11:28:43+00:00" + "time": "2024-03-15T13:55:21+00:00" }, { - "name": "phar-io/version", - "version": "3.2.1", + "name": "phpstan/extension-installer", + "version": "1.4.3", "source": { "type": "git", - "url": "https://github.com/phar-io/version.git", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + "url": "https://github.com/phpstan/extension-installer.git", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "url": "https://api.github.com/repos/phpstan/extension-installer/zipball/85e90b3942d06b2326fba0403ec24fe912372936", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "composer-plugin-api": "^2.0", + "php": "^7.2 || ^8.0", + "phpstan/phpstan": "^1.9.0 || ^2.0" + }, + "require-dev": { + "composer/composer": "^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2.0", + "phpstan/phpstan-strict-rules": "^0.11 || ^0.12 || ^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPStan\\ExtensionInstaller\\Plugin" }, - "type": "library", "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "PHPStan\\ExtensionInstaller\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], - "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": "Composer plugin for automatic installation of PHPStan extensions", + "keywords": [ + "dev", + "static analysis" ], - "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" + "issues": "https://github.com/phpstan/extension-installer/issues", + "source": "https://github.com/phpstan/extension-installer/tree/1.4.3" }, - "time": "2022-02-21T01:04:05+00:00" + "time": "2024-09-04T20:21:43+00:00" }, { "name": "phpstan/phpdoc-parser", - "version": "1.24.4", + "version": "2.3.2", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "6bd0c26f3786cd9b7c359675cb789e35a8e07496" + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/6bd0c26f3786cd9b7c359675cb789e35a8e07496", - "reference": "6bd0c26f3786cd9b7c359675cb789e35a8e07496", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "php": "^7.4 || ^8.0" }, "require-dev": { "doctrine/annotations": "^2.0", - "nikic/php-parser": "^4.15", + "nikic/php-parser": "^5.3.0", "php-parallel-lint/php-parallel-lint": "^1.2", "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^1.5", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.0", - "phpunit/phpunit": "^9.5", + "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", @@ -979,26 +2250,21 @@ "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/1.24.4" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" }, - "time": "2023-11-26T18:29:22+00:00" + "time": "2026-01-25T14:56:51+00:00" }, { "name": "phpstan/phpstan", - "version": "1.10.46", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan.git", - "reference": "90d3d25c5b98b8068916bbf08ce42d5cb6c54e70" - }, + "version": "2.2.1", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/90d3d25c5b98b8068916bbf08ce42d5cb6c54e70", - "reference": "90d3d25c5b98b8068916bbf08ce42d5cb6c54e70", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dea9c8f2d25cc849391042b71e429c1a4bf82660", + "reference": "dea9c8f2d25cc849391042b71e429c1a4bf82660", "shasum": "" }, "require": { - "php": "^7.2|^8.0" + "php": "^7.4|^8.0" }, "conflict": { "phpstan/phpstan-shim": "*" @@ -1017,6 +2283,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -1037,37 +2314,32 @@ { "url": "https://github.com/phpstan", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpstan/phpstan", - "type": "tidelift" } ], - "time": "2023-11-28T14:57:26+00:00" + "time": "2026-05-28T14:44:12+00:00" }, { "name": "phpstan/phpstan-deprecation-rules", - "version": "1.1.4", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-deprecation-rules.git", - "reference": "089d8a8258ed0aeefdc7b68b6c3d25572ebfdbaa" + "reference": "6b5571001a7f04fa0422254c30a0017ec2f2cacc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/089d8a8258ed0aeefdc7b68b6c3d25572ebfdbaa", - "reference": "089d8a8258ed0aeefdc7b68b6c3d25572ebfdbaa", + "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/6b5571001a7f04fa0422254c30a0017ec2f2cacc", + "reference": "6b5571001a7f04fa0422254c30a0017ec2f2cacc", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0", - "phpstan/phpstan": "^1.10.3" + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.39" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/phpstan-php-parser": "^1.1", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^9.5" + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6" }, "type": "phpstan-extension", "extra": { @@ -1087,38 +2359,42 @@ "MIT" ], "description": "PHPStan rules for detecting usage of deprecated classes, methods, properties, constants and traits.", + "keywords": [ + "static analysis" + ], "support": { "issues": "https://github.com/phpstan/phpstan-deprecation-rules/issues", - "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/1.1.4" + "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/2.0.4" }, - "time": "2023-08-05T09:02:04+00:00" + "time": "2026-02-09T13:21:14+00:00" }, { "name": "phpstan/phpstan-phpunit", - "version": "1.3.15", + "version": "2.0.16", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-phpunit.git", - "reference": "70ecacc64fe8090d8d2a33db5a51fe8e88acd93a" + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/70ecacc64fe8090d8d2a33db5a51fe8e88acd93a", - "reference": "70ecacc64fe8090d8d2a33db5a51fe8e88acd93a", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0", - "phpstan/phpstan": "^1.10" + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.32" }, "conflict": { "phpunit/phpunit": "<7.0" }, "require-dev": { - "nikic/php-parser": "^4.13.0", + "nikic/php-parser": "^5", "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/phpstan-strict-rules": "^1.5.1", - "phpunit/phpunit": "^9.5" + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6" }, "type": "phpstan-extension", "extra": { @@ -1139,36 +2415,38 @@ "MIT" ], "description": "PHPUnit extensions and rules for PHPStan", + "keywords": [ + "static analysis" + ], "support": { "issues": "https://github.com/phpstan/phpstan-phpunit/issues", - "source": "https://github.com/phpstan/phpstan-phpunit/tree/1.3.15" + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.16" }, - "time": "2023-10-09T18:58:39+00:00" + "time": "2026-02-14T09:05:21+00:00" }, { "name": "phpstan/phpstan-strict-rules", - "version": "1.5.2", + "version": "2.0.11", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-strict-rules.git", - "reference": "7a50e9662ee9f3942e4aaaf3d603653f60282542" + "reference": "9b000a578b85b32945b358b172c7b20e91189024" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/7a50e9662ee9f3942e4aaaf3d603653f60282542", - "reference": "7a50e9662ee9f3942e4aaaf3d603653f60282542", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/9b000a578b85b32945b358b172c7b20e91189024", + "reference": "9b000a578b85b32945b358b172c7b20e91189024", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0", - "phpstan/phpstan": "^1.10.34" + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.39" }, "require-dev": { - "nikic/php-parser": "^4.13.0", "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/phpstan-deprecation-rules": "^1.1", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^9.5" + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6" }, "type": "phpstan-extension", "extra": { @@ -1188,43 +2466,46 @@ "MIT" ], "description": "Extra strict and opinionated rules for PHPStan", + "keywords": [ + "static analysis" + ], "support": { "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", - "source": "https://github.com/phpstan/phpstan-strict-rules/tree/1.5.2" + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.11" }, - "time": "2023-10-30T14:35:06+00:00" + "time": "2026-05-02T06:54:10+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "10.1.9", + "version": "11.0.12", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "a56a9ab2f680246adcf3db43f38ddf1765774735" + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/a56a9ab2f680246adcf3db43f38ddf1765774735", - "reference": "a56a9ab2f680246adcf3db43f38ddf1765774735", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.15", - "php": ">=8.1", - "phpunit/php-file-iterator": "^4.0", - "phpunit/php-text-template": "^3.0", - "sebastian/code-unit-reverse-lookup": "^3.0", - "sebastian/complexity": "^3.0", - "sebastian/environment": "^6.0", - "sebastian/lines-of-code": "^2.0", - "sebastian/version": "^4.0", - "theseer/tokenizer": "^1.2.0" + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" }, "require-dev": { - "phpunit/phpunit": "^10.1" + "phpunit/phpunit": "^11.5.46" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -1233,7 +2514,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "10.1-dev" + "dev-main": "11.0.x-dev" } }, "autoload": { @@ -1262,40 +2543,52 @@ "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.9" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" }, "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/phpunit/php-code-coverage", + "type": "tidelift" } ], - "time": "2023-11-23T12:23:20+00:00" + "time": "2025-12-24T07:01:01+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "4.1.0", + "version": "5.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", - "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -1323,36 +2616,48 @@ "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" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.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/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2023-08-31T06:24:48+00:00" + "time": "2026-02-02T13:52:54+00:00" }, { "name": "phpunit/php-invoker", - "version": "4.0.0", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", - "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "suggest": { "ext-pcntl": "*" @@ -1360,7 +2665,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -1386,7 +2691,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" }, "funding": [ { @@ -1394,32 +2700,32 @@ "type": "github" } ], - "time": "2023-02-03T06:56:09+00:00" + "time": "2024-07-03T05:07:44+00:00" }, { "name": "phpunit/php-text-template", - "version": "3.0.1", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", - "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -1446,7 +2752,7 @@ "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" + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" }, "funding": [ { @@ -1454,32 +2760,32 @@ "type": "github" } ], - "time": "2023-08-31T14:07:24+00:00" + "time": "2024-07-03T05:08:43+00:00" }, { "name": "phpunit/php-timer", - "version": "6.0.0", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", - "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -1505,7 +2811,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" }, "funding": [ { @@ -1513,20 +2820,20 @@ "type": "github" } ], - "time": "2023-02-03T06:57:52+00:00" + "time": "2024-07-03T05:09:35+00:00" }, { "name": "phpunit/phpunit", - "version": "10.4.2", + "version": "11.5.55", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "cacd8b9dd224efa8eb28beb69004126c7ca1a1a1" + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/cacd8b9dd224efa8eb28beb69004126c7ca1a1a1", - "reference": "cacd8b9dd224efa8eb28beb69004126c7ca1a1a1", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", "shasum": "" }, "require": { @@ -1536,26 +2843,27 @@ "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": ">=8.1", - "phpunit/php-code-coverage": "^10.1.5", - "phpunit/php-file-iterator": "^4.0", - "phpunit/php-invoker": "^4.0", - "phpunit/php-text-template": "^3.0", - "phpunit/php-timer": "^6.0", - "sebastian/cli-parser": "^2.0", - "sebastian/code-unit": "^2.0", - "sebastian/comparator": "^5.0", - "sebastian/diff": "^5.0", - "sebastian/environment": "^6.0", - "sebastian/exporter": "^5.1", - "sebastian/global-state": "^6.0.1", - "sebastian/object-enumerator": "^5.0", - "sebastian/recursion-context": "^5.0", - "sebastian/type": "^4.0", - "sebastian/version": "^4.0" + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" }, "suggest": { "ext-soap": "To be able to generate mocks based on WSDL files" @@ -1566,7 +2874,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "10.4-dev" + "dev-main": "11.5-dev" } }, "autoload": { @@ -1598,7 +2906,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.4.2" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" }, "funding": [ { @@ -1609,37 +2917,89 @@ "url": "https://github.com/sebastianbergmann", "type": "github" }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", "type": "tidelift" } ], - "time": "2023-10-26T07:21:45+00:00" + "time": "2026-02-18T12:37:06+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" }, { "name": "sebastian/cli-parser", - "version": "2.0.0", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "efdc130dbbbb8ef0b545a994fd811725c5282cae" + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/efdc130dbbbb8ef0b545a994fd811725c5282cae", - "reference": "efdc130dbbbb8ef0b545a994fd811725c5282cae", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "2.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -1662,7 +3022,8 @@ "homepage": "https://github.com/sebastianbergmann/cli-parser", "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.0" + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" }, "funding": [ { @@ -1670,32 +3031,32 @@ "type": "github" } ], - "time": "2023-02-03T06:58:15+00:00" + "time": "2024-07-03T04:41:36+00:00" }, { "name": "sebastian/code-unit", - "version": "2.0.0", + "version": "3.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", - "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "2.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -1718,7 +3079,8 @@ "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" + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" }, "funding": [ { @@ -1726,32 +3088,32 @@ "type": "github" } ], - "time": "2023-02-03T06:58:43+00:00" + "time": "2025-03-19T07:56:08+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", - "version": "3.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", - "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -1773,7 +3135,8 @@ "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" + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" }, "funding": [ { @@ -1781,36 +3144,39 @@ "type": "github" } ], - "time": "2023-02-03T06:59:15+00:00" + "time": "2024-07-03T04:45:54+00:00" }, { "name": "sebastian/comparator", - "version": "5.0.1", + "version": "6.3.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "2db5010a484d53ebf536087a70b4a5423c102372" + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2db5010a484d53ebf536087a70b4a5423c102372", - "reference": "2db5010a484d53ebf536087a70b4a5423c102372", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", "shasum": "" }, "require": { "ext-dom": "*", "ext-mbstring": "*", - "php": ">=8.1", - "sebastian/diff": "^5.0", - "sebastian/exporter": "^5.0" + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^10.3" + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.3-dev" } }, "autoload": { @@ -1850,41 +3216,53 @@ "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.1" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" }, "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": "2023-08-14T13:18:12+00:00" + "time": "2026-01-24T09:26:40+00:00" }, { "name": "sebastian/complexity", - "version": "3.1.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "68cfb347a44871f01e33ab0ef8215966432f6957" + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68cfb347a44871f01e33ab0ef8215966432f6957", - "reference": "68cfb347a44871f01e33ab0ef8215966432f6957", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", "shasum": "" }, "require": { - "nikic/php-parser": "^4.10", - "php": ">=8.1" + "nikic/php-parser": "^5.0", + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.1-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -1908,7 +3286,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", "security": "https://github.com/sebastianbergmann/complexity/security/policy", - "source": "https://github.com/sebastianbergmann/complexity/tree/3.1.0" + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" }, "funding": [ { @@ -1916,33 +3294,33 @@ "type": "github" } ], - "time": "2023-09-28T11:50:59+00:00" + "time": "2024-07-03T04:49:50+00:00" }, { "name": "sebastian/diff", - "version": "5.0.3", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "912dc2fbe3e3c1e7873313cc801b100b6c68c87b" + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/912dc2fbe3e3c1e7873313cc801b100b6c68c87b", - "reference": "912dc2fbe3e3c1e7873313cc801b100b6c68c87b", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0", + "phpunit/phpunit": "^11.0", "symfony/process": "^4.2 || ^5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -1975,7 +3353,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/5.0.3" + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" }, "funding": [ { @@ -1983,27 +3361,27 @@ "type": "github" } ], - "time": "2023-05-01T07:48:21+00:00" + "time": "2024-07-03T04:53:05+00:00" }, { "name": "sebastian/environment", - "version": "6.0.1", + "version": "7.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "43c751b41d74f96cbbd4e07b7aec9675651e2951" + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/43c751b41d74f96cbbd4e07b7aec9675651e2951", - "reference": "43c751b41d74f96cbbd4e07b7aec9675651e2951", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.3" }, "suggest": { "ext-posix": "*" @@ -2011,7 +3389,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.2-dev" } }, "autoload": { @@ -2039,42 +3417,54 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/6.0.1" + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "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/environment", + "type": "tidelift" } ], - "time": "2023-04-11T05:39:26+00:00" + "time": "2025-05-21T11:55:47+00:00" }, { "name": "sebastian/exporter", - "version": "5.1.1", + "version": "6.3.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "64f51654862e0f5e318db7e9dcc2292c63cdbddc" + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/64f51654862e0f5e318db7e9dcc2292c63cdbddc", - "reference": "64f51654862e0f5e318db7e9dcc2292c63cdbddc", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": ">=8.1", - "sebastian/recursion-context": "^5.0" + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.1-dev" + "dev-main": "6.3-dev" } }, "autoload": { @@ -2117,43 +3507,55 @@ "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.1" + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" }, "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": "2023-09-24T13:22:09+00:00" + "time": "2025-09-24T06:12:51+00:00" }, { "name": "sebastian/global-state", - "version": "6.0.1", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "7ea9ead78f6d380d2a667864c132c2f7b83055e4" + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/7ea9ead78f6d380d2a667864c132c2f7b83055e4", - "reference": "7ea9ead78f6d380d2a667864c132c2f7b83055e4", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", "shasum": "" }, "require": { - "php": ">=8.1", - "sebastian/object-reflector": "^3.0", - "sebastian/recursion-context": "^5.0" + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -2172,14 +3574,14 @@ } ], "description": "Snapshotting of global state", - "homepage": "http://www.github.com/sebastianbergmann/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.1" + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" }, "funding": [ { @@ -2187,33 +3589,33 @@ "type": "github" } ], - "time": "2023-07-19T07:19:23+00:00" + "time": "2024-07-03T04:57:36+00:00" }, { "name": "sebastian/lines-of-code", - "version": "2.0.1", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "649e40d279e243d985aa8fb6e74dd5bb28dc185d" + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/649e40d279e243d985aa8fb6e74dd5bb28dc185d", - "reference": "649e40d279e243d985aa8fb6e74dd5bb28dc185d", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", "shasum": "" }, "require": { - "nikic/php-parser": "^4.10", - "php": ">=8.1" + "nikic/php-parser": "^5.0", + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "2.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -2237,7 +3639,7 @@ "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.1" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" }, "funding": [ { @@ -2245,34 +3647,34 @@ "type": "github" } ], - "time": "2023-08-31T09:25:50+00:00" + "time": "2024-07-03T04:58:38+00:00" }, { "name": "sebastian/object-enumerator", - "version": "5.0.0", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", - "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", "shasum": "" }, "require": { - "php": ">=8.1", - "sebastian/object-reflector": "^3.0", - "sebastian/recursion-context": "^5.0" + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -2294,7 +3696,8 @@ "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" + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" }, "funding": [ { @@ -2302,32 +3705,32 @@ "type": "github" } ], - "time": "2023-02-03T07:08:32+00:00" + "time": "2024-07-03T05:00:13+00:00" }, { "name": "sebastian/object-reflector", - "version": "3.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", - "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -2349,7 +3752,8 @@ "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" + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" }, "funding": [ { @@ -2357,32 +3761,32 @@ "type": "github" } ], - "time": "2023-02-03T07:06:18+00:00" + "time": "2024-07-03T05:01:32+00:00" }, { "name": "sebastian/recursion-context", - "version": "5.0.0", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "05909fb5bc7df4c52992396d0116aed689f93712" + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/05909fb5bc7df4c52992396d0116aed689f93712", - "reference": "05909fb5bc7df4c52992396d0116aed689f93712", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -2412,40 +3816,53 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.0" + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" }, "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": "2023-02-03T07:05:40+00:00" + "time": "2025-08-13T04:42:22+00:00" }, { "name": "sebastian/type", - "version": "4.0.0", + "version": "5.1.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", - "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -2468,37 +3885,50 @@ "homepage": "https://github.com/sebastianbergmann/type", "support": { "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" }, "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/type", + "type": "tidelift" } ], - "time": "2023-02-03T07:10:45+00:00" + "time": "2025-08-09T06:55:48+00:00" }, { "name": "sebastian/version", - "version": "4.0.1", + "version": "5.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", - "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -2521,7 +3951,8 @@ "homepage": "https://github.com/sebastianbergmann/version", "support": { "issues": "https://github.com/sebastianbergmann/version/issues", - "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" }, "funding": [ { @@ -2529,36 +3960,36 @@ "type": "github" } ], - "time": "2023-02-07T11:34:05+00:00" + "time": "2024-10-09T05:16:32+00:00" }, { "name": "slevomat/coding-standard", - "version": "8.14.1", + "version": "8.29.0", "source": { "type": "git", "url": "https://github.com/slevomat/coding-standard.git", - "reference": "fea1fd6f137cc84f9cba0ae30d549615dbc6a926" + "reference": "81fce13c4ef4b53a03e5cfa6ce36afc191c1598e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/fea1fd6f137cc84f9cba0ae30d549615dbc6a926", - "reference": "fea1fd6f137cc84f9cba0ae30d549615dbc6a926", + "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/81fce13c4ef4b53a03e5cfa6ce36afc191c1598e", + "reference": "81fce13c4ef4b53a03e5cfa6ce36afc191c1598e", "shasum": "" }, "require": { - "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7 || ^1.0", - "php": "^7.2 || ^8.0", - "phpstan/phpdoc-parser": "^1.23.1", - "squizlabs/php_codesniffer": "^3.7.1" + "dealerdirect/phpcodesniffer-composer-installer": "^0.7 || ^1.2.1", + "php": "^7.4 || ^8.0", + "phpstan/phpdoc-parser": "^2.3.2", + "squizlabs/php_codesniffer": "^4.0.1" }, "require-dev": { - "phing/phing": "2.17.4", - "php-parallel-lint/php-parallel-lint": "1.3.2", - "phpstan/phpstan": "1.10.37", - "phpstan/phpstan-deprecation-rules": "1.1.4", - "phpstan/phpstan-phpunit": "1.3.14", - "phpstan/phpstan-strict-rules": "1.5.1", - "phpunit/phpunit": "8.5.21|9.6.8|10.3.5" + "phing/phing": "3.0.1|3.1.2", + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/phpstan": "2.1.54", + "phpstan/phpstan-deprecation-rules": "2.0.4", + "phpstan/phpstan-phpunit": "2.0.16", + "phpstan/phpstan-strict-rules": "2.0.11", + "phpunit/phpunit": "9.6.34|10.5.63|11.4.4|11.5.55|12.5.24" }, "type": "phpcodesniffer-standard", "extra": { @@ -2582,7 +4013,7 @@ ], "support": { "issues": "https://github.com/slevomat/coding-standard/issues", - "source": "https://github.com/slevomat/coding-standard/tree/8.14.1" + "source": "https://github.com/slevomat/coding-standard/tree/8.29.0" }, "funding": [ { @@ -2594,41 +4025,36 @@ "type": "tidelift" } ], - "time": "2023-10-08T07:28:08+00:00" + "time": "2026-05-07T05:48:08+00:00" }, { "name": "squizlabs/php_codesniffer", - "version": "3.7.2", + "version": "4.0.1", "source": { "type": "git", - "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", - "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879" + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "0525c73950de35ded110cffafb9892946d7771b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/ed8e00df0a83aa96acf703f8c2979ff33341f879", - "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0525c73950de35ded110cffafb9892946d7771b5", + "reference": "0525c73950de35ded110cffafb9892946d7771b5", "shasum": "" }, "require": { "ext-simplexml": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": ">=5.4.0" + "php": ">=7.2.0" }, "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" + "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31" }, "bin": [ - "bin/phpcs", - "bin/phpcbf" + "bin/phpcbf", + "bin/phpcs" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" @@ -2636,35 +4062,340 @@ "authors": [ { "name": "Greg Sherwood", - "role": "lead" + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" } ], - "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": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", "keywords": [ "phpcs", "standards", "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" + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-10T16:43:36+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.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-13T15:52:40+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v8.1.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-29T05:06:50+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, - "time": "2023-02-22T23:07:41+00:00" + "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": "theseer/tokenizer", - "version": "1.2.2", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b2ad5003ca10d4ee50a12da31de12a5774ba6b96", - "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -2693,7 +4424,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.2" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -2701,17 +4432,20 @@ "type": "github" } ], - "time": "2023-11-20T00:12:19+00:00" + "time": "2025-11-17T20:03:58+00:00" } ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.0" + "php": "^8.2", + "ext-json": "*", + "ext-openssl": "*", + "composer-runtime-api": "^2.0" }, - "platform-dev": [], - "plugin-api-version": "2.6.0" + "platform-dev": {}, + "plugin-api-version": "2.9.0" } diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 0200aa9..dbb4e03 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -4,7 +4,7 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../vendor/squizlabs/php_codesniffer/phpcs.xsd" > - + @@ -12,7 +12,18 @@ - + + + + + + + + src/ApiKey.php + src tests diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 57b48b4..65feb88 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -10,13 +10,13 @@ parameters: uncheckedExceptionClasses: - LogicException - ArithmeticError + - Random\RandomException check: missingCheckedExceptionInThrows: true tooWideThrowType: true checkTooWideReturnTypesInProtectedAndPublicMethods: true checkUninitializedProperties: true checkMissingCallableSignature: true - checkGenericClassInNonGenericObjectType: true ignoreErrors: - message: '#.+::test.+\(\) throws checked exception .+ but it''s missing from the PHPDoc @throws tag#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7da1f25..0f50508 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -20,7 +20,7 @@ - + ./tests/ diff --git a/src/.gitkeep b/src/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/ApiClient.php b/src/ApiClient.php new file mode 100644 index 0000000..ff19863 --- /dev/null +++ b/src/ApiClient.php @@ -0,0 +1,25 @@ + $payload The request body. + * + * @throws ApiException If the request fails or the API returns an error. + */ + public function send(string $path, array $payload, RequestContext $context): mixed; +} diff --git a/src/ApiKey.php b/src/ApiKey.php new file mode 100644 index 0000000..42ba429 --- /dev/null +++ b/src/ApiKey.php @@ -0,0 +1,249 @@ +identifier = $identifier; + $this->algorithm = $algorithm; + $this->encodedKey = $encodedKey === null ? null : new \SensitiveParameterValue($encodedKey); + } + + /** + * Creates an API key from a serialized string, or returns the given instance unchanged. + * + * @throws ConfigurationException If the serialized key is malformed. + */ + public static function from( + #[\SensitiveParameter] + string|self $apiKey, + ): self { + if ($apiKey instanceof self) { + return $apiKey; + } + + return self::parse($apiKey); + } + + /** + * Parses an API key from its serialized string form. + * + * @throws ConfigurationException If the serialized key is malformed. + */ + public static function parse(#[\SensitiveParameter] string $apiKey): self + { + $parts = \explode(':', $apiKey); + + if (\count($parts) > 2) { + throw new ConfigurationException('Invalid API key format.'); + } + + return self::of($parts[0], $parts[1] ?? null); + } + + /** + * Creates an API key from an identifier and an optional private key. + * + * @throws ConfigurationException If the identifier or private key is invalid. + */ + public static function of( + string $identifier, + #[\SensitiveParameter] + ?string $privateKey = null, + ): self { + if (!Uuid::isValid($identifier)) { + throw new ConfigurationException('The API key identifier must be a UUID.'); + } + + if ($privateKey === null || $privateKey === '') { + return new self($identifier, null, null); + } + + if (\preg_match(self::PRIVATE_KEY_PATTERN, $privateKey) !== 1) { + throw new ConfigurationException('The API key private key is malformed.'); + } + + $segments = \explode(';', $privateKey, 2); + $algorithm = $segments[0]; + + if (!\in_array($algorithm, self::SUPPORTED_ALGORITHMS, true)) { + throw new ConfigurationException(\sprintf('Unsupported signing algorithm "%s".', $algorithm)); + } + + return new self($identifier, $algorithm, $segments[1] ?? ''); + } + + /** + * Gets the public identifier. + */ + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * Gets the key ID, the hexadecimal SHA-256 of the identifier's raw bytes. + */ + public function getIdentifierHash(): string + { + // The identifier is always a validated UUID, so hex2bin always succeeds. + return \hash('sha256', (string) \hex2bin(\str_replace('-', '', $this->identifier))); + } + + /** + * Checks whether the key carries a private key for signing. + */ + public function hasPrivateKey(): bool + { + return $this->encodedKey !== null; + } + + /** + * Gets the signing algorithm. + * + * @throws ConfigurationException If the key has no private key. + */ + public function getSigningAlgorithm(): string + { + if ($this->algorithm === null) { + throw new ConfigurationException('The API key does not have a private key.'); + } + + return $this->algorithm; + } + + /** + * Gets the serialized private key, including its algorithm. + * + * @throws ConfigurationException If the key has no private key. + */ + public function getPrivateKey(): string + { + if ($this->algorithm === null) { + throw new ConfigurationException('The API key does not have a private key.'); + } + + return $this->algorithm . ';' . $this->getEncodedKey(); + } + + /** + * Signs the given data with ES256, returning the raw 64-byte signature. + * + * @throws ConfigurationException If the key has no usable private key. + */ + public function sign(string $data): string + { + $result = ''; + + // The private key is validated by loadPrivateKey, so signing always succeeds. + \openssl_sign($data, $result, $this->loadPrivateKey(), \OPENSSL_ALGO_SHA256); + + /** @var string $signature */ + $signature = $result; + + return self::convertDerToRaw($signature); + } + + /** + * Exports the full serialized key, including the private key when present. + * + * @throws ConfigurationException If the private key cannot be read. + */ + public function export(): string + { + return $this->identifier . ($this->hasPrivateKey() ? ':' . $this->getPrivateKey() : ''); + } + + /** + * Gets a redacted representation that never reveals the private key. + */ + public function __toString(): string + { + return '[redacted]'; + } + + /** + * Loads and caches the OpenSSL handle for the private key. + * + * @throws ConfigurationException If the private key is missing or invalid. + */ + private function loadPrivateKey(): \OpenSSLAsymmetricKey + { + if ($this->loadedKey !== null) { + return $this->loadedKey; + } + + $key = \openssl_pkey_get_private( + "-----BEGIN PRIVATE KEY-----\n" + . \chunk_split($this->getEncodedKey(), 64, "\n") + . "-----END PRIVATE KEY-----\n", + ); + + if ($key === false) { + throw new ConfigurationException('The API key contains an invalid private key.'); + } + + return $this->loadedKey = $key; + } + + /** + * Gets the base64-encoded private key material. + * + * @throws ConfigurationException If the key has no private key. + */ + private function getEncodedKey(): string + { + $value = $this->encodedKey?->getValue(); + + if (!\is_string($value)) { + throw new ConfigurationException('The API key does not have a private key.'); + } + + return $value; + } + + /** + * Converts a DER-encoded ECDSA signature to the raw R||S concatenation required by JWS. + * + * Each component is left-padded to 32 bytes. + */ + private static function convertDerToRaw(string $der): string + { + $offset = 4; // 0x30 SEQUENCE, sequence length, 0x02 INTEGER tag, R length. + $rLength = \ord($der[3]); + $r = \substr($der, $offset, $rLength); + $offset += $rLength + 2; // Skip R, then the 0x02 INTEGER tag and S length. + $s = \substr($der, $offset, \ord($der[$offset - 1])); + + return \str_pad(\ltrim($r, "\x00"), 32, "\x00", \STR_PAD_LEFT) + . \str_pad(\ltrim($s, "\x00"), 32, "\x00", \STR_PAD_LEFT); + } +} diff --git a/src/Content/ArrayContentProvider.php b/src/Content/ArrayContentProvider.php new file mode 100644 index 0000000..315be27 --- /dev/null +++ b/src/Content/ArrayContentProvider.php @@ -0,0 +1,30 @@ +> */ + private array $content; + + /** + * @param array> $content The content of each slot, keyed by ID. + */ + public function __construct(array $content) + { + $this->content = $content; + } + + /** + * @return array|null + */ + public function getContent(string $id): ?array + { + return $this->content[$id] ?? null; + } +} diff --git a/src/Content/ContentProvider.php b/src/Content/ContentProvider.php new file mode 100644 index 0000000..e849e4d --- /dev/null +++ b/src/Content/ContentProvider.php @@ -0,0 +1,20 @@ +|null The content of the slot, or null when none is available. + */ + public function getContent(string $id): ?array; +} diff --git a/src/Content/ContentSource.php b/src/Content/ContentSource.php new file mode 100644 index 0000000..8805768 --- /dev/null +++ b/src/Content/ContentSource.php @@ -0,0 +1,26 @@ +experienceId = $experienceId; + $this->audienceId = $audienceId; + $this->experiment = $experiment; + } + + /** + * Creates an instance from the decoded experience metadata. + * + * @param array $data + * + * @throws \InvalidArgumentException If a required field is missing or invalid. + */ + public static function fromArray(array $data): self + { + $experienceId = $data['experienceId'] ?? null; + $audienceId = $data['audienceId'] ?? null; + $experiment = $data['experiment'] ?? null; + + if (!\is_string($experienceId)) { + throw new \InvalidArgumentException('The experience ID is missing or invalid.'); + } + + if (!\is_string($audienceId)) { + throw new \InvalidArgumentException('The audience ID is missing or invalid.'); + } + + if ($experiment !== null && !\is_array($experiment)) { + throw new \InvalidArgumentException('The experiment metadata is invalid.'); + } + + return new self( + $experienceId, + $audienceId, + $experiment !== null ? ExperimentMetadata::fromArray($experiment) : null, + ); + } + + /** + * Gets the experience ID. + */ + public function getExperienceId(): string + { + return $this->experienceId; + } + + /** + * Gets the audience ID. + */ + public function getAudienceId(): string + { + return $this->audienceId; + } + + /** + * Gets the experiment running within the experience. + * + * @return ExperimentMetadata|null The experiment metadata, or null if none is running. + */ + public function getExperiment(): ?ExperimentMetadata + { + return $this->experiment; + } +} diff --git a/src/Content/ExperimentMetadata.php b/src/Content/ExperimentMetadata.php new file mode 100644 index 0000000..8a7f102 --- /dev/null +++ b/src/Content/ExperimentMetadata.php @@ -0,0 +1,60 @@ +experimentId = $experimentId; + $this->variantId = $variantId; + } + + /** + * Creates an instance from the decoded experiment metadata. + * + * @param array $data + * + * @throws \InvalidArgumentException If a required field is missing or invalid. + */ + public static function fromArray(array $data): self + { + $experimentId = $data['experimentId'] ?? null; + $variantId = $data['variantId'] ?? null; + + if (!\is_string($experimentId)) { + throw new \InvalidArgumentException('The experiment ID is missing or invalid.'); + } + + if (!\is_string($variantId)) { + throw new \InvalidArgumentException('The variant ID is missing or invalid.'); + } + + return new self($experimentId, $variantId); + } + + /** + * Gets the experiment ID. + */ + public function getExperimentId(): string + { + return $this->experimentId; + } + + /** + * Gets the ID of the variant served to the visitor. + */ + public function getVariantId(): string + { + return $this->variantId; + } +} diff --git a/src/Content/NullContentProvider.php b/src/Content/NullContentProvider.php new file mode 100644 index 0000000..c3f2579 --- /dev/null +++ b/src/Content/NullContentProvider.php @@ -0,0 +1,19 @@ +|null + */ + public function getContent(string $id): ?array + { + return null; + } +} diff --git a/src/Content/SlotMetadata.php b/src/Content/SlotMetadata.php new file mode 100644 index 0000000..30aabee --- /dev/null +++ b/src/Content/SlotMetadata.php @@ -0,0 +1,147 @@ +|null */ + private ?array $schema; + + /** + * @param array|null $schema + */ + public function __construct( + ?string $version = null, + ?ContentSource $contentSource = null, + ?ExperienceMetadata $experience = null, + ?array $schema = null, + ) { + $this->version = $version; + $this->contentSource = $contentSource; + $this->experience = $experience; + $this->schema = $schema; + } + + /** + * Creates an instance from the decoded slot metadata. + * + * @param array $data + * + * @throws \InvalidArgumentException If a field is present but invalid. + */ + public static function fromArray(array $data): self + { + $version = $data['version'] ?? null; + + if ($version !== null && !\is_string($version)) { + throw new \InvalidArgumentException('The content version is invalid.'); + } + + $experience = $data['experience'] ?? null; + + if ($experience !== null && !\is_array($experience)) { + throw new \InvalidArgumentException('The experience metadata is invalid.'); + } + + $schema = $data['schema'] ?? null; + + if ($schema !== null && !\is_array($schema)) { + throw new \InvalidArgumentException('The content schema is invalid.'); + } + + return new self( + $version, + self::parseContentSource($data['contentSource'] ?? null), + $experience !== null ? ExperienceMetadata::fromArray($experience) : null, + $schema !== null ? self::stringifyKeys($schema) : null, + ); + } + + /** + * Parses the content source value. + * + * @throws \InvalidArgumentException If the value is present but not a known content source. + */ + private static function parseContentSource(mixed $value): ?ContentSource + { + if ($value === null) { + return null; + } + + if (!\is_string($value)) { + throw new \InvalidArgumentException('The content source is invalid.'); + } + + return ContentSource::tryFrom($value) + ?? throw new \InvalidArgumentException(\sprintf('Unknown content source "%s".', $value)); + } + + /** + * Gets the content version. + * + * @return string|null The version, or null if unversioned. + */ + public function getVersion(): ?string + { + return $this->version; + } + + /** + * Gets the source the content was served from. + * + * @return ContentSource|null The content source, or null if unknown. + */ + public function getContentSource(): ?ContentSource + { + return $this->contentSource; + } + + /** + * Gets the experience that served the content. + * + * @return ExperienceMetadata|null The experience metadata, or null if none applies. + */ + public function getExperience(): ?ExperienceMetadata + { + return $this->experience; + } + + /** + * Gets the content schema, present only when the schema was requested. + * + * @return array|null The schema, or null if not requested. + */ + public function getSchema(): ?array + { + return $this->schema; + } + + /** + * Casts every key of the given array to a string. + * + * @param array $data The array to normalize. + * + * @return array The array with string keys. + */ + private static function stringifyKeys(array $data): array + { + $result = []; + + foreach ($data as $key => $value) { + $result[(string) $key] = $value; + } + + return $result; + } +} diff --git a/src/ContentFetcher.php b/src/ContentFetcher.php new file mode 100644 index 0000000..be42181 --- /dev/null +++ b/src/ContentFetcher.php @@ -0,0 +1,22 @@ +name = $name; + $this->value = $value; + $this->expiration = $expiration; + $this->path = $path; + $this->domain = $domain; + $this->secure = $secure; + $this->httpOnly = $httpOnly; + $this->sameSite = $sameSite; + } + + /** + * Gets the cookie name. + */ + public function getName(): string + { + return $this->name; + } + + /** + * Gets the cookie value. + */ + public function getValue(): string + { + return $this->value; + } + + /** + * Gets the expiration time as a Unix timestamp. + * + * @return int|null The expiration timestamp, or null for a session cookie. + */ + public function getExpiration(): ?int + { + return $this->expiration; + } + + /** + * Gets the cookie path. + */ + public function getPath(): string + { + return $this->path; + } + + /** + * Gets the cookie domain. + * + * @return string|null The domain, or null if not scoped to one. + */ + public function getDomain(): ?string + { + return $this->domain; + } + + /** + * Checks whether the cookie is sent only over HTTPS. + */ + public function isSecure(): bool + { + return $this->secure; + } + + /** + * Checks whether the cookie is hidden from client-side scripts. + */ + public function isHttpOnly(): bool + { + return $this->httpOnly; + } + + /** + * Gets the SameSite policy. + * + * @return string|null The policy, or null if unset. + */ + public function getSameSite(): ?string + { + return $this->sameSite; + } + + /** + * Renders the cookie as the value of a Set-Cookie response header. + */ + public function toSetCookieHeader(?int $now = null): string + { + $parts = [\rawurlencode($this->name) . '=' . \rawurlencode($this->value)]; + + if ($this->expiration !== null) { + $parts[] = 'Expires=' . \gmdate('D, d M Y H:i:s', $this->expiration) . ' GMT'; + $parts[] = 'Max-Age=' . \max(0, $this->expiration - ($now ?? \time())); + } + + $parts[] = 'Path=' . $this->path; + + if ($this->domain !== null) { + $parts[] = 'Domain=' . $this->domain; + } + + if ($this->secure) { + $parts[] = 'Secure'; + } + + if ($this->httpOnly) { + $parts[] = 'HttpOnly'; + } + + if ($this->sameSite !== null) { + $parts[] = 'SameSite=' . $this->sameSite; + } + + return \implode('; ', $parts); + } +} diff --git a/src/CookieConfiguration.php b/src/CookieConfiguration.php new file mode 100644 index 0000000..8efceae --- /dev/null +++ b/src/CookieConfiguration.php @@ -0,0 +1,146 @@ +clientIdName = $clientIdName; + $this->userTokenName = $userTokenName; + $this->clientIdDuration = $clientIdDuration; + $this->userTokenDuration = $userTokenDuration; + $this->domain = $domain; + $this->secure = $secure; + $this->sameSite = $sameSite; + } + + /** + * Gets the name of the client ID cookie. + */ + public function getClientIdName(): string + { + return $this->clientIdName; + } + + /** + * Gets the name of the user token cookie. + */ + public function getUserTokenName(): string + { + return $this->userTokenName; + } + + /** + * Gets the lifetime of the client ID cookie, in seconds. + */ + public function getClientIdDuration(): int + { + return $this->clientIdDuration; + } + + /** + * Gets the lifetime of the user token cookie, in seconds. + */ + public function getUserTokenDuration(): int + { + return $this->userTokenDuration; + } + + /** + * Gets the cookie domain. + * + * @return string|null The domain, or null to scope cookies to the current host. + */ + public function getDomain(): ?string + { + return $this->domain; + } + + /** + * Checks whether the cookies are sent only over HTTPS. + */ + public function isSecure(): bool + { + return $this->secure; + } + + /** + * Gets the SameSite policy applied to the cookies. + */ + public function getSameSite(): string + { + return $this->sameSite; + } + + /** + * Builds the cookie settings for the browser-side SDK, so it reads and writes the same cookies. + * + * @return array{ + * clientId: array, + * userToken: array, + * } + */ + public function toBrowserCookies(): array + { + $clientId = [ + 'name' => $this->clientIdName, + 'maxAge' => $this->clientIdDuration, + 'path' => '/', + 'secure' => $this->secure, + 'sameSite' => \strtolower($this->sameSite), + ]; + + $userToken = [ + 'name' => $this->userTokenName, + 'maxAge' => $this->userTokenDuration, + 'path' => '/', + 'secure' => $this->secure, + 'sameSite' => \strtolower($this->sameSite), + ]; + + if ($this->domain !== null) { + $clientId['domain'] = $this->domain; + $userToken['domain'] = $this->domain; + } + + return [ + 'clientId' => $clientId, + 'userToken' => $userToken, + ]; + } +} diff --git a/src/CookieStorage.php b/src/CookieStorage.php new file mode 100644 index 0000000..87b0ae1 --- /dev/null +++ b/src/CookieStorage.php @@ -0,0 +1,207 @@ +clientId = $clientId; + $this->userToken = $userToken; + $this->configuration = $configuration ?? new CookieConfiguration(); + $this->now = $now; + } + + /** + * Creates an instance from the cookies of the current request. + */ + public static function fromGlobals(?CookieConfiguration $configuration = null, ?int $now = null): self + { + /** @var array $cookies */ + $cookies = $_COOKIE; + + return self::fromArray($cookies, $configuration, $now); + } + + /** + * Creates an instance from the cookies of a server request. + */ + public static function fromServerRequest( + ServerRequest $request, + ?CookieConfiguration $configuration = null, + ?int $now = null, + ): self { + return self::fromArray($request->getCookieParams(), $configuration, $now); + } + + /** + * Creates an instance from a raw cookie map. + * + * @param array $cookies The cookie name-value pairs. + */ + public static function fromArray( + array $cookies, + ?CookieConfiguration $configuration = null, + ?int $now = null, + ): self { + $configuration ??= new CookieConfiguration(); + + return new self( + self::readClientId($cookies, $configuration->getClientIdName()), + self::readUserToken($cookies, $configuration->getUserTokenName()), + $configuration, + $now, + ); + } + + public function getClientId(): ?Uuid + { + return $this->clientId; + } + + public function getUserToken(): ?Token + { + return $this->userToken; + } + + /** + * Gets the cookie configuration. + */ + public function getConfiguration(): CookieConfiguration + { + return $this->configuration; + } + + public function saveClientId(Uuid $clientId): void + { + $this->clientId = $clientId; + } + + public function saveUserToken(Token $userToken): void + { + $this->userToken = $userToken; + } + + /** + * Returns the cookies to set on the response, reflecting the saved values. + * + * @return array{Cookie, Cookie} + */ + public function getResponseCookies(): array + { + $now = $this->now ?? \time(); + + return [ + new Cookie( + name: $this->configuration->getClientIdName(), + value: $this->clientId?->toString() ?? '', + expiration: $now + $this->configuration->getClientIdDuration(), + path: '/', + domain: $this->configuration->getDomain(), + secure: $this->configuration->isSecure(), + httpOnly: false, + sameSite: $this->configuration->getSameSite(), + ), + new Cookie( + name: $this->configuration->getUserTokenName(), + value: $this->userToken?->toString() ?? '', + expiration: $now + $this->configuration->getUserTokenDuration(), + path: '/', + domain: $this->configuration->getDomain(), + secure: $this->configuration->isSecure(), + httpOnly: false, + sameSite: $this->configuration->getSameSite(), + ), + ]; + } + + /** + * Sends the response cookies to the browser. + * + * Intended for plain PHP scripts, and must be called before any output is sent. + * + * @param (callable(string, string, array): bool)|null $emitter + * The function used to send each cookie. Defaults to PHP's setcookie(). + */ + public function emit(?callable $emitter = null): void + { + $emitter ??= \setcookie(...); + + foreach ($this->getResponseCookies() as $cookie) { + $options = [ + 'expires' => $cookie->getExpiration() ?? 0, + 'path' => $cookie->getPath(), + 'domain' => $cookie->getDomain() ?? '', + 'secure' => $cookie->isSecure(), + 'httponly' => $cookie->isHttpOnly(), + ]; + + if (\in_array($cookie->getSameSite(), ['None', 'Lax', 'Strict'], true)) { + $options['samesite'] = $cookie->getSameSite(); + } + + $emitter($cookie->getName(), $cookie->getValue(), $options); + } + } + + /** + * @param array $cookies + */ + private static function readClientId(array $cookies, string $name): ?Uuid + { + $value = self::readCookie($cookies, $name); + + return $value !== null && Uuid::isValid($value) ? Uuid::parse($value) : null; + } + + /** + * @param array $cookies + */ + private static function readUserToken(array $cookies, string $name): ?Token + { + $value = self::readCookie($cookies, $name); + + if ($value === null) { + return null; + } + + try { + return Token::parse($value); + } catch (MalformedTokenException) { + return null; + } + } + + /** + * @param array $cookies + */ + private static function readCookie(array $cookies, string $name): ?string + { + $value = $cookies[$name] ?? null; + + return \is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/src/Croct.php b/src/Croct.php new file mode 100644 index 0000000..c48a77b --- /dev/null +++ b/src/Croct.php @@ -0,0 +1,247 @@ +appId = $appId; + $this->session = $session; + $this->evaluator = $evaluator; + $this->contentFetcher = $contentFetcher; + $this->cookieConfiguration = $cookieConfiguration; + } + + /** + * Creates an instance wired with sensible defaults. + * + * The HTTP client and PSR-17 factories are auto-discovered when not provided. + * + * @throws ConfigurationException If no PSR-18 client or PSR-17 factory is available. + */ + public static function plug( + string $appId, + #[\SensitiveParameter] + ApiKey|string $apiKey, + IdentityStore $storage, + string $baseEndpointUrl = self::DEFAULT_BASE_ENDPOINT_URL, + int $tokenDuration = self::DEFAULT_TOKEN_DURATION, + ?ContentProvider $contentProvider = null, + ?RequestContext $context = null, + ?HttpClient $httpClient = null, + ?RequestFactory $requestFactory = null, + ?StreamFactory $streamFactory = null, + ?Logger $logger = null, + ): self { + $key = ApiKey::from($apiKey); + $context ??= RequestContext::fromGlobals(); + + $session = new Session($appId, $key, $storage, $tokenDuration); + + try { + $httpClient ??= Psr18ClientDiscovery::find(); + $requestFactory ??= Psr17FactoryDiscovery::findRequestFactory(); + $streamFactory ??= Psr17FactoryDiscovery::findStreamFactory(); + } catch (NotFoundException $exception) { + throw new ConfigurationException( + 'No PSR-18 HTTP client or PSR-17 factory was found. Install one ' + . '(e.g. "composer require guzzlehttp/guzzle nyholm/psr7") or pass it explicitly.', + 0, + $exception, + ); + } + + $version = InstalledVersions::getPrettyVersion('croct/plug-php'); + + $client = new PsrApiClient( + httpClient: $httpClient, + requestFactory: $requestFactory, + streamFactory: $streamFactory, + apiKey: $key, + logger: $logger, + baseEndpointUrl: $baseEndpointUrl, + version: $version, + identity: $session, + ); + + $cookieConfiguration = $storage instanceof CookieStorage + ? $storage->getConfiguration() + : new CookieConfiguration(); + + return new self( + $appId, + $session, + new HttpEvaluator($client, $context), + new HttpContentFetcher($client, $context, $contentProvider ?? self::discoverContentProvider()), + $cookieConfiguration, + ); + } + + /** + * Creates an instance from the CROCT_* environment variables. + * + * @throws ConfigurationException If required variables are missing or no transport is available. + */ + public static function fromEnvironment(IdentityStore $storage): self + { + $appId = self::getEnv('CROCT_APP_ID'); + $apiKey = self::getEnv('CROCT_API_KEY'); + + if ($appId === null || $apiKey === null) { + throw new ConfigurationException( + 'The CROCT_APP_ID and CROCT_API_KEY environment variables are required.', + ); + } + + $tokenDuration = self::getEnv('CROCT_TOKEN_DURATION'); + + return self::plug( + appId: $appId, + apiKey: $apiKey, + storage: $storage, + baseEndpointUrl: self::getEnv('CROCT_BASE_ENDPOINT_URL') ?? self::DEFAULT_BASE_ENDPOINT_URL, + tokenDuration: $tokenDuration !== null ? (int) $tokenDuration : self::DEFAULT_TOKEN_DURATION, + ); + } + + /** + * Evaluates a CQL query against the visitor's context. + * + * @throws CroctException If the query is invalid or the request fails without a fallback. + */ + public function evaluate(string $query, ?EvaluationOptions $options = null): mixed + { + return $this->evaluator->evaluate($query, $options); + } + + /** + * Fetches the personalized content of a slot. + * + * @throws CroctException If the request fails without a fallback. + */ + public function fetchContent(string $slotId, ?FetchOptions $options = null): FetchResponse + { + return $this->contentFetcher->fetch($slotId, $options); + } + + /** + * Marks the visitor as a known user. + */ + public function identify(string $userId): void + { + $this->session->identify($userId); + } + + /** + * Resets the visitor to anonymous. + */ + public function anonymize(): void + { + $this->session->anonymize(); + } + + public function getAppId(): string + { + return $this->appId; + } + + public function getClientId(): string + { + return $this->session->getClientId()->toString(); + } + + public function getUserToken(): string + { + return $this->session->getUserToken()->toString(); + } + + /** + * @return array + */ + public function getPlugOptions(): array + { + return [ + 'appId' => $this->appId, + 'clientId' => $this->getClientId(), + 'token' => $this->getUserToken(), + 'disableCidMirroring' => true, + 'cookie' => $this->cookieConfiguration->toBrowserCookies(), + ]; + } + + /** + * Reads an environment variable. + * + * @return string|null The value, or null when it is unset or empty. + */ + private static function getEnv(string $name): ?string + { + $value = \getenv($name); + + return \is_string($value) && $value !== '' ? $value : null; + } + + /** + * Discovers the content provider generated by the CLI, or a null provider when none is installed. + */ + private static function discoverContentProvider(): ContentProvider + { + /** @var ContentProvider|null $provider **/ + static $provider = null; + + if ($provider === null) { + /** @var ContentProvider $provider **/ + $provider = \class_exists(self::DEFAULT_CONTENT_PROVIDER_CLASS) + ? new (self::DEFAULT_CONTENT_PROVIDER_CLASS)() + : new NullContentProvider(); + } + + return $provider; + } +} diff --git a/src/EvaluationOptions.php b/src/EvaluationOptions.php new file mode 100644 index 0000000..7bb011e --- /dev/null +++ b/src/EvaluationOptions.php @@ -0,0 +1,96 @@ + */ + private array $attributes; + + private mixed $fallback; + + private bool $fallbackProvided; + + /** + * @param array $attributes + */ + private function __construct(array $attributes, mixed $fallback, bool $fallbackProvided) + { + $this->attributes = $attributes; + $this->fallback = $fallback; + $this->fallbackProvided = $fallbackProvided; + } + + /** + * Creates an empty set of options. + */ + public static function empty(): self + { + return new self([], null, false); + } + + /** + * Returns a copy with the given custom attributes, replacing any existing ones. + * + * @param array $attributes + */ + public function withAttributes(array $attributes): self + { + return new self($attributes, $this->fallback, $this->fallbackProvided); + } + + /** + * Returns a copy with the given custom attribute added. + */ + public function withAttribute(string $name, mixed $value): self + { + $attributes = $this->attributes; + $attributes[$name] = $value; + + return new self($attributes, $this->fallback, $this->fallbackProvided); + } + + /** + * Returns a copy with a fallback result to return if the evaluation fails. + * + * Without a fallback, a failed evaluation throws an exception. + */ + public function withFallback(mixed $fallback): self + { + return new self($this->attributes, $fallback, true); + } + + /** + * Gets the custom attributes. + * + * @return array The custom attributes. + */ + public function getAttributes(): array + { + return $this->attributes; + } + + /** + * Checks whether a fallback result was provided. + */ + public function hasFallback(): bool + { + return $this->fallbackProvided; + } + + /** + * Gets the fallback result returned when the evaluation fails. + */ + public function getFallback(): mixed + { + return $this->fallback; + } +} diff --git a/src/Evaluator.php b/src/Evaluator.php new file mode 100644 index 0000000..add6845 --- /dev/null +++ b/src/Evaluator.php @@ -0,0 +1,22 @@ +statusCode = $statusCode; + } + + /** + * Creates an exception from an RFC 7807 problem response. + * + * @param array|null $problem + */ + public static function fromProblem(int $status, ?array $problem): self + { + $title = $problem['title'] ?? null; + + return new self( + \is_string($title) && $title !== '' + ? $title + : \sprintf('The Croct API responded with status %d.', $status), + $status, + ); + } + + /** + * Creates an exception for a failure to reach or exchange data with the API. + */ + public static function fromReason(string $reason, ?\Throwable $previous = null): self + { + return new self($reason, null, $previous); + } + + /** + * Gets the HTTP status code of the failed response. + * + * @return int|null The status code, or null for a transport-level failure. + */ + public function getStatusCode(): ?int + { + return $this->statusCode; + } +} diff --git a/src/Exception/ConfigurationException.php b/src/Exception/ConfigurationException.php new file mode 100644 index 0000000..8e84856 --- /dev/null +++ b/src/Exception/ConfigurationException.php @@ -0,0 +1,14 @@ + */ + private array $attributes; + + private bool $fallbackProvided; + + private mixed $fallback; + + /** + * @param array $attributes + */ + private function __construct( + ?string $preferredLocale, + int|string|null $version, + bool $static, + bool $includeSchema, + array $attributes, + bool $fallbackProvided, + mixed $fallback, + ) { + $this->preferredLocale = $preferredLocale; + $this->version = $version; + $this->static = $static; + $this->includeSchema = $includeSchema; + $this->attributes = $attributes; + $this->fallbackProvided = $fallbackProvided; + $this->fallback = $fallback; + } + + /** + * Creates an empty set of options. + */ + public static function empty(): self + { + return new self(null, null, false, false, [], false, null); + } + + /** + * Returns a copy that requests content in the given locale. + */ + public function withPreferredLocale(string $preferredLocale): self + { + return $this->copy(preferredLocale: $preferredLocale); + } + + /** + * Returns a copy that requests the given content version. + */ + public function withVersion(int|string $version): self + { + return $this->copy(version: $version); + } + + /** + * Returns a copy that fetches statically generated content (server-side only). + */ + public function withStatic(bool $static = true): self + { + return $this->copy(static: $static); + } + + /** + * Returns a copy that includes the content schema in the response metadata. + */ + public function withSchema(bool $includeSchema = true): self + { + return $this->copy(includeSchema: $includeSchema); + } + + /** + * Returns a copy with the given custom attributes, replacing any existing ones. + * + * @param array $attributes + */ + public function withAttributes(array $attributes): self + { + return $this->copy(attributes: $attributes); + } + + /** + * Returns a copy with the given custom attribute added. + */ + public function withAttribute(string $name, mixed $value): self + { + $attributes = $this->attributes; + $attributes[$name] = $value; + + return $this->copy(attributes: $attributes); + } + + /** + * Returns a copy with a fallback to return if the fetch fails. + * + * Without a fallback, a failed fetch throws an exception. The fallback may be any value, + * including null, which is treated as a provided fallback rather than the absence of one. + */ + public function withFallback(mixed $content): self + { + return new self( + $this->preferredLocale, + $this->version, + $this->static, + $this->includeSchema, + $this->attributes, + true, + $content, + ); + } + + /** + * Gets the preferred content locale. + * + * @return string|null The locale, or null to use the default. + */ + public function getPreferredLocale(): ?string + { + return $this->preferredLocale; + } + + /** + * Gets the requested content version. + * + * @return int|string|null The version, or null for the latest. + */ + public function getVersion(): int|string|null + { + return $this->version; + } + + /** + * Checks whether statically generated content is requested. + */ + public function isStatic(): bool + { + return $this->static; + } + + /** + * Checks whether the content schema is included in the response metadata. + */ + public function includesSchema(): bool + { + return $this->includeSchema; + } + + /** + * Gets the custom attributes. + * + * @return array The custom attributes. + */ + public function getAttributes(): array + { + return $this->attributes; + } + + /** + * Checks whether a fallback was provided. + */ + public function hasFallback(): bool + { + return $this->fallbackProvided; + } + + /** + * Gets the fallback content returned when the fetch fails. + */ + public function getFallback(): mixed + { + return $this->fallback; + } + + /** + * Returns a copy with the given fields overridden, keeping the rest. + * + * @param array|null $attributes + */ + private function copy( + ?string $preferredLocale = null, + int|string|null $version = null, + ?bool $static = null, + ?bool $includeSchema = null, + ?array $attributes = null, + ): self { + return new self( + $preferredLocale ?? $this->preferredLocale, + $version ?? $this->version, + $static ?? $this->static, + $includeSchema ?? $this->includeSchema, + $attributes ?? $this->attributes, + $this->fallbackProvided, + $this->fallback, + ); + } +} diff --git a/src/FetchResponse.php b/src/FetchResponse.php new file mode 100644 index 0000000..40ea455 --- /dev/null +++ b/src/FetchResponse.php @@ -0,0 +1,62 @@ +content = $content; + $this->metadata = $metadata; + } + + /** + * Gets the slot content. + */ + public function getContent(): mixed + { + return $this->content; + } + + /** + * Gets the content metadata. + * + * @return SlotMetadata|null The metadata, or null if none is available. + */ + public function getMetadata(): ?SlotMetadata + { + return $this->metadata; + } + + /** + * Creates a response from the decoded API payload. + */ + public static function fromResponse(mixed $data): self + { + $content = []; + $metadata = null; + + if (\is_array($data)) { + if (isset($data['content']) && \is_array($data['content'])) { + $content = $data['content']; + } + + if (isset($data['metadata']) && \is_array($data['metadata'])) { + $metadata = SlotMetadata::fromArray($data['metadata']); + } + } + + return new self($content, $metadata); + } +} diff --git a/src/HttpContentFetcher.php b/src/HttpContentFetcher.php new file mode 100644 index 0000000..c9fec6c --- /dev/null +++ b/src/HttpContentFetcher.php @@ -0,0 +1,90 @@ +client = $client; + $this->context = $context; + $this->contentProvider = $contentProvider ?? new NullContentProvider(); + } + + public function fetch(string $slotId, ?FetchOptions $options = null): FetchResponse + { + $options ??= FetchOptions::empty(); + $context = $this->context; + + $payload = ['slotId' => $slotId]; + + $version = $options->getVersion(); + + if ($version !== null) { + $payload['version'] = (string) $version; + } + + $locale = $options->getPreferredLocale() ?? $context->getPreferredLocale(); + + if ($locale !== null) { + $payload['preferredLocale'] = $locale; + } + + if ($options->includesSchema()) { + $payload['includeSchema'] = true; + } + + $previewToken = $context->getPreviewToken(); + + if ($previewToken !== null) { + $payload['previewToken'] = $previewToken; + } + + $evaluationContext = $context->toEvaluationContext($options->getAttributes()); + + if ($evaluationContext !== []) { + $payload['context'] = $evaluationContext; + } + + $endpoint = $options->isStatic() ? self::STATIC_ENDPOINT : self::ENDPOINT; + + try { + return FetchResponse::fromResponse($this->client->send($endpoint, $payload, $context)); + } catch (ApiException $exception) { + if ($options->hasFallback()) { + return new FetchResponse($options->getFallback()); + } + + $content = $this->contentProvider->getContent($slotId); + + if ($content !== null) { + return new FetchResponse($content); + } + + throw new ContentException($exception->getMessage(), 0, $exception); + } + } +} diff --git a/src/HttpEvaluator.php b/src/HttpEvaluator.php new file mode 100644 index 0000000..4bcb609 --- /dev/null +++ b/src/HttpEvaluator.php @@ -0,0 +1,50 @@ +client = $client; + $this->context = $context; + } + + public function evaluate(string $query, ?EvaluationOptions $options = null): mixed + { + $options ??= EvaluationOptions::empty(); + $context = $this->context; + + $payload = ['query' => $query]; + + $evaluationContext = $context->toEvaluationContext($options->getAttributes()); + + if ($evaluationContext !== []) { + $payload['context'] = $evaluationContext; + } + + try { + return $this->client->send(self::ENDPOINT, $payload, $context); + } catch (ApiException $exception) { + if ($options->hasFallback()) { + return $options->getFallback(); + } + + throw new EvaluationException($exception->getMessage(), 0, $exception); + } + } +} diff --git a/src/HttpHeader.php b/src/HttpHeader.php new file mode 100644 index 0000000..9c13d07 --- /dev/null +++ b/src/HttpHeader.php @@ -0,0 +1,41 @@ +clientId = $clientId; + $this->userToken = $userToken; + } + + public function getClientId(): ?Uuid + { + return $this->clientId; + } + + public function getUserToken(): ?Token + { + return $this->userToken; + } + + public function saveClientId(Uuid $clientId): void + { + $this->clientId = $clientId; + } + + public function saveUserToken(Token $userToken): void + { + $this->userToken = $userToken; + } +} diff --git a/src/Plug.php b/src/Plug.php new file mode 100644 index 0000000..5a78d73 --- /dev/null +++ b/src/Plug.php @@ -0,0 +1,62 @@ + + */ + public function getPlugOptions(): array; + + /** + * Marks the visitor as a known user. + */ + public function identify(string $userId): void; + + /** + * Resets the visitor to anonymous. + */ + public function anonymize(): void; + + /** + * Evaluates a CQL query against the visitor's context. + * + * @throws CroctException If the query is invalid or the request fails without a fallback. + */ + public function evaluate(string $query, ?EvaluationOptions $options = null): mixed; + + /** + * Fetches the personalized content of a slot. + * + * @throws CroctException If the request fails without a fallback. + */ + public function fetchContent(string $slotId, ?FetchOptions $options = null): FetchResponse; +} diff --git a/src/PsrApiClient.php b/src/PsrApiClient.php new file mode 100644 index 0000000..dcd3982 --- /dev/null +++ b/src/PsrApiClient.php @@ -0,0 +1,135 @@ +httpClient = $httpClient; + $this->requestFactory = $requestFactory; + $this->streamFactory = $streamFactory; + $this->apiKey = $apiKey; + $this->baseEndpointUrl = $baseEndpointUrl; + $this->clientLibrary = $version === null || $version === '' + ? self::CLIENT_LIBRARY + : self::CLIENT_LIBRARY . ' v' . $version; + $this->logger = $logger ?? new NullLogger(); + $this->identity = $identity; + } + + /** + * @param array $payload + */ + public function send(string $path, array $payload, RequestContext $context): mixed + { + $url = \rtrim($this->baseEndpointUrl, '/') . '/' . $path; + + try { + $body = \json_encode($payload, \JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw ApiException::fromReason('Failed to encode the request payload.', $exception); + } + + $request = $this->requestFactory->createRequest('POST', $url) + ->withHeader('Content-Type', 'application/json') + // Responses are per-visitor; never let a shared HTTP cache store them. + ->withHeader('Cache-Control', 'no-store') + ->withHeader(HttpHeader::CLIENT_LIBRARY->value, $this->clientLibrary) + ->withHeader(HttpHeader::API_KEY->value, $this->apiKey->getIdentifier()) + ->withBody($this->streamFactory->createStream($body)); + + $request = $this->withClientHeaders($request, $context); + + try { + $response = $this->httpClient->sendRequest($request); + } catch (ClientException $exception) { + $this->logger->error(\sprintf('Croct request to "%s" failed: %s', $path, $exception->getMessage())); + + throw ApiException::fromReason('Failed to communicate with the Croct API.', $exception); + } + + $status = $response->getStatusCode(); + $content = (string) $response->getBody(); + + try { + $data = $content === '' ? null : \json_decode($content, true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw ApiException::fromReason('The Croct API returned an invalid response.', $exception); + } + + if ($status === 202) { + throw new ApiException('The Croct service is temporarily unavailable. Please retry shortly.', $status); + } + + if ($status >= 400) { + throw ApiException::fromProblem($status, \is_array($data) ? $data : null); + } + + return $data; + } + + /** + * Adds the available visitor-identifying headers to the request. + */ + private function withClientHeaders(Request $request, RequestContext $context): Request + { + $headers = [ + HttpHeader::CLIENT_ID->value => $this->identity?->getClientId()?->toString(), + HttpHeader::TOKEN->value => $this->identity?->getUserToken()?->toString(), + HttpHeader::CLIENT_IP->value => $context->getClientIp(), + HttpHeader::CLIENT_AGENT->value => $context->getClientAgent(), + ]; + + foreach ($headers as $name => $value) { + if ($value !== null) { + $request = $request->withHeader($name, $value); + } + } + + return $request; + } +} diff --git a/src/RequestContext.php b/src/RequestContext.php new file mode 100644 index 0000000..e636953 --- /dev/null +++ b/src/RequestContext.php @@ -0,0 +1,203 @@ +previewToken = $previewToken; + $this->url = $url; + $this->referrer = $referrer; + $this->clientAgent = $clientAgent; + $this->clientIp = $clientIp; + $this->preferredLocale = $preferredLocale; + } + + /** + * Creates a context from the PHP request superglobals. + */ + public static function fromGlobals(): self + { + /** @var array $server */ + $server = $_SERVER; + + $https = self::getOptionalString($server['HTTPS'] ?? null); + $port = self::getOptionalString($server['SERVER_PORT'] ?? null); + $secure = ($https !== null && \strtolower($https) !== 'off') || $port === '443'; + + $host = self::getOptionalString($server['HTTP_HOST'] ?? null); + $uri = self::getOptionalString($server['REQUEST_URI'] ?? null); + $url = $host !== null ? ($secure ? 'https' : 'http') . '://' . $host . ($uri ?? '') : null; + + $forwardedFor = self::getOptionalString($server['HTTP_X_FORWARDED_FOR'] ?? null) + ?? self::getOptionalString($server['REMOTE_ADDR'] ?? null); + + return new self( + null, + $url, + self::getOptionalString($server['HTTP_REFERER'] ?? null), + self::getOptionalString($server['HTTP_USER_AGENT'] ?? null), + $forwardedFor !== null ? \trim(\explode(',', $forwardedFor)[0]) : null, + ); + } + + /** + * Creates a context from a PSR-7 server request. + */ + public static function fromServerRequest(ServerRequest $request): self + { + /** @var array $server */ + $server = $request->getServerParams(); + + $forwardedFor = self::getOptionalHeader($request, 'X-Forwarded-For') + ?? self::getOptionalString($server['REMOTE_ADDR'] ?? null); + + $url = (string) $request->getUri(); + + return new self( + null, + $url !== '' ? $url : null, + self::getOptionalHeader($request, 'Referer'), + self::getOptionalHeader($request, 'User-Agent'), + $forwardedFor !== null ? \trim(\explode(',', $forwardedFor)[0]) : null, + ); + } + + /** + * Gets the preview token. + * + * @return string|null The preview token, or null if not previewing. + */ + public function getPreviewToken(): ?string + { + return $this->previewToken; + } + + /** + * Gets the request URL. + * + * @return string|null The URL, or null if unknown. + */ + public function getUrl(): ?string + { + return $this->url; + } + + /** + * Gets the referrer URL. + * + * @return string|null The referrer, or null if absent. + */ + public function getReferrer(): ?string + { + return $this->referrer; + } + + /** + * Gets the client user agent. + * + * @return string|null The user agent, or null if absent. + */ + public function getClientAgent(): ?string + { + return $this->clientAgent; + } + + /** + * Gets the client IP address. + * + * @return string|null The IP address, or null if unknown. + */ + public function getClientIp(): ?string + { + return $this->clientIp; + } + + /** + * Gets the preferred locale. + * + * @return string|null The locale, or null if unspecified. + */ + public function getPreferredLocale(): ?string + { + return $this->preferredLocale; + } + + /** + * Builds the evaluation context, with the page and custom attributes, sent to the API. + * + * @param array $attributes The custom attributes to include. + * + * @return array The assembled evaluation context. + */ + public function toEvaluationContext(array $attributes = []): array + { + $context = []; + $page = []; + + if ($this->url !== null) { + $page['url'] = $this->url; + } + + if ($this->referrer !== null) { + $page['referrer'] = $this->referrer; + } + + if ($page !== []) { + $context['page'] = $page; + } + + if ($attributes !== []) { + $context['attributes'] = $attributes; + } + + return $context; + } + + /** + * Gets a request header value, or null when it is empty. + */ + private static function getOptionalHeader(ServerRequest $request, string $name): ?string + { + $value = $request->getHeaderLine($name); + + return $value !== '' ? $value : null; + } + + /** + * Coerces a value to a non-empty string, or null otherwise. + */ + private static function getOptionalString(mixed $value): ?string + { + return \is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/src/Session.php b/src/Session.php new file mode 100644 index 0000000..67dae33 --- /dev/null +++ b/src/Session.php @@ -0,0 +1,161 @@ +appId = $appId; + $this->apiKey = $apiKey; + $this->store = $store; + $this->tokenDuration = $tokenDuration; + $this->now = $now; + $this->signTokens = $signTokens ?? $apiKey->hasPrivateKey(); + } + + /** + * Gets the client ID, generating and storing one when none is set. + */ + public function getClientId(): Uuid + { + $clientId = $this->store->getClientId(); + + if ($clientId !== null) { + return $clientId; + } + + $clientId = Uuid::random(); + + $this->saveClientId($clientId); + + return $clientId; + } + + /** + * Gets the user token, issuing and storing one when it is absent or no longer usable. + */ + public function getUserToken(): Token + { + $stored = $this->store->getUserToken(); + $token = $this->reissue($stored); + + if ($stored === null || !$token->equals($stored)) { + $this->saveUserToken($token); + } + + return $token; + } + + public function saveClientId(Uuid $clientId): void + { + $this->store->saveClientId($clientId); + } + + public function saveUserToken(Token $userToken): void + { + $this->store->saveUserToken($userToken); + } + + /** + * Marks the visitor as a known user. + * + * @throws \InvalidArgumentException If the user ID is empty. + */ + public function identify(string $userId): void + { + if ($userId === '') { + throw new \InvalidArgumentException('The user ID must be non-empty.'); + } + + $this->saveUserToken($this->issueToken($userId)); + } + + /** + * Resets the visitor to anonymous. + */ + public function anonymize(): void + { + $this->saveUserToken($this->issueToken()); + } + + /** + * Reissues the user token when the stored one is absent or no longer usable. + */ + private function reissue(?Token $token): Token + { + if ($token === null) { + return $this->issueToken(); + } + + // The token belongs to another application: start fresh and anonymous, never carrying its + // subject over, regardless of the token's expiration or signature state. + $tokenAppId = $token->getApplicationId(); + + if ($tokenAppId !== null && $tokenAppId !== $this->appId) { + return $this->issueToken(); + } + + $subject = $token->getSubject(); + + // Upgrade an unsigned token to a signed one when signing is enabled. + if ($this->signTokens && !$token->isSigned()) { + return $this->issueToken($subject); + } + + // Refresh an expired (or not-yet-valid) token, preserving the subject. + if (!$token->isValidNow($this->now)) { + return $this->issueToken($subject); + } + + // Signed with a different key: re-sign, preserving the subject and token ID. + if ($token->isSigned() && !$token->matchesKeyId($this->apiKey)) { + return $this->issueToken($subject, $token->getTokenId()); + } + + return $token; + } + + /** + * Issues a fresh token for the given subject, signing it when signing is enabled. + */ + private function issueToken(?string $subject = null, ?string $tokenId = null): Token + { + if ($subject === '') { + $subject = null; + } + + $token = Token::issue($this->appId, $subject, $this->now) + ->withDuration($this->tokenDuration, $this->now); + + if ($this->signTokens) { + return $token->withTokenId($tokenId ?? Uuid::random()->toString()) + ->signedWith($this->apiKey); + } + + return $token; + } +} diff --git a/src/Token.php b/src/Token.php new file mode 100644 index 0000000..3222c5f --- /dev/null +++ b/src/Token.php @@ -0,0 +1,392 @@ + */ + private array $headers; + + /** @var array */ + private array $payload; + + private string $signature; + + /** + * @param array $headers + * @param array $payload + */ + private function __construct(array $headers, array $payload, string $signature) + { + $this->headers = $headers; + $this->payload = $payload; + $this->signature = $signature; + } + + /** + * Issues a new unsigned token for the given application and optional subject. + * + * @throws \InvalidArgumentException If the timestamp is negative or the subject is empty. + */ + public static function issue(string $appId, ?string $subject = null, ?int $now = null): self + { + $now ??= \time(); + + if ($now < 0) { + throw new \InvalidArgumentException('The timestamp must be non-negative.'); + } + + if ($subject === '') { + throw new \InvalidArgumentException('The subject must be non-empty.'); + } + + $payload = [ + 'iss' => 'croct.io', + 'aud' => 'croct.io', + 'iat' => $now, + ]; + + if ($subject !== null) { + $payload['sub'] = $subject; + } + + return new self( + [ + 'typ' => 'JWT', + 'alg' => 'none', + 'appId' => $appId, + ], + $payload, + '', + ); + } + + /** + * Parses a token from its serialized form. + * + * @throws MalformedTokenException If the token is malformed or corrupted. + */ + public static function parse(string $token): self + { + if ($token === '') { + throw new MalformedTokenException('The token cannot be empty.'); + } + + $parts = \explode('.', $token); + $count = \count($parts); + + if ($count < 2 || $count > 3) { + throw new MalformedTokenException('The token is malformed.'); + } + + return self::of(self::decodeSegment($parts[0]), self::decodeSegment($parts[1]), $parts[2] ?? ''); + } + + /** + * Creates a token from its decoded parts, validating the required claims. + * + * @param array $headers + * @param array $payload + * + * @throws MalformedTokenException If a required header or claim is missing or invalid. + */ + public static function of(array $headers, array $payload, string $signature = ''): self + { + foreach (['typ', 'alg'] as $header) { + if (!isset($headers[$header]) || !\is_string($headers[$header])) { + throw new MalformedTokenException(\sprintf('The token header "%s" is missing or invalid.', $header)); + } + } + + if (!isset($payload['iss']) || !\is_string($payload['iss'])) { + throw new MalformedTokenException('The token claim "iss" is missing or invalid.'); + } + + if (!isset($payload['aud']) || !(\is_string($payload['aud']) || \is_array($payload['aud']))) { + throw new MalformedTokenException('The token claim "aud" is missing or invalid.'); + } + + if (!isset($payload['iat']) || !\is_int($payload['iat'])) { + throw new MalformedTokenException('The token claim "iat" is missing or invalid.'); + } + + return new self($headers, $payload, $signature); + } + + /** + * Returns a signed copy of this token using the given API key. + * + * @throws ConfigurationException If the API key cannot sign the token. + */ + public function signedWith(ApiKey $apiKey): self + { + $headers = $this->headers; + $headers['kid'] = $apiKey->getIdentifierHash(); + $headers['alg'] = $apiKey->getSigningAlgorithm(); + + $input = self::base64UrlEncode(self::encodeJson($headers)) + . '.' + . self::base64UrlEncode(self::encodeJson($this->payload)); + + return new self($headers, $this->payload, self::base64UrlEncode($apiKey->sign($input))); + } + + /** + * Checks whether the token is cryptographically signed. + */ + public function isSigned(): bool + { + return $this->getAlgorithm() !== 'none' && $this->signature !== ''; + } + + /** + * Checks whether the token has no subject. + */ + public function isAnonymous(): bool + { + return $this->getSubject() === null; + } + + /** + * Checks whether the token's subject matches the given user. + */ + public function isSubject(string $subject): bool + { + return $this->getSubject() === $subject; + } + + /** + * Checks whether the token is valid at the given time, defaulting to the current time. + */ + public function isValidNow(?int $now = null): bool + { + $now ??= \time(); + $expiration = $this->getExpirationTime(); + + return ($expiration === null || $expiration >= $now) && $this->getIssueTime() <= $now; + } + + /** + * Checks whether this token was issued more recently than the given one. + */ + public function isNewerThan(self $token): bool + { + return $this->getIssueTime() > $token->getIssueTime(); + } + + /** + * Checks whether this token is equal to the given one. + */ + public function equals(self $token): bool + { + return $this->headers === $token->headers + && $this->payload === $token->payload + && $this->signature === $token->signature; + } + + /** + * Checks whether the token was signed with the given API key. + */ + public function matchesKeyId(ApiKey $apiKey): bool + { + return $this->getKeyId() === $apiKey->getIdentifierHash(); + } + + /** + * Returns a copy with the given token ID. + * + * @throws \InvalidArgumentException If the token ID is not a valid UUID. + */ + public function withTokenId(string $tokenId): self + { + $payload = $this->payload; + $payload['jti'] = Uuid::parse($tokenId)->toString(); + + return new self($this->headers, $payload, $this->signature); + } + + /** + * Returns a copy valid for the given duration, starting at the given time. + */ + public function withDuration(int $duration, ?int $now = null): self + { + $now ??= \time(); + + $payload = $this->payload; + $payload['iat'] = $now; + $payload['exp'] = $now + $duration; + + return new self($this->headers, $payload, $this->signature); + } + + /** + * Gets the application ID. + * + * @return string|null The application ID, or null if absent. + */ + public function getApplicationId(): ?string + { + $appId = $this->headers['appId'] ?? null; + + return \is_string($appId) ? $appId : null; + } + + /** + * Gets the signing algorithm. + * + * @return string The algorithm name, or "none" when the token is unsigned. + */ + public function getAlgorithm(): string + { + $algorithm = $this->headers['alg'] ?? null; + + return \is_string($algorithm) ? $algorithm : 'none'; + } + + /** + * Gets the signing key ID. + * + * @return string|null The key ID, or null when the token is unsigned. + */ + public function getKeyId(): ?string + { + $keyId = $this->headers['kid'] ?? null; + + return \is_string($keyId) ? $keyId : null; + } + + /** + * Gets the subject. + * + * @return string|null The user the token identifies, or null when anonymous. + */ + public function getSubject(): ?string + { + $subject = $this->payload['sub'] ?? null; + + return \is_string($subject) ? $subject : null; + } + + /** + * Gets the token ID. + * + * @return string|null The unique token ID, or null if not set. + */ + public function getTokenId(): ?string + { + $tokenId = $this->payload['jti'] ?? null; + + return \is_string($tokenId) ? $tokenId : null; + } + + /** + * Gets the issue time as a Unix timestamp. + */ + public function getIssueTime(): int + { + $issueTime = $this->payload['iat'] ?? 0; + + return \is_int($issueTime) ? $issueTime : 0; + } + + /** + * Gets the expiration time as a Unix timestamp. + * + * @return int|null The expiration timestamp, or null if the token never expires. + */ + public function getExpirationTime(): ?int + { + $expiration = $this->payload['exp'] ?? null; + + return \is_int($expiration) ? $expiration : null; + } + + /** + * Gets the serialized token string. + */ + public function toString(): string + { + return self::base64UrlEncode(self::encodeJson($this->headers)) + . '.' + . self::base64UrlEncode(self::encodeJson($this->payload)) + . '.' + . $this->signature; + } + + /** + * Gets the serialized token string. + */ + public function __toString(): string + { + return $this->toString(); + } + + /** + * Decodes a base64url-encoded token segment into an associative array. + * + * @return array The decoded segment. + * + * @throws MalformedTokenException If the segment cannot be decoded. + */ + private static function decodeSegment(string $segment): array + { + $decoded = \base64_decode(\strtr($segment, '-_', '+/'), true); + + if ($decoded === false) { + throw new MalformedTokenException('The token is corrupted.'); + } + + try { + $data = \json_decode($decoded, true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new MalformedTokenException('The token is corrupted.', 0, $exception); + } + + if (!\is_array($data)) { + throw new MalformedTokenException('The token is corrupted.'); + } + + $result = []; + + foreach ($data as $key => $value) { + $result[(string) $key] = $value; + } + + return $result; + } + + /** + * Encodes the given data as a compact JSON string. + * + * @param array $data The data to encode. + */ + private static function encodeJson(array $data): string + { + $json = \json_encode($data, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); + + if ($json === false) { + throw new \LogicException('Failed to encode the token: ' . \json_last_error_msg()); + } + + return $json; + } + + /** + * Encodes the given binary data using base64url, without padding. + */ + private static function base64UrlEncode(string $data): string + { + return \rtrim(\strtr(\base64_encode($data), '+/', '-_'), '='); + } +} diff --git a/src/Uuid.php b/src/Uuid.php new file mode 100644 index 0000000..e841e90 --- /dev/null +++ b/src/Uuid.php @@ -0,0 +1,80 @@ +value = $value; + } + + /** + * Generates a random version 4 UUID. + */ + public static function random(): self + { + $bytes = \random_bytes(16); + $bytes[6] = \chr((\ord($bytes[6]) & 0x0F) | 0x40); + $bytes[8] = \chr((\ord($bytes[8]) & 0x3F) | 0x80); + + return new self(\vsprintf('%s%s-%s-%s-%s-%s%s%s', \str_split(\bin2hex($bytes), 4))); + } + + /** + * Parses a UUID from its canonical string form, normalizing it to lowercase. + * + * @throws \InvalidArgumentException If the value is not a valid UUID. + */ + public static function parse(string $value): self + { + if (!self::isValid($value)) { + throw new \InvalidArgumentException(\sprintf('The value "%s" is not a valid UUID.', $value)); + } + + return new self(\strtolower($value)); + } + + /** + * Checks whether the given value is a valid UUID. + */ + public static function isValid(string $value): bool + { + return \preg_match(self::PATTERN, $value) === 1; + } + + /** + * Checks whether this UUID is equal to the given one. + */ + public function equals(self $uuid): bool + { + return $this->value === $uuid->value; + } + + /** + * Gets the canonical lowercase string representation. + */ + public function toString(): string + { + return $this->value; + } + + /** + * Gets the canonical lowercase string representation. + */ + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/VaryingResponseObserver.php b/src/VaryingResponseObserver.php new file mode 100644 index 0000000..5c1163c --- /dev/null +++ b/src/VaryingResponseObserver.php @@ -0,0 +1,88 @@ +plug = $plug; + $this->notify = \Closure::fromCallable($callback); + } + + public function getAppId(): string + { + return $this->plug->getAppId(); + } + + public function getClientId(): string + { + ($this->notify)(); + + return $this->plug->getClientId(); + } + + public function getUserToken(): string + { + ($this->notify)(); + + return $this->plug->getUserToken(); + } + + /** + * @return array + */ + public function getPlugOptions(): array + { + ($this->notify)(); + + return $this->plug->getPlugOptions(); + } + + public function evaluate(string $query, ?EvaluationOptions $options = null): mixed + { + ($this->notify)(); + + return $this->plug->evaluate($query, $options); + } + + public function fetchContent(string $slotId, ?FetchOptions $options = null): FetchResponse + { + if (!($options?->isStatic() ?? false)) { + ($this->notify)(); + } + + return $this->plug->fetchContent($slotId, $options); + } + + public function identify(string $userId): void + { + ($this->notify)(); + + $this->plug->identify($userId); + } + + public function anonymize(): void + { + ($this->notify)(); + + $this->plug->anonymize(); + } +} diff --git a/tests/.gitkeep b/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/ApiKeyTest.php b/tests/ApiKeyTest.php new file mode 100644 index 0000000..fc107d8 --- /dev/null +++ b/tests/ApiKeyTest.php @@ -0,0 +1,184 @@ +getIdentifier()); + self::assertFalse($apiKey->hasPrivateKey()); + } + + #[TestDox('Can carry a private key for signing.')] + public function testParsesKeyWithPrivateKey(): void + { + [$apiKey] = EcKeyFactory::create(); + + self::assertTrue($apiKey->hasPrivateKey()); + self::assertSame('ES256', $apiKey->getSigningAlgorithm()); + self::assertStringStartsWith('ES256;', $apiKey->getPrivateKey()); + } + + /** + * @return array + */ + public static function getTestsForInvalidKeys(): array + { + return [ + 'non-UUID identifier' => [ + 'identifier' => 'not-a-uuid', + 'privateKey' => null, + ], + 'too many segments' => [ + 'identifier' => 'a:b:c', + 'privateKey' => null, + ], + 'malformed private key' => [ + 'identifier' => self::IDENTIFIER, + 'privateKey' => 'no-separator', + ], + 'unsupported algorithm' => [ + 'identifier' => self::IDENTIFIER, + 'privateKey' => 'RS256;key', + ], + ]; + } + + #[DataProvider('getTestsForInvalidKeys')] + #[TestDox('Cannot be created from a malformed value.')] + public function testRejectsInvalidKeys(string $identifier, ?string $privateKey): void + { + $this->expectException(ConfigurationException::class); + + $privateKey === null ? ApiKey::parse($identifier) : ApiKey::of($identifier, $privateKey); + } + + #[TestDox('Derives the key ID from the SHA-256 of the identifier bytes.')] + public function testComputesIdentifierHash(): void + { + $bytes = \hex2bin(\str_replace('-', '', self::IDENTIFIER)); + + self::assertNotFalse($bytes); + self::assertSame(\hash('sha256', $bytes), ApiKey::of(self::IDENTIFIER)->getIdentifierHash()); + } + + #[TestDox('Signs data with a verifiable ES256 signature.')] + public function testSignsWithVerifiableSignature(): void + { + [$apiKey, $publicKey] = EcKeyFactory::create(); + + $signature = $apiKey->sign('header.payload'); + + self::assertSame(64, \strlen($signature)); + self::assertSame( + 1, + \openssl_verify('header.payload', EcKeyFactory::rawToDer($signature), $publicKey, \OPENSSL_ALGO_SHA256), + ); + } + + #[TestDox('Can be cast to a redacted string.')] + public function testRedactsToString(): void + { + [$apiKey] = EcKeyFactory::create(); + + self::assertSame('[redacted]', (string) $apiKey); + } + + #[TestDox('Round-trips through its exported form.')] + public function testRoundTripsThroughExport(): void + { + [$apiKey] = EcKeyFactory::create(); + + self::assertSame($apiKey->export(), ApiKey::parse($apiKey->export())->export()); + } + + #[TestDox('Exports an identifier-only key without a private key.')] + public function testExportsIdentifierOnlyKey(): void + { + self::assertSame(self::IDENTIFIER, ApiKey::of(self::IDENTIFIER)->export()); + } + + #[TestDox('Returns the same instance when created from one.')] + public function testReusesExistingInstance(): void + { + $apiKey = ApiKey::of(self::IDENTIFIER); + + self::assertSame($apiKey, ApiKey::from($apiKey)); + } + + #[TestDox('Parses an instance from its serialized string form.')] + public function testParsesFromSerializedString(): void + { + self::assertSame(self::IDENTIFIER, ApiKey::from(self::IDENTIFIER)->getIdentifier()); + } + + #[TestDox('Rejects reading the signing algorithm without a private key.')] + public function testRejectsAlgorithmWithoutPrivateKey(): void + { + $this->expectException(ConfigurationException::class); + + ApiKey::of(self::IDENTIFIER)->getSigningAlgorithm(); + } + + #[TestDox('Rejects reading the private key without one.')] + public function testRejectsPrivateKeyWithoutPrivateKey(): void + { + $this->expectException(ConfigurationException::class); + + ApiKey::of(self::IDENTIFIER)->getPrivateKey(); + } + + #[TestDox('Rejects signing without a private key.')] + public function testRejectsSigningWithoutPrivateKey(): void + { + $this->expectException(ConfigurationException::class); + + ApiKey::of(self::IDENTIFIER)->sign('header.payload'); + } + + #[TestDox('Rejects signing with an invalid private key.')] + public function testRejectsSigningWithInvalidPrivateKey(): void + { + $this->expectException(ConfigurationException::class); + + ApiKey::of(self::IDENTIFIER, 'ES256;not-a-real-key')->sign('header.payload'); + } + + #[TestDox('Loads the private key once across multiple signatures.')] + public function testCachesLoadedPrivateKey(): void + { + [$apiKey, $publicKey] = EcKeyFactory::create(); + + $first = $apiKey->sign('header.payload'); + $second = $apiKey->sign('header.payload'); + + foreach ([$first, $second] as $signature) { + self::assertSame( + 1, + \openssl_verify( + 'header.payload', + EcKeyFactory::rawToDer($signature), + $publicKey, + \OPENSSL_ALGO_SHA256, + ), + ); + } + } +} diff --git a/tests/Content/ArrayContentProviderTest.php b/tests/Content/ArrayContentProviderTest.php new file mode 100644 index 0000000..0b98f0b --- /dev/null +++ b/tests/Content/ArrayContentProviderTest.php @@ -0,0 +1,29 @@ + ['title' => 'Hello']]); + + self::assertSame(['title' => 'Hello'], $provider->getContent('home-hero')); + } + + #[TestDox('Returns null for an unknown slot ID.')] + public function testReturnsNullForUnknownSlot(): void + { + self::assertNull((new ArrayContentProvider([]))->getContent('missing')); + } +} diff --git a/tests/Content/ExperienceMetadataTest.php b/tests/Content/ExperienceMetadataTest.php new file mode 100644 index 0000000..e882534 --- /dev/null +++ b/tests/Content/ExperienceMetadataTest.php @@ -0,0 +1,85 @@ +getExperienceId()); + self::assertSame('aud-1', $metadata->getAudienceId()); + self::assertSame('e-1', $metadata->getExperiment()?->getExperimentId()); + } + + #[TestDox('Can be created from the response metadata, including the experiment.')] + public function testCreatesFromMetadata(): void + { + $metadata = ExperienceMetadata::fromArray([ + 'experienceId' => 'exp-2', + 'audienceId' => 'aud-2', + 'experiment' => ['experimentId' => 'e-2', 'variantId' => 'v-2'], + ]); + + self::assertSame('exp-2', $metadata->getExperienceId()); + self::assertSame('aud-2', $metadata->getAudienceId()); + + $experiment = $metadata->getExperiment(); + + self::assertSame('e-2', $experiment?->getExperimentId()); + self::assertSame('v-2', $experiment->getVariantId()); + } + + #[TestDox('Can be created without a running experiment.')] + public function testCreatesWithoutExperiment(): void + { + $metadata = ExperienceMetadata::fromArray(['experienceId' => 'exp-2', 'audienceId' => 'aud-2']); + + self::assertSame('exp-2', $metadata->getExperienceId()); + self::assertSame('aud-2', $metadata->getAudienceId()); + self::assertNull($metadata->getExperiment()); + } + + /** + * @return array}> + */ + public static function getTestsForInvalidData(): array + { + return [ + 'missing experience ID' => [ + 'data' => ['audienceId' => 'aud-1'], + ], + 'missing audience ID' => [ + 'data' => ['experienceId' => 'exp-1'], + ], + 'invalid experiment' => [ + 'data' => ['experienceId' => 'exp-1', 'audienceId' => 'aud-1', 'experiment' => 'x'], + ], + ]; + } + + /** + * @param array $data + */ + #[DataProvider('getTestsForInvalidData')] + #[TestDox('Rejects missing or invalid fields.')] + public function testRejectsInvalidData(array $data): void + { + $this->expectException(\InvalidArgumentException::class); + + ExperienceMetadata::fromArray($data); + } +} diff --git a/tests/Content/ExperimentMetadataTest.php b/tests/Content/ExperimentMetadataTest.php new file mode 100644 index 0000000..af60cfa --- /dev/null +++ b/tests/Content/ExperimentMetadataTest.php @@ -0,0 +1,64 @@ +getExperimentId()); + self::assertSame('v-1', $metadata->getVariantId()); + } + + #[TestDox('Can be created from the response metadata.')] + public function testCreatesFromMetadata(): void + { + $metadata = ExperimentMetadata::fromArray(['experimentId' => 'e-2', 'variantId' => 'v-2']); + + self::assertSame('e-2', $metadata->getExperimentId()); + self::assertSame('v-2', $metadata->getVariantId()); + } + + /** + * @return array}> + */ + public static function getTestsForInvalidData(): array + { + return [ + 'missing experiment ID' => [ + 'data' => ['variantId' => 'v-1'], + ], + 'non-string experiment ID' => [ + 'data' => ['experimentId' => 42, 'variantId' => 'v-1'], + ], + 'missing variant ID' => [ + 'data' => ['experimentId' => 'e-1'], + ], + ]; + } + + /** + * @param array $data + */ + #[DataProvider('getTestsForInvalidData')] + #[TestDox('Rejects missing or invalid fields.')] + public function testRejectsInvalidData(array $data): void + { + $this->expectException(\InvalidArgumentException::class); + + ExperimentMetadata::fromArray($data); + } +} diff --git a/tests/Content/NullContentProviderTest.php b/tests/Content/NullContentProviderTest.php new file mode 100644 index 0000000..863dafa --- /dev/null +++ b/tests/Content/NullContentProviderTest.php @@ -0,0 +1,21 @@ +getContent('home-hero')); + } +} diff --git a/tests/Content/SlotMetadataTest.php b/tests/Content/SlotMetadataTest.php new file mode 100644 index 0000000..3cac838 --- /dev/null +++ b/tests/Content/SlotMetadataTest.php @@ -0,0 +1,93 @@ + '3', + 'contentSource' => 'experiment', + 'schema' => ['type' => 'structure'], + 'experience' => [ + 'experienceId' => 'exp-1', + 'audienceId' => 'aud-1', + 'experiment' => ['experimentId' => 'e-1', 'variantId' => 'v-1'], + ], + ]); + + self::assertSame('3', $metadata->getVersion()); + self::assertSame(ContentSource::EXPERIMENT, $metadata->getContentSource()); + self::assertSame(['type' => 'structure'], $metadata->getSchema()); + + $experience = $metadata->getExperience(); + + self::assertSame('exp-1', $experience?->getExperienceId()); + self::assertSame('aud-1', $experience->getAudienceId()); + + $experiment = $experience->getExperiment(); + + self::assertSame('e-1', $experiment?->getExperimentId()); + self::assertSame('v-1', $experiment->getVariantId()); + } + + #[TestDox('Defaults to null when the optional fields are absent.')] + public function testCreatesFromMinimalMetadata(): void + { + $metadata = SlotMetadata::fromArray([]); + + self::assertNull($metadata->getVersion()); + self::assertNull($metadata->getContentSource()); + self::assertNull($metadata->getSchema()); + self::assertNull($metadata->getExperience()); + } + + /** + * @return array}> + */ + public static function getTestsForInvalidData(): array + { + return [ + 'invalid version' => [ + 'data' => ['version' => 3], + ], + 'invalid content source type' => [ + 'data' => ['contentSource' => 3], + ], + 'unknown content source' => [ + 'data' => ['contentSource' => 'unknown'], + ], + 'invalid experience' => [ + 'data' => ['experience' => 'x'], + ], + 'invalid schema' => [ + 'data' => ['schema' => 'x'], + ], + ]; + } + + /** + * @param array $data + */ + #[DataProvider('getTestsForInvalidData')] + #[TestDox('Rejects fields that are present but invalid.')] + public function testRejectsInvalidData(array $data): void + { + $this->expectException(\InvalidArgumentException::class); + + SlotMetadata::fromArray($data); + } +} diff --git a/tests/CookieConfigurationTest.php b/tests/CookieConfigurationTest.php new file mode 100644 index 0000000..09ba03b --- /dev/null +++ b/tests/CookieConfigurationTest.php @@ -0,0 +1,85 @@ +getClientIdName()); + self::assertSame('ct.user_token', $configuration->getUserTokenName()); + self::assertSame(31536000, $configuration->getClientIdDuration()); + self::assertSame(604800, $configuration->getUserTokenDuration()); + self::assertNull($configuration->getDomain()); + self::assertTrue($configuration->isSecure()); + self::assertSame('None', $configuration->getSameSite()); + } + + #[TestDox('Exposes the configured values.')] + public function testExposesConfiguredValues(): void + { + $configuration = new CookieConfiguration( + clientIdName: 'cid', + userTokenName: 'tok', + clientIdDuration: 10, + userTokenDuration: 20, + domain: 'example.com', + secure: false, + sameSite: 'Lax', + ); + + self::assertSame('cid', $configuration->getClientIdName()); + self::assertSame('tok', $configuration->getUserTokenName()); + self::assertSame(10, $configuration->getClientIdDuration()); + self::assertSame(20, $configuration->getUserTokenDuration()); + self::assertSame('example.com', $configuration->getDomain()); + self::assertFalse($configuration->isSecure()); + self::assertSame('Lax', $configuration->getSameSite()); + } + + #[TestDox('Builds the browser cookie settings, lower-casing the SameSite policy.')] + public function testConvertsToBrowserCookies(): void + { + self::assertSame( + [ + 'clientId' => [ + 'name' => 'ct.client_id', + 'maxAge' => 31536000, + 'path' => '/', + 'secure' => true, + 'sameSite' => 'none', + ], + 'userToken' => [ + 'name' => 'ct.user_token', + 'maxAge' => 604800, + 'path' => '/', + 'secure' => true, + 'sameSite' => 'none', + ], + ], + (new CookieConfiguration())->toBrowserCookies(), + ); + } + + #[TestDox('Includes the domain in the browser cookie settings when configured.')] + public function testIncludesDomainInBrowserCookies(): void + { + $cookies = (new CookieConfiguration(domain: 'example.com', sameSite: 'Lax'))->toBrowserCookies(); + + self::assertSame('example.com', $cookies['clientId']['domain']); + self::assertSame('example.com', $cookies['userToken']['domain']); + self::assertSame('lax', $cookies['clientId']['sameSite']); + } +} diff --git a/tests/CookieStorageTest.php b/tests/CookieStorageTest.php new file mode 100644 index 0000000..52e8096 --- /dev/null +++ b/tests/CookieStorageTest.php @@ -0,0 +1,178 @@ +getClientId()); + self::assertSame($token, $storage->getUserToken()); + } + + #[TestDox('Exposes the cookie configuration it was given.')] + public function testExposesConfiguration(): void + { + $configuration = new CookieConfiguration(); + + $storage = new CookieStorage(configuration: $configuration); + + self::assertSame($configuration, $storage->getConfiguration()); + } + + #[TestDox('Reads and parses the client ID and user token from a cookie map.')] + public function testReadsFromCookies(): void + { + $token = Token::issue(appId: self::APP_ID, subject: 'user-1', now: 1000); + + $storage = CookieStorage::fromArray([ + 'ct.client_id' => self::CLIENT_ID, + 'ct.user_token' => $token->toString(), + ]); + + self::assertSame(self::CLIENT_ID, $storage->getClientId()?->toString()); + self::assertSame($token->toString(), $storage->getUserToken()?->toString()); + } + + #[TestDox('Ignores an unparseable client ID or user token.')] + public function testIgnoresUnparseableValues(): void + { + $storage = CookieStorage::fromArray([ + 'ct.client_id' => 'not-a-uuid', + 'ct.user_token' => 'garbage', + ]); + + self::assertNull($storage->getClientId()); + self::assertNull($storage->getUserToken()); + } + + #[TestDox('Ignores absent cookies.')] + public function testIgnoresAbsentCookies(): void + { + $storage = CookieStorage::fromArray([]); + + self::assertNull($storage->getClientId()); + self::assertNull($storage->getUserToken()); + } + + #[TestDox('Exposes the saved values as response cookies.')] + public function testSavesAndExposesCookies(): void + { + $clientId = Uuid::random(); + $token = Token::issue(appId: self::APP_ID, subject: 'user-1', now: 1000); + + $storage = new CookieStorage(now: 1000); + $storage->saveClientId($clientId); + $storage->saveUserToken($token); + + self::assertSame($clientId, $storage->getClientId()); + self::assertSame($token, $storage->getUserToken()); + + [$clientIdCookie, $userTokenCookie] = $storage->getResponseCookies(); + + self::assertSame('ct.client_id', $clientIdCookie->getName()); + self::assertSame($clientId->toString(), $clientIdCookie->getValue()); + self::assertSame('ct.user_token', $userTokenCookie->getName()); + self::assertSame($token->toString(), $userTokenCookie->getValue()); + } + + #[TestDox('Emits the response cookies through the given emitter.')] + public function testEmitsResponseCookies(): void + { + $clientId = Uuid::random(); + $token = Token::issue(appId: self::APP_ID, subject: 'user-1', now: 1000); + + $configuration = new CookieConfiguration( + clientIdDuration: 100, + userTokenDuration: 50, + domain: 'example.com', + secure: true, + sameSite: 'Lax', + ); + + $storage = new CookieStorage(configuration: $configuration, now: 1000); + $storage->saveClientId($clientId); + $storage->saveUserToken($token); + + $calls = []; + $storage->emit(static function (string $name, string $value, array $options) use (&$calls): bool { + $calls[] = ['name' => $name, 'value' => $value, 'options' => $options]; + + return true; + }); + + self::assertSame( + [ + [ + 'name' => 'ct.client_id', + 'value' => $clientId->toString(), + 'options' => [ + 'expires' => 1100, + 'path' => '/', + 'domain' => 'example.com', + 'secure' => true, + 'httponly' => false, + 'samesite' => 'Lax', + ], + ], + [ + 'name' => 'ct.user_token', + 'value' => $token->toString(), + 'options' => [ + 'expires' => 1050, + 'path' => '/', + 'domain' => 'example.com', + 'secure' => true, + 'httponly' => false, + 'samesite' => 'Lax', + ], + ], + ], + $calls, + ); + } + + #[TestDox('Reads the cookies from the request superglobals.')] + public function testReadsFromGlobals(): void + { + $_COOKIE = ['ct.client_id' => self::CLIENT_ID]; + + $storage = CookieStorage::fromGlobals(); + + self::assertSame(self::CLIENT_ID, $storage->getClientId()?->toString()); + } + + #[TestDox('Reads the cookies from a server request.')] + public function testReadsFromServerRequest(): void + { + $request = (new Psr17Factory())->createServerRequest('GET', 'https://example.com/') + ->withCookieParams(['ct.client_id' => self::CLIENT_ID]); + + $storage = CookieStorage::fromServerRequest($request); + + self::assertSame(self::CLIENT_ID, $storage->getClientId()?->toString()); + } +} diff --git a/tests/CookieTest.php b/tests/CookieTest.php new file mode 100644 index 0000000..40bde69 --- /dev/null +++ b/tests/CookieTest.php @@ -0,0 +1,94 @@ +getName()); + self::assertSame('value', $cookie->getValue()); + self::assertSame(5, $cookie->getExpiration()); + self::assertSame('/path', $cookie->getPath()); + self::assertSame('domain', $cookie->getDomain()); + self::assertFalse($cookie->isSecure()); + self::assertTrue($cookie->isHttpOnly()); + self::assertSame('Lax', $cookie->getSameSite()); + } + + #[TestDox('Serializes a minimal HTTP-only cookie without optional attributes.')] + public function testSerializesMinimalHttpOnlyCookie(): void + { + $header = (new Cookie( + name: 'ct.x', + value: 'v', + expiration: null, + path: '/', + domain: null, + secure: false, + httpOnly: true, + sameSite: null, + ))->toSetCookieHeader(); + + self::assertStringContainsString('HttpOnly', $header); + self::assertStringNotContainsString('Domain', $header); + self::assertStringNotContainsString('Secure', $header); + self::assertStringNotContainsString('SameSite', $header); + } + + #[TestDox('Serializes to a Set-Cookie header.')] + public function testSerializesToSetCookieHeader(): void + { + $cookie = new Cookie( + name: 'ct.user_token', + value: 'abc.def', + expiration: 1000, + path: '/', + domain: 'example.com', + secure: true, + httpOnly: false, + sameSite: 'None', + ); + + $header = $cookie->toSetCookieHeader(900); + + self::assertStringContainsString('ct.user_token=abc.def', $header); + self::assertStringContainsString('Expires=Thu, 01 Jan 1970 00:16:40 GMT', $header); + self::assertStringContainsString('Max-Age=100', $header); + self::assertStringContainsString('Domain=example.com', $header); + self::assertStringContainsString('Path=/', $header); + self::assertStringContainsString('Secure', $header); + self::assertStringContainsString('SameSite=None', $header); + self::assertStringNotContainsString('HttpOnly', $header); + } + + #[TestDox('Has no expiry when it is a session cookie.')] + public function testSessionCookieHasNoExpiry(): void + { + $header = (new Cookie(name: 'ct.preview_token', value: 'value', expiration: null))->toSetCookieHeader(); + + self::assertStringNotContainsString('Max-Age', $header); + self::assertStringNotContainsString('Expires', $header); + } +} diff --git a/tests/CroctTest.php b/tests/CroctTest.php new file mode 100644 index 0000000..7e89bcb --- /dev/null +++ b/tests/CroctTest.php @@ -0,0 +1,250 @@ +addResponse( + $factory->createResponse(200) + ->withBody($factory->createStream((string) \json_encode(true))), + ); + + $croct = $this->createCroct($mock, new InMemoryIdentityStore(Uuid::parse(self::CLIENT_ID))); + + self::assertTrue($croct->evaluate('user is returning')); + + $request = $mock->getLastRequest(); + + self::assertInstanceOf(RequestInterface::class, $request); + self::assertSame(self::CLIENT_ID, $request->getHeaderLine('X-Client-Id')); + self::assertSame($croct->getUserToken(), $request->getHeaderLine('X-Token')); + } + + #[TestDox('Persists the resolved session to the storage.')] + public function testPersistsResolvedSession(): void + { + $storage = new InMemoryIdentityStore(Uuid::parse(self::CLIENT_ID)); + + $croct = $this->createCroct(new MockClient(), $storage); + + self::assertSame($croct->getClientId(), $storage->getClientId()?->toString()); + self::assertSame($croct->getUserToken(), $storage->getUserToken()?->toString()); + + $croct->identify('user-77'); + + self::assertSame($croct->getUserToken(), $storage->getUserToken()->toString()); + } + + #[TestDox('Reflects identification and anonymization in the user token.')] + public function testIdentifyChangesUserToken(): void + { + $croct = $this->createCroct(new MockClient()); + + $croct->identify('user-77'); + + self::assertSame('user-77', Token::parse($croct->getUserToken())->getSubject()); + + $croct->anonymize(); + + self::assertTrue(Token::parse($croct->getUserToken())->isAnonymous()); + } + + #[TestDox('Exposes the application ID, client ID, and user token.')] + public function testExposesIdentityValues(): void + { + $croct = $this->createCroct(new MockClient(), new InMemoryIdentityStore(Uuid::parse(self::CLIENT_ID))); + + self::assertSame(self::APP_ID, $croct->getAppId()); + self::assertSame(self::CLIENT_ID, $croct->getClientId()); + self::assertNotSame('', $croct->getUserToken()); + } + + #[TestDox('Exposes the browser plug options, including the cookie settings.')] + public function testExposesPlugOptions(): void + { + $storage = new CookieStorage( + clientId: Uuid::parse(self::CLIENT_ID), + configuration: new CookieConfiguration( + clientIdName: 'cid', + userTokenName: 'tok', + domain: 'example.com', + ), + ); + + $croct = $this->createCroct(new MockClient(), $storage); + + self::assertSame( + [ + 'appId' => self::APP_ID, + 'clientId' => self::CLIENT_ID, + 'token' => $croct->getUserToken(), + 'disableCidMirroring' => true, + 'cookie' => [ + 'clientId' => [ + 'name' => 'cid', + 'maxAge' => 31536000, + 'path' => '/', + 'secure' => true, + 'sameSite' => 'none', + 'domain' => 'example.com', + ], + 'userToken' => [ + 'name' => 'tok', + 'maxAge' => 604800, + 'path' => '/', + 'secure' => true, + 'sameSite' => 'none', + 'domain' => 'example.com', + ], + ], + ], + $croct->getPlugOptions(), + ); + } + + #[TestDox('Fetches slot content with the resolved session.')] + public function testFetchContentUsesResolvedSession(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse( + $factory->createResponse(200)->withBody( + $factory->createStream((string) \json_encode(['content' => ['title' => 'Hello']])), + ), + ); + + $croct = $this->createCroct($mock, new InMemoryIdentityStore(Uuid::parse(self::CLIENT_ID))); + + $response = $croct->fetchContent('home-hero'); + + self::assertSame(['title' => 'Hello'], $response->getContent()); + + $request = $mock->getLastRequest(); + + self::assertInstanceOf(RequestInterface::class, $request); + self::assertSame(self::CLIENT_ID, $request->getHeaderLine('X-Client-Id')); + self::assertSame($croct->getUserToken(), $request->getHeaderLine('X-Token')); + } + + #[TestDox('Can be built from the environment variables.')] + public function testCreatesFromEnvironment(): void + { + \putenv('CROCT_APP_ID=' . self::APP_ID); + \putenv('CROCT_API_KEY=' . EcKeyFactory::IDENTIFIER); + + $croct = Croct::fromEnvironment(new InMemoryIdentityStore(Uuid::parse(self::CLIENT_ID))); + + self::assertSame(self::APP_ID, $croct->getAppId()); + self::assertSame(self::CLIENT_ID, $croct->getClientId()); + + $token = Token::parse($croct->getUserToken()); + + self::assertSame(self::APP_ID, $token->getApplicationId()); + self::assertTrue($token->isAnonymous()); + } + + #[TestDox('Rejects building from the environment when required variables are missing.')] + public function testRejectsMissingEnvironment(): void + { + \putenv('CROCT_APP_ID'); + + $this->expectException(ConfigurationException::class); + + Croct::fromEnvironment(new InMemoryIdentityStore()); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + #[TestDox('Falls back to the discovered content provider when one is installed.')] + public function testUsesDiscoveredContentProvider(): void + { + \class_alias(InstalledContentProvider::class, 'Croct\\Content\\GeneratedContentProvider'); + + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse($factory->createResponse(500)); + + $storage = new InMemoryIdentityStore(Uuid::parse(self::CLIENT_ID)); + + $croct = Croct::plug( + appId: self::APP_ID, + apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), + storage: $storage, + context: new RequestContext(), + httpClient: $mock, + requestFactory: $factory, + streamFactory: $factory, + ); + + self::assertSame(['title' => 'Generated default'], $croct->fetchContent('home-hero')->getContent()); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + #[TestDox('Reports a missing transport when no HTTP client can be discovered.')] + public function testReportsMissingTransport(): void + { + Psr18ClientDiscovery::setStrategies([]); + + $this->expectException(ConfigurationException::class); + + $storage = new InMemoryIdentityStore(); + + Croct::plug( + appId: self::APP_ID, + apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), + storage: $storage, + context: new RequestContext(), + ); + } + + private function createCroct(MockClient $client, ?IdentityStore $storage = null): Croct + { + $factory = new Psr17Factory(); + $storage ??= new InMemoryIdentityStore(); + + return Croct::plug( + appId: self::APP_ID, + apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), + storage: $storage, + context: new RequestContext(), + httpClient: $client, + requestFactory: $factory, + streamFactory: $factory, + ); + } +} diff --git a/tests/EcKeyFactory.php b/tests/EcKeyFactory.php new file mode 100644 index 0000000..d4bb5f4 --- /dev/null +++ b/tests/EcKeyFactory.php @@ -0,0 +1,80 @@ + \OPENSSL_KEYTYPE_EC, + 'curve_name' => 'prime256v1', + ]); + + if ($pair === false) { + throw new \RuntimeException('Failed to generate an EC key pair.'); + } + + $result = ''; + \openssl_pkey_export($pair, $result); + + \assert(\is_string($result), 'openssl_pkey_export returns the PEM-encoded key as a string.'); + + $pkcs8 = \preg_replace('/-----[^-]+-----|\s+/', '', $result); + + $details = \openssl_pkey_get_details($pair); + + if (!\is_string($pkcs8) || $details === false || !\is_string($details['key'] ?? null)) { + throw new \RuntimeException('Failed to export the EC key pair.'); + } + + return [ApiKey::of($identifier, 'ES256;' . $pkcs8), $details['key']]; + } + + /** + * Re-encodes a raw R||S ECDSA signature as DER, so OpenSSL can verify it. + */ + public static function rawToDer(string $signature): string + { + $component = static function (string $value): string { + $value = \ltrim($value, "\x00"); + + if ($value === '' || (\ord($value[0]) & 0x80) !== 0) { + $value = "\x00" . $value; + } + + $length = \strlen($value); + + \assert($length < 128, 'A P-256 signature component fits short-form DER.'); + + return "\x02" . \chr($length) . $value; + }; + + $body = $component(\substr($signature, 0, 32)) . $component(\substr($signature, 32)); + $bodyLength = \strlen($body); + + \assert($bodyLength < 128, 'The DER SEQUENCE body fits short-form DER.'); + + return "\x30" . \chr($bodyLength) . $body; + } +} diff --git a/tests/EvaluationOptionsTest.php b/tests/EvaluationOptionsTest.php new file mode 100644 index 0000000..2d1b823 --- /dev/null +++ b/tests/EvaluationOptionsTest.php @@ -0,0 +1,63 @@ +getAttributes()); + self::assertFalse($options->hasFallback()); + } + + #[TestDox('Carry a fallback distinct from an unset one, even when null.')] + public function testCarriesFallback(): void + { + $options = EvaluationOptions::empty()->withFallback(null); + + self::assertTrue($options->hasFallback()); + self::assertNull($options->getFallback()); + } + + #[TestDox('Add attributes one at a time.')] + public function testAddsAttributes(): void + { + $options = EvaluationOptions::empty() + ->withAttribute('plan', 'pro') + ->withAttribute('seats', 5); + + self::assertSame(['plan' => 'pro', 'seats' => 5], $options->getAttributes()); + } + + #[TestDox('Replace all attributes when set as a whole.')] + public function testReplacesAttributes(): void + { + $options = EvaluationOptions::empty() + ->withAttribute('plan', 'pro') + ->withAttributes(['seats' => 5]); + + self::assertSame(['seats' => 5], $options->getAttributes()); + } + + #[TestDox('Do not mutate the original instance.')] + public function testWithMethodsAreImmutable(): void + { + $options = EvaluationOptions::empty(); + + $options->withAttribute('plan', 'pro'); + + self::assertSame([], $options->getAttributes()); + } +} diff --git a/tests/Exception/ApiExceptionTest.php b/tests/Exception/ApiExceptionTest.php new file mode 100644 index 0000000..df12231 --- /dev/null +++ b/tests/Exception/ApiExceptionTest.php @@ -0,0 +1,45 @@ + 'Invalid query']); + + self::assertSame('Invalid query', $exception->getMessage()); + self::assertSame(400, $exception->getStatusCode()); + } + + #[TestDox('Falls back to a generic message without a title.')] + public function testForStatusWithoutTitle(): void + { + $exception = ApiException::fromProblem(500, null); + + self::assertStringContainsString('500', $exception->getMessage()); + self::assertSame(500, $exception->getStatusCode()); + } + + #[TestDox('Wraps the cause of a transport error.')] + public function testForTransportError(): void + { + $previous = new \RuntimeException('boom'); + + $exception = ApiException::fromReason('Failed to communicate.', $previous); + + self::assertSame('Failed to communicate.', $exception->getMessage()); + self::assertNull($exception->getStatusCode()); + self::assertSame($previous, $exception->getPrevious()); + } +} diff --git a/tests/FetchOptionsTest.php b/tests/FetchOptionsTest.php new file mode 100644 index 0000000..bc190dd --- /dev/null +++ b/tests/FetchOptionsTest.php @@ -0,0 +1,82 @@ +getPreferredLocale()); + self::assertNull($options->getVersion()); + self::assertFalse($options->isStatic()); + self::assertFalse($options->includesSchema()); + self::assertSame([], $options->getAttributes()); + self::assertFalse($options->hasFallback()); + } + + #[TestDox('Build up immutably through the fluent API.')] + public function testBuildsOptionsFluently(): void + { + $options = FetchOptions::empty() + ->withPreferredLocale('en-us') + ->withVersion(2) + ->withStatic() + ->withSchema() + ->withAttribute('plan', 'pro') + ->withFallback(['headline' => 'Welcome']); + + self::assertSame('en-us', $options->getPreferredLocale()); + self::assertSame(2, $options->getVersion()); + self::assertTrue($options->isStatic()); + self::assertTrue($options->includesSchema()); + self::assertSame(['plan' => 'pro'], $options->getAttributes()); + self::assertTrue($options->hasFallback()); + self::assertSame(['headline' => 'Welcome'], $options->getFallback()); + } + + #[TestDox('Distinguish a null fallback from no fallback.')] + public function testDistinguishesNullFallback(): void + { + self::assertFalse(FetchOptions::empty()->hasFallback()); + + $options = FetchOptions::empty()->withFallback(null); + + self::assertTrue($options->hasFallback()); + self::assertNull($options->getFallback()); + } + + #[TestDox('Do not mutate the original instance.')] + public function testWithMethodsAreImmutable(): void + { + $options = FetchOptions::empty(); + + $options->withPreferredLocale('en-us')->withVersion(3)->withStatic()->withSchema(); + + self::assertNull($options->getPreferredLocale()); + self::assertNull($options->getVersion()); + self::assertFalse($options->isStatic()); + self::assertFalse($options->includesSchema()); + } + + #[TestDox('Replace all attributes when set as a whole.')] + public function testReplacesAttributes(): void + { + $options = FetchOptions::empty() + ->withAttribute('a', 1) + ->withAttributes(['b' => 2]); + + self::assertSame(['b' => 2], $options->getAttributes()); + } +} diff --git a/tests/FetchResponseTest.php b/tests/FetchResponseTest.php new file mode 100644 index 0000000..f8cef3f --- /dev/null +++ b/tests/FetchResponseTest.php @@ -0,0 +1,64 @@ + 'Hello'], new SlotMetadata('1')); + + self::assertSame(['title' => 'Hello'], $response->getContent()); + self::assertSame('1', $response->getMetadata()?->getVersion()); + } + + #[TestDox('Defaults to no metadata for a bare content value.')] + public function testDefaultsToNoMetadata(): void + { + $response = new FetchResponse('fallback'); + + self::assertSame('fallback', $response->getContent()); + self::assertNull($response->getMetadata()); + } + + #[TestDox('Can be built from the decoded response payload.')] + public function testBuildsFromResponse(): void + { + $response = FetchResponse::fromResponse([ + 'content' => ['title' => 'Hello'], + 'metadata' => ['version' => '2'], + ]); + + self::assertSame(['title' => 'Hello'], $response->getContent()); + self::assertSame('2', $response->getMetadata()?->getVersion()); + } + + #[TestDox('Falls back to empty content for a non-array payload.')] + public function testHandlesNonArrayPayload(): void + { + $response = FetchResponse::fromResponse('unexpected'); + + self::assertSame([], $response->getContent()); + self::assertNull($response->getMetadata()); + } + + #[TestDox('Ignores content and metadata of the wrong type.')] + public function testIgnoresWrongTypes(): void + { + $response = FetchResponse::fromResponse(['content' => 'not-an-array', 'metadata' => 'not-an-array']); + + self::assertSame([], $response->getContent()); + self::assertNull($response->getMetadata()); + } +} diff --git a/tests/Fixtures/InstalledContentProvider.php b/tests/Fixtures/InstalledContentProvider.php new file mode 100644 index 0000000..9763a44 --- /dev/null +++ b/tests/Fixtures/InstalledContentProvider.php @@ -0,0 +1,18 @@ + ['title' => 'Generated default']]); + } +} diff --git a/tests/HttpContentFetcherTest.php b/tests/HttpContentFetcherTest.php new file mode 100644 index 0000000..b75e98a --- /dev/null +++ b/tests/HttpContentFetcherTest.php @@ -0,0 +1,234 @@ +addResponse( + $factory->createResponse(200)->withBody( + $factory->createStream( + (string) \json_encode([ + 'content' => ['title' => 'Hello'], + 'metadata' => [ + 'version' => '2', + 'contentSource' => 'experiment', + 'experience' => [ + 'experienceId' => 'exp-1', + 'audienceId' => 'aud-1', + 'experiment' => ['experimentId' => 'e-1', 'variantId' => 'v-1'], + ], + ], + ]), + ), + ), + ); + + $fetcher = $this->createFetcher($mock, $factory, new RequestContext(url: 'https://example.com/')); + + $response = $fetcher->fetch( + 'home-hero', + FetchOptions::empty()->withPreferredLocale('en-us')->withVersion(2), + ); + + self::assertSame(['title' => 'Hello'], $response->getContent()); + + $metadata = $response->getMetadata(); + + self::assertSame('2', $metadata?->getVersion()); + self::assertSame(ContentSource::EXPERIMENT, $metadata->getContentSource()); + + $experience = $metadata->getExperience(); + + self::assertSame('exp-1', $experience?->getExperienceId()); + self::assertSame('v-1', $experience->getExperiment()?->getVariantId()); + + $request = $mock->getLastRequest(); + + self::assertInstanceOf(RequestInterface::class, $request); + self::assertSame('https://api.croct.io/external/web/content', (string) $request->getUri()); + self::assertSame( + [ + 'slotId' => 'home-hero', + 'version' => '2', + 'preferredLocale' => 'en-us', + 'context' => ['page' => ['url' => 'https://example.com/']], + ], + \json_decode((string) $request->getBody(), true), + ); + } + + #[TestDox('Includes and exposes the content schema when requested.')] + public function testIncludesSchemaWhenRequested(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse( + $factory->createResponse(200)->withBody( + $factory->createStream( + (string) \json_encode([ + 'content' => ['title' => 'Hello'], + 'metadata' => ['version' => '1', 'schema' => ['type' => 'structure']], + ]), + ), + ), + ); + + $fetcher = $this->createFetcher($mock, $factory); + + $response = $fetcher->fetch('home-hero', FetchOptions::empty()->withSchema()); + + self::assertSame(['type' => 'structure'], $response->getMetadata()?->getSchema()); + + $request = $mock->getLastRequest(); + + self::assertInstanceOf(RequestInterface::class, $request); + self::assertSame( + ['slotId' => 'home-hero', 'includeSchema' => true], + \json_decode((string) $request->getBody(), true), + ); + } + + #[TestDox('Uses the static-content endpoint for static fetches.')] + public function testFetchesStaticContent(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse( + $factory->createResponse(200)->withBody($factory->createStream((string) \json_encode(['content' => []]))), + ); + + $this->createFetcher($mock, $factory) + ->fetch('home-hero', FetchOptions::empty()->withStatic()); + + $request = $mock->getLastRequest(); + + self::assertInstanceOf(RequestInterface::class, $request); + self::assertSame('https://api.croct.io/external/web/static-content', (string) $request->getUri()); + } + + #[TestDox('Forwards the preview token from the request context.')] + public function testForwardsPreviewToken(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse( + $factory->createResponse(200)->withBody($factory->createStream((string) \json_encode(['content' => []]))), + ); + + $context = new RequestContext(previewToken: 'preview-token'); + + $this->createFetcher($mock, $factory, $context)->fetch('home-hero'); + + $request = $mock->getLastRequest(); + + self::assertInstanceOf(RequestInterface::class, $request); + self::assertSame( + ['slotId' => 'home-hero', 'previewToken' => 'preview-token'], + \json_decode((string) $request->getBody(), true), + ); + } + + #[TestDox('Returns the fallback content when the fetch fails.')] + public function testReturnsFallbackOnFailure(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse($factory->createResponse(500)); + + $response = $this->createFetcher($mock, $factory) + ->fetch('home-hero', FetchOptions::empty()->withFallback(['title' => 'Default'])); + + self::assertSame(['title' => 'Default'], $response->getContent()); + } + + #[TestDox('Throws a content exception when the fetch fails without a fallback.')] + public function testThrowsWithoutFallback(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse( + $factory->createResponse(500)->withBody($factory->createStream((string) \json_encode(['title' => 'Boom']))), + ); + + $this->expectException(ContentException::class); + $this->expectExceptionMessage('Boom'); + + $this->createFetcher($mock, $factory)->fetch('home-hero'); + } + + #[TestDox('Falls back to the content provider when the fetch fails.')] + public function testFallsBackToContentProvider(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse($factory->createResponse(500)); + + $provider = new ArrayContentProvider(['home-hero' => ['title' => 'Generated']]); + + $response = $this->createFetcher($mock, $factory, contentProvider: $provider)->fetch('home-hero'); + + self::assertSame(['title' => 'Generated'], $response->getContent()); + } + + #[TestDox('Prefers an explicit fallback over the content provider.')] + public function testExplicitFallbackWinsOverProvider(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse($factory->createResponse(500)); + + $provider = new ArrayContentProvider(['home-hero' => ['title' => 'Generated']]); + + $response = $this->createFetcher($mock, $factory, contentProvider: $provider) + ->fetch('home-hero', FetchOptions::empty()->withFallback(['title' => 'Explicit'])); + + self::assertSame(['title' => 'Explicit'], $response->getContent()); + } + + private function createFetcher( + MockClient $client, + Psr17Factory $factory, + ?RequestContext $context = null, + ?ContentProvider $contentProvider = null, + ): HttpContentFetcher { + $context ??= new RequestContext(); + + return new HttpContentFetcher( + new PsrApiClient( + httpClient: $client, + requestFactory: $factory, + streamFactory: $factory, + apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), + logger: null, + baseEndpointUrl: 'https://api.croct.io', + ), + $context, + $contentProvider, + ); + } +} diff --git a/tests/HttpEvaluatorTest.php b/tests/HttpEvaluatorTest.php new file mode 100644 index 0000000..08de917 --- /dev/null +++ b/tests/HttpEvaluatorTest.php @@ -0,0 +1,146 @@ +addResponse( + $factory->createResponse(200)->withBody($factory->createStream((string) \json_encode(true))), + ); + + $evaluator = new HttpEvaluator( + new PsrApiClient( + httpClient: $mock, + requestFactory: $factory, + streamFactory: $factory, + apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), + logger: null, + baseEndpointUrl: 'https://api.croct.io', + ), + new RequestContext(url: 'https://example.com/y'), + ); + + $result = $evaluator->evaluate( + 'user is returning', + EvaluationOptions::empty()->withAttribute('plan', 'pro'), + ); + + self::assertTrue($result); + + $request = $mock->getLastRequest(); + + self::assertInstanceOf(RequestInterface::class, $request); + self::assertSame('https://api.croct.io/external/web/evaluate', (string) $request->getUri()); + self::assertSame( + [ + 'query' => 'user is returning', + 'context' => [ + 'page' => ['url' => 'https://example.com/y'], + 'attributes' => ['plan' => 'pro'], + ], + ], + \json_decode((string) $request->getBody(), true), + ); + } + + #[TestDox('Maps an error response to an evaluation exception.')] + public function testMapsErrorResponseToException(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse( + $factory->createResponse(400) + ->withBody($factory->createStream((string) \json_encode(['title' => 'Invalid query']))), + ); + + $evaluator = new HttpEvaluator( + new PsrApiClient( + httpClient: $mock, + requestFactory: $factory, + streamFactory: $factory, + apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), + logger: null, + baseEndpointUrl: 'https://api.croct.io', + ), + new RequestContext(), + ); + + $this->expectException(EvaluationException::class); + $this->expectExceptionMessage('Invalid query'); + + $evaluator->evaluate('???'); + } + + #[TestDox('Maps a transport error to an evaluation exception.')] + public function testMapsTransportErrorToException(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addException( + new NetworkException('Connection failed', $factory->createRequest('POST', 'https://api.croct.io')), + ); + + $evaluator = new HttpEvaluator( + new PsrApiClient( + httpClient: $mock, + requestFactory: $factory, + streamFactory: $factory, + apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), + logger: null, + baseEndpointUrl: 'https://api.croct.io', + ), + new RequestContext(), + ); + + $this->expectException(EvaluationException::class); + + $evaluator->evaluate('user is returning'); + } + + #[TestDox('Returns the fallback result when the evaluation fails.')] + public function testReturnsFallbackOnFailure(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse($factory->createResponse(422)); + + $evaluator = new HttpEvaluator( + new PsrApiClient( + httpClient: $mock, + requestFactory: $factory, + streamFactory: $factory, + apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), + logger: null, + baseEndpointUrl: 'https://api.croct.io', + ), + new RequestContext(), + ); + + $result = $evaluator->evaluate('???', EvaluationOptions::empty()->withFallback(false)); + + self::assertFalse($result); + } +} diff --git a/tests/InMemoryIdentityStoreTest.php b/tests/InMemoryIdentityStoreTest.php new file mode 100644 index 0000000..451a434 --- /dev/null +++ b/tests/InMemoryIdentityStoreTest.php @@ -0,0 +1,54 @@ +getClientId()); + self::assertNull($store->getUserToken()); + } + + #[TestDox('Returns the client ID and user token it holds.')] + public function testReturnsHeldValues(): void + { + $clientId = Uuid::random(); + $token = Token::issue(appId: self::APP_ID, subject: 'user-1', now: 1000); + + $store = new InMemoryIdentityStore($clientId, $token); + + self::assertSame($clientId, $store->getClientId()); + self::assertSame($token, $store->getUserToken()); + } + + #[TestDox('Keeps the most recently saved values.')] + public function testSavesValues(): void + { + $clientId = Uuid::random(); + $token = Token::issue(appId: self::APP_ID, subject: 'user-2', now: 1000); + + $store = new InMemoryIdentityStore(); + $store->saveClientId($clientId); + $store->saveUserToken($token); + + self::assertSame($clientId, $store->getClientId()); + self::assertSame($token, $store->getUserToken()); + } +} diff --git a/tests/PsrApiClientTest.php b/tests/PsrApiClientTest.php new file mode 100644 index 0000000..fe2d837 --- /dev/null +++ b/tests/PsrApiClientTest.php @@ -0,0 +1,211 @@ +addResponse( + $factory->createResponse(200)->withBody($factory->createStream((string) \json_encode(['ok' => true]))), + ); + + $apiKey = ApiKey::of(EcKeyFactory::IDENTIFIER); + $clientId = Uuid::parse(self::CLIENT_ID); + $token = Token::issue(appId: self::APP_ID, subject: 'user-1', now: 1000); + + $client = new PsrApiClient( + httpClient: $mock, + requestFactory: $factory, + streamFactory: $factory, + apiKey: $apiKey, + logger: new NullLogger(), + baseEndpointUrl: 'https://api.croct.io', + version: '1.0.0', + identity: new InMemoryIdentityStore($clientId, $token), + ); + + $context = new RequestContext( + clientAgent: 'Test/1.0', + clientIp: '8.8.8.8', + ); + + $result = $client->send('external/web/evaluate', ['query' => 'true'], $context); + + self::assertSame(['ok' => true], $result); + + $request = $mock->getLastRequest(); + + self::assertInstanceOf(RequestInterface::class, $request); + self::assertSame('POST', $request->getMethod()); + self::assertSame('https://api.croct.io/external/web/evaluate', (string) $request->getUri()); + self::assertSame('application/json', $request->getHeaderLine('Content-Type')); + self::assertSame('no-store', $request->getHeaderLine('Cache-Control')); + self::assertSame('Croct SDK PHP v1.0.0', $request->getHeaderLine('X-Client-Library')); + self::assertSame($apiKey->getIdentifier(), $request->getHeaderLine('X-Api-Key')); + self::assertSame($clientId->toString(), $request->getHeaderLine('X-Client-Id')); + self::assertSame($token->toString(), $request->getHeaderLine('X-Token')); + self::assertSame('8.8.8.8', $request->getHeaderLine('X-Client-Ip')); + self::assertSame('Test/1.0', $request->getHeaderLine('X-Client-Agent')); + self::assertSame(['query' => 'true'], \json_decode((string) $request->getBody(), true)); + } + + #[TestDox('Omits absent visitor headers and the version when none is set.')] + public function testOmitsAbsentHeaders(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse($factory->createResponse(200)); + + $client = new PsrApiClient( + httpClient: $mock, + requestFactory: $factory, + streamFactory: $factory, + apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), + ); + + $result = $client->send('external/web/evaluate', [], new RequestContext()); + + self::assertNull($result); + + $request = $mock->getLastRequest(); + + self::assertInstanceOf(RequestInterface::class, $request); + self::assertSame('Croct SDK PHP', $request->getHeaderLine('X-Client-Library')); + self::assertFalse($request->hasHeader('X-Client-Id')); + self::assertFalse($request->hasHeader('X-Token')); + self::assertFalse($request->hasHeader('X-Client-Ip')); + self::assertFalse($request->hasHeader('X-Client-Agent')); + } + + #[TestDox('Reports a suspended service as an exception.')] + public function testReportsSuspendedService(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse($factory->createResponse(202)); + + $client = new PsrApiClient( + httpClient: $mock, + requestFactory: $factory, + streamFactory: $factory, + apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), + ); + + $this->expectException(ApiException::class); + + $client->send('external/web/evaluate', [], new RequestContext()); + } + + #[TestDox('Reports an error status with the problem title.')] + public function testReportsErrorStatus(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse( + $factory->createResponse(400)->withBody($factory->createStream((string) \json_encode(['title' => 'Bad']))), + ); + + $client = new PsrApiClient( + httpClient: $mock, + requestFactory: $factory, + streamFactory: $factory, + apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), + ); + + try { + $client->send('external/web/evaluate', [], new RequestContext()); + self::fail('Expected an ApiException.'); + } catch (ApiException $exception) { + self::assertSame('Bad', $exception->getMessage()); + self::assertSame(400, $exception->getStatusCode()); + } + } + + #[TestDox('Reports a transport error as an exception.')] + public function testReportsTransportError(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addException( + new NetworkException('Connection failed', $factory->createRequest('POST', 'https://api.croct.io')), + ); + + $client = new PsrApiClient( + httpClient: $mock, + requestFactory: $factory, + streamFactory: $factory, + apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), + ); + + $this->expectException(ApiException::class); + + $client->send('external/web/evaluate', [], new RequestContext()); + } + + #[TestDox('Reports an invalid response body as an exception.')] + public function testReportsInvalidResponse(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse($factory->createResponse(200)->withBody($factory->createStream('not json'))); + + $client = new PsrApiClient( + httpClient: $mock, + requestFactory: $factory, + streamFactory: $factory, + apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), + ); + + $this->expectException(ApiException::class); + + $client->send('external/web/evaluate', [], new RequestContext()); + } + + #[TestDox('Reports an unencodable payload as an exception.')] + public function testReportsUnencodablePayload(): void + { + $factory = new Psr17Factory(); + $client = new PsrApiClient( + httpClient: new MockClient(), + requestFactory: $factory, + streamFactory: $factory, + apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), + ); + + $this->expectException(ApiException::class); + + $client->send( + 'external/web/evaluate', + ['value' => "\xB1\x31"], + new RequestContext(), + ); + } +} diff --git a/tests/RequestContextTest.php b/tests/RequestContextTest.php new file mode 100644 index 0000000..01bef6c --- /dev/null +++ b/tests/RequestContextTest.php @@ -0,0 +1,114 @@ +createServerRequest('GET', 'https://example.com/pricing', ['REMOTE_ADDR' => '8.8.8.8']) + ->withHeader('User-Agent', 'Test/1.0') + ->withHeader('Referer', 'https://google.com'); + + $context = RequestContext::fromServerRequest($request); + + self::assertSame('https://example.com/pricing', $context->getUrl()); + self::assertSame('Test/1.0', $context->getClientAgent()); + self::assertSame('https://google.com', $context->getReferrer()); + self::assertSame('8.8.8.8', $context->getClientIp()); + } + + #[TestDox('Prefers the first X-Forwarded-For address as the client IP.')] + public function testPrefersForwardedForClientIp(): void + { + $factory = new Psr17Factory(); + $request = $factory->createServerRequest('GET', 'https://example.com/') + ->withHeader('X-Forwarded-For', '1.2.3.4, 5.6.7.8'); + + self::assertSame('1.2.3.4', RequestContext::fromServerRequest($request)->getClientIp()); + } + + #[TestDox('Builds the evaluation context from the page and custom attributes.')] + public function testBuildsEvaluationContext(): void + { + $context = new RequestContext(url: 'https://example.com/y', referrer: 'https://ref.example'); + + self::assertSame( + [ + 'page' => [ + 'url' => 'https://example.com/y', + 'referrer' => 'https://ref.example', + ], + 'attributes' => ['plan' => 'pro'], + ], + $context->toEvaluationContext(['plan' => 'pro']), + ); + } + + #[TestDox('Reads the request signals from the superglobals.')] + public function testReadsSignalsFromGlobals(): void + { + $context = self::withServer( + [ + 'HTTPS' => 'off', + 'SERVER_PORT' => '443', + 'HTTP_HOST' => 'example.com', + 'REQUEST_URI' => '/pricing', + 'HTTP_X_FORWARDED_FOR' => '1.2.3.4, 5.6.7.8', + 'HTTP_REFERER' => 'https://google.com', + 'HTTP_USER_AGENT' => 'Test/1.0', + ], + static fn (): RequestContext => RequestContext::fromGlobals(), + ); + + self::assertSame('https://example.com/pricing', $context->getUrl()); + self::assertSame('1.2.3.4', $context->getClientIp()); + self::assertSame('https://google.com', $context->getReferrer()); + self::assertSame('Test/1.0', $context->getClientAgent()); + } + + #[TestDox('Builds an insecure URL and reads the remote address without proxy headers.')] + public function testReadsPlainGlobals(): void + { + $context = self::withServer( + ['HTTP_HOST' => 'example.com', 'REQUEST_URI' => '/', 'REMOTE_ADDR' => '9.9.9.9'], + static fn (): RequestContext => RequestContext::fromGlobals(), + ); + + self::assertSame('http://example.com/', $context->getUrl()); + self::assertSame('9.9.9.9', $context->getClientIp()); + self::assertNull($context->getReferrer()); + } + + #[TestDox('Exposes the preview token and preferred locale.')] + public function testExposesPreviewTokenAndLocale(): void + { + $context = new RequestContext(previewToken: 'preview', preferredLocale: 'en-us'); + + self::assertSame('preview', $context->getPreviewToken()); + self::assertSame('en-us', $context->getPreferredLocale()); + } + + /** + * @param array $server + * @param callable(): RequestContext $callback + */ + private static function withServer(array $server, callable $callback): RequestContext + { + $_SERVER = $server; + + return $callback(); + } +} diff --git a/tests/SessionTest.php b/tests/SessionTest.php new file mode 100644 index 0000000..3a00b9c --- /dev/null +++ b/tests/SessionTest.php @@ -0,0 +1,194 @@ +createSession(null); + + self::assertMatchesRegularExpression( + '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/', + $session->getClientId()->toString(), + ); + } + + #[TestDox('Reuses the stored client ID.')] + public function testReusesStoredClientId(): void + { + $clientId = Uuid::parse(self::CLIENT_ID); + + $session = $this->createSession($clientId); + + self::assertSame($clientId, $session->getClientId()); + } + + #[TestDox('Issues an anonymous, unsigned token without a prior token.')] + public function testIssuesAnonymousUnsignedTokenWithoutToken(): void + { + $session = $this->createSession(null); + + self::assertTrue($session->getUserToken()->isAnonymous()); + self::assertFalse($session->getUserToken()->isSigned()); + } + + #[TestDox('Signs the token when the API key carries a private key.')] + public function testSignsTokenWhenKeyHasPrivateKey(): void + { + [$apiKey] = EcKeyFactory::create(); + + $session = $this->createSession(null, null, $apiKey); + + self::assertTrue($session->getUserToken()->isSigned()); + self::assertTrue($session->getUserToken()->matchesKeyId($apiKey)); + } + + #[TestDox('Keeps a valid token untouched.')] + public function testKeepsValidToken(): void + { + $token = Token::issue(appId: self::APP_ID, subject: 'user-7', now: 1000)->withDuration(86400, 1000); + + $session = $this->createSession(null, $token); + + self::assertSame($token->toString(), $session->getUserToken()->toString()); + } + + #[TestDox('Carries the subject over when refreshing an expired token.')] + public function testCarriesOverSubjectFromExpiredToken(): void + { + $expired = Token::issue(appId: self::APP_ID, subject: 'user-9', now: 100)->withDuration(86400, 100); + + $session = $this->createSession(null, $expired, now: 200000); + + self::assertSame('user-9', $session->getUserToken()->getSubject()); + self::assertTrue($session->getUserToken()->isValidNow(200000)); + } + + #[TestDox('Upgrades an unsigned token to a signed one.')] + public function testUpgradesUnsignedTokenToSigned(): void + { + [$apiKey] = EcKeyFactory::create(); + $unsigned = Token::issue(appId: self::APP_ID, subject: 'user-3', now: 1000)->withDuration(86400, 1000); + + $session = $this->createSession(null, $unsigned, $apiKey); + + self::assertTrue($session->getUserToken()->isSigned()); + self::assertSame('user-3', $session->getUserToken()->getSubject()); + } + + #[TestDox('Issues an anonymous token when the token belongs to another application.')] + public function testIssuesAnonymousForForeignAppToken(): void + { + $foreign = Token::issue(appId: '99999999-9999-4999-8999-999999999999', subject: 'user-x', now: 1000) + ->withDuration(86400, 1000); + + $session = $this->createSession(null, $foreign); + + self::assertTrue($session->getUserToken()->isAnonymous()); + self::assertSame(self::APP_ID, $session->getUserToken()->getApplicationId()); + } + + #[TestDox('Discards a foreign application token even when it is expired, never carrying its subject over.')] + public function testIssuesAnonymousForExpiredForeignAppToken(): void + { + $foreign = Token::issue(appId: '99999999-9999-4999-8999-999999999999', subject: 'user-x', now: 100) + ->withDuration(86400, 100); + + $session = $this->createSession(null, $foreign, now: 200000); + + self::assertTrue($session->getUserToken()->isAnonymous()); + self::assertSame(self::APP_ID, $session->getUserToken()->getApplicationId()); + } + + #[TestDox('Reflects identification and anonymization in the token.')] + public function testIdentifyAndAnonymize(): void + { + $session = $this->createSession(null); + + $session->identify('user-42'); + self::assertSame('user-42', $session->getUserToken()->getSubject()); + + $session->anonymize(); + self::assertTrue($session->getUserToken()->isAnonymous()); + } + + #[TestDox('Rejects identifying with an empty user ID.')] + public function testRejectsEmptyUserId(): void + { + $session = $this->createSession(null); + + $this->expectException(\InvalidArgumentException::class); + + $session->identify(''); + } + + #[TestDox('Re-signs a token that was signed with a different key, preserving subject and ID.')] + public function testReSignsTokenFromDifferentKey(): void + { + [$sessionKey] = EcKeyFactory::create(); + [$otherKey] = EcKeyFactory::create('11111111-1111-4111-8111-111111111111'); + + $foreign = Token::issue(appId: self::APP_ID, subject: 'user-1', now: 1000) + ->withDuration(86400, 1000) + ->withTokenId('22222222-2222-4222-8222-222222222222') + ->signedWith($otherKey); + + $token = $this->createSession(null, $foreign, $sessionKey)->getUserToken(); + + self::assertTrue($token->isSigned()); + self::assertTrue($token->matchesKeyId($sessionKey)); + self::assertSame('user-1', $token->getSubject()); + self::assertSame('22222222-2222-4222-8222-222222222222', $token->getTokenId()); + } + + #[TestDox('Treats an empty subject as anonymous when reissuing.')] + public function testTreatsEmptySubjectAsAnonymous(): void + { + [$sessionKey] = EcKeyFactory::create(); + + $unsigned = Token::of( + ['typ' => 'JWT', 'alg' => 'none', 'appId' => self::APP_ID], + ['iss' => 'croct.io', 'aud' => 'croct.io', 'iat' => 1000, 'exp' => 87400, 'sub' => ''], + ); + + $token = $this->createSession(null, $unsigned, $sessionKey)->getUserToken(); + + self::assertTrue($token->isSigned()); + self::assertTrue($token->isAnonymous()); + } + + private function createSession( + ?Uuid $clientId, + ?Token $userToken = null, + ?ApiKey $apiKey = null, + int $now = 1000, + ): Session { + return new Session( + appId: self::APP_ID, + apiKey: $apiKey ?? ApiKey::of(EcKeyFactory::IDENTIFIER), + store: new InMemoryIdentityStore($clientId, $userToken), + tokenDuration: 86400, + signTokens: null, + now: $now, + ); + } +} diff --git a/tests/TokenTest.php b/tests/TokenTest.php new file mode 100644 index 0000000..b9d7283 --- /dev/null +++ b/tests/TokenTest.php @@ -0,0 +1,288 @@ +getAlgorithm()); + self::assertSame(self::APP_ID, $token->getApplicationId()); + self::assertSame(1000, $token->getIssueTime()); + self::assertTrue($token->isAnonymous()); + self::assertFalse($token->isSigned()); + self::assertStringEndsWith('.', $token->toString()); + } + + #[TestDox('Carries the subject when issued for a user.')] + public function testIssuesTokenWithSubject(): void + { + $token = Token::issue(appId: self::APP_ID, subject: 'user-42', now: 1000); + + self::assertFalse($token->isAnonymous()); + self::assertSame('user-42', $token->getSubject()); + self::assertTrue($token->isSubject('user-42')); + } + + #[TestDox('Cannot be issued with an empty subject.')] + public function testRejectsEmptySubject(): void + { + $this->expectException(\InvalidArgumentException::class); + + Token::issue(self::APP_ID, ''); + } + + #[TestDox('Round-trips through its serialized form.')] + public function testRoundTripsThroughSerialization(): void + { + $token = Token::issue(appId: self::APP_ID, subject: 'user-1', now: 1000)->withDuration(86400, 1000); + $parsed = Token::parse($token->toString()); + + self::assertSame($token->toString(), $parsed->toString()); + self::assertSame('user-1', $parsed->getSubject()); + self::assertSame(1000, $parsed->getIssueTime()); + self::assertSame(87400, $parsed->getExpirationTime()); + } + + /** + * @return array + */ + public static function getTestsForValidity(): array + { + return [ + 'before the issue time' => [ + 'now' => 999, + 'expected' => false, + ], + 'at the issue time' => [ + 'now' => 1000, + 'expected' => true, + ], + 'at the expiration time' => [ + 'now' => 1100, + 'expected' => true, + ], + 'after the expiration time' => [ + 'now' => 1101, + 'expected' => false, + ], + ]; + } + + #[DataProvider('getTestsForValidity')] + #[TestDox('Can only be valid between its issue and expiration times.')] + public function testReportsValidity(int $now, bool $expected): void + { + $token = Token::issue(appId: self::APP_ID, subject: null, now: 1000)->withDuration(100, 1000); + + self::assertSame($expected, $token->isValidNow($now)); + } + + #[TestDox('Compares issue times to tell which token is newer.')] + public function testComparesIssueTimes(): void + { + $older = Token::issue(appId: self::APP_ID, subject: null, now: 1000); + $newer = Token::issue(appId: self::APP_ID, subject: null, now: 2000); + + self::assertTrue($newer->isNewerThan($older)); + self::assertFalse($older->isNewerThan($newer)); + } + + #[TestDox('Compares equal by its headers, payload, and signature.')] + public function testEquals(): void + { + $token = Token::issue(appId: self::APP_ID, subject: 'user-1', now: 1000); + + self::assertTrue($token->equals(Token::issue(appId: self::APP_ID, subject: 'user-1', now: 1000))); + self::assertFalse($token->equals(Token::issue(appId: self::APP_ID, subject: 'user-2', now: 1000))); + } + + #[TestDox('Requires a valid UUID as the token ID.')] + public function testRejectsNonUuidTokenId(): void + { + $this->expectException(\InvalidArgumentException::class); + + Token::issue(self::APP_ID)->withTokenId('not-a-uuid'); + } + + #[TestDox('Produces a signature that verifies against the API key.')] + public function testProducesVerifiableSignatureWhenSigned(): void + { + [$apiKey, $publicKey] = EcKeyFactory::create(); + + $token = Token::issue(appId: self::APP_ID, subject: 'user-1', now: 1000)->signedWith($apiKey); + + self::assertSame('ES256', $token->getAlgorithm()); + self::assertSame($apiKey->getIdentifierHash(), $token->getKeyId()); + self::assertTrue($token->isSigned()); + self::assertTrue($token->matchesKeyId($apiKey)); + + $parts = \explode('.', $token->toString()); + + self::assertCount(3, $parts); + + $signature = \base64_decode(\strtr($parts[2], '-_', '+/'), true); + + self::assertNotFalse($signature); + self::assertSame(64, \strlen($signature)); + self::assertSame( + 1, + \openssl_verify( + $parts[0] . '.' . $parts[1], + EcKeyFactory::rawToDer($signature), + $publicKey, + \OPENSSL_ALGO_SHA256, + ), + ); + } + + /** + * @return array + */ + public static function getTestsForMalformedTokens(): array + { + return [ + 'empty string' => [ + 'token' => '', + ], + 'single segment' => [ + 'token' => 'not-a-token', + ], + 'corrupted segments' => [ + 'token' => '@@@.@@@', + ], + ]; + } + + #[DataProvider('getTestsForMalformedTokens')] + #[TestDox('Cannot be parsed when malformed.')] + public function testRejectsMalformedToken(string $token): void + { + $this->expectException(MalformedTokenException::class); + + Token::parse($token); + } + + #[TestDox('Cannot be parsed when an otherwise valid token carries a fourth segment.')] + public function testRejectsExtraSegments(): void + { + $token = Token::issue(appId: self::APP_ID, subject: null, now: 1000)->toString() . '.extra'; + + $this->expectException(MalformedTokenException::class); + + Token::parse($token); + } + + #[TestDox('Cannot be issued with a negative timestamp.')] + public function testRejectsNegativeTimestamp(): void + { + $this->expectException(\InvalidArgumentException::class); + + Token::issue(appId: self::APP_ID, subject: null, now: -1); + } + + /** + * @return array, payload: array}> + */ + public static function getTestsForInvalidClaims(): array + { + return [ + 'missing header' => [ + 'headers' => ['typ' => 'JWT'], + 'payload' => ['iss' => 'croct.io', 'aud' => 'croct.io', 'iat' => 1], + ], + 'missing issuer' => [ + 'headers' => ['typ' => 'JWT', 'alg' => 'none'], + 'payload' => ['aud' => 'croct.io', 'iat' => 1], + ], + 'missing audience' => [ + 'headers' => ['typ' => 'JWT', 'alg' => 'none'], + 'payload' => ['iss' => 'croct.io', 'iat' => 1], + ], + 'missing issue time' => [ + 'headers' => ['typ' => 'JWT', 'alg' => 'none'], + 'payload' => ['iss' => 'croct.io', 'aud' => 'croct.io'], + ], + ]; + } + + /** + * @param array $headers + * @param array $payload + */ + #[DataProvider('getTestsForInvalidClaims')] + #[TestDox('Cannot be created with missing or invalid claims.')] + public function testRejectsInvalidClaims(array $headers, array $payload): void + { + $this->expectException(MalformedTokenException::class); + + Token::of($headers, $payload); + } + + #[TestDox('Cannot be parsed when a segment is not valid JSON.')] + public function testRejectsNonJsonSegment(): void + { + $this->expectException(MalformedTokenException::class); + + Token::parse(self::base64Url('not json') . '.' . self::base64Url('{}')); + } + + #[TestDox('Cannot be parsed when a segment is not a JSON object.')] + public function testRejectsNonObjectSegment(): void + { + $this->expectException(MalformedTokenException::class); + + Token::parse(self::base64Url('123') . '.' . self::base64Url('{}')); + } + + #[TestDox('Carries a token ID when one is set.')] + public function testSetsTokenId(): void + { + $tokenId = '22222222-2222-4222-8222-222222222222'; + + self::assertSame($tokenId, Token::issue(self::APP_ID)->withTokenId($tokenId)->getTokenId()); + self::assertNull(Token::issue(self::APP_ID)->getTokenId()); + } + + #[TestDox('Casts to its serialized form.')] + public function testCastsToString(): void + { + $token = Token::issue(appId: self::APP_ID, subject: 'user-1', now: 1000); + + self::assertSame($token->toString(), (string) $token); + } + + #[TestDox('Cannot be serialized when a claim is not encodable.')] + public function testRejectsUnencodableClaims(): void + { + $token = Token::of( + ['typ' => 'JWT', 'alg' => 'none', 'appId' => "\xB1\x31"], + ['iss' => 'croct.io', 'aud' => 'croct.io', 'iat' => 1000], + ); + + $this->expectException(\LogicException::class); + + $token->toString(); + } + + private static function base64Url(string $data): string + { + return \rtrim(\strtr(\base64_encode($data), '+/', '-_'), '='); + } +} diff --git a/tests/UuidTest.php b/tests/UuidTest.php new file mode 100644 index 0000000..19ecfa8 --- /dev/null +++ b/tests/UuidTest.php @@ -0,0 +1,94 @@ +toString(), + ); + self::assertNotSame(Uuid::random()->toString(), $uuid->toString()); + } + + /** + * @return array + */ + public static function getTestsForValidation(): array + { + return [ + 'canonical' => [ + 'value' => '11111111-2222-4333-8444-555555555555', + 'valid' => true, + ], + 'uppercase' => [ + 'value' => 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE', + 'valid' => true, + ], + 'compact (no hyphens)' => [ + 'value' => '11111111222243338444555555555555', + 'valid' => false, + ], + 'too short' => [ + 'value' => '11111111-2222-4333-8444', + 'valid' => false, + ], + 'non-hexadecimal' => [ + 'value' => 'gggggggg-2222-4333-8444-555555555555', + 'valid' => false, + ], + 'empty' => [ + 'value' => '', + 'valid' => false, + ], + ]; + } + + #[DataProvider('getTestsForValidation')] + #[TestDox('Validates the canonical UUID format, case-insensitively.')] + public function testValidatesFormat(string $value, bool $valid): void + { + self::assertSame($valid, Uuid::isValid($value)); + } + + #[TestDox('Parses a UUID, normalizing it to lowercase.')] + public function testParsesAndNormalizes(): void + { + $uuid = Uuid::parse('AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE'); + + self::assertSame('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', $uuid->toString()); + self::assertSame($uuid->toString(), (string) $uuid); + } + + #[TestDox('Compares UUIDs by their canonical value.')] + public function testEquals(): void + { + $uuid = Uuid::parse('AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE'); + + self::assertTrue($uuid->equals(Uuid::parse('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'))); + self::assertFalse($uuid->equals(Uuid::parse('11111111-2222-4333-8444-555555555555'))); + } + + #[TestDox('Rejects parsing an invalid value.')] + public function testRejectsInvalidValue(): void + { + $this->expectException(\InvalidArgumentException::class); + + Uuid::parse('not-a-uuid'); + } +} diff --git a/tests/VaryingResponseObserverTest.php b/tests/VaryingResponseObserverTest.php new file mode 100644 index 0000000..acd606c --- /dev/null +++ b/tests/VaryingResponseObserverTest.php @@ -0,0 +1,153 @@ +createPlug(); + + $calls = 0; + $plug = new VaryingResponseObserver($inner, static function () use (&$calls): void { + ++$calls; + }); + + self::assertSame('cid', $plug->getClientId()); + self::assertSame('tok', $plug->getUserToken()); + self::assertSame(['appId' => 'app'], $plug->getPlugOptions()); + self::assertTrue($plug->evaluate('user is returning')); + self::assertSame(['title' => 'Hello'], $plug->fetchContent('home-hero')->getContent()); + + $plug->identify('user-1'); + $plug->anonymize(); + + self::assertSame(7, $calls); + self::assertSame( + [ + 'getClientId', + 'getUserToken', + 'getPlugOptions', + 'evaluate', + 'fetchContent', + 'identify', + 'anonymize', + ], + $inner->calls, + ); + } + + #[TestDox('Does not run the callback for the application ID.')] + public function testDoesNotVaryOnApplicationId(): void + { + $inner = $this->createPlug(); + + $calls = 0; + $plug = new VaryingResponseObserver($inner, static function () use (&$calls): void { + ++$calls; + }); + + self::assertSame('app', $plug->getAppId()); + self::assertSame(0, $calls); + self::assertSame(['getAppId'], $inner->calls); + } + + #[TestDox('Does not run the callback for a static content fetch.')] + public function testDoesNotVaryOnStaticContentFetch(): void + { + $inner = $this->createPlug(); + + $calls = 0; + $plug = new VaryingResponseObserver($inner, static function () use (&$calls): void { + ++$calls; + }); + + self::assertSame( + ['title' => 'Hello'], + $plug->fetchContent('home-hero', FetchOptions::empty()->withStatic())->getContent(), + ); + + self::assertSame(0, $calls); + self::assertSame(['fetchContent'], $inner->calls); + } + + /** + * @return Plug&object{calls: list} + */ + private function createPlug(): Plug + { + return new class implements Plug { + /** @var list */ + public array $calls = []; + + public function getAppId(): string + { + $this->calls[] = 'getAppId'; + + return 'app'; + } + + public function getClientId(): string + { + $this->calls[] = 'getClientId'; + + return 'cid'; + } + + public function getUserToken(): string + { + $this->calls[] = 'getUserToken'; + + return 'tok'; + } + + /** + * @return array + */ + public function getPlugOptions(): array + { + $this->calls[] = 'getPlugOptions'; + + return ['appId' => 'app']; + } + + public function evaluate(string $query, ?EvaluationOptions $options = null): mixed + { + $this->calls[] = 'evaluate'; + + return true; + } + + public function fetchContent(string $slotId, ?FetchOptions $options = null): FetchResponse + { + $this->calls[] = 'fetchContent'; + + return new FetchResponse(['title' => 'Hello']); + } + + public function identify(string $userId): void + { + $this->calls[] = 'identify'; + } + + public function anonymize(): void + { + $this->calls[] = 'anonymize'; + } + }; + } +} From 6655a87304db4ca01f3a11c7284bb3b138d4b7e1 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Thu, 4 Jun 2026 13:59:31 -0300 Subject: [PATCH 02/18] Apply review --- src/ApiClient.php | 5 +- src/Croct.php | 13 +++-- src/HttpContentFetcher.php | 35 +++++++++---- src/HttpEvaluator.php | 29 ++++++++++- src/PsrApiClient.php | 39 ++++----------- src/RequestContext.php | 52 ++++++++++++------- tests/HttpContentFetcherTest.php | 58 ++++++++++++++++++++-- tests/HttpEvaluatorTest.php | 82 ++++++++++++++++++++++++++++++ tests/PsrApiClientTest.php | 85 ++++++++++++++++++-------------- tests/RequestContextTest.php | 56 +++++++++++++++++++++ 10 files changed, 350 insertions(+), 104 deletions(-) diff --git a/src/ApiClient.php b/src/ApiClient.php index ff19863..32f297d 100644 --- a/src/ApiClient.php +++ b/src/ApiClient.php @@ -17,9 +17,10 @@ interface ApiClient /** * Sends a request to the given API path and returns the decoded response. * - * @param array $payload The request body. + * @param array $payload The request body. + * @param array $headers Request-specific headers; null values are skipped. * * @throws ApiException If the request fails or the API returns an error. */ - public function send(string $path, array $payload, RequestContext $context): mixed; + public function send(string $path, array $payload, array $headers = []): mixed; } diff --git a/src/Croct.php b/src/Croct.php index c48a77b..d71c4db 100644 --- a/src/Croct.php +++ b/src/Croct.php @@ -70,7 +70,7 @@ public static function plug( #[\SensitiveParameter] ApiKey|string $apiKey, IdentityStore $storage, - string $baseEndpointUrl = self::DEFAULT_BASE_ENDPOINT_URL, + ?string $baseEndpointUrl = null, int $tokenDuration = self::DEFAULT_TOKEN_DURATION, ?ContentProvider $contentProvider = null, ?RequestContext $context = null, @@ -81,6 +81,7 @@ public static function plug( ): self { $key = ApiKey::from($apiKey); $context ??= RequestContext::fromGlobals(); + $baseEndpointUrl ??= self::DEFAULT_BASE_ENDPOINT_URL; $session = new Session($appId, $key, $storage, $tokenDuration); @@ -107,7 +108,6 @@ public static function plug( logger: $logger, baseEndpointUrl: $baseEndpointUrl, version: $version, - identity: $session, ); $cookieConfiguration = $storage instanceof CookieStorage @@ -117,8 +117,13 @@ public static function plug( return new self( $appId, $session, - new HttpEvaluator($client, $context), - new HttpContentFetcher($client, $context, $contentProvider ?? self::discoverContentProvider()), + new HttpEvaluator($client, $context, $session), + new HttpContentFetcher( + $client, + $context, + $session, + $contentProvider ?? self::discoverContentProvider(), + ), $cookieConfiguration, ); } diff --git a/src/HttpContentFetcher.php b/src/HttpContentFetcher.php index c9fec6c..8f71ec1 100644 --- a/src/HttpContentFetcher.php +++ b/src/HttpContentFetcher.php @@ -22,15 +22,19 @@ final class HttpContentFetcher implements ContentFetcher private RequestContext $context; + private ?IdentityStore $identity; + private ContentProvider $contentProvider; public function __construct( ApiClient $client, RequestContext $context, + ?IdentityStore $identity = null, ?ContentProvider $contentProvider = null, ) { $this->client = $client; $this->context = $context; + $this->identity = $identity; $this->contentProvider = $contentProvider ?? new NullContentProvider(); } @@ -38,6 +42,7 @@ public function fetch(string $slotId, ?FetchOptions $options = null): FetchRespo { $options ??= FetchOptions::empty(); $context = $this->context; + $static = $options->isStatic(); $payload = ['slotId' => $slotId]; @@ -57,22 +62,34 @@ public function fetch(string $slotId, ?FetchOptions $options = null): FetchRespo $payload['includeSchema'] = true; } - $previewToken = $context->getPreviewToken(); + // Static content is impersonal: it carries no visitor signals, preview, or page context. + $headers = []; - if ($previewToken !== null) { - $payload['previewToken'] = $previewToken; - } + if (!$static) { + $previewToken = $context->getPreviewToken(); + + if ($previewToken !== null) { + $payload['previewToken'] = $previewToken; + } + + $evaluationContext = $context->toEvaluationContext($options->getAttributes()); - $evaluationContext = $context->toEvaluationContext($options->getAttributes()); + if ($evaluationContext !== []) { + $payload['context'] = $evaluationContext; + } - if ($evaluationContext !== []) { - $payload['context'] = $evaluationContext; + $headers = [ + HttpHeader::CLIENT_ID->value => $this->identity?->getClientId()?->toString(), + HttpHeader::TOKEN->value => $this->identity?->getUserToken()?->toString(), + HttpHeader::CLIENT_IP->value => $context->getClientIp(), + HttpHeader::CLIENT_AGENT->value => $context->getClientAgent(), + ]; } - $endpoint = $options->isStatic() ? self::STATIC_ENDPOINT : self::ENDPOINT; + $endpoint = $static ? self::STATIC_ENDPOINT : self::ENDPOINT; try { - return FetchResponse::fromResponse($this->client->send($endpoint, $payload, $context)); + return FetchResponse::fromResponse($this->client->send($endpoint, $payload, $headers)); } catch (ApiException $exception) { if ($options->hasFallback()) { return new FetchResponse($options->getFallback()); diff --git a/src/HttpEvaluator.php b/src/HttpEvaluator.php index 4bcb609..9b04638 100644 --- a/src/HttpEvaluator.php +++ b/src/HttpEvaluator.php @@ -14,18 +14,36 @@ final class HttpEvaluator implements Evaluator { private const ENDPOINT = 'external/web/evaluate'; + private const MAX_QUERY_LENGTH = 500; + private ApiClient $client; private RequestContext $context; - public function __construct(ApiClient $client, RequestContext $context) + private ?IdentityStore $identity; + + public function __construct(ApiClient $client, RequestContext $context, ?IdentityStore $identity = null) { $this->client = $client; $this->context = $context; + $this->identity = $identity; } public function evaluate(string $query, ?EvaluationOptions $options = null): mixed { + // Reject oversized queries before reaching the API, and never mask the misuse with a fallback. + $length = \mb_strlen($query, 'UTF-8'); + + if ($length > self::MAX_QUERY_LENGTH) { + throw new EvaluationException( + \sprintf( + 'The query must be at most %d characters long, but it is %d characters long.', + self::MAX_QUERY_LENGTH, + $length, + ), + ); + } + $options ??= EvaluationOptions::empty(); $context = $this->context; @@ -37,8 +55,15 @@ public function evaluate(string $query, ?EvaluationOptions $options = null): mix $payload['context'] = $evaluationContext; } + $headers = [ + HttpHeader::CLIENT_ID->value => $this->identity?->getClientId()?->toString(), + HttpHeader::TOKEN->value => $this->identity?->getUserToken()?->toString(), + HttpHeader::CLIENT_IP->value => $context->getClientIp(), + HttpHeader::CLIENT_AGENT->value => $context->getClientAgent(), + ]; + try { - return $this->client->send(self::ENDPOINT, $payload, $context); + return $this->client->send(self::ENDPOINT, $payload, $headers); } catch (ApiException $exception) { if ($options->hasFallback()) { return $options->getFallback(); diff --git a/src/PsrApiClient.php b/src/PsrApiClient.php index dcd3982..2039f13 100644 --- a/src/PsrApiClient.php +++ b/src/PsrApiClient.php @@ -8,7 +8,6 @@ use Psr\Http\Client\ClientExceptionInterface as ClientException; use Psr\Http\Client\ClientInterface as HttpClient; use Psr\Http\Message\RequestFactoryInterface as RequestFactory; -use Psr\Http\Message\RequestInterface as Request; use Psr\Http\Message\StreamFactoryInterface as StreamFactory; use Psr\Log\LoggerInterface as Logger; use Psr\Log\NullLogger; @@ -37,8 +36,6 @@ final class PsrApiClient implements ApiClient private Logger $logger; - private ?IdentityStore $identity; - public function __construct( HttpClient $httpClient, RequestFactory $requestFactory, @@ -47,7 +44,6 @@ public function __construct( ?Logger $logger = null, string $baseEndpointUrl = Croct::DEFAULT_BASE_ENDPOINT_URL, ?string $version = null, - ?IdentityStore $identity = null, ) { $this->httpClient = $httpClient; $this->requestFactory = $requestFactory; @@ -58,13 +54,13 @@ public function __construct( ? self::CLIENT_LIBRARY : self::CLIENT_LIBRARY . ' v' . $version; $this->logger = $logger ?? new NullLogger(); - $this->identity = $identity; } /** - * @param array $payload + * @param array $payload + * @param array $headers */ - public function send(string $path, array $payload, RequestContext $context): mixed + public function send(string $path, array $payload, array $headers = []): mixed { $url = \rtrim($this->baseEndpointUrl, '/') . '/' . $path; @@ -82,7 +78,11 @@ public function send(string $path, array $payload, RequestContext $context): mix ->withHeader(HttpHeader::API_KEY->value, $this->apiKey->getIdentifier()) ->withBody($this->streamFactory->createStream($body)); - $request = $this->withClientHeaders($request, $context); + foreach ($headers as $name => $value) { + if ($value !== null) { + $request = $request->withHeader($name, $value); + } + } try { $response = $this->httpClient->sendRequest($request); @@ -102,7 +102,7 @@ public function send(string $path, array $payload, RequestContext $context): mix } if ($status === 202) { - throw new ApiException('The Croct service is temporarily unavailable. Please retry shortly.', $status); + throw new ApiException('The Croct service is temporarily unavailable.', $status); } if ($status >= 400) { @@ -111,25 +111,4 @@ public function send(string $path, array $payload, RequestContext $context): mix return $data; } - - /** - * Adds the available visitor-identifying headers to the request. - */ - private function withClientHeaders(Request $request, RequestContext $context): Request - { - $headers = [ - HttpHeader::CLIENT_ID->value => $this->identity?->getClientId()?->toString(), - HttpHeader::TOKEN->value => $this->identity?->getUserToken()?->toString(), - HttpHeader::CLIENT_IP->value => $context->getClientIp(), - HttpHeader::CLIENT_AGENT->value => $context->getClientAgent(), - ]; - - foreach ($headers as $name => $value) { - if ($value !== null) { - $request = $request->withHeader($name, $value); - } - } - - return $request; - } } diff --git a/src/RequestContext.php b/src/RequestContext.php index e636953..d847440 100644 --- a/src/RequestContext.php +++ b/src/RequestContext.php @@ -14,6 +14,10 @@ */ final class RequestContext { + private const PREVIEW_QUERY_PARAMETER = 'croct-preview'; + + private const PREVIEW_EXIT = 'exit'; + private ?string $previewToken; private ?string $url; @@ -61,12 +65,17 @@ public static function fromGlobals(): self $forwardedFor = self::getOptionalString($server['HTTP_X_FORWARDED_FOR'] ?? null) ?? self::getOptionalString($server['REMOTE_ADDR'] ?? null); + /** @var array $query */ + $query = $_GET; + return new self( - null, - $url, - self::getOptionalString($server['HTTP_REFERER'] ?? null), - self::getOptionalString($server['HTTP_USER_AGENT'] ?? null), - $forwardedFor !== null ? \trim(\explode(',', $forwardedFor)[0]) : null, + previewToken: self::resolvePreviewToken( + self::getOptionalString($query[self::PREVIEW_QUERY_PARAMETER] ?? null), + ), + url: $url, + referrer: self::getOptionalString($server['HTTP_REFERER'] ?? null), + clientAgent: self::getOptionalString($server['HTTP_USER_AGENT'] ?? null), + clientIp: $forwardedFor !== null ? \trim(\explode(',', $forwardedFor)[0]) : null, ); } @@ -83,12 +92,16 @@ public static function fromServerRequest(ServerRequest $request): self $url = (string) $request->getUri(); + $query = $request->getQueryParams(); + return new self( - null, - $url !== '' ? $url : null, - self::getOptionalHeader($request, 'Referer'), - self::getOptionalHeader($request, 'User-Agent'), - $forwardedFor !== null ? \trim(\explode(',', $forwardedFor)[0]) : null, + previewToken: self::resolvePreviewToken( + self::getOptionalString($query[self::PREVIEW_QUERY_PARAMETER] ?? null), + ), + url: $url !== '' ? $url : null, + referrer: self::getOptionalHeader($request, 'Referer'), + clientAgent: self::getOptionalHeader($request, 'User-Agent'), + clientIp: $forwardedFor !== null ? \trim(\explode(',', $forwardedFor)[0]) : null, ); } @@ -162,17 +175,14 @@ public function getPreferredLocale(): ?string public function toEvaluationContext(array $attributes = []): array { $context = []; - $page = []; if ($this->url !== null) { - $page['url'] = $this->url; - } + $page = ['url' => $this->url]; - if ($this->referrer !== null) { - $page['referrer'] = $this->referrer; - } + if ($this->referrer !== null) { + $page['referrer'] = $this->referrer; + } - if ($page !== []) { $context['page'] = $page; } @@ -183,6 +193,14 @@ public function toEvaluationContext(array $attributes = []): array return $context; } + /** + * Resolves the preview token from the request, treating the preview-exit sentinel as no preview. + */ + private static function resolvePreviewToken(?string $token): ?string + { + return $token === null || $token === self::PREVIEW_EXIT ? null : $token; + } + /** * Gets a request header value, or null when it is empty. */ diff --git a/tests/HttpContentFetcherTest.php b/tests/HttpContentFetcherTest.php index b75e98a..d62424c 100644 --- a/tests/HttpContentFetcherTest.php +++ b/tests/HttpContentFetcherTest.php @@ -11,8 +11,12 @@ use Croct\Plug\Exception\ContentException; use Croct\Plug\FetchOptions; use Croct\Plug\HttpContentFetcher; +use Croct\Plug\IdentityStore; +use Croct\Plug\InMemoryIdentityStore; use Croct\Plug\PsrApiClient; use Croct\Plug\RequestContext; +use Croct\Plug\Token; +use Croct\Plug\Uuid; use Http\Mock\Client as MockClient; use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\Attributes\CoversClass; @@ -24,6 +28,10 @@ #[TestDox('A content fetcher')] final class HttpContentFetcherTest extends TestCase { + private const APP_ID = '7e9d59a9-e4b3-45d4-b1c7-48287f1e5e8a'; + + private const CLIENT_ID = '11111111-2222-4333-8444-555555555555'; + #[TestDox('Fetches a slot and returns its content and typed metadata.')] public function testFetchesContent(): void { @@ -113,7 +121,7 @@ public function testIncludesSchemaWhenRequested(): void ); } - #[TestDox('Uses the static-content endpoint for static fetches.')] + #[TestDox('Uses the static-content endpoint and omits the visitor signals for static fetches.')] public function testFetchesStaticContent(): void { $factory = new Psr17Factory(); @@ -122,13 +130,54 @@ public function testFetchesStaticContent(): void $factory->createResponse(200)->withBody($factory->createStream((string) \json_encode(['content' => []]))), ); - $this->createFetcher($mock, $factory) + // Static content is impersonal: neither the page context nor the visitor headers are sent. + $context = new RequestContext( + previewToken: 'preview-token', + url: 'https://example.com/', + clientAgent: 'Test/1.0', + clientIp: '8.8.8.8', + ); + $identity = new InMemoryIdentityStore( + Uuid::parse(self::CLIENT_ID), + Token::issue(appId: self::APP_ID, now: 1000), + ); + + $this->createFetcher($mock, $factory, $context, identity: $identity) ->fetch('home-hero', FetchOptions::empty()->withStatic()); $request = $mock->getLastRequest(); self::assertInstanceOf(RequestInterface::class, $request); self::assertSame('https://api.croct.io/external/web/static-content', (string) $request->getUri()); + self::assertSame(['slotId' => 'home-hero'], \json_decode((string) $request->getBody(), true)); + self::assertFalse($request->hasHeader('X-Client-Id')); + self::assertFalse($request->hasHeader('X-Token')); + self::assertFalse($request->hasHeader('X-Client-Ip')); + self::assertFalse($request->hasHeader('X-Client-Agent')); + } + + #[TestDox('Sends the visitor headers from the session and context for dynamic content.')] + public function testSendsVisitorHeadersForDynamicContent(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse( + $factory->createResponse(200)->withBody($factory->createStream((string) \json_encode(['content' => []]))), + ); + + $context = new RequestContext(clientAgent: 'Test/1.0', clientIp: '8.8.8.8'); + $token = Token::issue(appId: self::APP_ID, now: 1000); + $identity = new InMemoryIdentityStore(Uuid::parse(self::CLIENT_ID), $token); + + $this->createFetcher($mock, $factory, $context, identity: $identity)->fetch('home-hero'); + + $request = $mock->getLastRequest(); + + self::assertInstanceOf(RequestInterface::class, $request); + self::assertSame(self::CLIENT_ID, $request->getHeaderLine('X-Client-Id')); + self::assertSame($token->toString(), $request->getHeaderLine('X-Token')); + self::assertSame('8.8.8.8', $request->getHeaderLine('X-Client-Ip')); + self::assertSame('Test/1.0', $request->getHeaderLine('X-Client-Agent')); } #[TestDox('Forwards the preview token from the request context.')] @@ -215,6 +264,7 @@ private function createFetcher( Psr17Factory $factory, ?RequestContext $context = null, ?ContentProvider $contentProvider = null, + ?IdentityStore $identity = null, ): HttpContentFetcher { $context ??= new RequestContext(); @@ -224,11 +274,11 @@ private function createFetcher( requestFactory: $factory, streamFactory: $factory, apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), - logger: null, baseEndpointUrl: 'https://api.croct.io', ), $context, - $contentProvider, + identity: $identity, + contentProvider: $contentProvider, ); } } diff --git a/tests/HttpEvaluatorTest.php b/tests/HttpEvaluatorTest.php index 08de917..e45b5f9 100644 --- a/tests/HttpEvaluatorTest.php +++ b/tests/HttpEvaluatorTest.php @@ -8,8 +8,11 @@ use Croct\Plug\EvaluationOptions; use Croct\Plug\Exception\EvaluationException; use Croct\Plug\HttpEvaluator; +use Croct\Plug\InMemoryIdentityStore; use Croct\Plug\PsrApiClient; use Croct\Plug\RequestContext; +use Croct\Plug\Token; +use Croct\Plug\Uuid; use Http\Client\Exception\NetworkException; use Http\Mock\Client as MockClient; use Nyholm\Psr7\Factory\Psr17Factory; @@ -22,6 +25,10 @@ #[TestDox('A CQL evaluator')] final class HttpEvaluatorTest extends TestCase { + private const APP_ID = '7e9d59a9-e4b3-45d4-b1c7-48287f1e5e8a'; + + private const CLIENT_ID = '11111111-2222-4333-8444-555555555555'; + #[TestDox('Sends the query with the visitor context and returns the result.')] public function testEvaluatesQuery(): void { @@ -66,6 +73,81 @@ public function testEvaluatesQuery(): void ); } + #[TestDox('Sends the visitor headers from the session and context.')] + public function testSendsVisitorHeaders(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse( + $factory->createResponse(200)->withBody($factory->createStream((string) \json_encode(true))), + ); + + $token = Token::issue(appId: self::APP_ID, now: 1000); + + $evaluator = new HttpEvaluator( + new PsrApiClient( + httpClient: $mock, + requestFactory: $factory, + streamFactory: $factory, + apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), + ), + new RequestContext(clientAgent: 'Test/1.0', clientIp: '8.8.8.8'), + new InMemoryIdentityStore(Uuid::parse(self::CLIENT_ID), $token), + ); + + $evaluator->evaluate('user is returning'); + + $request = $mock->getLastRequest(); + + self::assertInstanceOf(RequestInterface::class, $request); + self::assertSame(self::CLIENT_ID, $request->getHeaderLine('X-Client-Id')); + self::assertSame($token->toString(), $request->getHeaderLine('X-Token')); + self::assertSame('8.8.8.8', $request->getHeaderLine('X-Client-Ip')); + self::assertSame('Test/1.0', $request->getHeaderLine('X-Client-Agent')); + } + + #[TestDox('Rejects a query longer than the maximum length before sending a request.')] + public function testRejectsOverlongQuery(): void + { + $factory = new Psr17Factory(); + $evaluator = new HttpEvaluator( + new PsrApiClient( + httpClient: new MockClient(), + requestFactory: $factory, + streamFactory: $factory, + apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), + ), + new RequestContext(), + ); + + $this->expectException(EvaluationException::class); + $this->expectExceptionMessage('The query must be at most 500 characters long, but it is 501 characters long.'); + + $evaluator->evaluate(\str_repeat('a', 501)); + } + + #[TestDox('Accepts a query at the maximum length.')] + public function testAcceptsMaxLengthQuery(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse( + $factory->createResponse(200)->withBody($factory->createStream((string) \json_encode(true))), + ); + + $evaluator = new HttpEvaluator( + new PsrApiClient( + httpClient: $mock, + requestFactory: $factory, + streamFactory: $factory, + apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), + ), + new RequestContext(), + ); + + self::assertTrue($evaluator->evaluate(\str_repeat('a', 500))); + } + #[TestDox('Maps an error response to an evaluation exception.')] public function testMapsErrorResponseToException(): void { diff --git a/tests/PsrApiClientTest.php b/tests/PsrApiClientTest.php index fe2d837..2b6e11a 100644 --- a/tests/PsrApiClientTest.php +++ b/tests/PsrApiClientTest.php @@ -6,11 +6,7 @@ use Croct\Plug\ApiKey; use Croct\Plug\Exception\ApiException; -use Croct\Plug\InMemoryIdentityStore; use Croct\Plug\PsrApiClient; -use Croct\Plug\RequestContext; -use Croct\Plug\Token; -use Croct\Plug\Uuid; use Http\Client\Exception\NetworkException; use Http\Mock\Client as MockClient; use Nyholm\Psr7\Factory\Psr17Factory; @@ -18,18 +14,13 @@ use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; use Psr\Http\Message\RequestInterface; -use Psr\Log\NullLogger; #[CoversClass(PsrApiClient::class)] #[TestDox('A PSR-based API client')] final class PsrApiClientTest extends TestCase { - private const APP_ID = '7e9d59a9-e4b3-45d4-b1c7-48287f1e5e8a'; - - private const CLIENT_ID = '11111111-2222-4333-8444-555555555555'; - - #[TestDox('Sends an authenticated request with the visitor headers and decodes the response.')] - public function testSendsAuthenticatedRequest(): void + #[TestDox('Sends a request with the given headers and decodes the response.')] + public function testSendsRequestWithHeaders(): void { $factory = new Psr17Factory(); $mock = new MockClient(); @@ -38,27 +29,22 @@ public function testSendsAuthenticatedRequest(): void ); $apiKey = ApiKey::of(EcKeyFactory::IDENTIFIER); - $clientId = Uuid::parse(self::CLIENT_ID); - $token = Token::issue(appId: self::APP_ID, subject: 'user-1', now: 1000); $client = new PsrApiClient( httpClient: $mock, requestFactory: $factory, streamFactory: $factory, apiKey: $apiKey, - logger: new NullLogger(), baseEndpointUrl: 'https://api.croct.io', version: '1.0.0', - identity: new InMemoryIdentityStore($clientId, $token), ); - $context = new RequestContext( - clientAgent: 'Test/1.0', - clientIp: '8.8.8.8', + $result = $client->send( + 'external/web/evaluate', + ['query' => 'true'], + ['X-Client-Id' => 'client-1', 'X-Client-Ip' => '8.8.8.8'], ); - $result = $client->send('external/web/evaluate', ['query' => 'true'], $context); - self::assertSame(['ok' => true], $result); $request = $mock->getLastRequest(); @@ -70,33 +56,64 @@ public function testSendsAuthenticatedRequest(): void self::assertSame('no-store', $request->getHeaderLine('Cache-Control')); self::assertSame('Croct SDK PHP v1.0.0', $request->getHeaderLine('X-Client-Library')); self::assertSame($apiKey->getIdentifier(), $request->getHeaderLine('X-Api-Key')); - self::assertSame($clientId->toString(), $request->getHeaderLine('X-Client-Id')); - self::assertSame($token->toString(), $request->getHeaderLine('X-Token')); + self::assertSame('client-1', $request->getHeaderLine('X-Client-Id')); self::assertSame('8.8.8.8', $request->getHeaderLine('X-Client-Ip')); - self::assertSame('Test/1.0', $request->getHeaderLine('X-Client-Agent')); self::assertSame(['query' => 'true'], \json_decode((string) $request->getBody(), true)); } - #[TestDox('Omits absent visitor headers and the version when none is set.')] - public function testOmitsAbsentHeaders(): void + #[TestDox('Skips headers with a null value while keeping the application headers.')] + public function testSkipsNullHeaders(): void { $factory = new Psr17Factory(); $mock = new MockClient(); $mock->addResponse($factory->createResponse(200)); + $apiKey = ApiKey::of(EcKeyFactory::IDENTIFIER); + $client = new PsrApiClient( httpClient: $mock, requestFactory: $factory, streamFactory: $factory, - apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), + apiKey: $apiKey, ); - $result = $client->send('external/web/evaluate', [], new RequestContext()); + $result = $client->send( + 'external/web/static-content', + [], + ['X-Client-Id' => null, 'X-Token' => null, 'X-Client-Ip' => '8.8.8.8'], + ); self::assertNull($result); $request = $mock->getLastRequest(); + self::assertInstanceOf(RequestInterface::class, $request); + self::assertFalse($request->hasHeader('X-Client-Id')); + self::assertFalse($request->hasHeader('X-Token')); + self::assertSame('8.8.8.8', $request->getHeaderLine('X-Client-Ip')); + // The library and key headers identify the application, not the visitor, so they remain. + self::assertSame('Croct SDK PHP', $request->getHeaderLine('X-Client-Library')); + self::assertSame($apiKey->getIdentifier(), $request->getHeaderLine('X-Api-Key')); + } + + #[TestDox('Sends only the application headers when none are given.')] + public function testSendsWithoutRequestHeaders(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse($factory->createResponse(200)); + + $client = new PsrApiClient( + httpClient: $mock, + requestFactory: $factory, + streamFactory: $factory, + apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), + ); + + $client->send('external/web/evaluate', []); + + $request = $mock->getLastRequest(); + self::assertInstanceOf(RequestInterface::class, $request); self::assertSame('Croct SDK PHP', $request->getHeaderLine('X-Client-Library')); self::assertFalse($request->hasHeader('X-Client-Id')); @@ -121,7 +138,7 @@ public function testReportsSuspendedService(): void $this->expectException(ApiException::class); - $client->send('external/web/evaluate', [], new RequestContext()); + $client->send('external/web/evaluate', []); } #[TestDox('Reports an error status with the problem title.')] @@ -141,7 +158,7 @@ public function testReportsErrorStatus(): void ); try { - $client->send('external/web/evaluate', [], new RequestContext()); + $client->send('external/web/evaluate', []); self::fail('Expected an ApiException.'); } catch (ApiException $exception) { self::assertSame('Bad', $exception->getMessage()); @@ -167,7 +184,7 @@ public function testReportsTransportError(): void $this->expectException(ApiException::class); - $client->send('external/web/evaluate', [], new RequestContext()); + $client->send('external/web/evaluate', []); } #[TestDox('Reports an invalid response body as an exception.')] @@ -186,7 +203,7 @@ public function testReportsInvalidResponse(): void $this->expectException(ApiException::class); - $client->send('external/web/evaluate', [], new RequestContext()); + $client->send('external/web/evaluate', []); } #[TestDox('Reports an unencodable payload as an exception.')] @@ -202,10 +219,6 @@ public function testReportsUnencodablePayload(): void $this->expectException(ApiException::class); - $client->send( - 'external/web/evaluate', - ['value' => "\xB1\x31"], - new RequestContext(), - ); + $client->send('external/web/evaluate', ['value' => "\xB1\x31"]); } } diff --git a/tests/RequestContextTest.php b/tests/RequestContextTest.php index 01bef6c..cf8a109 100644 --- a/tests/RequestContextTest.php +++ b/tests/RequestContextTest.php @@ -57,6 +57,17 @@ public function testBuildsEvaluationContext(): void ); } + #[TestDox('Omits the page context when the URL is unknown, even with a referrer.')] + public function testOmitsPageWithoutUrl(): void + { + $context = new RequestContext(referrer: 'https://ref.example'); + + self::assertSame( + ['attributes' => ['plan' => 'pro']], + $context->toEvaluationContext(['plan' => 'pro']), + ); + } + #[TestDox('Reads the request signals from the superglobals.')] public function testReadsSignalsFromGlobals(): void { @@ -101,13 +112,58 @@ public function testExposesPreviewTokenAndLocale(): void self::assertSame('en-us', $context->getPreferredLocale()); } + #[TestDox('Reads the preview token from the query parameter of the superglobals.')] + public function testReadsPreviewTokenFromGlobals(): void + { + $context = self::withGlobals( + ['HTTP_HOST' => 'example.com', 'REQUEST_URI' => '/'], + ['croct-preview' => 'preview-jwt'], + static fn (): RequestContext => RequestContext::fromGlobals(), + ); + + self::assertSame('preview-jwt', $context->getPreviewToken()); + } + + #[TestDox('Treats the preview-exit sentinel as no preview.')] + public function testIgnoresPreviewExitSentinel(): void + { + $context = self::withGlobals( + ['HTTP_HOST' => 'example.com', 'REQUEST_URI' => '/'], + ['croct-preview' => 'exit'], + static fn (): RequestContext => RequestContext::fromGlobals(), + ); + + self::assertNull($context->getPreviewToken()); + } + + #[TestDox('Reads the preview token from the query parameters of a PSR-7 server request.')] + public function testReadsPreviewTokenFromServerRequest(): void + { + $factory = new Psr17Factory(); + $request = $factory->createServerRequest('GET', 'https://example.com/') + ->withQueryParams(['croct-preview' => 'preview-jwt']); + + self::assertSame('preview-jwt', RequestContext::fromServerRequest($request)->getPreviewToken()); + } + /** * @param array $server * @param callable(): RequestContext $callback */ private static function withServer(array $server, callable $callback): RequestContext + { + return self::withGlobals($server, [], $callback); + } + + /** + * @param array $server + * @param array $query + * @param callable(): RequestContext $callback + */ + private static function withGlobals(array $server, array $query, callable $callback): RequestContext { $_SERVER = $server; + $_GET = $query; return $callback(); } From abcbc2c2e73914aa22fb40306b197596418de71a Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Thu, 4 Jun 2026 16:31:55 -0300 Subject: [PATCH 03/18] Update badges --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b0def41..986e6c1 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

Version - PHP version + Build

## Introduction From d5a82c96742d48f5a9cdf72ad9151220114c363e Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Thu, 4 Jun 2026 16:34:25 -0300 Subject: [PATCH 04/18] Update badges --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 986e6c1..c494c83 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

Version - Build + Build

## Introduction From dfdf60816d57fa27ab265693c321936d21c994c3 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Fri, 5 Jun 2026 11:41:25 -0300 Subject: [PATCH 05/18] Make get options static --- src/Croct.php | 2 -- src/Plug.php | 3 --- src/VaryingResponseObserver.php | 9 ++++----- tests/CroctTest.php | 2 -- tests/VaryingResponseObserverTest.php | 12 ++++++------ 5 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/Croct.php b/src/Croct.php index d71c4db..3f60b38 100644 --- a/src/Croct.php +++ b/src/Croct.php @@ -213,8 +213,6 @@ public function getPlugOptions(): array { return [ 'appId' => $this->appId, - 'clientId' => $this->getClientId(), - 'token' => $this->getUserToken(), 'disableCidMirroring' => true, 'cookie' => $this->cookieConfiguration->toBrowserCookies(), ]; diff --git a/src/Plug.php b/src/Plug.php index 5a78d73..405d73f 100644 --- a/src/Plug.php +++ b/src/Plug.php @@ -29,9 +29,6 @@ public function getUserToken(): string; /** * Returns the options for bootstrapping the client-side SDK. * - * The result is JSON-encodable and carries the resolved identity together with the cookie - * settings, so the client SDK reads and writes the same cookies as the server. - * * @return array */ public function getPlugOptions(): array; diff --git a/src/VaryingResponseObserver.php b/src/VaryingResponseObserver.php index 5c1163c..52947e8 100644 --- a/src/VaryingResponseObserver.php +++ b/src/VaryingResponseObserver.php @@ -7,9 +7,10 @@ /** * Decorates another plug instance to detect when the response is varying by visitor. * - * It runs a callback whenever request-specific data is read or changed, that is, every operation - * except reading the application ID. It is useful for integrations that need to keep the response - * out of shared caches. + * It runs a callback whenever visitor-specific data is read or changed. Operations that return the + * same result for every visitor — reading the application ID or the plug options, and static content + * fetches — do not run it. It is useful for integrations that need to keep the response out of shared + * caches. */ final class VaryingResponseObserver implements Plug { @@ -51,8 +52,6 @@ public function getUserToken(): string */ public function getPlugOptions(): array { - ($this->notify)(); - return $this->plug->getPlugOptions(); } diff --git a/tests/CroctTest.php b/tests/CroctTest.php index 7e89bcb..056147f 100644 --- a/tests/CroctTest.php +++ b/tests/CroctTest.php @@ -110,8 +110,6 @@ public function testExposesPlugOptions(): void self::assertSame( [ 'appId' => self::APP_ID, - 'clientId' => self::CLIENT_ID, - 'token' => $croct->getUserToken(), 'disableCidMirroring' => true, 'cookie' => [ 'clientId' => [ diff --git a/tests/VaryingResponseObserverTest.php b/tests/VaryingResponseObserverTest.php index acd606c..3c3fa71 100644 --- a/tests/VaryingResponseObserverTest.php +++ b/tests/VaryingResponseObserverTest.php @@ -29,19 +29,17 @@ public function testRunsCallbackAndDelegates(): void self::assertSame('cid', $plug->getClientId()); self::assertSame('tok', $plug->getUserToken()); - self::assertSame(['appId' => 'app'], $plug->getPlugOptions()); self::assertTrue($plug->evaluate('user is returning')); self::assertSame(['title' => 'Hello'], $plug->fetchContent('home-hero')->getContent()); $plug->identify('user-1'); $plug->anonymize(); - self::assertSame(7, $calls); + self::assertSame(6, $calls); self::assertSame( [ 'getClientId', 'getUserToken', - 'getPlugOptions', 'evaluate', 'fetchContent', 'identify', @@ -51,8 +49,8 @@ public function testRunsCallbackAndDelegates(): void ); } - #[TestDox('Does not run the callback for the application ID.')] - public function testDoesNotVaryOnApplicationId(): void + #[TestDox('Does not run the callback for visitor-independent reads.')] + public function testDoesNotVaryOnStaticReads(): void { $inner = $this->createPlug(); @@ -62,8 +60,10 @@ public function testDoesNotVaryOnApplicationId(): void }); self::assertSame('app', $plug->getAppId()); + self::assertSame(['appId' => 'app'], $plug->getPlugOptions()); + self::assertSame(0, $calls); - self::assertSame(['getAppId'], $inner->calls); + self::assertSame(['getAppId', 'getPlugOptions'], $inner->calls); } #[TestDox('Does not run the callback for a static content fetch.')] From db1a74c8bfb05fcb12f8d87182d8a6b0ee560dab Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Sat, 6 Jun 2026 13:39:09 -0300 Subject: [PATCH 06/18] Add abstrations --- phpstan.neon.dist | 1 + src/ApiClient.php | 2 +- src/ContentFetcher.php | 2 +- src/CroctScript.php | 52 +++++++++++++++++++++++++++++++++ src/Evaluator.php | 2 +- src/IdentityResolver.php | 16 ++++++++++ src/LocaleResolver.php | 16 ++++++++++ src/PsrApiClient.php | 2 +- src/VaryingResponseObserver.php | 7 ++--- tests/ApiKeyTest.php | 22 +++++++------- 10 files changed, 103 insertions(+), 19 deletions(-) create mode 100644 src/CroctScript.php create mode 100644 src/IdentityResolver.php create mode 100644 src/LocaleResolver.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 65feb88..550b30d 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -11,6 +11,7 @@ parameters: - LogicException - ArithmeticError - Random\RandomException + - JsonException check: missingCheckedExceptionInThrows: true tooWideThrowType: true diff --git a/src/ApiClient.php b/src/ApiClient.php index 32f297d..386aa84 100644 --- a/src/ApiClient.php +++ b/src/ApiClient.php @@ -18,7 +18,7 @@ interface ApiClient * Sends a request to the given API path and returns the decoded response. * * @param array $payload The request body. - * @param array $headers Request-specific headers; null values are skipped. + * @param array $headers Request-specific headers. Null values are skipped. * * @throws ApiException If the request fails or the API returns an error. */ diff --git a/src/ContentFetcher.php b/src/ContentFetcher.php index be42181..625ac5e 100644 --- a/src/ContentFetcher.php +++ b/src/ContentFetcher.php @@ -14,7 +14,7 @@ interface ContentFetcher /** * Fetches the content of a slot. * - * Returns the configured fallback if the fetch fails; otherwise it throws. + * Returns the configured fallback if the fetch fails, otherwise it throws. * * @throws ContentException If the request fails without a fallback. */ diff --git a/src/CroctScript.php b/src/CroctScript.php new file mode 100644 index 0000000..5a9c95d --- /dev/null +++ b/src/CroctScript.php @@ -0,0 +1,52 @@ + */ + private array $options; + + private ?string $nonce; + + /** + * @param array $options + */ + public function __construct(string $scriptSrc, array $options, ?string $nonce = null) + { + $this->scriptSrc = $scriptSrc; + $this->options = $options; + $this->nonce = $nonce; + } + + public function __toString(): string + { + $nonceAttribute = $this->nonce === null + ? '' + : \sprintf(' nonce="%s"', \htmlspecialchars($this->nonce, ENT_QUOTES)); + + $options = \json_encode( + $this->options, + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP, + ); + + return \sprintf( + '' + . 'document.currentScript.previousElementSibling.onload=()=>croct.plug(%s)', + \htmlspecialchars($this->scriptSrc, ENT_QUOTES), + $nonceAttribute, + $nonceAttribute, + $options, + ); + } +} diff --git a/src/Evaluator.php b/src/Evaluator.php index add6845..378991f 100644 --- a/src/Evaluator.php +++ b/src/Evaluator.php @@ -14,7 +14,7 @@ interface Evaluator /** * Evaluates a CQL query and returns its result. * - * Returns the configured fallback if the evaluation fails; otherwise it throws. + * Returns the configured fallback if the evaluation fails, otherwise it throws. * * @throws EvaluationException If the query is invalid or the request fails without a fallback. */ diff --git a/src/IdentityResolver.php b/src/IdentityResolver.php new file mode 100644 index 0000000..39be45e --- /dev/null +++ b/src/IdentityResolver.php @@ -0,0 +1,16 @@ +requestFactory->createRequest('POST', $url) ->withHeader('Content-Type', 'application/json') - // Responses are per-visitor; never let a shared HTTP cache store them. + // Responses are per-visitor. Never let a shared HTTP cache store them. ->withHeader('Cache-Control', 'no-store') ->withHeader(HttpHeader::CLIENT_LIBRARY->value, $this->clientLibrary) ->withHeader(HttpHeader::API_KEY->value, $this->apiKey->getIdentifier()) diff --git a/src/VaryingResponseObserver.php b/src/VaryingResponseObserver.php index 52947e8..b14cf8d 100644 --- a/src/VaryingResponseObserver.php +++ b/src/VaryingResponseObserver.php @@ -7,10 +7,9 @@ /** * Decorates another plug instance to detect when the response is varying by visitor. * - * It runs a callback whenever visitor-specific data is read or changed. Operations that return the - * same result for every visitor — reading the application ID or the plug options, and static content - * fetches — do not run it. It is useful for integrations that need to keep the response out of shared - * caches. + * It runs a callback whenever visitor-specific data is read or changed. Reading the application ID + * or the plug options and fetching static content return the same result for every visitor, so they + * do not run it. It is useful for integrations that need to keep the response out of shared caches. */ final class VaryingResponseObserver implements Plug { diff --git a/tests/ApiKeyTest.php b/tests/ApiKeyTest.php index fc107d8..2434e42 100644 --- a/tests/ApiKeyTest.php +++ b/tests/ApiKeyTest.php @@ -169,16 +169,16 @@ public function testCachesLoadedPrivateKey(): void $first = $apiKey->sign('header.payload'); $second = $apiKey->sign('header.payload'); - foreach ([$first, $second] as $signature) { - self::assertSame( - 1, - \openssl_verify( - 'header.payload', - EcKeyFactory::rawToDer($signature), - $publicKey, - \OPENSSL_ALGO_SHA256, - ), - ); - } + $verifications = \array_map( + static fn (string $signature): int|false => \openssl_verify( + 'header.payload', + EcKeyFactory::rawToDer($signature), + $publicKey, + \OPENSSL_ALGO_SHA256, + ), + [$first, $second], + ); + + self::assertSame([1, 1], $verifications); } } From 96299763fed20d241a7612a7a6e893aa7b254f72 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Mon, 8 Jun 2026 11:03:54 -0300 Subject: [PATCH 07/18] Wip --- composer.json | 6 +- composer.lock | 596 +++++++++++++++++++++++++++++- phpstan.neon.dist | 2 + src/CroctScript.php | 7 +- src/CroctScriptProvider.php | 120 ++++++ src/CroctScriptResponse.php | 46 +++ src/RequestContext.php | 10 +- tests/CroctScriptProviderTest.php | 196 ++++++++++ tests/CroctScriptResponseTest.php | 25 ++ tests/CroctScriptTest.php | 56 +++ tests/RequestContextTest.php | 8 + 11 files changed, 1065 insertions(+), 7 deletions(-) create mode 100644 src/CroctScriptProvider.php create mode 100644 src/CroctScriptResponse.php create mode 100644 tests/CroctScriptProviderTest.php create mode 100644 tests/CroctScriptResponseTest.php create mode 100644 tests/CroctScriptTest.php diff --git a/composer.json b/composer.json index 15f313a..bb47908 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,8 @@ "psr/http-client": "^1.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.1 || ^2.0", - "psr/log": "^2.0 || ^3.0" + "psr/log": "^2.0 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" }, "require-dev": { "croct/coding-standard": "^0.4.5", @@ -44,7 +45,8 @@ "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^11.0", - "squizlabs/php_codesniffer": "^4.0" + "squizlabs/php_codesniffer": "^4.0", + "symfony/cache": "^6.4 || ^7.0" }, "suggest": { "guzzlehttp/guzzle": "A PSR-18 HTTP client used to talk to the Croct API (any PSR-18 client works).", diff --git a/composer.lock b/composer.lock index fcc1e55..a8b5532 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "992b9f692e92e9cc9abc6f49fb8a0abf", + "content-hash": "4e21de2ed1853d5654a683e1b68d30c6", "packages": [ { "name": "php-http/discovery", @@ -294,6 +294,57 @@ "source": "https://github.com/php-fig/log/tree/3.0.2" }, "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" } ], "packages-dev": [ @@ -2932,6 +2983,108 @@ ], "time": "2026-02-18T12:37:06+00:00" }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+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": "ralouphie/getallheaders", "version": "3.0.3", @@ -4158,6 +4311,190 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/cache", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "4c09e18a92cce126cc0d1155825279fca8cd0673" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/4c09e18a92cce126cc0d1155825279fca8cd0673", + "reference": "4c09e18a92cce126cc0d1155825279fca8cd0673", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "ext-redis": "<6.1", + "ext-relay": "<0.12.1", + "symfony/dependency-injection": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/var-dumper": "<6.4" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v7.4.13" + }, + "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-24T08:43:14+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "225e8a254166bd3442e370c6f50145465db63831" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/225e8a254166bd3442e370c6f50145465db63831", + "reference": "225e8a254166bd3442e370c6f50145465db63831", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.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-05T15:33:14+00:00" + }, { "name": "symfony/deprecation-contracts", "version": "v3.7.0", @@ -4300,6 +4637,93 @@ ], "time": "2026-05-29T05:06:50+00:00" }, + { + "name": "symfony/polyfill-deepclone", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-deepclone.git", + "reference": "2ca9e9e75ead5174f2b44613a646bdc9338b8eb4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/2ca9e9e75ead5174f2b44613a646bdc9338b8eb4", + "reference": "2ca9e9e75ead5174f2b44613a646bdc9338b8eb4", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "provide": { + "ext-deepclone": "*" + }, + "suggest": { + "ext-deepclone": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\DeepClone\\": "" + }, + "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 the deepclone extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "deepclone", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-deepclone/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-26T13:03:27+00:00" + }, { "name": "symfony/polyfill-php80", "version": "v1.37.0", @@ -4384,6 +4808,176 @@ ], "time": "2026-04-10T16:19:22+00:00" }, + { + "name": "symfony/service-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.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-03-28T09:44:51+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "2dd18582c5f6c024db9fc0ff9c76d873af726f34" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/2dd18582c5f6c024db9fc0ff9c76d873af726f34", + "reference": "2dd18582c5f6c024db9fc0ff9c76d873af726f34", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-deepclone": "^1.37" + }, + "require-dev": { + "symfony/property-access": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to export, instantiate, hydrate, clone and lazy-load PHP objects", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "deep-clone", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v8.1.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-29T05:06:50+00:00" + }, { "name": "theseer/tokenizer", "version": "1.3.1", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 550b30d..fa9a317 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -12,6 +12,8 @@ parameters: - ArithmeticError - Random\RandomException - JsonException + - Psr\Http\Client\ClientExceptionInterface + - Psr\SimpleCache\InvalidArgumentException check: missingCheckedExceptionInThrows: true tooWideThrowType: true diff --git a/src/CroctScript.php b/src/CroctScript.php index 5a9c95d..0765e11 100644 --- a/src/CroctScript.php +++ b/src/CroctScript.php @@ -12,6 +12,11 @@ */ final class CroctScript implements \Stringable { + /** + * The default URL of the client-side SDK loader. + */ + public const DEFAULT_SCRIPT_URL = 'https://cdn.croct.io/js/v1/lib/plug.js'; + private string $scriptSrc; /** @var array */ @@ -37,7 +42,7 @@ public function __toString(): string $options = \json_encode( $this->options, - JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP, + \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES | \JSON_HEX_TAG | \JSON_HEX_AMP, ); return \sprintf( diff --git a/src/CroctScriptProvider.php b/src/CroctScriptProvider.php new file mode 100644 index 0000000..b01c466 --- /dev/null +++ b/src/CroctScriptProvider.php @@ -0,0 +1,120 @@ +httpClient = $httpClient; + $this->requestFactory = $requestFactory; + $this->cache = $cache; + $this->scriptUrl = $scriptUrl; + } + + /** + * @param array $requestHeaders The visitor request headers, keyed by lower-case name. + */ + public function load(array $requestHeaders): CroctScriptResponse + { + $encoding = $requestHeaders['accept-encoding'] ?? ''; + $key = 'croct.plug_script.' . \hash('xxh128', $this->scriptUrl . '|' . $encoding); + + $cached = $this->cache->get($key); + + if ($cached instanceof CroctScriptResponse) { + return $cached; + } + + $request = $this->requestFactory->createRequest('GET', $this->scriptUrl); + + foreach ($requestHeaders as $name => $value) { + if (!\in_array(\strtolower($name), self::UNFORWARDED_REQUEST_HEADERS, true)) { + $request = $request->withHeader($name, $value); + } + } + + $response = $this->httpClient->sendRequest($request); + + $headers = []; + + foreach ($response->getHeaders() as $name => $values) { + // PSR-7 types header names loosely; they are strings at runtime. + $header = (string) $name; + + if (!\in_array(\strtolower($header), self::UNRELAYED_RESPONSE_HEADERS, true)) { + $headers[$header] = \implode(', ', $values); + } + } + + $content = new CroctScriptResponse($response->getStatusCode(), $headers, (string) $response->getBody()); + + // Only cache successful responses so a transient upstream error is not pinned for the whole TTL. + if ($response->getStatusCode() >= 200 && $response->getStatusCode() < 300) { + $this->cache->set($key, $content, self::TTL); + } + + return $content; + } +} diff --git a/src/CroctScriptResponse.php b/src/CroctScriptResponse.php new file mode 100644 index 0000000..9d7ea89 --- /dev/null +++ b/src/CroctScriptResponse.php @@ -0,0 +1,46 @@ + */ + private array $headers; + + private string $content; + + /** + * @param array $headers + */ + public function __construct(int $statusCode, array $headers, string $content) + { + $this->statusCode = $statusCode; + $this->headers = $headers; + $this->content = $content; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + /** + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } + + public function getContent(): string + { + return $this->content; + } +} diff --git a/src/RequestContext.php b/src/RequestContext.php index d847440..4a23d39 100644 --- a/src/RequestContext.php +++ b/src/RequestContext.php @@ -14,7 +14,7 @@ */ final class RequestContext { - private const PREVIEW_QUERY_PARAMETER = 'croct-preview'; + public const PREVIEW_QUERY_PARAMETER = 'croct-preview'; private const PREVIEW_EXIT = 'exit'; @@ -194,9 +194,13 @@ public function toEvaluationContext(array $attributes = []): array } /** - * Resolves the preview token from the request, treating the preview-exit sentinel as no preview. + * Resolves the preview token from a raw request value. + * + * @param string|null $token The raw `croct-preview` value, or null if absent. + * + * @return string|null The preview token, or null if not previewing. */ - private static function resolvePreviewToken(?string $token): ?string + public static function resolvePreviewToken(?string $token): ?string { return $token === null || $token === self::PREVIEW_EXIT ? null : $token; } diff --git a/tests/CroctScriptProviderTest.php b/tests/CroctScriptProviderTest.php new file mode 100644 index 0000000..dedd79f --- /dev/null +++ b/tests/CroctScriptProviderTest.php @@ -0,0 +1,196 @@ +factory = new Psr17Factory(); + $this->httpClient = new MockClient(); + } + + #[TestDox('Forwards the visitor headers upstream and relays the captured response verbatim.')] + public function testForwardsHeadersAndRelaysResponse(): void + { + $this->httpClient->addResponse( + $this->factory->createResponse(200) + ->withHeader('Content-Type', 'text/javascript') + ->withHeader('Content-Encoding', 'br') + ->withBody($this->factory->createStream('// plug')), + ); + + $content = $this->provider()->load([ + 'accept-encoding' => 'gzip, br', + 'user-agent' => 'Test/1.0', + ]); + + self::assertSame(200, $content->getStatusCode()); + self::assertSame('// plug', $content->getContent()); + self::assertSame('text/javascript', $content->getHeaders()['Content-Type']); + self::assertSame('br', $content->getHeaders()['Content-Encoding']); + + $request = $this->httpClient->getLastRequest(); + + self::assertInstanceOf(RequestInterface::class, $request); + self::assertSame('gzip, br', $request->getHeaderLine('Accept-Encoding')); + self::assertSame('Test/1.0', $request->getHeaderLine('User-Agent')); + } + + /** + * @return array + */ + public static function unforwardedRequestHeaders(): array + { + return self::createDataset([ + 'host', + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'content-length', + 'if-none-match', + 'if-modified-since', + 'if-match', + 'if-unmodified-since', + 'if-range', + ]); + } + + #[DataProvider('unforwardedRequestHeaders')] + #[TestDox('Never forwards hop-by-hop, host or conditional request headers upstream.')] + public function testDoesNotForwardRequestHeader(string $header): void + { + $this->httpClient->addResponse($this->createOkResponse('// plug')); + + $this->provider()->load([ + 'accept-encoding' => 'gzip', + $header => 'sentinel', + ]); + + $request = $this->httpClient->getLastRequest(); + + self::assertInstanceOf(RequestInterface::class, $request); + self::assertNotSame('sentinel', $request->getHeaderLine($header)); + } + + /** + * @return array + */ + public static function unrelayedResponseHeaders(): array + { + return self::createDataset([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'content-length', + 'set-cookie', + ]); + } + + #[DataProvider('unrelayedResponseHeaders')] + #[TestDox('Never relays hop-by-hop, framework-managed or cookie response headers back.')] + public function testDoesNotRelayResponseHeader(string $header): void + { + $this->httpClient->addResponse( + $this->factory->createResponse(200) + ->withHeader($header, 'sentinel') + ->withBody($this->factory->createStream('// plug')), + ); + + $content = $this->provider()->load(['accept-encoding' => 'gzip']); + + self::assertArrayNotHasKey($header, \array_change_key_case($content->getHeaders())); + } + + #[TestDox('Caches per Accept-Encoding and reuses the cached response.')] + public function testCachesByAcceptEncoding(): void + { + $this->httpClient->addResponse($this->createOkResponse('brotli')); + $this->httpClient->addResponse($this->createOkResponse('gzipped')); + + $provider = $this->provider(); + + self::assertSame('brotli', $provider->load(['accept-encoding' => 'br'])->getContent()); + self::assertSame('brotli', $provider->load(['accept-encoding' => 'br'])->getContent()); + self::assertSame('gzipped', $provider->load(['accept-encoding' => 'gzip'])->getContent()); + self::assertCount(2, $this->httpClient->getRequests()); + } + + #[TestDox('Does not cache an unsuccessful upstream response.')] + public function testDoesNotCacheErrors(): void + { + $error = $this->factory->createResponse(503)->withBody($this->factory->createStream('down')); + + $this->httpClient->addResponse($error); + $this->httpClient->addResponse($this->createOkResponse('// plug')); + + $provider = $this->provider(); + + self::assertSame(503, $provider->load(['accept-encoding' => 'br'])->getStatusCode()); + self::assertSame('// plug', $provider->load(['accept-encoding' => 'br'])->getContent()); + self::assertCount(2, $this->httpClient->getRequests()); + } + + /** + * @param list $headers + * + * @return array + */ + private static function createDataset(array $headers): array + { + $datasets = []; + + foreach ($headers as $header) { + $datasets[$header] = [$header]; + } + + return $datasets; + } + + private function provider(): CroctScriptProvider + { + return new CroctScriptProvider( + $this->httpClient, + $this->factory, + new Psr16Cache(new ArrayAdapter()), + self::SCRIPT_URL, + ); + } + + private function createOkResponse(string $body): ResponseInterface + { + return $this->factory->createResponse(200)->withBody($this->factory->createStream($body)); + } +} diff --git a/tests/CroctScriptResponseTest.php b/tests/CroctScriptResponseTest.php new file mode 100644 index 0000000..a3cae5b --- /dev/null +++ b/tests/CroctScriptResponseTest.php @@ -0,0 +1,25 @@ + 'text/javascript'], '// plug'); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame(['Content-Type' => 'text/javascript'], $response->getHeaders()); + self::assertSame('// plug', $response->getContent()); + } +} diff --git a/tests/CroctScriptTest.php b/tests/CroctScriptTest.php new file mode 100644 index 0000000..84f104f --- /dev/null +++ b/tests/CroctScriptTest.php @@ -0,0 +1,56 @@ + 'app-1', + 'disableCidMirroring' => true, + ], + ); + + self::assertSame( + '' + . '', + $html, + ); + } + + #[TestDox('Adds the CSP nonce to both tags when provided.')] + public function testAddsNonceToBothTags(): void + { + $html = (string) new CroctScript('https://cdn.example/plug.js', ['appId' => 'app-1'], 'r4nd0m'); + + self::assertStringContainsString( + '', + $html, + ); + self::assertStringContainsString('']); + + self::assertStringContainsString('?a=1&b=2', $html); + self::assertStringContainsString('', $html); + self::assertStringNotContainsString('""', $html); + } +} diff --git a/tests/RequestContextTest.php b/tests/RequestContextTest.php index cf8a109..84d6e89 100644 --- a/tests/RequestContextTest.php +++ b/tests/RequestContextTest.php @@ -146,6 +146,14 @@ public function testReadsPreviewTokenFromServerRequest(): void self::assertSame('preview-jwt', RequestContext::fromServerRequest($request)->getPreviewToken()); } + #[TestDox('Resolves a raw preview value, treating the exit sentinel and an absent value as no preview.')] + public function testResolvesPreviewToken(): void + { + self::assertSame('preview-jwt', RequestContext::resolvePreviewToken('preview-jwt')); + self::assertNull(RequestContext::resolvePreviewToken('exit')); + self::assertNull(RequestContext::resolvePreviewToken(null)); + } + /** * @param array $server * @param callable(): RequestContext $callback From e22f328bae375f083040752c7953c46473702f3e Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Mon, 8 Jun 2026 16:32:06 -0300 Subject: [PATCH 08/18] Wip --- src/Croct.php | 3 +- src/Session.php | 42 +++++++++++++------------- tests/SessionTest.php | 68 +++++++++++++++++++++++++++++++++++-------- 3 files changed, 79 insertions(+), 34 deletions(-) diff --git a/src/Croct.php b/src/Croct.php index 3f60b38..58f4a74 100644 --- a/src/Croct.php +++ b/src/Croct.php @@ -70,6 +70,7 @@ public static function plug( #[\SensitiveParameter] ApiKey|string $apiKey, IdentityStore $storage, + ?IdentityResolver $identity = null, ?string $baseEndpointUrl = null, int $tokenDuration = self::DEFAULT_TOKEN_DURATION, ?ContentProvider $contentProvider = null, @@ -83,7 +84,7 @@ public static function plug( $context ??= RequestContext::fromGlobals(); $baseEndpointUrl ??= self::DEFAULT_BASE_ENDPOINT_URL; - $session = new Session($appId, $key, $storage, $tokenDuration); + $session = new Session($appId, $key, $storage, $tokenDuration, identity: $identity); try { $httpClient ??= Psr18ClientDiscovery::find(); diff --git a/src/Session.php b/src/Session.php index 67dae33..cbc969b 100644 --- a/src/Session.php +++ b/src/Session.php @@ -21,6 +21,8 @@ final class Session implements IdentityStore private bool $signTokens; + private ?IdentityResolver $identity; + public function __construct( string $appId, ApiKey $apiKey, @@ -28,6 +30,7 @@ public function __construct( int $tokenDuration = 86400, ?bool $signTokens = null, ?int $now = null, + ?IdentityResolver $identity = null, ) { $this->appId = $appId; $this->apiKey = $apiKey; @@ -35,6 +38,7 @@ public function __construct( $this->tokenDuration = $tokenDuration; $this->now = $now; $this->signTokens = $signTokens ?? $apiKey->hasPrivateKey(); + $this->identity = $identity; } /** @@ -61,7 +65,7 @@ public function getClientId(): Uuid public function getUserToken(): Token { $stored = $this->store->getUserToken(); - $token = $this->reissue($stored); + $token = $this->resolveToken($stored); if ($stored === null || !$token->equals($stored)) { $this->saveUserToken($token); @@ -103,37 +107,33 @@ public function anonymize(): void } /** - * Reissues the user token when the stored one is absent or no longer usable. + * Resolves the token to use, reissuing the stored one when it is absent or no longer usable. */ - private function reissue(?Token $token): Token + private function resolveToken(?Token $token): Token { - if ($token === null) { - return $this->issueToken(); + $userId = $this->identity?->getUserId(); + $hasResolver = $this->identity !== null; + + // Re-issue for the authenticated user when the token is missing, no longer usable, or its + // subject no longer matches. + if ($token === null + || ($this->signTokens && !$token->isSigned()) + || !$token->isValidNow($this->now) + || ($hasResolver && ($userId === null ? !$token->isAnonymous() : !$token->isSubject($userId))) + ) { + return $this->issueToken($userId); } - // The token belongs to another application: start fresh and anonymous, never carrying its - // subject over, regardless of the token's expiration or signature state. + // The token belongs to another application: start fresh. $tokenAppId = $token->getApplicationId(); if ($tokenAppId !== null && $tokenAppId !== $this->appId) { - return $this->issueToken(); - } - - $subject = $token->getSubject(); - - // Upgrade an unsigned token to a signed one when signing is enabled. - if ($this->signTokens && !$token->isSigned()) { - return $this->issueToken($subject); - } - - // Refresh an expired (or not-yet-valid) token, preserving the subject. - if (!$token->isValidNow($this->now)) { - return $this->issueToken($subject); + return $this->issueToken($userId); } // Signed with a different key: re-sign, preserving the subject and token ID. if ($token->isSigned() && !$token->matchesKeyId($this->apiKey)) { - return $this->issueToken($subject, $token->getTokenId()); + return $this->issueToken($token->getSubject(), $token->getTokenId()); } return $token; diff --git a/tests/SessionTest.php b/tests/SessionTest.php index 3a00b9c..dd09bae 100644 --- a/tests/SessionTest.php +++ b/tests/SessionTest.php @@ -5,6 +5,7 @@ namespace Croct\Plug\Tests; use Croct\Plug\ApiKey; +use Croct\Plug\IdentityResolver; use Croct\Plug\InMemoryIdentityStore; use Croct\Plug\Session; use Croct\Plug\Token; @@ -72,24 +73,62 @@ public function testKeepsValidToken(): void self::assertSame($token->toString(), $session->getUserToken()->toString()); } - #[TestDox('Carries the subject over when refreshing an expired token.')] - public function testCarriesOverSubjectFromExpiredToken(): void + #[TestDox('Anonymizes an expired token when no user is resolved.')] + public function testAnonymizesExpiredTokenWithoutResolver(): void { $expired = Token::issue(appId: self::APP_ID, subject: 'user-9', now: 100)->withDuration(86400, 100); $session = $this->createSession(null, $expired, now: 200000); - self::assertSame('user-9', $session->getUserToken()->getSubject()); + self::assertTrue($session->getUserToken()->isAnonymous()); self::assertTrue($session->getUserToken()->isValidNow(200000)); } - #[TestDox('Upgrades an unsigned token to a signed one.')] + #[TestDox('Identifies the visitor from the resolver when no token is stored.')] + public function testIdentifiesFromResolverWithoutToken(): void + { + $session = $this->createSession(null, null, identity: $this->resolver('alice')); + + self::assertSame('alice', $session->getUserToken()->getSubject()); + } + + #[TestDox('Re-identifies the visitor when the resolved user changes on login.')] + public function testReconcilesSubjectOnLogin(): void + { + $anonymous = Token::issue(appId: self::APP_ID, now: 1000)->withDuration(86400, 1000); + + $session = $this->createSession(null, $anonymous, identity: $this->resolver('alice')); + + self::assertSame('alice', $session->getUserToken()->getSubject()); + } + + #[TestDox('Anonymizes the visitor when the user logs out.')] + public function testReconcilesSubjectOnLogout(): void + { + $identified = Token::issue(appId: self::APP_ID, subject: 'alice', now: 1000)->withDuration(86400, 1000); + + $session = $this->createSession(null, $identified, identity: $this->resolver(null)); + + self::assertTrue($session->getUserToken()->isAnonymous()); + } + + #[TestDox('Keeps the token when the resolved user already matches.')] + public function testKeepsTokenWhenResolverMatches(): void + { + $identified = Token::issue(appId: self::APP_ID, subject: 'alice', now: 1000)->withDuration(86400, 1000); + + $session = $this->createSession(null, $identified, identity: $this->resolver('alice')); + + self::assertSame($identified->toString(), $session->getUserToken()->toString()); + } + + #[TestDox('Signs an unsigned token, keeping the resolved user.')] public function testUpgradesUnsignedTokenToSigned(): void { [$apiKey] = EcKeyFactory::create(); $unsigned = Token::issue(appId: self::APP_ID, subject: 'user-3', now: 1000)->withDuration(86400, 1000); - $session = $this->createSession(null, $unsigned, $apiKey); + $session = $this->createSession(null, $unsigned, $apiKey, identity: $this->resolver('user-3')); self::assertTrue($session->getUserToken()->isSigned()); self::assertSame('user-3', $session->getUserToken()->getSubject()); @@ -160,17 +199,12 @@ public function testReSignsTokenFromDifferentKey(): void self::assertSame('22222222-2222-4222-8222-222222222222', $token->getTokenId()); } - #[TestDox('Treats an empty subject as anonymous when reissuing.')] + #[TestDox('Treats an empty resolved user ID as anonymous.')] public function testTreatsEmptySubjectAsAnonymous(): void { [$sessionKey] = EcKeyFactory::create(); - $unsigned = Token::of( - ['typ' => 'JWT', 'alg' => 'none', 'appId' => self::APP_ID], - ['iss' => 'croct.io', 'aud' => 'croct.io', 'iat' => 1000, 'exp' => 87400, 'sub' => ''], - ); - - $token = $this->createSession(null, $unsigned, $sessionKey)->getUserToken(); + $token = $this->createSession(null, null, $sessionKey, identity: $this->resolver(''))->getUserToken(); self::assertTrue($token->isSigned()); self::assertTrue($token->isAnonymous()); @@ -181,6 +215,7 @@ private function createSession( ?Token $userToken = null, ?ApiKey $apiKey = null, int $now = 1000, + ?IdentityResolver $identity = null, ): Session { return new Session( appId: self::APP_ID, @@ -189,6 +224,15 @@ private function createSession( tokenDuration: 86400, signTokens: null, now: $now, + identity: $identity, ); } + + private function resolver(?string $userId): IdentityResolver + { + $identity = $this->createMock(IdentityResolver::class); + $identity->method('getUserId')->willReturn($userId); + + return $identity; + } } From 6d1d2d98d85c9f5fdd252a5c2f070f88a024cdfd Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Tue, 9 Jun 2026 12:23:13 -0300 Subject: [PATCH 09/18] Declare ext-mbstring and ext-hash in composer.json HttpEvaluator uses mb_strlen() and CroctScriptProvider/ApiKey use hash(), so declare ext-mbstring and ext-hash as runtime requirements to prevent incompatible installs. Addresses PR review feedback. Co-Authored-By: Claude Opus 4.8 (1M context) --- composer.json | 2 ++ composer.lock | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index bb47908..a3306ac 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,9 @@ }, "require": { "php": "^8.2", + "ext-hash": "*", "ext-json": "*", + "ext-mbstring": "*", "ext-openssl": "*", "composer-runtime-api": "^2.0", "php-http/discovery": "^1.19", diff --git a/composer.lock b/composer.lock index a8b5532..b9ccb4e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4e21de2ed1853d5654a683e1b68d30c6", + "content-hash": "6b33b6c1609b77e9c55f8a0eb3914e44", "packages": [ { "name": "php-http/discovery", @@ -5036,7 +5036,9 @@ "prefer-lowest": false, "platform": { "php": "^8.2", + "ext-hash": "*", "ext-json": "*", + "ext-mbstring": "*", "ext-openssl": "*", "composer-runtime-api": "^2.0" }, From 10631c1730073d6bd67e67a1730255d00334a275 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Tue, 9 Jun 2026 12:30:45 -0300 Subject: [PATCH 10/18] Harden token re-signing and isolate superglobals in tests Validate the preserved token ID when re-signing a token issued under a different key. Token::parse() does not validate the "jti" claim, so a tampered cookie carrying a non-UUID token ID would make withTokenId() throw and break session resolution; fall back to a fresh UUID instead. Also save and restore $_SERVER/$_GET/$_COOKIE in the request/cookie tests so they no longer leak superglobal state across the suite. Addresses PR review feedback. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Session.php | 10 ++++++++-- tests/CookieStorageTest.php | 9 +++++++-- tests/RequestContextTest.php | 10 +++++++++- tests/SessionTest.php | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/Session.php b/src/Session.php index cbc969b..45cbc85 100644 --- a/src/Session.php +++ b/src/Session.php @@ -131,9 +131,15 @@ private function resolveToken(?Token $token): Token return $this->issueToken($userId); } - // Signed with a different key: re-sign, preserving the subject and token ID. + // Signed with a different key: re-sign, preserving the subject and token ID. The token ID + // comes from untrusted input, so a fresh one is generated when it is not a valid UUID. if ($token->isSigned() && !$token->matchesKeyId($this->apiKey)) { - return $this->issueToken($token->getSubject(), $token->getTokenId()); + $tokenId = $token->getTokenId(); + + return $this->issueToken( + $token->getSubject(), + $tokenId !== null && Uuid::isValid($tokenId) ? $tokenId : null, + ); } return $token; diff --git a/tests/CookieStorageTest.php b/tests/CookieStorageTest.php index 52e8096..b7ce291 100644 --- a/tests/CookieStorageTest.php +++ b/tests/CookieStorageTest.php @@ -158,11 +158,16 @@ public function testEmitsResponseCookies(): void #[TestDox('Reads the cookies from the request superglobals.')] public function testReadsFromGlobals(): void { + $originalCookie = $_COOKIE; $_COOKIE = ['ct.client_id' => self::CLIENT_ID]; - $storage = CookieStorage::fromGlobals(); + try { + $storage = CookieStorage::fromGlobals(); - self::assertSame(self::CLIENT_ID, $storage->getClientId()?->toString()); + self::assertSame(self::CLIENT_ID, $storage->getClientId()?->toString()); + } finally { + $_COOKIE = $originalCookie; + } } #[TestDox('Reads the cookies from a server request.')] diff --git a/tests/RequestContextTest.php b/tests/RequestContextTest.php index 84d6e89..155565c 100644 --- a/tests/RequestContextTest.php +++ b/tests/RequestContextTest.php @@ -170,9 +170,17 @@ private static function withServer(array $server, callable $callback): RequestCo */ private static function withGlobals(array $server, array $query, callable $callback): RequestContext { + $originalServer = $_SERVER; + $originalQuery = $_GET; + $_SERVER = $server; $_GET = $query; - return $callback(); + try { + return $callback(); + } finally { + $_SERVER = $originalServer; + $_GET = $originalQuery; + } } } diff --git a/tests/SessionTest.php b/tests/SessionTest.php index dd09bae..88132f3 100644 --- a/tests/SessionTest.php +++ b/tests/SessionTest.php @@ -199,6 +199,38 @@ public function testReSignsTokenFromDifferentKey(): void self::assertSame('22222222-2222-4222-8222-222222222222', $token->getTokenId()); } + #[TestDox('Generates a fresh token ID when re-signing a token whose ID is not a valid UUID.')] + public function testReSignsTokenWithInvalidTokenId(): void + { + [$sessionKey] = EcKeyFactory::create(); + [$otherKey] = EcKeyFactory::create('11111111-1111-4111-8111-111111111111'); + + // A tampered cookie can carry a non-UUID "jti", which Token::parse() does not reject. + $foreign = Token::of( + ['typ' => 'JWT', 'alg' => 'none', 'appId' => self::APP_ID], + [ + 'iss' => 'croct.io', + 'aud' => 'croct.io', + 'iat' => 1000, + 'exp' => 1000 + 86400, + 'sub' => 'user-1', + 'jti' => 'not-a-uuid', + ], + )->signedWith($otherKey); + + $token = $this->createSession(null, $foreign, $sessionKey)->getUserToken(); + + self::assertTrue($token->isSigned()); + self::assertTrue($token->matchesKeyId($sessionKey)); + self::assertSame('user-1', $token->getSubject()); + + $tokenId = $token->getTokenId(); + + self::assertNotNull($tokenId); + self::assertNotSame('not-a-uuid', $tokenId); + self::assertTrue(Uuid::isValid($tokenId)); + } + #[TestDox('Treats an empty resolved user ID as anonymous.')] public function testTreatsEmptySubjectAsAnonymous(): void { From 6a1500df6275f313446e02cdd4878fa930b00961 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Tue, 9 Jun 2026 12:38:24 -0300 Subject: [PATCH 11/18] Wip --- phpstan.neon.dist | 1 - src/CroctScriptProvider.php | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index fa9a317..a8a3cb0 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -12,7 +12,6 @@ parameters: - ArithmeticError - Random\RandomException - JsonException - - Psr\Http\Client\ClientExceptionInterface - Psr\SimpleCache\InvalidArgumentException check: missingCheckedExceptionInThrows: true diff --git a/src/CroctScriptProvider.php b/src/CroctScriptProvider.php index b01c466..e845b8a 100644 --- a/src/CroctScriptProvider.php +++ b/src/CroctScriptProvider.php @@ -4,6 +4,7 @@ namespace Croct\Plug; +use Psr\Http\Client\ClientExceptionInterface as ClientException; use Psr\Http\Client\ClientInterface as HttpClient; use Psr\Http\Message\RequestFactoryInterface as RequestFactory; use Psr\SimpleCache\CacheInterface as Cache; @@ -75,6 +76,8 @@ public function __construct(HttpClient $httpClient, RequestFactory $requestFacto /** * @param array $requestHeaders The visitor request headers, keyed by lower-case name. + * + * @throws ClientException If the upstream request fails. */ public function load(array $requestHeaders): CroctScriptResponse { From 5c9ad815ceab77e5e74c6e9d75829de4a022ed09 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Tue, 9 Jun 2026 13:34:07 -0300 Subject: [PATCH 12/18] Add PHP Stan and Psalm plugins --- composer.json | 14 +- composer.lock | 7380 ++++++++++++++------- extension.neon | 5 + src/PhpStan/ContentStubFilesExtension.php | 35 + src/Psalm/ContentStubPlugin.php | 36 + 5 files changed, 5222 insertions(+), 2248 deletions(-) create mode 100644 extension.neon create mode 100644 src/PhpStan/ContentStubFilesExtension.php create mode 100644 src/Psalm/ContentStubPlugin.php diff --git a/composer.json b/composer.json index a3306ac..1561159 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" }, "require-dev": { + "ext-simplexml": "*", "croct/coding-standard": "^0.4.5", "ergebnis/composer-normalize": "^2.28", "guzzlehttp/guzzle": "^7.5", @@ -48,7 +49,8 @@ "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^11.0", "squizlabs/php_codesniffer": "^4.0", - "symfony/cache": "^6.4 || ^7.0" + "symfony/cache": "^6.4 || ^7.0", + "vimeo/psalm": "^5.26 || ^6.0" }, "suggest": { "guzzlehttp/guzzle": "A PSR-18 HTTP client used to talk to the Croct API (any PSR-18 client works).", @@ -81,6 +83,16 @@ "phpstan/extension-installer": true } }, + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "psalm": { + "pluginClass": "Croct\\Plug\\Psalm\\ContentStubPlugin" + } + }, "scripts": { "test": [ "composer validate", diff --git a/composer.lock b/composer.lock index b9ccb4e..d386fb7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6b33b6c1609b77e9c55f8a0eb3914e44", + "content-hash": "74746d27ce51ed7e1bf2f7cb872354dd", "packages": [ { "name": "php-http/discovery", @@ -349,32 +349,37 @@ ], "packages-dev": [ { - "name": "clue/stream-filter", - "version": "v1.7.0", + "name": "amphp/amp", + "version": "v3.1.1", "source": { "type": "git", - "url": "https://github.com/clue/stream-filter.git", - "reference": "049509fef80032cb3f051595029ab75b49a3c2f7" + "url": "https://github.com/amphp/amp.git", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/clue/stream-filter/zipball/049509fef80032cb3f051595029ab75b49a3c2f7", - "reference": "049509fef80032cb3f051595029ab75b49a3c2f7", + "url": "https://api.github.com/repos/amphp/amp/zipball/fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f", "shasum": "" }, "require": { - "php": ">=5.3" + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23.1" }, "type": "library", "autoload": { "files": [ - "src/functions_include.php" + "src/functions.php", + "src/Future/functions.php", + "src/Internal/functions.php" ], "psr-4": { - "Clue\\StreamFilter\\": "src/" + "Amp\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -383,154 +388,153 @@ ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering" + "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 simple and modern approach to stream filtering in PHP", - "homepage": "https://github.com/clue/stream-filter", + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", "keywords": [ - "bucket brigade", - "callback", - "filter", - "php_user_filter", - "stream", - "stream_filter_append", - "stream_filter_register" + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" ], "support": { - "issues": "https://github.com/clue/stream-filter/issues", - "source": "https://github.com/clue/stream-filter/tree/v1.7.0" + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v3.1.1" }, "funding": [ { - "url": "https://clue.engineering/support", - "type": "custom" - }, - { - "url": "https://github.com/clue", + "url": "https://github.com/amphp", "type": "github" } ], - "time": "2023-12-20T15:40:13+00:00" + "time": "2025-08-27T21:42:00+00:00" }, { - "name": "croct/coding-standard", - "version": "0.4.5", + "name": "amphp/byte-stream", + "version": "v2.1.2", "source": { "type": "git", - "url": "git@github.com:croct-tech/coding-standard-php.git", - "reference": "cdd2d44ac4801137e52d21f27b3be22afe80144a" + "url": "https://github.com/amphp/byte-stream.git", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/croct-tech/coding-standard-php/zipball/cdd2d44ac4801137e52d21f27b3be22afe80144a", - "reference": "cdd2d44ac4801137e52d21f27b3be22afe80144a", - "shasum": "", - "mirrors": [ - { - "url": "https://croct.repo.repman.io/dists/%package%/%version%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "shasum": "" }, "require": { - "php": "^8.5", - "slevomat/coding-standard": "^8.28", - "squizlabs/php_codesniffer": "^4.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": { - "ergebnis/composer-normalize": "^2.28", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-deprecation-rules": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^13.1" + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.22.1" }, - "type": "phpcodesniffer-standard", + "type": "library", "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], "psr-4": { - "Croct\\": "src/Croct/" + "Amp\\ByteStream\\": "src" } }, - "autoload-dev": { - "psr-4": { - "Croct\\Tests\\": "tests/" - }, - "files": [ - "vendor/squizlabs/php_codesniffer/autoload.php" - ] - }, - "notification-url": "https://croct.repo.repman.io/downloads", - "scripts": { - "test": [ - "composer validate", - "composer normalize --dry-run", - "mkdir -p .cache", - "phpcs", - "phpstan analyse", - "./run-sniffs-tests.sh" - ] - }, + "notification-url": "https://packagist.org/downloads/", "license": [ - "proprietary" + "MIT" ], "authors": [ { - "name": "Croct", - "email": "lib+coding-standard-php@croct.com", - "homepage": "https://croct.com" + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" } ], - "description": "A set of Code Sniffer rules applied to all Croct PHP projects.", - "homepage": "https://github.com/croct-tech/coding-standard-php", + "description": "A stream abstraction to make working with non-blocking I/O simple.", + "homepage": "https://amphp.org/byte-stream", "keywords": [ - "coding", - "croct", - "phpcs", - "standard" + "amp", + "amphp", + "async", + "io", + "non-blocking", + "stream" ], "support": { - "source": "https://github.com/croct-tech/coding-standard-php/tree/0.4.5", - "issues": "https://github.com/croct-tech/coding-standard-php/issues" + "issues": "https://github.com/amphp/byte-stream/issues", + "source": "https://github.com/amphp/byte-stream/tree/v2.1.2" }, - "time": "2026-05-06T14:38:44+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T17:10:27+00:00" }, { - "name": "dealerdirect/phpcodesniffer-composer-installer", - "version": "v1.2.1", + "name": "amphp/cache", + "version": "v2.0.1", "source": { "type": "git", - "url": "https://github.com/PHPCSStandards/composer-installer.git", - "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd" + "url": "https://github.com/amphp/cache.git", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd", - "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", "shasum": "" }, "require": { - "composer-plugin-api": "^2.2", - "php": ">=5.4", - "squizlabs/php_codesniffer": "^3.1.0 || ^4.0" + "amphp/amp": "^3", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, "require-dev": { - "composer/composer": "^2.2", - "ext-json": "*", - "ext-zip": "*", - "php-parallel-lint/php-parallel-lint": "^1.4.0", - "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev", - "yoast/phpunit-polyfills": "^1.0" - }, - "type": "composer-plugin", - "extra": { - "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" }, + "type": "library", "autoload": { "psr-4": { - "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + "Amp\\Cache\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -539,116 +543,71 @@ ], "authors": [ { - "name": "Franck Nijhof", - "email": "opensource@frenck.dev", - "homepage": "https://frenck.dev", - "role": "Open source developer" + "name": "Niklas Keller", + "email": "me@kelunik.com" }, { - "name": "Contributors", - "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" } ], - "description": "PHP_CodeSniffer Standards Composer Installer Plugin", - "keywords": [ - "PHPCodeSniffer", - "PHP_CodeSniffer", - "code quality", - "codesniffer", - "composer", - "installer", - "phpcbf", - "phpcs", - "plugin", - "qa", - "quality", - "standard", - "standards", - "style guide", - "stylecheck", - "tests" - ], + "description": "A fiber-aware cache API based on Amp and Revolt.", + "homepage": "https://amphp.org/cache", "support": { - "issues": "https://github.com/PHPCSStandards/composer-installer/issues", - "security": "https://github.com/PHPCSStandards/composer-installer/security/policy", - "source": "https://github.com/PHPCSStandards/composer-installer" + "issues": "https://github.com/amphp/cache/issues", + "source": "https://github.com/amphp/cache/tree/v2.0.1" }, "funding": [ { - "url": "https://github.com/PHPCSStandards", - "type": "github" - }, - { - "url": "https://github.com/jrfnl", + "url": "https://github.com/amphp", "type": "github" - }, - { - "url": "https://opencollective.com/php_codesniffer", - "type": "open_collective" - }, - { - "url": "https://thanks.dev/u/gh/phpcsstandards", - "type": "thanks_dev" } ], - "time": "2026-05-06T08:26:05+00:00" + "time": "2024-04-19T03:38:06+00:00" }, { - "name": "ergebnis/composer-normalize", - "version": "2.52.0", + "name": "amphp/dns", + "version": "v2.4.0", "source": { "type": "git", - "url": "https://github.com/ergebnis/composer-normalize.git", - "reference": "988f83f5e51a42cdd2337e5fcd935432f8dfa33c" + "url": "https://github.com/amphp/dns.git", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/composer-normalize/zipball/988f83f5e51a42cdd2337e5fcd935432f8dfa33c", - "reference": "988f83f5e51a42cdd2337e5fcd935432f8dfa33c", + "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", "shasum": "" }, "require": { - "composer-plugin-api": "^2.0.0", - "ergebnis/json": "^1.4.0", - "ergebnis/json-normalizer": "^4.9.0", - "ergebnis/json-printer": "^3.7.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": "*", - "justinrainbow/json-schema": "^5.2.12 || ^6.0.0", - "localheinz/diff": "^1.3.0", - "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, "require-dev": { - "composer/composer": "^2.9.8", - "ergebnis/license": "^2.7.0", - "ergebnis/php-cs-fixer-config": "^6.62.1", - "ergebnis/phpstan-rules": "^2.13.1", - "ergebnis/phpunit-slow-test-detector": "^2.24.0", - "ergebnis/rector-rules": "^1.18.1", - "fakerphp/faker": "^1.24.1", - "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.54", - "phpstan/phpstan-deprecation-rules": "^2.0.4", - "phpstan/phpstan-phpunit": "^2.0.16", - "phpstan/phpstan-strict-rules": "^2.0.11", - "phpunit/phpunit": "^9.6.33", - "rector/rector": "^2.4.3", - "symfony/filesystem": "^5.4.41" - }, - "type": "composer-plugin", - "extra": { - "class": "Ergebnis\\Composer\\Normalize\\NormalizePlugin", - "branch-alias": { - "dev-main": "2.52-dev" - }, - "plugin-optional": true, - "composer-normalize": { - "indent-size": 2, - "indent-style": "space" - } + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" }, + "type": "library", "autoload": { + "files": [ + "src/functions.php" + ], "psr-4": { - "Ergebnis\\Composer\\Normalize\\": "src/" + "Amp\\Dns\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -657,74 +616,91 @@ ], "authors": [ { - "name": "Andreas Möller", - "email": "am@localheinz.com", - "homepage": "https://localheinz.com" + "name": "Chris Wright", + "email": "addr@daverandom.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" } ], - "description": "Provides a composer plugin for normalizing composer.json.", - "homepage": "https://github.com/ergebnis/composer-normalize", + "description": "Async DNS resolution for Amp.", + "homepage": "https://github.com/amphp/dns", "keywords": [ - "composer", - "normalize", - "normalizer", - "plugin" + "amp", + "amphp", + "async", + "client", + "dns", + "resolve" ], "support": { - "issues": "https://github.com/ergebnis/composer-normalize/issues", - "security": "https://github.com/ergebnis/composer-normalize/blob/main/.github/SECURITY.md", - "source": "https://github.com/ergebnis/composer-normalize" + "issues": "https://github.com/amphp/dns/issues", + "source": "https://github.com/amphp/dns/tree/v2.4.0" }, - "time": "2026-05-15T15:39:24+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-01-19T15:43:40+00:00" }, { - "name": "ergebnis/json", - "version": "1.6.0", + "name": "amphp/parallel", + "version": "v2.4.0", "source": { "type": "git", - "url": "https://github.com/ergebnis/json.git", - "reference": "7b56d2b5d9e897e75b43e2e753075a0904c921b1" + "url": "https://github.com/amphp/parallel.git", + "reference": "37f5b2754fadc229c00f9416bd68fb8d04529a81" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/json/zipball/7b56d2b5d9e897e75b43e2e753075a0904c921b1", - "reference": "7b56d2b5d9e897e75b43e2e753075a0904c921b1", + "url": "https://api.github.com/repos/amphp/parallel/zipball/37f5b2754fadc229c00f9416bd68fb8d04529a81", + "reference": "37f5b2754fadc229c00f9416bd68fb8d04529a81", "shasum": "" }, "require": { - "ext-json": "*", - "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.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": { - "ergebnis/composer-normalize": "^2.44.0", - "ergebnis/data-provider": "^3.3.0", - "ergebnis/license": "^2.5.0", - "ergebnis/php-cs-fixer-config": "^6.37.0", - "ergebnis/phpstan-rules": "^2.11.0", - "ergebnis/phpunit-slow-test-detector": "^2.16.1", - "fakerphp/faker": "^1.24.0", - "infection/infection": "~0.26.6", - "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.22", - "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.7", - "phpstan/phpstan-strict-rules": "^2.0.6", - "phpunit/phpunit": "^9.6.24", - "rector/rector": "^2.1.4" + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.7-dev" - }, - "composer-normalize": { - "indent-size": 2, - "indent-style": "space" - } - }, "autoload": { + "files": [ + "src/Context/functions.php", + "src/Context/Internal/functions.php", + "src/Ipc/functions.php", + "src/Worker/functions.php" + ], "psr-4": { - "Ergebnis\\Json\\": "src/" + "Amp\\Parallel\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -733,79 +709,65 @@ ], "authors": [ { - "name": "Andreas Möller", - "email": "am@localheinz.com", - "homepage": "https://localheinz.com" + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" } ], - "description": "Provides a Json value object for representing a valid JSON string.", - "homepage": "https://github.com/ergebnis/json", + "description": "Parallel processing component for Amp.", + "homepage": "https://github.com/amphp/parallel", "keywords": [ - "json" + "async", + "asynchronous", + "concurrent", + "multi-processing", + "multi-threading" ], "support": { - "issues": "https://github.com/ergebnis/json/issues", - "security": "https://github.com/ergebnis/json/blob/main/.github/SECURITY.md", - "source": "https://github.com/ergebnis/json" + "issues": "https://github.com/amphp/parallel/issues", + "source": "https://github.com/amphp/parallel/tree/v2.4.0" }, - "time": "2025-09-06T09:08:45+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-16T16:54:01+00:00" }, { - "name": "ergebnis/json-normalizer", - "version": "4.10.1", + "name": "amphp/parser", + "version": "v1.1.1", "source": { "type": "git", - "url": "https://github.com/ergebnis/json-normalizer.git", - "reference": "77961faf2c651c3f05977b53c6c68e8434febf62" + "url": "https://github.com/amphp/parser.git", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/json-normalizer/zipball/77961faf2c651c3f05977b53c6c68e8434febf62", - "reference": "77961faf2c651c3f05977b53c6c68e8434febf62", + "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", "shasum": "" }, "require": { - "ergebnis/json": "^1.2.0", - "ergebnis/json-pointer": "^3.4.0", - "ergebnis/json-printer": "^3.5.0", - "ergebnis/json-schema-validator": "^4.2.0", - "ext-json": "*", - "justinrainbow/json-schema": "^5.2.12 || ^6.0.0", - "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + "php": ">=7.4" }, "require-dev": { - "composer/semver": "^3.4.3", - "ergebnis/composer-normalize": "^2.44.0", - "ergebnis/data-provider": "^3.3.0", - "ergebnis/license": "^2.5.0", - "ergebnis/php-cs-fixer-config": "^6.37.0", - "ergebnis/phpunit-slow-test-detector": "^2.16.1", - "fakerphp/faker": "^1.24.0", - "infection/infection": "~0.26.6", - "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^1.12.10", - "phpstan/phpstan-deprecation-rules": "^1.2.1", - "phpstan/phpstan-phpunit": "^1.4.0", - "phpstan/phpstan-strict-rules": "^1.6.1", - "phpunit/phpunit": "^9.6.19", - "rector/rector": "^1.2.10" - }, - "suggest": { - "composer/semver": "If you want to use ComposerJsonNormalizer or VersionConstraintNormalizer" + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "4.11-dev" - }, - "composer-normalize": { - "indent-size": 2, - "indent-style": "space" - } - }, "autoload": { "psr-4": { - "Ergebnis\\Json\\Normalizer\\": "src/" + "Amp\\Parser\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -814,72 +776,63 @@ ], "authors": [ { - "name": "Andreas Möller", - "email": "am@localheinz.com", - "homepage": "https://localheinz.com" + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" } ], - "description": "Provides generic and vendor-specific normalizers for normalizing JSON documents.", - "homepage": "https://github.com/ergebnis/json-normalizer", + "description": "A generator parser to make streaming parsers simple.", + "homepage": "https://github.com/amphp/parser", "keywords": [ - "json", - "normalizer" + "async", + "non-blocking", + "parser", + "stream" ], "support": { - "issues": "https://github.com/ergebnis/json-normalizer/issues", - "security": "https://github.com/ergebnis/json-normalizer/blob/main/.github/SECURITY.md", - "source": "https://github.com/ergebnis/json-normalizer" + "issues": "https://github.com/amphp/parser/issues", + "source": "https://github.com/amphp/parser/tree/v1.1.1" }, - "time": "2025-09-06T09:18:13+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:16:53+00:00" }, { - "name": "ergebnis/json-pointer", - "version": "3.8.0", + "name": "amphp/pipeline", + "version": "v1.2.4", "source": { "type": "git", - "url": "https://github.com/ergebnis/json-pointer.git", - "reference": "b58c3c468a7ff109fdf9a255f17de29ecbe5276c" + "url": "https://github.com/amphp/pipeline.git", + "reference": "a044733e080940d1483f56caff0c412ad6982776" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/json-pointer/zipball/b58c3c468a7ff109fdf9a255f17de29ecbe5276c", - "reference": "b58c3c468a7ff109fdf9a255f17de29ecbe5276c", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/a044733e080940d1483f56caff0c412ad6982776", + "reference": "a044733e080940d1483f56caff0c412ad6982776", "shasum": "" }, "require": { - "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + "amphp/amp": "^3", + "php": ">=8.1", + "revolt/event-loop": "^1" }, "require-dev": { - "ergebnis/composer-normalize": "^2.50.0", - "ergebnis/data-provider": "^3.6.0", - "ergebnis/license": "^2.7.0", - "ergebnis/php-cs-fixer-config": "^6.60.2", - "ergebnis/phpstan-rules": "^2.13.1", - "ergebnis/phpunit-slow-test-detector": "^2.24.0", - "ergebnis/rector-rules": "^1.16.0", - "fakerphp/faker": "^1.24.1", - "infection/infection": "~0.26.6", - "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.46", - "phpstan/phpstan-deprecation-rules": "^2.0.4", - "phpstan/phpstan-phpunit": "^2.0.16", - "phpstan/phpstan-strict-rules": "^2.0.10", - "phpunit/phpunit": "^9.6.34", - "rector/rector": "^2.4.0" + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.8-dev" - }, - "composer-normalize": { - "indent-size": 2, - "indent-style": "space" - } - }, "autoload": { "psr-4": { - "Ergebnis\\Json\\Pointer\\": "src/" + "Amp\\Pipeline\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -888,73 +841,70 @@ ], "authors": [ { - "name": "Andreas Möller", - "email": "am@localheinz.com", - "homepage": "https://localheinz.com" + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" } ], - "description": "Provides an abstraction of a JSON pointer.", - "homepage": "https://github.com/ergebnis/json-pointer", + "description": "Asynchronous iterators and operators.", + "homepage": "https://amphp.org/pipeline", "keywords": [ - "RFC6901", - "json", - "pointer" + "amp", + "amphp", + "async", + "io", + "iterator", + "non-blocking" ], "support": { - "issues": "https://github.com/ergebnis/json-pointer/issues", - "security": "https://github.com/ergebnis/json-pointer/blob/main/.github/SECURITY.md", - "source": "https://github.com/ergebnis/json-pointer" + "issues": "https://github.com/amphp/pipeline/issues", + "source": "https://github.com/amphp/pipeline/tree/v1.2.4" }, - "time": "2026-04-07T14:52:13+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-06T05:37:57+00:00" }, { - "name": "ergebnis/json-printer", - "version": "3.8.1", + "name": "amphp/process", + "version": "v2.1.0", "source": { "type": "git", - "url": "https://github.com/ergebnis/json-printer.git", - "reference": "211d73fc7ec6daf98568ee6ed6e6d133dee8503e" + "url": "https://github.com/amphp/process.git", + "reference": "583959df17d00304ad7b0b32285373f985935643" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/json-printer/zipball/211d73fc7ec6daf98568ee6ed6e6d133dee8503e", - "reference": "211d73fc7ec6daf98568ee6ed6e6d133dee8503e", + "url": "https://api.github.com/repos/amphp/process/zipball/583959df17d00304ad7b0b32285373f985935643", + "reference": "583959df17d00304ad7b0b32285373f985935643", "shasum": "" }, "require": { - "ext-json": "*", - "ext-mbstring": "*", - "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, "require-dev": { - "ergebnis/composer-normalize": "^2.44.0", - "ergebnis/data-provider": "^3.3.0", - "ergebnis/license": "^2.5.0", - "ergebnis/php-cs-fixer-config": "^6.37.0", - "ergebnis/phpunit-slow-test-detector": "^2.16.1", - "fakerphp/faker": "^1.24.0", - "infection/infection": "~0.26.6", - "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^1.12.10", - "phpstan/phpstan-deprecation-rules": "^1.2.1", - "phpstan/phpstan-phpunit": "^1.4.1", - "phpstan/phpstan-strict-rules": "^1.6.1", - "phpunit/phpunit": "^9.6.21", - "rector/rector": "^1.2.10" + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.9-dev" - }, - "composer-normalize": { - "indent-size": 2, - "indent-style": "space" - } - }, "autoload": { + "files": [ + "src/functions.php" + ], "psr-4": { - "Ergebnis\\Json\\Printer\\": "src/" + "Amp\\Process\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -963,75 +913,63 @@ ], "authors": [ { - "name": "Andreas Möller", - "email": "am@localheinz.com", - "homepage": "https://localheinz.com" + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" } ], - "description": "Provides a JSON printer, allowing for flexible indentation.", - "homepage": "https://github.com/ergebnis/json-printer", - "keywords": [ - "formatter", - "json", - "printer" - ], + "description": "A fiber-aware process manager based on Amp and Revolt.", + "homepage": "https://amphp.org/process", "support": { - "issues": "https://github.com/ergebnis/json-printer/issues", - "security": "https://github.com/ergebnis/json-printer/blob/main/.github/SECURITY.md", - "source": "https://github.com/ergebnis/json-printer" + "issues": "https://github.com/amphp/process/issues", + "source": "https://github.com/amphp/process/tree/v2.1.0" }, - "time": "2025-09-06T09:59:26+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-31T15:11:55+00:00" }, { - "name": "ergebnis/json-schema-validator", - "version": "4.5.1", + "name": "amphp/serialization", + "version": "v1.1.0", "source": { "type": "git", - "url": "https://github.com/ergebnis/json-schema-validator.git", - "reference": "b739527a480a9e3651360ad351ea77e7e9019df2" + "url": "https://github.com/amphp/serialization.git", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/json-schema-validator/zipball/b739527a480a9e3651360ad351ea77e7e9019df2", - "reference": "b739527a480a9e3651360ad351ea77e7e9019df2", + "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0", "shasum": "" }, "require": { - "ergebnis/json": "^1.2.0", - "ergebnis/json-pointer": "^3.4.0", - "ext-json": "*", - "justinrainbow/json-schema": "^5.2.12 || ^6.0.0", - "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + "php": ">=7.4" }, "require-dev": { - "ergebnis/composer-normalize": "^2.44.0", - "ergebnis/data-provider": "^3.3.0", - "ergebnis/license": "^2.5.0", - "ergebnis/php-cs-fixer-config": "^6.37.0", - "ergebnis/phpunit-slow-test-detector": "^2.16.1", - "fakerphp/faker": "^1.24.0", - "infection/infection": "~0.26.6", - "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^1.12.10", - "phpstan/phpstan-deprecation-rules": "^1.2.1", - "phpstan/phpstan-phpunit": "^1.4.0", - "phpstan/phpstan-strict-rules": "^1.6.1", - "phpunit/phpunit": "^9.6.20", - "rector/rector": "^1.2.10" + "amphp/php-cs-fixer-config": "^2", + "ext-json": "*", + "ext-zlib": "*", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "4.6-dev" - }, - "composer-normalize": { - "indent-size": 2, - "indent-style": "space" - } - }, "autoload": { + "files": [ + "src/functions.php" + ], "psr-4": { - "Ergebnis\\Json\\SchemaValidator\\": "src/" + "Amp\\Serialization\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -1040,77 +978,75 @@ ], "authors": [ { - "name": "Andreas Möller", - "email": "am@localheinz.com", - "homepage": "https://localheinz.com" + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" } ], - "description": "Provides a JSON schema validator, building on top of justinrainbow/json-schema.", - "homepage": "https://github.com/ergebnis/json-schema-validator", + "description": "Serialization tools for IPC and data storage in PHP.", + "homepage": "https://github.com/amphp/serialization", "keywords": [ - "json", - "schema", - "validator" + "async", + "asynchronous", + "serialization", + "serialize" ], "support": { - "issues": "https://github.com/ergebnis/json-schema-validator/issues", - "security": "https://github.com/ergebnis/json-schema-validator/blob/main/.github/SECURITY.md", - "source": "https://github.com/ergebnis/json-schema-validator" + "issues": "https://github.com/amphp/serialization/issues", + "source": "https://github.com/amphp/serialization/tree/v1.1.0" }, - "time": "2025-09-06T11:37:35+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-05T15:59:53+00:00" }, { - "name": "guzzlehttp/guzzle", - "version": "7.10.6", + "name": "amphp/socket", + "version": "v2.4.0", "source": { "type": "git", - "url": "https://github.com/guzzle/guzzle.git", - "reference": "e7412b3180912c01650cc66647f18c1d1cbe9b94" + "url": "https://github.com/amphp/socket.git", + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/e7412b3180912c01650cc66647f18c1d1cbe9b94", - "reference": "e7412b3180912c01650cc66647f18c1d1cbe9b94", + "url": "https://api.github.com/repos/amphp/socket/zipball/dadb63c5d3179fd83803e29dfeac27350e619314", + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314", "shasum": "" }, "require": { - "ext-json": "*", - "guzzlehttp/promises": "^2.3", - "guzzlehttp/psr7": "^2.8", - "php": "^7.2.5 || ^8.0", - "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" - }, - "provide": { - "psr/http-client-implementation": "1.0" + "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" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.4", - "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.52 || ^9.6.34", - "psr/log": "^1.1 || ^2.0 || ^3.0" - }, - "suggest": { - "ext-curl": "Required for CURL handler support", - "ext-intl": "Required for Internationalized Domain Name (IDN) support", - "psr/log": "Required for using the Log middleware" + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/process": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" }, "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, "autoload": { "files": [ - "src/functions_include.php" + "src/functions.php", + "src/Internal/functions.php", + "src/SocketAddress/functions.php" ], "psr-4": { - "GuzzleHttp\\": "src/" + "Amp\\Socket\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -1119,104 +1055,75 @@ ], "authors": [ { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "Jeremy Lindblom", - "email": "jeremeamia@gmail.com", - "homepage": "https://github.com/jeremeamia" - }, - { - "name": "George Mponos", - "email": "gmponos@gmail.com", - "homepage": "https://github.com/gmponos" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" }, { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://github.com/sagikazarmark" + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" }, { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" + "name": "Niklas Keller", + "email": "me@kelunik.com" } ], - "description": "Guzzle is a PHP HTTP client library", + "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", + "homepage": "https://github.com/amphp/socket", "keywords": [ - "client", - "curl", - "framework", - "http", - "http client", - "psr-18", - "psr-7", - "rest", - "web service" + "amp", + "async", + "encryption", + "non-blocking", + "sockets", + "tcp", + "tls" ], "support": { - "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.6" + "issues": "https://github.com/amphp/socket/issues", + "source": "https://github.com/amphp/socket/tree/v2.4.0" }, "funding": [ { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", + "url": "https://github.com/amphp", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", - "type": "tidelift" } ], - "time": "2026-06-01T13:06:22+00:00" + "time": "2026-04-19T15:09:56+00:00" }, { - "name": "guzzlehttp/promises", - "version": "2.4.1", + "name": "amphp/sync", + "version": "v2.3.0", "source": { "type": "git", - "url": "https://github.com/guzzle/promises.git", - "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2" + "url": "https://github.com/amphp/sync.git", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2", - "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2", + "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0" + "amphp/amp": "^3", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.52 || ^9.6.34" + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23" }, "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, "autoload": { + "files": [ + "src/functions.php" + ], "psr-4": { - "GuzzleHttp\\Promise\\": "src/" + "Amp\\Sync\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -1225,93 +1132,66 @@ ], "authors": [ { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" }, { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" + "name": "Niklas Keller", + "email": "me@kelunik.com" }, { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" } ], - "description": "Guzzle promises library", + "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", + "homepage": "https://github.com/amphp/sync", "keywords": [ - "promise" + "async", + "asynchronous", + "mutex", + "semaphore", + "synchronization" ], "support": { - "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.4.1" + "issues": "https://github.com/amphp/sync/issues", + "source": "https://github.com/amphp/sync/tree/v2.3.0" }, "funding": [ { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", + "url": "https://github.com/amphp", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", - "type": "tidelift" } ], - "time": "2026-05-20T22:57:30+00:00" + "time": "2024-08-03T19:31:26+00:00" }, { - "name": "guzzlehttp/psr7", - "version": "2.10.4", + "name": "clue/stream-filter", + "version": "v1.7.0", "source": { "type": "git", - "url": "https://github.com/guzzle/psr7.git", - "reference": "d2a1a094e396da8957e797489fddaf860c340cfc" + "url": "https://github.com/clue/stream-filter.git", + "reference": "049509fef80032cb3f051595029ab75b49a3c2f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/d2a1a094e396da8957e797489fddaf860c340cfc", - "reference": "d2a1a094e396da8957e797489fddaf860c340cfc", + "url": "https://api.github.com/repos/clue/stream-filter/zipball/049509fef80032cb3f051595029ab75b49a3c2f7", + "reference": "049509fef80032cb3f051595029ab75b49a3c2f7", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0" - }, - "provide": { - "psr/http-factory-implementation": "1.0", - "psr/http-message-implementation": "1.0" + "php": ">=5.3" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "1.1.0", - "jshttp/mime-db": "1.54.0.1", - "phpunit/phpunit": "^8.5.52 || ^9.6.34" - }, - "suggest": { - "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" }, "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, "autoload": { + "files": [ + "src/functions_include.php" + ], "psr-4": { - "GuzzleHttp\\Psr7\\": "src/" + "Clue\\StreamFilter\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1320,111 +1200,76 @@ ], "authors": [ { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "George Mponos", - "email": "gmponos@gmail.com", - "homepage": "https://github.com/gmponos" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://github.com/sagikazarmark" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://sagikazarmark.hu" + "name": "Christian Lück", + "email": "christian@clue.engineering" } ], - "description": "PSR-7 message implementation that also provides common utility methods", + "description": "A simple and modern approach to stream filtering in PHP", + "homepage": "https://github.com/clue/stream-filter", "keywords": [ - "http", - "message", - "psr-7", - "request", - "response", + "bucket brigade", + "callback", + "filter", + "php_user_filter", "stream", - "uri", - "url" + "stream_filter_append", + "stream_filter_register" ], "support": { - "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.10.4" + "issues": "https://github.com/clue/stream-filter/issues", + "source": "https://github.com/clue/stream-filter/tree/v1.7.0" }, "funding": [ { - "url": "https://github.com/GrahamCampbell", - "type": "github" + "url": "https://clue.engineering/support", + "type": "custom" }, { - "url": "https://github.com/Nyholm", + "url": "https://github.com/clue", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", - "type": "tidelift" } ], - "time": "2026-05-29T12:59:07+00:00" + "time": "2023-12-20T15:40:13+00:00" }, { - "name": "justinrainbow/json-schema", - "version": "6.8.2", + "name": "composer/pcre", + "version": "3.3.2", "source": { "type": "git", - "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "2c89ebb95ca9cedc9347f780333f7b25792dcb76" + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/2c89ebb95ca9cedc9347f780333f7b25792dcb76", - "reference": "2c89ebb95ca9cedc9347f780333f7b25792dcb76", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", "shasum": "" }, "require": { - "ext-json": "*", - "marc-mabe/php-enum": "^4.4", - "php": "^7.2 || ^8.0" + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" }, "require-dev": { - "friendsofphp/php-cs-fixer": "3.3.0", - "json-schema/json-schema-test-suite": "dev-main", - "marc-mabe/php-enum-phpstan": "^2.0", - "phpspec/prophecy": "^1.19", - "phpstan/phpstan": "^1.12", - "phpunit/phpunit": "^8.5" + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" }, - "bin": [ - "bin/validate-json" - ], "type": "library", "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, "branch-alias": { - "dev-master": "6.x-dev" + "dev-main": "3.x-dev" } }, "autoload": { "psr-4": { - "JsonSchema\\": "src/JsonSchema/" + "Composer\\Pcre\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -1433,571 +1278,597 @@ ], "authors": [ { - "name": "Bruno Prieto Reis", - "email": "bruno.p.reis@gmail.com" - }, - { - "name": "Justin Rainbow", - "email": "justin.rainbow@gmail.com" - }, - { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" - }, - { - "name": "Robert Schönthal", - "email": "seroscho@googlemail.com" + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" } ], - "description": "A library to validate a json schema.", - "homepage": "https://github.com/jsonrainbow/json-schema", + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", "keywords": [ - "json", - "schema" + "PCRE", + "preg", + "regex", + "regular expression" ], "support": { - "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.8.2" - }, - "time": "2026-05-05T05:39:01+00:00" - }, - { - "name": "localheinz/diff", - "version": "1.3.0", - "source": { - "type": "git", - "url": "https://github.com/localheinz/diff.git", - "reference": "33bd840935970cda6691c23fc7d94ae764c0734c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/localheinz/diff/zipball/33bd840935970cda6691c23fc7d94ae764c0734c", - "reference": "33bd840935970cda6691c23fc7d94ae764c0734c", - "shasum": "" - }, - "require": { - "php": "~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" - }, - "require-dev": { - "phpunit/phpunit": "^7.5.0 || ^8.5.23", - "symfony/process": "^4.2 || ^5" - }, - "type": "library", - "autoload": { - "classmap": [ - "src/" - ] + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ + "funding": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "url": "https://packagist.com", + "type": "custom" }, { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" } ], - "description": "Fork of sebastian/diff for use with ergebnis/composer-normalize", - "homepage": "https://github.com/localheinz/diff", - "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" - ], - "support": { - "issues": "https://github.com/localheinz/diff/issues", - "source": "https://github.com/localheinz/diff/tree/1.3.0" - }, - "time": "2025-08-30T09:44:18+00:00" + "time": "2024-11-12T16:29:46+00:00" }, { - "name": "marc-mabe/php-enum", - "version": "v4.7.2", + "name": "composer/semver", + "version": "3.4.4", "source": { "type": "git", - "url": "https://github.com/marc-mabe/php-enum.git", - "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef" + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/marc-mabe/php-enum/zipball/bb426fcdd65c60fb3638ef741e8782508fda7eef", - "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", "shasum": "" }, "require": { - "ext-reflection": "*", - "php": "^7.1 | ^8.0" + "php": "^5.3.2 || ^7.0 || ^8.0" }, "require-dev": { - "phpbench/phpbench": "^0.16.10 || ^1.0.4", - "phpstan/phpstan": "^1.3.1", - "phpunit/phpunit": "^7.5.20 | ^8.5.22 | ^9.5.11", - "vimeo/psalm": "^4.17.0 | ^5.26.1" + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" }, "type": "library", "extra": { "branch-alias": { - "dev-3.x": "3.2-dev", - "dev-master": "4.7-dev" + "dev-main": "3.x-dev" } }, "autoload": { "psr-4": { - "MabeEnum\\": "src/" - }, - "classmap": [ - "stubs/Stringable.php" - ] + "Composer\\Semver\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Marc Bennewitz", - "email": "dev@mabe.berlin", - "homepage": "https://mabe.berlin/", - "role": "Lead" + "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": "Simple and fast implementation of enumerations with native PHP", - "homepage": "https://github.com/marc-mabe/php-enum", + "description": "Semver library that offers utilities, version constraint parsing and validation.", "keywords": [ - "enum", - "enum-map", - "enum-set", - "enumeration", - "enumerator", - "enummap", - "enumset", - "map", - "set", - "type", - "type-hint", - "typehint" + "semantic", + "semver", + "validation", + "versioning" ], "support": { - "issues": "https://github.com/marc-mabe/php-enum/issues", - "source": "https://github.com/marc-mabe/php-enum/tree/v4.7.2" + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" }, - "time": "2025-09-14T11:18:39+00:00" + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" }, { - "name": "myclabs/deep-copy", - "version": "1.13.4", + "name": "composer/xdebug-handler", + "version": "3.0.5", "source": { "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3 <3.2.2" + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" }, "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" + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" }, "type": "library", "autoload": { - "files": [ - "src/DeepCopy/deep_copy.php" - ], "psr-4": { - "DeepCopy\\": "src/DeepCopy/" + "Composer\\XdebugHandler\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "Create deep copies (clones) of your objects", + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", "keywords": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" + "Xdebug", + "performance" ], "support": { - "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + "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://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "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": "2025-08-01T08:46:24+00:00" + "time": "2024-05-06T16:37:16+00:00" }, { - "name": "nikic/php-parser", - "version": "v5.7.0", + "name": "croct/coding-standard", + "version": "0.4.5", "source": { "type": "git", - "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "url": "https://github.com/croct-tech/coding-standard-php.git", + "reference": "cdd2d44ac4801137e52d21f27b3be22afe80144a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "shasum": "" + "url": "https://api.github.com/repos/croct-tech/coding-standard-php/zipball/cdd2d44ac4801137e52d21f27b3be22afe80144a", + "reference": "cdd2d44ac4801137e52d21f27b3be22afe80144a", + "shasum": "", + "mirrors": [ + { + "url": "https://croct.repo.repman.io/dists/%package%/%version%/%reference%.%type%", + "preferred": true + } + ] }, "require": { - "ext-ctype": "*", - "ext-json": "*", - "ext-tokenizer": "*", - "php": ">=7.4" + "php": "^8.5", + "slevomat/coding-standard": "^8.28", + "squizlabs/php_codesniffer": "^4.0" }, "require-dev": { - "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^9.0" - }, - "bin": [ - "bin/php-parse" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.x-dev" - } + "ergebnis/composer-normalize": "^2.28", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^13.1" }, + "type": "phpcodesniffer-standard", "autoload": { "psr-4": { - "PhpParser\\": "lib/PhpParser" + "Croct\\": "src/Croct/" } }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" + "autoload-dev": { + "psr-4": { + "Croct\\Tests\\": "tests/" + }, + "files": [ + "vendor/squizlabs/php_codesniffer/autoload.php" + ] + }, + "notification-url": "https://croct.repo.repman.io/downloads", + "scripts": { + "test": [ + "composer validate", + "composer normalize --dry-run", + "mkdir -p .cache", + "phpcs", + "phpstan analyse", + "./run-sniffs-tests.sh" + ] + }, + "license": [ + "proprietary" ], "authors": [ { - "name": "Nikita Popov" + "name": "Croct", + "email": "lib+coding-standard-php@croct.com", + "homepage": "https://croct.com" } ], - "description": "A PHP parser written in PHP", + "description": "A set of Code Sniffer rules applied to all Croct PHP projects.", + "homepage": "https://github.com/croct-tech/coding-standard-php", "keywords": [ - "parser", - "php" + "coding", + "croct", + "phpcs", + "standard" ], "support": { - "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/croct-tech/coding-standard-php/tree/0.4.5", + "issues": "https://github.com/croct-tech/coding-standard-php/issues" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-05-06T14:38:44+00:00" }, { - "name": "nyholm/psr7", - "version": "1.8.2", + "name": "danog/advanced-json-rpc", + "version": "v3.2.3", "source": { "type": "git", - "url": "https://github.com/Nyholm/psr7.git", - "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + "url": "https://github.com/danog/php-advanced-json-rpc.git", + "reference": "ae703ea7b4811797a10590b6078de05b3b33dd91" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", - "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "url": "https://api.github.com/repos/danog/php-advanced-json-rpc/zipball/ae703ea7b4811797a10590b6078de05b3b33dd91", + "reference": "ae703ea7b4811797a10590b6078de05b3b33dd91", "shasum": "" }, "require": { - "php": ">=7.2", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.1 || ^2.0" + "netresearch/jsonmapper": "^5", + "php": ">=8.1", + "phpdocumentor/reflection-docblock": "^4.3.4 || ^5.0.0 || ^6" }, - "provide": { - "php-http/message-factory-implementation": "1.0", - "psr/http-factory-implementation": "1.0", - "psr/http-message-implementation": "1.0" + "replace": { + "felixfbecker/php-advanced-json-rpc": "^3" }, "require-dev": { - "http-interop/http-factory-tests": "^0.9", - "php-http/message-factory": "^1.0", - "php-http/psr7-integration-tests": "^1.0", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", - "symfony/error-handler": "^4.4" + "phpunit/phpunit": "^9" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.8-dev" - } - }, "autoload": { "psr-4": { - "Nyholm\\Psr7\\": "src/" + "AdvancedJsonRpc\\": "lib/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "ISC" ], "authors": [ { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com" + "name": "Felix Becker", + "email": "felix.b@outlook.com" }, { - "name": "Martijn van der Ven", - "email": "martijn@vanderven.se" + "name": "Daniil Gentili", + "email": "daniil@daniil.it" } ], - "description": "A fast PHP7 implementation of PSR-7", - "homepage": "https://tnyholm.se", - "keywords": [ - "psr-17", - "psr-7" - ], + "description": "A more advanced JSONRPC implementation", "support": { - "issues": "https://github.com/Nyholm/psr7/issues", - "source": "https://github.com/Nyholm/psr7/tree/1.8.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/Zegnat", - "type": "github" - }, - { - "url": "https://github.com/nyholm", - "type": "github" + "time": "2026-01-12T21:07:10+00:00" + }, + { + "name": "daverandom/libdns", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/DaveRandom/LibDNS.git", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "Required for IDN support" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "LibDNS\\": "src/" } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" ], - "time": "2024-09-09T07:06:30+00:00" + "description": "DNS protocol implementation written in pure PHP", + "keywords": [ + "dns" + ], + "support": { + "issues": "https://github.com/DaveRandom/LibDNS/issues", + "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" + }, + "time": "2024-04-12T12:12:48+00:00" }, { - "name": "phar-io/manifest", - "version": "2.0.4", + "name": "dealerdirect/phpcodesniffer-composer-installer", + "version": "v1.2.1", "source": { "type": "git", - "url": "https://github.com/phar-io/manifest.git", - "reference": "54750ef60c58e43759730615a392c31c80e23176" + "url": "https://github.com/PHPCSStandards/composer-installer.git", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", - "reference": "54750ef60c58e43759730615a392c31c80e23176", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-libxml": "*", - "ext-phar": "*", - "ext-xmlwriter": "*", - "phar-io/version": "^3.0.1", - "php": "^7.2 || ^8.0" + "composer-plugin-api": "^2.2", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.1.0 || ^4.0" }, - "type": "library", + "require-dev": { + "composer/composer": "^2.2", + "ext-json": "*", + "ext-zip": "*", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev", + "yoast/phpunit-polyfills": "^1.0" + }, + "type": "composer-plugin", "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } + "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" - }, - { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" + "name": "Franck Nijhof", + "email": "opensource@frenck.dev", + "homepage": "https://frenck.dev", + "role": "Open source developer" }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" } ], - "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "description": "PHP_CodeSniffer Standards Composer Installer Plugin", + "keywords": [ + "PHPCodeSniffer", + "PHP_CodeSniffer", + "code quality", + "codesniffer", + "composer", + "installer", + "phpcbf", + "phpcs", + "plugin", + "qa", + "quality", + "standard", + "standards", + "style guide", + "stylecheck", + "tests" + ], "support": { - "issues": "https://github.com/phar-io/manifest/issues", - "source": "https://github.com/phar-io/manifest/tree/2.0.4" + "issues": "https://github.com/PHPCSStandards/composer-installer/issues", + "security": "https://github.com/PHPCSStandards/composer-installer/security/policy", + "source": "https://github.com/PHPCSStandards/composer-installer" }, "funding": [ { - "url": "https://github.com/theseer", + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" } ], - "time": "2024-03-03T12:33:53+00:00" + "time": "2026-05-06T08:26:05+00:00" }, { - "name": "phar-io/version", - "version": "3.2.1", + "name": "dnoegel/php-xdg-base-dir", + "version": "v0.1.1", "source": { "type": "git", - "url": "https://github.com/phar-io/version.git", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + "url": "https://github.com/dnoegel/php-xdg-base-dir.git", + "reference": "8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "url": "https://api.github.com/repos/dnoegel/php-xdg-base-dir/zipball/8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd", + "reference": "8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "php": ">=5.3.2" + }, + "require-dev": { + "phpunit/phpunit": "~7.0|~6.0|~5.0|~4.8.35" }, "type": "library", "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "XdgBaseDir\\": "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" - } + "MIT" ], - "description": "Library for handling version information and constraints", + "description": "implementation of xdg base directory specification for php", "support": { - "issues": "https://github.com/phar-io/version/issues", - "source": "https://github.com/phar-io/version/tree/3.2.1" + "issues": "https://github.com/dnoegel/php-xdg-base-dir/issues", + "source": "https://github.com/dnoegel/php-xdg-base-dir/tree/v0.1.1" }, - "time": "2022-02-21T01:04:05+00:00" + "time": "2019-12-04T15:06:13+00:00" }, { - "name": "php-http/client-common", - "version": "2.7.3", + "name": "doctrine/deprecations", + "version": "1.1.6", "source": { "type": "git", - "url": "https://github.com/php-http/client-common.git", - "reference": "dcc6de29c90dd74faab55f71b79d89409c4bf0c1" + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-http/client-common/zipball/dcc6de29c90dd74faab55f71b79d89409c4bf0c1", - "reference": "dcc6de29c90dd74faab55f71b79d89409c4bf0c1", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0", - "php-http/httplug": "^2.0", - "php-http/message": "^1.6", - "psr/http-client": "^1.0", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.0 || ^2.0", - "symfony/options-resolver": "~4.0.15 || ~4.1.9 || ^4.2.1 || ^5.0 || ^6.0 || ^7.0 || ^8.0", - "symfony/polyfill-php80": "^1.17" + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/instantiator": "^1.1", - "guzzlehttp/psr7": "^1.4", - "nyholm/psr7": "^1.2", - "phpunit/phpunit": "^7.5.20 || ^8.5.33 || ^9.6.7" + "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-json": "To detect JSON responses with the ContentTypePlugin", - "ext-libxml": "To detect XML responses with the ContentTypePlugin", - "php-http/cache-plugin": "PSR-6 Cache plugin", - "php-http/logger-plugin": "PSR-3 Logger plugin", - "php-http/stopwatch-plugin": "Symfony Stopwatch plugin" + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" }, "type": "library", "autoload": { "psr-4": { - "Http\\Client\\Common\\": "src/" + "Doctrine\\Deprecations\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com" - } - ], - "description": "Common HTTP Client implementations and tools for HTTPlug", - "homepage": "http://httplug.io", - "keywords": [ - "client", - "common", - "http", - "httplug" - ], + "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/php-http/client-common/issues", - "source": "https://github.com/php-http/client-common/tree/2.7.3" + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-11-29T19:12:34+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { - "name": "php-http/httplug", - "version": "2.4.1", + "name": "ergebnis/composer-normalize", + "version": "2.52.0", "source": { "type": "git", - "url": "https://github.com/php-http/httplug.git", - "reference": "5cad731844891a4c282f3f3e1b582c46839d22f4" + "url": "https://github.com/ergebnis/composer-normalize.git", + "reference": "988f83f5e51a42cdd2337e5fcd935432f8dfa33c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-http/httplug/zipball/5cad731844891a4c282f3f3e1b582c46839d22f4", - "reference": "5cad731844891a4c282f3f3e1b582c46839d22f4", + "url": "https://api.github.com/repos/ergebnis/composer-normalize/zipball/988f83f5e51a42cdd2337e5fcd935432f8dfa33c", + "reference": "988f83f5e51a42cdd2337e5fcd935432f8dfa33c", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0", - "php-http/promise": "^1.1", - "psr/http-client": "^1.0", - "psr/http-message": "^1.0 || ^2.0" + "composer-plugin-api": "^2.0.0", + "ergebnis/json": "^1.4.0", + "ergebnis/json-normalizer": "^4.9.0", + "ergebnis/json-printer": "^3.7.0", + "ext-json": "*", + "justinrainbow/json-schema": "^5.2.12 || ^6.0.0", + "localheinz/diff": "^1.3.0", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { - "friends-of-phpspec/phpspec-code-coverage": "^4.1 || ^5.0 || ^6.0", - "phpspec/phpspec": "^5.1 || ^6.0 || ^7.0" + "composer/composer": "^2.9.8", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.62.1", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", + "fakerphp/faker": "^1.24.1", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.11", + "phpunit/phpunit": "^9.6.33", + "rector/rector": "^2.4.3", + "symfony/filesystem": "^5.4.41" + }, + "type": "composer-plugin", + "extra": { + "class": "Ergebnis\\Composer\\Normalize\\NormalizePlugin", + "branch-alias": { + "dev-main": "2.52-dev" + }, + "plugin-optional": true, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } }, - "type": "library", "autoload": { "psr-4": { - "Http\\Client\\": "src/" + "Ergebnis\\Composer\\Normalize\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2006,71 +1877,74 @@ ], "authors": [ { - "name": "Eric GELOEN", - "email": "geloen.eric@gmail.com" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://sagikazarmark.hu" + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" } ], - "description": "HTTPlug, the HTTP client abstraction for PHP", - "homepage": "http://httplug.io", + "description": "Provides a composer plugin for normalizing composer.json.", + "homepage": "https://github.com/ergebnis/composer-normalize", "keywords": [ - "client", - "http" + "composer", + "normalize", + "normalizer", + "plugin" ], "support": { - "issues": "https://github.com/php-http/httplug/issues", - "source": "https://github.com/php-http/httplug/tree/2.4.1" + "issues": "https://github.com/ergebnis/composer-normalize/issues", + "security": "https://github.com/ergebnis/composer-normalize/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/composer-normalize" }, - "time": "2024-09-23T11:39:58+00:00" + "time": "2026-05-15T15:39:24+00:00" }, { - "name": "php-http/message", - "version": "1.16.2", + "name": "ergebnis/json", + "version": "1.6.0", "source": { "type": "git", - "url": "https://github.com/php-http/message.git", - "reference": "06dd5e8562f84e641bf929bfe699ee0f5ce8080a" + "url": "https://github.com/ergebnis/json.git", + "reference": "7b56d2b5d9e897e75b43e2e753075a0904c921b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-http/message/zipball/06dd5e8562f84e641bf929bfe699ee0f5ce8080a", - "reference": "06dd5e8562f84e641bf929bfe699ee0f5ce8080a", + "url": "https://api.github.com/repos/ergebnis/json/zipball/7b56d2b5d9e897e75b43e2e753075a0904c921b1", + "reference": "7b56d2b5d9e897e75b43e2e753075a0904c921b1", "shasum": "" }, "require": { - "clue/stream-filter": "^1.5", - "php": "^7.2 || ^8.0", - "psr/http-message": "^1.1 || ^2.0" - }, - "provide": { - "php-http/message-factory-implementation": "1.0" + "ext-json": "*", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { - "ergebnis/composer-normalize": "^2.6", - "ext-zlib": "*", - "guzzlehttp/psr7": "^1.0 || ^2.0", - "laminas/laminas-diactoros": "^2.0 || ^3.0", - "php-http/message-factory": "^1.0.2", - "phpspec/phpspec": "^5.1 || ^6.3 || ^7.1", - "slim/slim": "^3.0" - }, - "suggest": { - "ext-zlib": "Used with compressor/decompressor streams", - "guzzlehttp/psr7": "Used with Guzzle PSR-7 Factories", - "laminas/laminas-diactoros": "Used with Diactoros Factories", - "slim/slim": "Used with Slim Framework PSR-7 implementation" + "ergebnis/composer-normalize": "^2.44.0", + "ergebnis/data-provider": "^3.3.0", + "ergebnis/license": "^2.5.0", + "ergebnis/php-cs-fixer-config": "^6.37.0", + "ergebnis/phpstan-rules": "^2.11.0", + "ergebnis/phpunit-slow-test-detector": "^2.16.1", + "fakerphp/faker": "^1.24.0", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpstan/phpstan-strict-rules": "^2.0.6", + "phpunit/phpunit": "^9.6.24", + "rector/rector": "^2.1.4" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.7-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, "autoload": { - "files": [ - "src/filters.php" - ], "psr-4": { - "Http\\Message\\": "src/" + "Ergebnis\\Json\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2079,59 +1953,79 @@ ], "authors": [ { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com" + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" } ], - "description": "HTTP Message related tools", - "homepage": "http://php-http.org", + "description": "Provides a Json value object for representing a valid JSON string.", + "homepage": "https://github.com/ergebnis/json", "keywords": [ - "http", - "message", - "psr-7" + "json" ], "support": { - "issues": "https://github.com/php-http/message/issues", - "source": "https://github.com/php-http/message/tree/1.16.2" + "issues": "https://github.com/ergebnis/json/issues", + "security": "https://github.com/ergebnis/json/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/json" }, - "time": "2024-10-02T11:34:13+00:00" + "time": "2025-09-06T09:08:45+00:00" }, { - "name": "php-http/mock-client", - "version": "1.6.1", + "name": "ergebnis/json-normalizer", + "version": "4.10.1", "source": { "type": "git", - "url": "https://github.com/php-http/mock-client.git", - "reference": "81f558234421f7da58ed015604a03808996017d0" + "url": "https://github.com/ergebnis/json-normalizer.git", + "reference": "77961faf2c651c3f05977b53c6c68e8434febf62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-http/mock-client/zipball/81f558234421f7da58ed015604a03808996017d0", - "reference": "81f558234421f7da58ed015604a03808996017d0", + "url": "https://api.github.com/repos/ergebnis/json-normalizer/zipball/77961faf2c651c3f05977b53c6c68e8434febf62", + "reference": "77961faf2c651c3f05977b53c6c68e8434febf62", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0", - "php-http/client-common": "^2.0", - "php-http/discovery": "^1.16", - "php-http/httplug": "^2.0", - "psr/http-client": "^1.0", - "psr/http-factory-implementation": "^1.0", - "psr/http-message": "^1.0 || ^2.0", - "symfony/polyfill-php80": "^1.17" - }, - "provide": { - "php-http/async-client-implementation": "1.0", - "php-http/client-implementation": "1.0", - "psr/http-client-implementation": "1.0" + "ergebnis/json": "^1.2.0", + "ergebnis/json-pointer": "^3.4.0", + "ergebnis/json-printer": "^3.5.0", + "ergebnis/json-schema-validator": "^4.2.0", + "ext-json": "*", + "justinrainbow/json-schema": "^5.2.12 || ^6.0.0", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { - "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3" + "composer/semver": "^3.4.3", + "ergebnis/composer-normalize": "^2.44.0", + "ergebnis/data-provider": "^3.3.0", + "ergebnis/license": "^2.5.0", + "ergebnis/php-cs-fixer-config": "^6.37.0", + "ergebnis/phpunit-slow-test-detector": "^2.16.1", + "fakerphp/faker": "^1.24.0", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.10", + "phpstan/phpstan-deprecation-rules": "^1.2.1", + "phpstan/phpstan-phpunit": "^1.4.0", + "phpstan/phpstan-strict-rules": "^1.6.1", + "phpunit/phpunit": "^9.6.19", + "rector/rector": "^1.2.10" + }, + "suggest": { + "composer/semver": "If you want to use ComposerJsonNormalizer or VersionConstraintNormalizer" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.11-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, "autoload": { "psr-4": { - "Http\\Mock\\": "src/" + "Ergebnis\\Json\\Normalizer\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2140,49 +2034,224 @@ ], "authors": [ { - "name": "David de Boer", - "email": "david@ddeboer.nl" + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" } ], - "description": "Mock HTTP client", - "homepage": "http://httplug.io", + "description": "Provides generic and vendor-specific normalizers for normalizing JSON documents.", + "homepage": "https://github.com/ergebnis/json-normalizer", "keywords": [ - "client", - "http", - "mock", - "psr7" + "json", + "normalizer" ], "support": { - "issues": "https://github.com/php-http/mock-client/issues", - "source": "https://github.com/php-http/mock-client/tree/1.6.1" + "issues": "https://github.com/ergebnis/json-normalizer/issues", + "security": "https://github.com/ergebnis/json-normalizer/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/json-normalizer" }, - "time": "2024-10-31T10:30:18+00:00" + "time": "2025-09-06T09:18:13+00:00" }, { - "name": "php-http/promise", - "version": "1.3.1", + "name": "ergebnis/json-pointer", + "version": "3.8.0", "source": { "type": "git", - "url": "https://github.com/php-http/promise.git", - "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83" + "url": "https://github.com/ergebnis/json-pointer.git", + "reference": "b58c3c468a7ff109fdf9a255f17de29ecbe5276c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-http/promise/zipball/fc85b1fba37c169a69a07ef0d5a8075770cc1f83", - "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "url": "https://api.github.com/repos/ergebnis/json-pointer/zipball/b58c3c468a7ff109fdf9a255f17de29ecbe5276c", + "reference": "b58c3c468a7ff109fdf9a255f17de29ecbe5276c", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { - "friends-of-phpspec/phpspec-code-coverage": "^4.3.2 || ^6.3", - "phpspec/phpspec": "^5.1.2 || ^6.2 || ^7.4" + "ergebnis/composer-normalize": "^2.50.0", + "ergebnis/data-provider": "^3.6.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.16.0", + "fakerphp/faker": "^1.24.1", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.46", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.8-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, "autoload": { "psr-4": { - "Http\\Promise\\": "src/" + "Ergebnis\\Json\\Pointer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides an abstraction of a JSON pointer.", + "homepage": "https://github.com/ergebnis/json-pointer", + "keywords": [ + "RFC6901", + "json", + "pointer" + ], + "support": { + "issues": "https://github.com/ergebnis/json-pointer/issues", + "security": "https://github.com/ergebnis/json-pointer/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/json-pointer" + }, + "time": "2026-04-07T14:52:13+00:00" + }, + { + "name": "ergebnis/json-printer", + "version": "3.8.1", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/json-printer.git", + "reference": "211d73fc7ec6daf98568ee6ed6e6d133dee8503e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/json-printer/zipball/211d73fc7ec6daf98568ee6ed6e6d133dee8503e", + "reference": "211d73fc7ec6daf98568ee6ed6e6d133dee8503e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.44.0", + "ergebnis/data-provider": "^3.3.0", + "ergebnis/license": "^2.5.0", + "ergebnis/php-cs-fixer-config": "^6.37.0", + "ergebnis/phpunit-slow-test-detector": "^2.16.1", + "fakerphp/faker": "^1.24.0", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.10", + "phpstan/phpstan-deprecation-rules": "^1.2.1", + "phpstan/phpstan-phpunit": "^1.4.1", + "phpstan/phpstan-strict-rules": "^1.6.1", + "phpunit/phpunit": "^9.6.21", + "rector/rector": "^1.2.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.9-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Json\\Printer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a JSON printer, allowing for flexible indentation.", + "homepage": "https://github.com/ergebnis/json-printer", + "keywords": [ + "formatter", + "json", + "printer" + ], + "support": { + "issues": "https://github.com/ergebnis/json-printer/issues", + "security": "https://github.com/ergebnis/json-printer/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/json-printer" + }, + "time": "2025-09-06T09:59:26+00:00" + }, + { + "name": "ergebnis/json-schema-validator", + "version": "4.5.1", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/json-schema-validator.git", + "reference": "b739527a480a9e3651360ad351ea77e7e9019df2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/json-schema-validator/zipball/b739527a480a9e3651360ad351ea77e7e9019df2", + "reference": "b739527a480a9e3651360ad351ea77e7e9019df2", + "shasum": "" + }, + "require": { + "ergebnis/json": "^1.2.0", + "ergebnis/json-pointer": "^3.4.0", + "ext-json": "*", + "justinrainbow/json-schema": "^5.2.12 || ^6.0.0", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.44.0", + "ergebnis/data-provider": "^3.3.0", + "ergebnis/license": "^2.5.0", + "ergebnis/php-cs-fixer-config": "^6.37.0", + "ergebnis/phpunit-slow-test-detector": "^2.16.1", + "fakerphp/faker": "^1.24.0", + "infection/infection": "~0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.10", + "phpstan/phpstan-deprecation-rules": "^1.2.1", + "phpstan/phpstan-phpunit": "^1.4.0", + "phpstan/phpstan-strict-rules": "^1.6.1", + "phpunit/phpunit": "^9.6.20", + "rector/rector": "^1.2.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.6-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Json\\SchemaValidator\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2191,144 +2260,2609 @@ ], "authors": [ { - "name": "Joel Wurtz", - "email": "joel.wurtz@gmail.com" + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a JSON schema validator, building on top of justinrainbow/json-schema.", + "homepage": "https://github.com/ergebnis/json-schema-validator", + "keywords": [ + "json", + "schema", + "validator" + ], + "support": { + "issues": "https://github.com/ergebnis/json-schema-validator/issues", + "security": "https://github.com/ergebnis/json-schema-validator/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/json-schema-validator" + }, + "time": "2025-09-06T11:37:35+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": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "LanguageServerProtocol\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "ISC" + ], + "authors": [ + { + "name": "Felix Becker", + "email": "felix.b@outlook.com" + } + ], + "description": "PHP classes for the Language Server Protocol", + "keywords": [ + "language", + "microsoft", + "php", + "server" + ], + "support": { + "issues": "https://github.com/felixfbecker/php-language-server-protocol/issues", + "source": "https://github.com/felixfbecker/php-language-server-protocol/tree/v1.5.3" + }, + "time": "2024-04-30T00:40:11+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.6", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "e7412b3180912c01650cc66647f18c1d1cbe9b94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/e7412b3180912c01650cc66647f18c1d1cbe9b94", + "reference": "e7412b3180912c01650cc66647f18c1d1cbe9b94", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.4", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.6" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-06-01T13:06:22+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2", + "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.4.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-05-20T22:57:30+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.10.4", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "d2a1a094e396da8957e797489fddaf860c340cfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/d2a1a094e396da8957e797489fddaf860c340cfc", + "reference": "d2a1a094e396da8957e797489fddaf860c340cfc", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.10.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-05-29T12:59:07+00:00" + }, + { + "name": "justinrainbow/json-schema", + "version": "6.8.2", + "source": { + "type": "git", + "url": "https://github.com/jsonrainbow/json-schema.git", + "reference": "2c89ebb95ca9cedc9347f780333f7b25792dcb76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/2c89ebb95ca9cedc9347f780333f7b25792dcb76", + "reference": "2c89ebb95ca9cedc9347f780333f7b25792dcb76", + "shasum": "" + }, + "require": { + "ext-json": "*", + "marc-mabe/php-enum": "^4.4", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.3.0", + "json-schema/json-schema-test-suite": "dev-main", + "marc-mabe/php-enum-phpstan": "^2.0", + "phpspec/prophecy": "^1.19", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^8.5" + }, + "bin": [ + "bin/validate-json" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "JsonSchema\\": "src/JsonSchema/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bruno Prieto Reis", + "email": "bruno.p.reis@gmail.com" + }, + { + "name": "Justin Rainbow", + "email": "justin.rainbow@gmail.com" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Robert Schönthal", + "email": "seroscho@googlemail.com" + } + ], + "description": "A library to validate a json schema.", + "homepage": "https://github.com/jsonrainbow/json-schema", + "keywords": [ + "json", + "schema" + ], + "support": { + "issues": "https://github.com/jsonrainbow/json-schema/issues", + "source": "https://github.com/jsonrainbow/json-schema/tree/6.8.2" + }, + "time": "2026-05-05T05:39:01+00:00" + }, + { + "name": "kelunik/certificate", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/kelunik/certificate.git", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">=7.0" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^6 | 7 | ^8 | ^9" + }, + "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": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "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": { + "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/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "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": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "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": { + "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/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "localheinz/diff", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/localheinz/diff.git", + "reference": "33bd840935970cda6691c23fc7d94ae764c0734c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/localheinz/diff/zipball/33bd840935970cda6691c23fc7d94ae764c0734c", + "reference": "33bd840935970cda6691c23fc7d94ae764c0734c", + "shasum": "" + }, + "require": { + "php": "~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5.0 || ^8.5.23", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "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": "Fork of sebastian/diff for use with ergebnis/composer-normalize", + "homepage": "https://github.com/localheinz/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/localheinz/diff/issues", + "source": "https://github.com/localheinz/diff/tree/1.3.0" + }, + "time": "2025-08-30T09:44:18+00:00" + }, + { + "name": "marc-mabe/php-enum", + "version": "v4.7.2", + "source": { + "type": "git", + "url": "https://github.com/marc-mabe/php-enum.git", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/marc-mabe/php-enum/zipball/bb426fcdd65c60fb3638ef741e8782508fda7eef", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef", + "shasum": "" + }, + "require": { + "ext-reflection": "*", + "php": "^7.1 | ^8.0" + }, + "require-dev": { + "phpbench/phpbench": "^0.16.10 || ^1.0.4", + "phpstan/phpstan": "^1.3.1", + "phpunit/phpunit": "^7.5.20 | ^8.5.22 | ^9.5.11", + "vimeo/psalm": "^4.17.0 | ^5.26.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-3.x": "3.2-dev", + "dev-master": "4.7-dev" + } + }, + "autoload": { + "psr-4": { + "MabeEnum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Marc Bennewitz", + "email": "dev@mabe.berlin", + "homepage": "https://mabe.berlin/", + "role": "Lead" + } + ], + "description": "Simple and fast implementation of enumerations with native PHP", + "homepage": "https://github.com/marc-mabe/php-enum", + "keywords": [ + "enum", + "enum-map", + "enum-set", + "enumeration", + "enumerator", + "enummap", + "enumset", + "map", + "set", + "type", + "type-hint", + "typehint" + ], + "support": { + "issues": "https://github.com/marc-mabe/php-enum/issues", + "source": "https://github.com/marc-mabe/php-enum/tree/v4.7.2" + }, + "time": "2025-09-14T11:18:39+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "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.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "php-http/client-common", + "version": "2.7.3", + "source": { + "type": "git", + "url": "https://github.com/php-http/client-common.git", + "reference": "dcc6de29c90dd74faab55f71b79d89409c4bf0c1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/client-common/zipball/dcc6de29c90dd74faab55f71b79d89409c4bf0c1", + "reference": "dcc6de29c90dd74faab55f71b79d89409c4bf0c1", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/httplug": "^2.0", + "php-http/message": "^1.6", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0 || ^2.0", + "symfony/options-resolver": "~4.0.15 || ~4.1.9 || ^4.2.1 || ^5.0 || ^6.0 || ^7.0 || ^8.0", + "symfony/polyfill-php80": "^1.17" + }, + "require-dev": { + "doctrine/instantiator": "^1.1", + "guzzlehttp/psr7": "^1.4", + "nyholm/psr7": "^1.2", + "phpunit/phpunit": "^7.5.20 || ^8.5.33 || ^9.6.7" + }, + "suggest": { + "ext-json": "To detect JSON responses with the ContentTypePlugin", + "ext-libxml": "To detect XML responses with the ContentTypePlugin", + "php-http/cache-plugin": "PSR-6 Cache plugin", + "php-http/logger-plugin": "PSR-3 Logger plugin", + "php-http/stopwatch-plugin": "Symfony Stopwatch plugin" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Client\\Common\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Common HTTP Client implementations and tools for HTTPlug", + "homepage": "http://httplug.io", + "keywords": [ + "client", + "common", + "http", + "httplug" + ], + "support": { + "issues": "https://github.com/php-http/client-common/issues", + "source": "https://github.com/php-http/client-common/tree/2.7.3" + }, + "time": "2025-11-29T19:12:34+00:00" + }, + { + "name": "php-http/httplug", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/httplug.git", + "reference": "5cad731844891a4c282f3f3e1b582c46839d22f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/httplug/zipball/5cad731844891a4c282f3f3e1b582c46839d22f4", + "reference": "5cad731844891a4c282f3f3e1b582c46839d22f4", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/promise": "^1.1", + "psr/http-client": "^1.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.1 || ^5.0 || ^6.0", + "phpspec/phpspec": "^5.1 || ^6.0 || ^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eric GELOEN", + "email": "geloen.eric@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "HTTPlug, the HTTP client abstraction for PHP", + "homepage": "http://httplug.io", + "keywords": [ + "client", + "http" + ], + "support": { + "issues": "https://github.com/php-http/httplug/issues", + "source": "https://github.com/php-http/httplug/tree/2.4.1" + }, + "time": "2024-09-23T11:39:58+00:00" + }, + { + "name": "php-http/message", + "version": "1.16.2", + "source": { + "type": "git", + "url": "https://github.com/php-http/message.git", + "reference": "06dd5e8562f84e641bf929bfe699ee0f5ce8080a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/message/zipball/06dd5e8562f84e641bf929bfe699ee0f5ce8080a", + "reference": "06dd5e8562f84e641bf929bfe699ee0f5ce8080a", + "shasum": "" + }, + "require": { + "clue/stream-filter": "^1.5", + "php": "^7.2 || ^8.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.6", + "ext-zlib": "*", + "guzzlehttp/psr7": "^1.0 || ^2.0", + "laminas/laminas-diactoros": "^2.0 || ^3.0", + "php-http/message-factory": "^1.0.2", + "phpspec/phpspec": "^5.1 || ^6.3 || ^7.1", + "slim/slim": "^3.0" + }, + "suggest": { + "ext-zlib": "Used with compressor/decompressor streams", + "guzzlehttp/psr7": "Used with Guzzle PSR-7 Factories", + "laminas/laminas-diactoros": "Used with Diactoros Factories", + "slim/slim": "Used with Slim Framework PSR-7 implementation" + }, + "type": "library", + "autoload": { + "files": [ + "src/filters.php" + ], + "psr-4": { + "Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "HTTP Message related tools", + "homepage": "http://php-http.org", + "keywords": [ + "http", + "message", + "psr-7" + ], + "support": { + "issues": "https://github.com/php-http/message/issues", + "source": "https://github.com/php-http/message/tree/1.16.2" + }, + "time": "2024-10-02T11:34:13+00:00" + }, + { + "name": "php-http/mock-client", + "version": "1.6.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/mock-client.git", + "reference": "81f558234421f7da58ed015604a03808996017d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/mock-client/zipball/81f558234421f7da58ed015604a03808996017d0", + "reference": "81f558234421f7da58ed015604a03808996017d0", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/client-common": "^2.0", + "php-http/discovery": "^1.16", + "php-http/httplug": "^2.0", + "psr/http-client": "^1.0", + "psr/http-factory-implementation": "^1.0", + "psr/http-message": "^1.0 || ^2.0", + "symfony/polyfill-php80": "^1.17" + }, + "provide": { + "php-http/async-client-implementation": "1.0", + "php-http/client-implementation": "1.0", + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Mock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "David de Boer", + "email": "david@ddeboer.nl" + } + ], + "description": "Mock HTTP client", + "homepage": "http://httplug.io", + "keywords": [ + "client", + "http", + "mock", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/mock-client/issues", + "source": "https://github.com/php-http/mock-client/tree/1.6.1" + }, + "time": "2024-10-31T10:30:18+00:00" + }, + { + "name": "php-http/promise", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/promise.git", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/promise/zipball/fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.3.2 || ^6.3", + "phpspec/phpspec": "^5.1.2 || ^6.2 || ^7.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joel Wurtz", + "email": "joel.wurtz@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Promise used for asynchronous HTTP requests", + "homepage": "http://httplug.io", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/php-http/promise/issues", + "source": "https://github.com/php-http/promise/tree/1.3.1" + }, + "time": "2024-03-15T13:55:21+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/extension-installer", + "version": "1.4.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/extension-installer.git", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/extension-installer/zipball/85e90b3942d06b2326fba0403ec24fe912372936", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "php": "^7.2 || ^8.0", + "phpstan/phpstan": "^1.9.0 || ^2.0" + }, + "require-dev": { + "composer/composer": "^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2.0", + "phpstan/phpstan-strict-rules": "^0.11 || ^0.12 || ^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPStan\\ExtensionInstaller\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPStan\\ExtensionInstaller\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Composer plugin for automatic installation of PHPStan extensions", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/extension-installer/issues", + "source": "https://github.com/phpstan/extension-installer/tree/1.4.3" + }, + "time": "2024-09-04T20:21:43+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.2", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "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.2" + }, + "time": "2026-01-25T14:56:51+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.2.1", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dea9c8f2d25cc849391042b71e429c1a4bf82660", + "reference": "dea9c8f2d25cc849391042b71e429c1a4bf82660", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-05-28T14:44:12+00:00" + }, + { + "name": "phpstan/phpstan-deprecation-rules", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-deprecation-rules.git", + "reference": "6b5571001a7f04fa0422254c30a0017ec2f2cacc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/6b5571001a7f04fa0422254c30a0017ec2f2cacc", + "reference": "6b5571001a7f04fa0422254c30a0017ec2f2cacc", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.39" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan rules for detecting usage of deprecated classes, methods, properties, constants and traits.", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-deprecation-rules/issues", + "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/2.0.4" + }, + "time": "2026-02-09T13:21:14+00:00" + }, + { + "name": "phpstan/phpstan-phpunit", + "version": "2.0.16", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-phpunit.git", + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.32" + }, + "conflict": { + "phpunit/phpunit": "<7.0" + }, + "require-dev": { + "nikic/php-parser": "^5", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPUnit extensions and rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-phpunit/issues", + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.16" + }, + "time": "2026-02-14T09:05:21+00:00" + }, + { + "name": "phpstan/phpstan-strict-rules", + "version": "2.0.11", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-strict-rules.git", + "reference": "9b000a578b85b32945b358b172c7b20e91189024" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/9b000a578b85b32945b358b172c7b20e91189024", + "reference": "9b000a578b85b32945b358b172c7b20e91189024", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.39" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Extra strict and opinionated rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.11" + }, + "time": "2026-05-02T06:54:10+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "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": "11.0.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/11.0.12" + }, + "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/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "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", + "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/5.1.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/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "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", + "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", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.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": "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/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.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", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.55", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.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/11.5.55" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" }, { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com" + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" } ], - "description": "Promise used for asynchronous HTTP requests", - "homepage": "http://httplug.io", - "keywords": [ - "promise" - ], - "support": { - "issues": "https://github.com/php-http/promise/issues", - "source": "https://github.com/php-http/promise/tree/1.3.1" - }, - "time": "2024-03-15T13:55:21+00:00" + "time": "2026-02-18T12:37:06+00:00" }, { - "name": "phpstan/extension-installer", - "version": "1.4.3", + "name": "psr/cache", + "version": "3.0.0", "source": { "type": "git", - "url": "https://github.com/phpstan/extension-installer.git", - "reference": "85e90b3942d06b2326fba0403ec24fe912372936" + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/extension-installer/zipball/85e90b3942d06b2326fba0403ec24fe912372936", - "reference": "85e90b3942d06b2326fba0403ec24fe912372936", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", "shasum": "" }, "require": { - "composer-plugin-api": "^2.0", - "php": "^7.2 || ^8.0", - "phpstan/phpstan": "^1.9.0 || ^2.0" - }, - "require-dev": { - "composer/composer": "^2.0", - "php-parallel-lint/php-parallel-lint": "^1.2.0", - "phpstan/phpstan-strict-rules": "^0.11 || ^0.12 || ^1.0" + "php": ">=8.0.0" }, - "type": "composer-plugin", + "type": "library", "extra": { - "class": "PHPStan\\ExtensionInstaller\\Plugin" + "branch-alias": { + "dev-master": "1.0.x-dev" + } }, "autoload": { "psr-4": { - "PHPStan\\ExtensionInstaller\\": "src/" + "Psr\\Cache\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "Composer plugin for automatic installation of PHPStan extensions", + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", "keywords": [ - "dev", - "static analysis" + "cache", + "psr", + "psr-6" ], "support": { - "issues": "https://github.com/phpstan/extension-installer/issues", - "source": "https://github.com/phpstan/extension-installer/tree/1.4.3" + "source": "https://github.com/php-fig/cache/tree/3.0.0" }, - "time": "2024-09-04T20:21:43+00:00" + "time": "2021-02-03T23:26:27+00:00" }, { - "name": "phpstan/phpdoc-parser", - "version": "2.3.2", + "name": "psr/container", + "version": "2.0.2", "source": { "type": "git", - "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", "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" + "php": ">=7.4.0" }, "type": "library", - "autoload": { - "psr-4": { - "PHPStan\\PhpDocParser\\": [ - "src/" - ] + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" } }, - "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.2" - }, - "time": "2026-01-25T14:56:51+00:00" - }, - { - "name": "phpstan/phpstan", - "version": "2.2.1", - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dea9c8f2d25cc849391042b71e429c1a4bf82660", - "reference": "dea9c8f2d25cc849391042b71e429c1a4bf82660", - "shasum": "" - }, - "require": { - "php": "^7.4|^8.0" - }, - "conflict": { - "phpstan/phpstan-shim": "*" - }, - "bin": [ - "phpstan", - "phpstan.phar" - ], - "type": "library", "autoload": { - "files": [ - "bootstrap.php" - ] + "psr-4": { + "Psr\\Container\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2336,236 +4870,222 @@ ], "authors": [ { - "name": "Ondřej Mirtes" - }, - { - "name": "Markus Staab" - }, - { - "name": "Vincent Langlet" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "description": "PHPStan - PHP Static Analysis Tool", + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", "keywords": [ - "dev", - "static analysis" + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" ], "support": { - "docs": "https://phpstan.org/user-guide/getting-started", - "forum": "https://github.com/phpstan/phpstan/discussions", - "issues": "https://github.com/phpstan/phpstan/issues", - "security": "https://github.com/phpstan/phpstan/security/policy", - "source": "https://github.com/phpstan/phpstan-src" + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" }, - "funding": [ - { - "url": "https://github.com/ondrejmirtes", - "type": "github" - }, - { - "url": "https://github.com/phpstan", - "type": "github" - } - ], - "time": "2026-05-28T14:44:12+00:00" + "time": "2021-11-05T16:47:00+00:00" }, { - "name": "phpstan/phpstan-deprecation-rules", - "version": "2.0.4", + "name": "ralouphie/getallheaders", + "version": "3.0.3", "source": { "type": "git", - "url": "https://github.com/phpstan/phpstan-deprecation-rules.git", - "reference": "6b5571001a7f04fa0422254c30a0017ec2f2cacc" + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/6b5571001a7f04fa0422254c30a0017ec2f2cacc", - "reference": "6b5571001a7f04fa0422254c30a0017ec2f2cacc", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.1.39" + "php": ">=5.6" }, "require-dev": { - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^9.6" - }, - "type": "phpstan-extension", - "extra": { - "phpstan": { - "includes": [ - "rules.neon" - ] - } + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" }, + "type": "library", "autoload": { - "psr-4": { - "PHPStan\\": "src/" - } + "files": [ + "src/getallheaders.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "PHPStan rules for detecting usage of deprecated classes, methods, properties, constants and traits.", - "keywords": [ - "static analysis" + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } ], + "description": "A polyfill for getallheaders.", "support": { - "issues": "https://github.com/phpstan/phpstan-deprecation-rules/issues", - "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/2.0.4" + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" }, - "time": "2026-02-09T13:21:14+00:00" + "time": "2019-03-08T08:55:37+00:00" }, { - "name": "phpstan/phpstan-phpunit", - "version": "2.0.16", + "name": "revolt/event-loop", + "version": "v1.0.9", "source": { "type": "git", - "url": "https://github.com/phpstan/phpstan-phpunit.git", - "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32" + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "44061cf513e53c6200372fc935ac42271566295d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32", - "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/44061cf513e53c6200372fc935ac42271566295d", + "reference": "44061cf513e53c6200372fc935ac42271566295d", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.1.32" - }, - "conflict": { - "phpunit/phpunit": "<7.0" + "php": ">=8.1" }, "require-dev": { - "nikic/php-parser": "^5", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/phpstan-deprecation-rules": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^9.6" - }, - "type": "phpstan-extension", - "extra": { - "phpstan": { - "includes": [ - "extension.neon", - "rules.neon" - ] + "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": { - "PHPStan\\": "src/" + "Revolt\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "PHPUnit extensions and rules for PHPStan", + "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": [ - "static analysis" + "async", + "asynchronous", + "concurrency", + "event", + "event-loop", + "non-blocking", + "scheduler" ], "support": { - "issues": "https://github.com/phpstan/phpstan-phpunit/issues", - "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.16" + "issues": "https://github.com/revoltphp/event-loop/issues", + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.9" }, - "time": "2026-02-14T09:05:21+00:00" + "time": "2026-05-16T17:55:38+00:00" }, { - "name": "phpstan/phpstan-strict-rules", - "version": "2.0.11", + "name": "sebastian/cli-parser", + "version": "3.0.2", "source": { "type": "git", - "url": "https://github.com/phpstan/phpstan-strict-rules.git", - "reference": "9b000a578b85b32945b358b172c7b20e91189024" + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/9b000a578b85b32945b358b172c7b20e91189024", - "reference": "9b000a578b85b32945b358b172c7b20e91189024", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.1.39" + "php": ">=8.2" }, "require-dev": { - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/phpstan-deprecation-rules": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^11.0" }, - "type": "phpstan-extension", + "type": "library", "extra": { - "phpstan": { - "includes": [ - "rules.neon" - ] + "branch-alias": { + "dev-main": "3.0-dev" } }, "autoload": { - "psr-4": { - "PHPStan\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], - "description": "Extra strict and opinionated rules for PHPStan", - "keywords": [ - "static analysis" + "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/phpstan/phpstan-strict-rules/issues", - "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.11" + "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/3.0.2" }, - "time": "2026-05-02T06:54:10+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" }, { - "name": "phpunit/php-code-coverage", - "version": "11.0.12", + "name": "sebastian/code-unit", + "version": "3.0.3", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", - "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-libxml": "*", - "ext-xmlwriter": "*", - "nikic/php-parser": "^5.7.0", - "php": ">=8.2", - "phpunit/php-file-iterator": "^5.1.0", - "phpunit/php-text-template": "^4.0.1", - "sebastian/code-unit-reverse-lookup": "^4.0.1", - "sebastian/complexity": "^4.0.1", - "sebastian/environment": "^7.2.1", - "sebastian/lines-of-code": "^3.0.1", - "sebastian/version": "^5.0.2", - "theseer/tokenizer": "^1.3.1" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.5.46" - }, - "suggest": { - "ext-pcov": "PHP extension that provides line coverage", - "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + "phpunit/phpunit": "^11.5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "11.0.x-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -2584,62 +5104,45 @@ "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" - ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", "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/11.0.12" + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" }, "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/phpunit/php-code-coverage", - "type": "tidelift" } ], - "time": "2025-12-24T07:01:01+00:00" + "time": "2025-03-19T07:56:08+00:00" }, { - "name": "phpunit/php-file-iterator", - "version": "5.1.1", + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", - "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.1-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -2654,69 +5157,55 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" } ], - "description": "FilterIterator implementation that filters files based on a list of suffixes.", - "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", - "keywords": [ - "filesystem", - "iterator" - ], + "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/php-file-iterator/issues", - "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.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/phpunit/php-file-iterator", - "type": "tidelift" } ], - "time": "2026-02-02T13:52:54+00:00" + "time": "2024-07-03T04:45:54+00:00" }, { - "name": "phpunit/php-invoker", - "version": "5.0.1", + "name": "sebastian/comparator", + "version": "6.3.3", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", - "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", "shasum": "" }, "require": { - "php": ">=8.2" + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" }, "require-dev": { - "ext-pcntl": "*", - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.4" }, "suggest": { - "ext-pcntl": "*" + "ext-bcmath": "For comparing BcMath\\Number objects" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "6.3-dev" } }, "autoload": { @@ -2731,43 +5220,69 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "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": "Invoke callables with a timeout", - "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", "keywords": [ - "process" + "comparator", + "compare", + "equality" ], "support": { - "issues": "https://github.com/sebastianbergmann/php-invoker/issues", - "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "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": "2024-07-03T05:07:44+00:00" + "time": "2026-01-24T09:26:40+00:00" }, { - "name": "phpunit/php-text-template", + "name": "sebastian/complexity", "version": "4.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", - "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", "shasum": "" }, "require": { + "nikic/php-parser": "^5.0", "php": ">=8.2" }, "require-dev": { @@ -2795,15 +5310,12 @@ "role": "lead" } ], - "description": "Simple template engine.", - "homepage": "https://github.com/sebastianbergmann/php-text-template/", - "keywords": [ - "template" - ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", "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/4.0.1" + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" }, "funding": [ { @@ -2811,32 +5323,33 @@ "type": "github" } ], - "time": "2024-07-03T05:08:43+00:00" + "time": "2024-07-03T04:49:50+00:00" }, { - "name": "phpunit/php-timer", - "version": "7.0.1", + "name": "sebastian/diff", + "version": "6.0.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", - "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -2851,19 +5364,25 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" } ], - "description": "Utility class for timing", - "homepage": "https://github.com/sebastianbergmann/php-timer/", + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", "keywords": [ - "timer" + "diff", + "udiff", + "unidiff", + "unified diff" ], "support": { - "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "security": "https://github.com/sebastianbergmann/php-timer/security/policy", - "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" }, "funding": [ { @@ -2871,67 +5390,38 @@ "type": "github" } ], - "time": "2024-07-03T05:09:35+00:00" + "time": "2024-07-03T04:53:05+00:00" }, { - "name": "phpunit/phpunit", - "version": "11.5.55", + "name": "sebastian/environment", + "version": "7.2.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", - "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-json": "*", - "ext-libxml": "*", - "ext-mbstring": "*", - "ext-xml": "*", - "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.4", - "phar-io/manifest": "^2.0.4", - "phar-io/version": "^3.2.1", - "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0.12", - "phpunit/php-file-iterator": "^5.1.1", - "phpunit/php-invoker": "^5.0.1", - "phpunit/php-text-template": "^4.0.1", - "phpunit/php-timer": "^7.0.1", - "sebastian/cli-parser": "^3.0.2", - "sebastian/code-unit": "^3.0.3", - "sebastian/comparator": "^6.3.3", - "sebastian/diff": "^6.0.2", - "sebastian/environment": "^7.2.1", - "sebastian/exporter": "^6.3.2", - "sebastian/global-state": "^7.0.2", - "sebastian/object-enumerator": "^6.0.1", - "sebastian/recursion-context": "^6.0.3", - "sebastian/type": "^5.1.3", - "sebastian/version": "^5.0.2", - "staabm/side-effects-detector": "^1.0.5" + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" }, "suggest": { - "ext-soap": "To be able to generate mocks based on WSDL files" + "ext-posix": "*" }, - "bin": [ - "phpunit" - ], "type": "library", "extra": { "branch-alias": { - "dev-main": "11.5-dev" + "dev-main": "7.2-dev" } }, "autoload": { - "files": [ - "src/Framework/Assert/Functions.php" - ], "classmap": [ "src/" ] @@ -2943,27 +5433,22 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" } ], - "description": "The PHP Unit Testing framework.", - "homepage": "https://phpunit.de/", + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", "keywords": [ - "phpunit", - "testing", - "xunit" + "Xdebug", + "environment", + "hhvm" ], "support": { - "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" }, "funding": [ - { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, { "url": "https://github.com/sebastianbergmann", "type": "github" @@ -2977,173 +5462,180 @@ "type": "thanks_dev" }, { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", "type": "tidelift" } ], - "time": "2026-02-18T12:37:06+00:00" + "time": "2025-05-21T11:55:47+00:00" }, { - "name": "psr/cache", - "version": "3.0.0", + "name": "sebastian/exporter", + "version": "6.3.2", "source": { "type": "git", - "url": "https://github.com/php-fig/cache.git", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", "shasum": "" }, "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Cache\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for caching libraries", - "keywords": [ - "cache", - "psr", - "psr-6" - ], - "support": { - "source": "https://github.com/php-fig/cache/tree/3.0.0" - }, - "time": "2021-02-03T23:26:27+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": "" + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" }, - "require": { - "php": ">=7.4.0" + "require-dev": { + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-main": "6.3-dev" } }, "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "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": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" + "export", + "exporter" ], "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" }, - "time": "2021-11-05T16:47:00+00:00" + "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:12:51+00:00" }, { - "name": "ralouphie/getallheaders", - "version": "3.0.3", + "name": "sebastian/global-state", + "version": "7.0.2", "source": { "type": "git", - "url": "https://github.com/ralouphie/getallheaders.git", - "reference": "120b605dfeb996808c31b6477290a714d356e822" + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", - "reference": "120b605dfeb996808c31b6477290a714d356e822", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", "shasum": "" }, "require": { - "php": ">=5.6" + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" }, "require-dev": { - "php-coveralls/php-coveralls": "^2.1", - "phpunit/phpunit": "^5 || ^6.5" + "ext-dom": "*", + "phpunit/phpunit": "^11.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, "autoload": { - "files": [ - "src/getallheaders.php" + "classmap": [ + "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Ralph Khattar", - "email": "ralph.khattar@gmail.com" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" } ], - "description": "A polyfill for getallheaders.", + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], "support": { - "issues": "https://github.com/ralouphie/getallheaders/issues", - "source": "https://github.com/ralouphie/getallheaders/tree/develop" + "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/7.0.2" }, - "time": "2019-03-08T08:55:37+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" }, { - "name": "sebastian/cli-parser", - "version": "3.0.2", + "name": "sebastian/lines-of-code", + "version": "3.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", - "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", "shasum": "" }, "require": { + "nikic/php-parser": "^5.0", "php": ">=8.2" }, "require-dev": { @@ -3171,12 +5663,12 @@ "role": "lead" } ], - "description": "Library for parsing CLI options", - "homepage": "https://github.com/sebastianbergmann/cli-parser", + "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/cli-parser/issues", - "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + "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/3.0.1" }, "funding": [ { @@ -3184,32 +5676,34 @@ "type": "github" } ], - "time": "2024-07-03T04:41:36+00:00" + "time": "2024-07-03T04:58:38+00:00" }, { - "name": "sebastian/code-unit", - "version": "3.0.3", + "name": "sebastian/object-enumerator", + "version": "6.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", - "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^11.5" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -3224,16 +5718,15 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" } ], - "description": "Collection of value objects that represent the PHP code units", - "homepage": "https://github.com/sebastianbergmann/code-unit", + "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/code-unit/issues", - "security": "https://github.com/sebastianbergmann/code-unit/security/policy", - "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" }, "funding": [ { @@ -3241,20 +5734,20 @@ "type": "github" } ], - "time": "2025-03-19T07:56:08+00:00" + "time": "2024-07-03T05:00:13+00:00" }, { - "name": "sebastian/code-unit-reverse-lookup", + "name": "sebastian/object-reflector", "version": "4.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", - "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", "shasum": "" }, "require": { @@ -3284,12 +5777,12 @@ "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/", + "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/code-unit-reverse-lookup/issues", - "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" }, "funding": [ { @@ -3297,39 +5790,32 @@ "type": "github" } ], - "time": "2024-07-03T04:45:54+00:00" + "time": "2024-07-03T05:01:32+00:00" }, { - "name": "sebastian/comparator", - "version": "6.3.3", + "name": "sebastian/recursion-context", + "version": "6.0.3", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", - "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-mbstring": "*", - "php": ">=8.2", - "sebastian/diff": "^6.0", - "sebastian/exporter": "^6.0" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.4" - }, - "suggest": { - "ext-bcmath": "For comparing BcMath\\Number objects" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.3-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -3351,25 +5837,16 @@ "email": "whatthejeff@gmail.com" }, { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, - { - "name": "Bernhard Schussek", - "email": "bschussek@2bepublished.at" + "name": "Adam Harvey", + "email": "aharvey@php.net" } ], - "description": "Provides the functionality to compare PHP values for equality", - "homepage": "https://github.com/sebastianbergmann/comparator", - "keywords": [ - "comparator", - "compare", - "equality" - ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { - "issues": "https://github.com/sebastianbergmann/comparator/issues", - "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + "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/6.0.3" }, "funding": [ { @@ -3385,37 +5862,36 @@ "type": "thanks_dev" }, { - "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", "type": "tidelift" } ], - "time": "2026-01-24T09:26:40+00:00" + "time": "2025-08-13T04:42:22+00:00" }, { - "name": "sebastian/complexity", - "version": "4.0.1", + "name": "sebastian/type", + "version": "5.1.3", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", - "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -3434,46 +5910,54 @@ "role": "lead" } ], - "description": "Library for calculating the complexity of PHP code units", - "homepage": "https://github.com/sebastianbergmann/complexity", + "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/complexity/issues", - "security": "https://github.com/sebastianbergmann/complexity/security/policy", - "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" }, "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/type", + "type": "tidelift" } ], - "time": "2024-07-03T04:49:50+00:00" + "time": "2025-08-09T06:55:48+00:00" }, { - "name": "sebastian/diff", - "version": "6.0.2", + "name": "sebastian/version", + "version": "5.0.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", - "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", "shasum": "" }, "require": { "php": ">=8.2" }, - "require-dev": { - "phpunit/phpunit": "^11.0", - "symfony/process": "^4.2 || ^5" - }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -3488,25 +5972,16 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Diff implementation", - "homepage": "https://github.com/sebastianbergmann/diff", - "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" - ], + "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/diff/issues", - "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" }, "funding": [ { @@ -3514,863 +5989,973 @@ "type": "github" } ], - "time": "2024-07-03T04:53:05+00:00" + "time": "2024-10-09T05:16:32+00:00" }, { - "name": "sebastian/environment", - "version": "7.2.1", + "name": "slevomat/coding-standard", + "version": "8.29.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + "url": "https://github.com/slevomat/coding-standard.git", + "reference": "81fce13c4ef4b53a03e5cfa6ce36afc191c1598e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", - "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/81fce13c4ef4b53a03e5cfa6ce36afc191c1598e", + "reference": "81fce13c4ef4b53a03e5cfa6ce36afc191c1598e", "shasum": "" }, "require": { - "php": ">=8.2" + "dealerdirect/phpcodesniffer-composer-installer": "^0.7 || ^1.2.1", + "php": "^7.4 || ^8.0", + "phpstan/phpdoc-parser": "^2.3.2", + "squizlabs/php_codesniffer": "^4.0.1" }, "require-dev": { - "phpunit/phpunit": "^11.3" - }, - "suggest": { - "ext-posix": "*" + "phing/phing": "3.0.1|3.1.2", + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/phpstan": "2.1.54", + "phpstan/phpstan-deprecation-rules": "2.0.4", + "phpstan/phpstan-phpunit": "2.0.16", + "phpstan/phpstan-strict-rules": "2.0.11", + "phpunit/phpunit": "9.6.34|10.5.63|11.4.4|11.5.55|12.5.24" }, - "type": "library", + "type": "phpcodesniffer-standard", "extra": { "branch-alias": { - "dev-main": "7.2-dev" + "dev-master": "8.x-dev" } }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "SlevomatCodingStandard\\": "SlevomatCodingStandard/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } + "MIT" ], - "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "https://github.com/sebastianbergmann/environment", + "description": "Slevomat Coding Standard for PHP_CodeSniffer complements Consistence Coding Standard by providing sniffs with additional checks.", "keywords": [ - "Xdebug", - "environment", - "hhvm" + "dev", + "phpcs" ], "support": { - "issues": "https://github.com/sebastianbergmann/environment/issues", - "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + "issues": "https://github.com/slevomat/coding-standard/issues", + "source": "https://github.com/slevomat/coding-standard/tree/8.29.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/kukulich", "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/environment", + "url": "https://tidelift.com/funding/github/packagist/slevomat/coding-standard", "type": "tidelift" } ], - "time": "2025-05-21T11:55:47+00:00" + "time": "2026-05-07T05:48:08+00:00" }, { - "name": "sebastian/exporter", - "version": "6.3.2", + "name": "spatie/array-to-xml", + "version": "3.4.4", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + "url": "https://github.com/spatie/array-to-xml.git", + "reference": "88b2f3852a922dd73177a68938f8eb2ec70c7224" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", - "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "url": "https://api.github.com/repos/spatie/array-to-xml/zipball/88b2f3852a922dd73177a68938f8eb2ec70c7224", + "reference": "88b2f3852a922dd73177a68938f8eb2ec70c7224", "shasum": "" }, "require": { - "ext-mbstring": "*", - "php": ">=8.2", - "sebastian/recursion-context": "^6.0" + "ext-dom": "*", + "php": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^11.3" + "mockery/mockery": "^1.2", + "pestphp/pest": "^1.21", + "spatie/pest-plugin-snapshots": "^1.1" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.3-dev" + "dev-main": "3.x-dev" } }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Spatie\\ArrayToXml\\": "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": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://freek.dev", + "role": "Developer" } ], - "description": "Provides the functionality to export PHP variables for visualization", - "homepage": "https://www.github.com/sebastianbergmann/exporter", + "description": "Convert an array to xml", + "homepage": "https://github.com/spatie/array-to-xml", "keywords": [ - "export", - "exporter" + "array", + "convert", + "xml" ], "support": { - "issues": "https://github.com/sebastianbergmann/exporter/issues", - "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + "source": "https://github.com/spatie/array-to-xml/tree/3.4.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://spatie.be/open-source/support-us", + "type": "custom" }, { - "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", - "type": "tidelift" + "url": "https://github.com/spatie", + "type": "github" } ], - "time": "2025-09-24T06:12:51+00:00" + "time": "2025-12-15T09:00:41+00:00" }, { - "name": "sebastian/global-state", - "version": "7.0.2", + "name": "squizlabs/php_codesniffer", + "version": "4.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "0525c73950de35ded110cffafb9892946d7771b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", - "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0525c73950de35ded110cffafb9892946d7771b5", + "reference": "0525c73950de35ded110cffafb9892946d7771b5", "shasum": "" }, "require": { - "php": ">=8.2", - "sebastian/object-reflector": "^4.0", - "sebastian/recursion-context": "^6.0" + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=7.2.0" }, "require-dev": { - "ext-dom": "*", - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31" }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], "type": "library", - "extra": { - "branch-alias": { - "dev-main": "7.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" } ], - "description": "Snapshotting of global state", - "homepage": "https://www.github.com/sebastianbergmann/global-state", + "description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", "keywords": [ - "global state" + "phpcs", + "standards", + "static analysis" ], "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/7.0.2" + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" } ], - "time": "2024-07-03T04:57:36+00:00" + "time": "2025-11-10T16:43:36+00:00" }, { - "name": "sebastian/lines-of-code", - "version": "3.0.1", + "name": "staabm/side-effects-detector", + "version": "1.0.5", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", - "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", - "php": ">=8.2" + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.0-dev" - } - }, "autoload": { "classmap": [ - "src/" + "lib/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" ], - "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/3.0.1" + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/staabm", "type": "github" } ], - "time": "2024-07-03T04:58:38+00:00" + "time": "2024-10-20T05:08:20+00:00" }, { - "name": "sebastian/object-enumerator", - "version": "6.0.1", + "name": "symfony/cache", + "version": "v7.4.13", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + "url": "https://github.com/symfony/cache.git", + "reference": "4c09e18a92cce126cc0d1155825279fca8cd0673" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", - "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "url": "https://api.github.com/repos/symfony/cache/zipball/4c09e18a92cce126cc0d1155825279fca8cd0673", + "reference": "4c09e18a92cce126cc0d1155825279fca8cd0673", "shasum": "" }, "require": { "php": ">=8.2", - "sebastian/object-reflector": "^4.0", - "sebastian/recursion-context": "^6.0" + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "ext-redis": "<6.1", + "ext-relay": "<0.12.1", + "symfony/dependency-injection": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/var-dumper": "<6.4" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "cache/integration-tests": "dev-master", + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "6.0-dev" - } - }, "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, "classmap": [ - "src/" + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" ] }, "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": "Traverses array structures and object graphs to enumerate all referenced objects", - "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], "support": { - "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + "source": "https://github.com/symfony/cache/tree/v7.4.13" }, "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": "2024-07-03T05:00:13+00:00" + "time": "2026-05-24T08:43:14+00:00" }, { - "name": "sebastian/object-reflector", - "version": "4.0.1", + "name": "symfony/cache-contracts", + "version": "v3.7.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "225e8a254166bd3442e370c6f50145465db63831" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", - "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/225e8a254166bd3442e370c6f50145465db63831", + "reference": "225e8a254166bd3442e370c6f50145465db63831", "shasum": "" }, "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" + "php": ">=8.1", + "psr/cache": "^3.0" }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "3.7-dev" } }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } }, "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": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], "support": { - "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.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": "2024-07-03T05:01:32+00:00" + "time": "2026-05-05T15:33:14+00:00" }, { - "name": "sebastian/recursion-context", - "version": "6.0.3", + "name": "symfony/console", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + "url": "https://github.com/symfony/console.git", + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", - "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "url": "https://api.github.com/repos/symfony/console/zipball/f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", "shasum": "" }, "require": { - "php": ">=8.2" + "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": { - "phpunit/phpunit": "^11.3" + "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", - "extra": { - "branch-alias": { - "dev-main": "6.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "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": "Fabien Potencier", + "email": "fabien@symfony.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": "https://github.com/sebastianbergmann/recursion-context", + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], "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/6.0.3" + "source": "https://github.com/symfony/console/tree/v8.1.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://symfony.com/sponsor", + "type": "custom" }, { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" + "url": "https://github.com/fabpot", + "type": "github" }, { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" + "url": "https://github.com/nicolas-grekas", + "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-08-13T04:42:22+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "sebastian/type", - "version": "5.1.3", + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/type.git", - "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", - "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.3" + "php": ">=8.1" }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { - "dev-main": "5.1-dev" + "dev-main": "3.7-dev" } }, "autoload": { - "classmap": [ - "src/" + "files": [ + "function.php" ] }, "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": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/sebastianbergmann/type/issues", - "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://symfony.com/sponsor", + "type": "custom" }, { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" + "url": "https://github.com/fabpot", + "type": "github" }, { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" + "url": "https://github.com/nicolas-grekas", + "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-08-09T06:55:48+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { - "name": "sebastian/version", - "version": "5.0.2", + "name": "symfony/filesystem", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + "url": "https://github.com/symfony/filesystem.git", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", - "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "5.0-dev" - } + "require-dev": { + "symfony/process": "^7.4|^8.0" }, + "type": "library", "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "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": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Library that helps with managing the version number of Git-hosted PHP projects", - "homepage": "https://github.com/sebastianbergmann/version", + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/sebastianbergmann/version/issues", - "security": "https://github.com/sebastianbergmann/version/security/policy", - "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + "source": "https://github.com/symfony/filesystem/tree/v8.1.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": "2024-10-09T05:16:32+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "slevomat/coding-standard", - "version": "8.29.0", + "name": "symfony/options-resolver", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/slevomat/coding-standard.git", - "reference": "81fce13c4ef4b53a03e5cfa6ce36afc191c1598e" + "url": "https://github.com/symfony/options-resolver.git", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/81fce13c4ef4b53a03e5cfa6ce36afc191c1598e", - "reference": "81fce13c4ef4b53a03e5cfa6ce36afc191c1598e", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82", "shasum": "" }, "require": { - "dealerdirect/phpcodesniffer-composer-installer": "^0.7 || ^1.2.1", - "php": "^7.4 || ^8.0", - "phpstan/phpdoc-parser": "^2.3.2", - "squizlabs/php_codesniffer": "^4.0.1" - }, - "require-dev": { - "phing/phing": "3.0.1|3.1.2", - "php-parallel-lint/php-parallel-lint": "1.4.0", - "phpstan/phpstan": "2.1.54", - "phpstan/phpstan-deprecation-rules": "2.0.4", - "phpstan/phpstan-phpunit": "2.0.16", - "phpstan/phpstan-strict-rules": "2.0.11", - "phpunit/phpunit": "9.6.34|10.5.63|11.4.4|11.5.55|12.5.24" - }, - "type": "phpcodesniffer-standard", - "extra": { - "branch-alias": { - "dev-master": "8.x-dev" - } + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3" }, + "type": "library", "autoload": { "psr-4": { - "SlevomatCodingStandard\\": "SlevomatCodingStandard/" - } + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "Slevomat Coding Standard for PHP_CodeSniffer complements Consistence Coding Standard by providing sniffs with additional checks.", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", "keywords": [ - "dev", - "phpcs" + "config", + "configuration", + "options" ], "support": { - "issues": "https://github.com/slevomat/coding-standard/issues", - "source": "https://github.com/slevomat/coding-standard/tree/8.29.0" + "source": "https://github.com/symfony/options-resolver/tree/v8.1.0" }, "funding": [ { - "url": "https://github.com/kukulich", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/slevomat/coding-standard", + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2026-05-07T05:48:08+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "squizlabs/php_codesniffer", - "version": "4.0.1", + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "0525c73950de35ded110cffafb9892946d7771b5" + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0525c73950de35ded110cffafb9892946d7771b5", - "reference": "0525c73950de35ded110cffafb9892946d7771b5", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { - "ext-simplexml": "*", - "ext-tokenizer": "*", - "ext-xmlwriter": "*", - "php": ">=7.2.0" + "php": ">=7.2" }, - "require-dev": { - "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31" + "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\\": "" + } }, - "bin": [ - "bin/phpcbf", - "bin/phpcs" - ], - "type": "library", "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Greg Sherwood", - "role": "Former lead" - }, - { - "name": "Juliette Reinders Folmer", - "role": "Current lead" + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" }, { - "name": "Contributors", - "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.", - "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", "keywords": [ - "phpcs", - "standards", - "static analysis" + "compatibility", + "ctype", + "polyfill", + "portable" ], "support": { - "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", - "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", - "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", - "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { - "url": "https://github.com/PHPCSStandards", - "type": "github" + "url": "https://symfony.com/sponsor", + "type": "custom" }, { - "url": "https://github.com/jrfnl", + "url": "https://github.com/fabpot", "type": "github" }, { - "url": "https://opencollective.com/php_codesniffer", - "type": "open_collective" + "url": "https://github.com/nicolas-grekas", + "type": "github" }, { - "url": "https://thanks.dev/u/gh/phpcsstandards", - "type": "thanks_dev" + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2025-11-10T16:43:36+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { - "name": "staabm/side-effects-detector", - "version": "1.0.5", + "name": "symfony/polyfill-deepclone", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/staabm/side-effects-detector.git", - "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + "url": "https://github.com/symfony/polyfill-deepclone.git", + "reference": "2ca9e9e75ead5174f2b44613a646bdc9338b8eb4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", - "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/2ca9e9e75ead5174f2b44613a646bdc9338b8eb4", + "reference": "2ca9e9e75ead5174f2b44613a646bdc9338b8eb4", "shasum": "" }, "require": { - "ext-tokenizer": "*", - "php": "^7.4 || ^8.0" + "php": ">=8.1" }, - "require-dev": { - "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^1.12.6", - "phpunit/phpunit": "^9.6.21", - "symfony/var-dumper": "^5.4.43", - "tomasvotruba/type-coverage": "1.0.0", - "tomasvotruba/unused-public": "1.0.0" + "provide": { + "ext-deepclone": "*" + }, + "suggest": { + "ext-deepclone": "For best performance" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\DeepClone\\": "" + }, "classmap": [ - "lib/" + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "A static analysis tool to detect side effects in PHP code", + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the deepclone extension", + "homepage": "https://symfony.com", "keywords": [ - "static analysis" + "compatibility", + "deepclone", + "polyfill", + "portable", + "shim" ], "support": { - "issues": "https://github.com/staabm/side-effects-detector/issues", - "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.37.0" }, "funding": [ { - "url": "https://github.com/staabm", + "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": "2024-10-20T05:08:20+00:00" + "time": "2026-04-26T13:03:27+00:00" }, { - "name": "symfony/cache", - "version": "v7.4.13", + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/cache.git", - "reference": "4c09e18a92cce126cc0d1155825279fca8cd0673" + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/4c09e18a92cce126cc0d1155825279fca8cd0673", - "reference": "4c09e18a92cce126cc0d1155825279fca8cd0673", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { - "php": ">=8.2", - "psr/cache": "^2.0|^3.0", - "psr/log": "^1.1|^2|^3", - "symfony/cache-contracts": "^3.6", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/service-contracts": "^2.5|^3", - "symfony/var-exporter": "^6.4|^7.0|^8.0" - }, - "conflict": { - "doctrine/dbal": "<3.6", - "ext-redis": "<6.1", - "ext-relay": "<0.12.1", - "symfony/dependency-injection": "<6.4", - "symfony/http-kernel": "<6.4", - "symfony/var-dumper": "<6.4" - }, - "provide": { - "psr/cache-implementation": "2.0|3.0", - "psr/simple-cache-implementation": "1.0|2.0|3.0", - "symfony/cache-implementation": "1.1|2.0|3.0" + "php": ">=7.2" }, - "require-dev": { - "cache/integration-tests": "dev-master", - "doctrine/dbal": "^3.6|^4", - "predis/predis": "^1.1|^2.0", - "psr/simple-cache": "^1.0|^2.0|^3.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/filesystem": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "suggest": { + "ext-intl": "For best performance" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { - "psr-4": { - "Symfony\\Component\\Cache\\": "" - }, - "classmap": [ - "Traits/ValueWrapper.php" + "files": [ + "bootstrap.php" ], - "exclude-from-classmap": [ - "/Tests/" - ] + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4386,14 +6971,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "description": "Symfony polyfill for intl's grapheme_* functions", "homepage": "https://symfony.com", "keywords": [ - "caching", - "psr6" + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" ], "support": { - "source": "https://github.com/symfony/cache/tree/v7.4.13" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -4413,40 +7002,45 @@ "type": "tidelift" } ], - "time": "2026-05-24T08:43:14+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { - "name": "symfony/cache-contracts", - "version": "v3.7.0", + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", "source": { "type": "git", - "url": "https://github.com/symfony/cache-contracts.git", - "reference": "225e8a254166bd3442e370c6f50145465db63831" + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/225e8a254166bd3442e370c6f50145465db63831", - "reference": "225e8a254166bd3442e370c6f50145465db63831", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/cache": "^3.0" + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" }, "type": "library", "extra": { "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Contracts\\Cache\\": "" - } + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4462,18 +7056,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Generic abstractions related to caching", + "description": "Symfony polyfill for intl's Normalizer class and related functions", "homepage": "https://symfony.com", "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" ], "support": { - "source": "https://github.com/symfony/cache-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -4493,39 +7087,46 @@ "type": "tidelift" } ], - "time": "2026-05-05T15:33:14+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { - "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", "source": { "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { - "php": ">=8.1" + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" }, "type": "library", "extra": { "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { "files": [ - "function.php" - ] + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4541,10 +7142,17 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "A generic function and convention to trigger deprecation notices", + "description": "Symfony polyfill for the Mbstring extension", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -4564,33 +7172,41 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { - "name": "symfony/options-resolver", - "version": "v8.1.0", + "name": "symfony/polyfill-php80", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/symfony/options-resolver.git", - "reference": "88f9c561f678a02d54b897014049fa839e33ff82" + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82", - "reference": "88f9c561f678a02d54b897014049fa839e33ff82", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3" + "php": ">=7.2" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\OptionsResolver\\": "" + "Symfony\\Polyfill\\Php80\\": "" }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", @@ -4599,23 +7215,28 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides an improved replacement for the array_replace PHP function", + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ - "config", - "configuration", - "options" + "compatibility", + "polyfill", + "portable", + "shim" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v8.1.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -4635,30 +7256,24 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { - "name": "symfony/polyfill-deepclone", - "version": "v1.37.0", + "name": "symfony/polyfill-php84", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-deepclone.git", - "reference": "2ca9e9e75ead5174f2b44613a646bdc9338b8eb4" + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/2ca9e9e75ead5174f2b44613a646bdc9338b8eb4", - "reference": "2ca9e9e75ead5174f2b44613a646bdc9338b8eb4", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { - "php": ">=8.1" - }, - "provide": { - "ext-deepclone": "*" - }, - "suggest": { - "ext-deepclone": "For best performance" + "php": ">=7.2" }, "type": "library", "extra": { @@ -4672,7 +7287,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\DeepClone\\": "" + "Symfony\\Polyfill\\Php84\\": "" }, "classmap": [ "Resources/stubs" @@ -4692,17 +7307,16 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for the deepclone extension", + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", - "deepclone", "polyfill", "portable", "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -4722,20 +7336,20 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:03:27+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { - "name": "symfony/polyfill-php80", - "version": "v1.37.0", + "name": "symfony/polyfill-php85", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", "shasum": "" }, "require": { @@ -4753,7 +7367,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" + "Symfony\\Polyfill\\Php85\\": "" }, "classmap": [ "Resources/stubs" @@ -4764,10 +7378,6 @@ "MIT" ], "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, { "name": "Nicolas Grekas", "email": "p@tchwork.com" @@ -4777,7 +7387,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -4786,7 +7396,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" }, "funding": [ { @@ -4806,7 +7416,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-05-26T02:25:22+00:00" }, { "name": "symfony/service-contracts", @@ -4895,6 +7505,96 @@ ], "time": "2026-03-28T09:44:51+00:00" }, + { + "name": "symfony/string", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "shasum": "" + }, + "require": { + "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": { + "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", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.1.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-29T05:06:50+00:00" + }, { "name": "symfony/var-exporter", "version": "v8.1.0", @@ -5027,6 +7727,190 @@ } ], "time": "2025-11-17T20:03:58+00:00" + }, + { + "name": "vimeo/psalm", + "version": "6.16.1", + "source": { + "type": "git", + "url": "https://github.com/vimeo/psalm.git", + "reference": "f1f5de594dc76faf8784e02d3dc4716c91c6f6ac" + }, + "dist": { + "type": "zip", + "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": "*", + "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": { + "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": [ + "psalm", + "psalm-language-server", + "psalm-plugin", + "psalm-refactor", + "psalm-review", + "psalter" + ], + "type": "project", + "extra": { + "branch-alias": { + "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": [ + "MIT" + ], + "authors": [ + { + "name": "Matthew Brown" + }, + { + "name": "Daniil Gentili", + "email": "daniil@daniil.it" + } + ], + "description": "A static analysis tool for finding errors in PHP applications", + "keywords": [ + "code", + "inspection", + "php", + "static analysis" + ], + "support": { + "docs": "https://psalm.dev/docs", + "issues": "https://github.com/vimeo/psalm/issues", + "source": "https://github.com/vimeo/psalm" + }, + "time": "2026-03-19T10:56:09+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", + "shasum": "" + }, + "require": { + "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": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.0" + }, + "time": "2026-05-20T13:07:01+00:00" } ], "aliases": [], @@ -5042,6 +7926,8 @@ "ext-openssl": "*", "composer-runtime-api": "^2.0" }, - "platform-dev": {}, + "platform-dev": { + "ext-simplexml": "*" + }, "plugin-api-version": "2.9.0" } diff --git a/extension.neon b/extension.neon new file mode 100644 index 0000000..4ca9ed6 --- /dev/null +++ b/extension.neon @@ -0,0 +1,5 @@ +services: + - + class: Croct\Plug\PhpStan\ContentStubFilesExtension + tags: + - phpstan.stubFilesExtension diff --git a/src/PhpStan/ContentStubFilesExtension.php b/src/PhpStan/ContentStubFilesExtension.php new file mode 100644 index 0000000..74efb0f --- /dev/null +++ b/src/PhpStan/ContentStubFilesExtension.php @@ -0,0 +1,35 @@ + + */ + public function getFiles(): array + { + $directory = \getcwd(); + + if ($directory === false) { + return []; + } + + $path = $directory . \DIRECTORY_SEPARATOR . self::STUB_PATH; + + return \is_file($path) ? [$path] : []; + } +} diff --git a/src/Psalm/ContentStubPlugin.php b/src/Psalm/ContentStubPlugin.php new file mode 100644 index 0000000..bda5f55 --- /dev/null +++ b/src/Psalm/ContentStubPlugin.php @@ -0,0 +1,36 @@ +addStubFile($path); + } + } +} From 44ddd6c7b6ee77342c031b0df3a340f8f895d85d Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Tue, 9 Jun 2026 15:09:37 -0300 Subject: [PATCH 13/18] Add test for plugin --- extension.neon | 2 + src/Content/SlotsContentProvider.php | 194 ++++++++++++++++++ src/Croct.php | 13 +- src/PhpStan/ContentStubFilesExtension.php | 15 +- src/Psalm/ContentStubPlugin.php | 18 +- tests/Content/SlotsContentProviderTest.php | 176 ++++++++++++++++ .../PhpStan/ContentStubFilesExtensionTest.php | 46 +++++ tests/Psalm/ContentStubPluginTest.php | 96 +++++++++ 8 files changed, 545 insertions(+), 15 deletions(-) create mode 100644 src/Content/SlotsContentProvider.php create mode 100644 tests/Content/SlotsContentProviderTest.php create mode 100644 tests/PhpStan/ContentStubFilesExtensionTest.php create mode 100644 tests/Psalm/ContentStubPluginTest.php diff --git a/extension.neon b/extension.neon index 4ca9ed6..f546dcb 100644 --- a/extension.neon +++ b/extension.neon @@ -1,5 +1,7 @@ services: - class: Croct\Plug\PhpStan\ContentStubFilesExtension + arguments: + workingDirectory: %currentWorkingDirectory% tags: - phpstan.stubFilesExtension diff --git a/src/Content/SlotsContentProvider.php b/src/Content/SlotsContentProvider.php new file mode 100644 index 0000000..cd57692 --- /dev/null +++ b/src/Content/SlotsContentProvider.php @@ -0,0 +1,194 @@ + $content + * + * @return array> + */ + private static function resolve(array $content, ?string $locale): array + { + $resolved = []; + + foreach ($content as $id => $versions) { + if (!\is_array($versions)) { + continue; + } + + $localized = self::getLatestContent($versions); + + if ($localized === null) { + continue; + } + + $value = self::getLocalizedContent($localized, $locale); + + if ($value !== null) { + $resolved[(string) $id] = $value; + } + } + + return $resolved; + } + + /** + * @param array $versions + * + * @return array|null + */ + private static function getLatestContent(array $versions): ?array + { + $latest = null; + $latestVersion = -1; + + foreach ($versions as $entry) { + if (!\is_array($entry)) { + continue; + } + + $version = $entry['version'] ?? null; + $localized = $entry['content'] ?? null; + + if (\is_int($version) && \is_array($localized) && $version > $latestVersion) { + $latest = $localized; + $latestVersion = $version; + } + } + + return $latest; + } + + /** + * @param array $localized + * + * @return array|null + */ + private static function getLocalizedContent(array $localized, ?string $locale): ?array + { + $value = null; + + if ($locale !== null && \is_array($localized[$locale] ?? null)) { + $value = $localized[$locale]; + } else { + foreach ($localized as $candidate) { + if (\is_array($candidate)) { + $value = $candidate; + + break; + } + } + } + + if (!\is_array($value)) { + return null; + } + + /** @var array $content */ + $content = $value; + + return $content; + } + + /** + * @param array $configuration + */ + private static function getContentDirectory(array $configuration): string + { + $paths = $configuration['paths'] ?? null; + + if (\is_array($paths) && \is_string($paths['content'] ?? null)) { + return $paths['content']; + } + + return '.'; + } + + /** + * @param array $configuration + */ + private static function getDefaultLocale(array $configuration): ?string + { + $locale = $configuration['defaultLocale'] ?? null; + + return \is_string($locale) ? $locale : null; + } + + private static function getProjectDirectory(): string + { + return \rtrim(InstalledVersions::getRootPackage()['install_path'], \DIRECTORY_SEPARATOR); + } + + /** + * @return array|null + */ + private static function readJson(string $path): ?array + { + if (!\is_file($path)) { + return null; + } + + $contents = \file_get_contents($path); + + if ($contents === false) { + return null; + } + + $data = \json_decode($contents, true); + + return \is_array($data) ? $data : null; + } +} diff --git a/src/Croct.php b/src/Croct.php index 58f4a74..eaab890 100644 --- a/src/Croct.php +++ b/src/Croct.php @@ -7,6 +7,7 @@ use Composer\InstalledVersions; use Croct\Plug\Content\ContentProvider; use Croct\Plug\Content\NullContentProvider; +use Croct\Plug\Content\SlotsContentProvider; use Croct\Plug\Exception\ConfigurationException; use Croct\Plug\Exception\CroctException; use Http\Discovery\Exception\NotFoundException; @@ -232,7 +233,10 @@ private static function getEnv(string $name): ?string } /** - * Discovers the content provider generated by the CLI, or a null provider when none is installed. + * Discovers the content provider for the project. + * + * Prefers the content committed by the CLI as `slots.json`, falling back to a + * generated provider class when present, or a null provider otherwise. */ private static function discoverContentProvider(): ContentProvider { @@ -241,9 +245,10 @@ private static function discoverContentProvider(): ContentProvider if ($provider === null) { /** @var ContentProvider $provider **/ - $provider = \class_exists(self::DEFAULT_CONTENT_PROVIDER_CLASS) - ? new (self::DEFAULT_CONTENT_PROVIDER_CLASS)() - : new NullContentProvider(); + $provider = SlotsContentProvider::fromProject() + ?? (\class_exists(self::DEFAULT_CONTENT_PROVIDER_CLASS) + ? new (self::DEFAULT_CONTENT_PROVIDER_CLASS)() + : new NullContentProvider()); } return $provider; diff --git a/src/PhpStan/ContentStubFilesExtension.php b/src/PhpStan/ContentStubFilesExtension.php index 74efb0f..f1b1bd7 100644 --- a/src/PhpStan/ContentStubFilesExtension.php +++ b/src/PhpStan/ContentStubFilesExtension.php @@ -17,18 +17,19 @@ final class ContentStubFilesExtension implements StubFilesExtension { private const STUB_PATH = '.croct' . \DIRECTORY_SEPARATOR . 'types.php'; + private string $workingDirectory; + + public function __construct(string $workingDirectory) + { + $this->workingDirectory = $workingDirectory; + } + /** * @return list */ public function getFiles(): array { - $directory = \getcwd(); - - if ($directory === false) { - return []; - } - - $path = $directory . \DIRECTORY_SEPARATOR . self::STUB_PATH; + $path = $this->workingDirectory . \DIRECTORY_SEPARATOR . self::STUB_PATH; return \is_file($path) ? [$path] : []; } diff --git a/src/Psalm/ContentStubPlugin.php b/src/Psalm/ContentStubPlugin.php index bda5f55..9111782 100644 --- a/src/Psalm/ContentStubPlugin.php +++ b/src/Psalm/ContentStubPlugin.php @@ -19,15 +19,25 @@ final class ContentStubPlugin implements PluginEntryPoint { private const STUB_PATH = '.croct' . \DIRECTORY_SEPARATOR . 'types.php'; - public function __invoke(PluginRegistration $registration, ?SimpleXMLElement $config = null): void + private ?string $baseDirectory; + + public function __construct(?string $baseDirectory = null) { - $directory = \getcwd(); + if ($baseDirectory === null) { + $directory = \getcwd(); + $baseDirectory = $directory === false ? null : $directory; + } - if ($directory === false) { + $this->baseDirectory = $baseDirectory; + } + + public function __invoke(PluginRegistration $registration, ?SimpleXMLElement $config = null): void + { + if ($this->baseDirectory === null) { return; } - $path = $directory . \DIRECTORY_SEPARATOR . self::STUB_PATH; + $path = $this->baseDirectory . \DIRECTORY_SEPARATOR . self::STUB_PATH; if (\is_file($path)) { $registration->addStubFile($path); diff --git a/tests/Content/SlotsContentProviderTest.php b/tests/Content/SlotsContentProviderTest.php new file mode 100644 index 0000000..3195c07 --- /dev/null +++ b/tests/Content/SlotsContentProviderTest.php @@ -0,0 +1,176 @@ +write('croct.json', '42'); + + self::assertNull(SlotsContentProvider::fromProject(VirtualFilesystem::path())); + } + + #[TestDox('Returns null when the content file is missing.')] + public function testReturnsNullWhenContentIsMissing(): void + { + $this->write('croct.json', '{}'); + + self::assertNull(SlotsContentProvider::fromProject(VirtualFilesystem::path())); + } + + #[TestDox('Returns null when the configuration cannot be read.')] + public function testReturnsNullWhenConfigurationCannotBeRead(): void + { + VirtualFilesystem::writeUnreadable(VirtualFilesystem::path('croct.json')); + + // The file reports as readable but fails to open; the resulting warning + // is silenced so the failure path can be asserted. + $provider = @SlotsContentProvider::fromProject(VirtualFilesystem::path()); + + self::assertNull($provider); + } + + #[TestDox('Resolves the latest version of each slot in the default locale.')] + public function testResolvesLatestVersionInDefaultLocale(): void + { + $this->write('croct.json', [ + 'defaultLocale' => 'en', + 'paths' => ['content' => 'content'], + ]); + + $this->write('content/slots.json', [ + 'home-hero' => [ + [ + 'version' => 1, + 'content' => [ + 'en' => ['title' => 'Old'], + ], + ], + [ + 'version' => 3, + 'content' => [ + 'en' => ['title' => 'Latest'], + 'pt' => ['title' => 'Recente'], + ], + ], + [ + 'version' => 2, + 'content' => [ + 'en' => ['title' => 'Middle'], + ], + ], + ], + 'cta' => [ + [ + 'version' => 1, + 'content' => [ + 'pt' => ['label' => 'Comprar'], + ], + ], + ], + ]); + + $provider = SlotsContentProvider::fromProject(VirtualFilesystem::path()); + + self::assertNotNull($provider); + self::assertSame(['title' => 'Latest'], $provider->getContent('home-hero')); + // Falls back to the first available locale when the default is absent. + self::assertSame(['label' => 'Comprar'], $provider->getContent('cta')); + self::assertNull($provider->getContent('missing')); + } + + #[TestDox('Skips malformed entries and resolves without a default locale.')] + public function testSkipsMalformedEntriesWithoutDefaultLocale(): void + { + $this->write('croct.json', '{}'); + + $this->write('slots.json', [ + 'not-versions' => 'i am not a list', + 'no-valid-latest' => [ + 'not-an-entry', + [ + 'version' => 'not-an-int', + 'content' => [ + 'en' => ['k' => 'v'], + ], + ], + ['version' => 5, 'content' => 'not-an-array'], + ], + 'no-localized-array' => [ + [ + 'version' => 1, + 'content' => ['en' => 'not-an-array'], + ], + ], + 'valid' => [ + 'not-an-entry', + [ + 'version' => 1, + 'content' => [ + 'en' => ['k' => 'v1'], + ], + ], + [ + 'version' => 2, + 'content' => [ + 'en' => ['k' => 'v2'], + ], + ], + ], + ]); + + $provider = SlotsContentProvider::fromProject(VirtualFilesystem::path()); + + self::assertNotNull($provider); + self::assertNull($provider->getContent('not-versions')); + self::assertNull($provider->getContent('no-valid-latest')); + self::assertNull($provider->getContent('no-localized-array')); + self::assertSame(['k' => 'v2'], $provider->getContent('valid')); + } + + /** + * @param array|string $data + */ + private function write(string $relativePath, array|string $data): void + { + VirtualFilesystem::write( + VirtualFilesystem::path($relativePath), + \is_string($data) ? $data : (string) \json_encode($data), + ); + } +} diff --git a/tests/PhpStan/ContentStubFilesExtensionTest.php b/tests/PhpStan/ContentStubFilesExtensionTest.php new file mode 100644 index 0000000..ab3f99b --- /dev/null +++ b/tests/PhpStan/ContentStubFilesExtensionTest.php @@ -0,0 +1,46 @@ +getFiles()); + } + + #[TestDox('Provides no files when the stub is absent.')] + public function testProvidesNothingWhenAbsent(): void + { + $extension = new ContentStubFilesExtension(VirtualFilesystem::path()); + + self::assertSame([], $extension->getFiles()); + } +} diff --git a/tests/Psalm/ContentStubPluginTest.php b/tests/Psalm/ContentStubPluginTest.php new file mode 100644 index 0000000..e481fbf --- /dev/null +++ b/tests/Psalm/ContentStubPluginTest.php @@ -0,0 +1,96 @@ +originalWorkingDirectory = $cwd; + + VirtualFilesystem::setUp(); + } + + protected function tearDown(): void + { + \chdir($this->originalWorkingDirectory); + + VirtualFilesystem::tearDown(); + } + + #[TestDox('Registers the generated stub when it exists in the base directory.')] + public function testRegistersTheStubWhenPresent(): void + { + $stub = VirtualFilesystem::path('.croct/types.php'); + + VirtualFilesystem::write($stub, 'createMock(PluginRegistration::class); + $registration->expects($this->once()) + ->method('addStubFile') + ->with($stub); + + (new ContentStubPlugin(VirtualFilesystem::path()))($registration); + } + + #[TestDox('Registers nothing when the stub is absent.')] + public function testRegistersNothingWhenAbsent(): void + { + $registration = $this->createMock(PluginRegistration::class); + $registration->expects($this->never()) + ->method('addStubFile'); + + (new ContentStubPlugin(VirtualFilesystem::path()))($registration); + } + + #[TestDox('Defaults the base directory to the current working directory.')] + public function testDefaultsToTheCurrentWorkingDirectory(): void + { + // With no explicit directory it resolves to the process working directory, + // which holds no generated stub, so nothing is registered. + $registration = $this->createMock(PluginRegistration::class); + $registration->expects($this->never()) + ->method('addStubFile'); + + (new ContentStubPlugin())($registration); + } + + #[TestDox('Registers nothing when the working directory cannot be resolved.')] + public function testRegistersNothingWhenWorkingDirectoryIsUnavailable(): void + { + // getcwd() only fails when the working directory no longer exists, which + // cannot be simulated virtually, so an empty directory is removed underfoot. + $removed = \sys_get_temp_dir() . \DIRECTORY_SEPARATOR . \uniqid('croct-psalm-gone-', true); + + \mkdir($removed, 0777, true); + \chdir($removed); + \rmdir($removed); + + $plugin = new ContentStubPlugin(); + + \chdir($this->originalWorkingDirectory); + + $registration = $this->createMock(PluginRegistration::class); + $registration->expects($this->never()) + ->method('addStubFile'); + + $plugin($registration); + } +} From 3cdc3fa0595e713afc73878374a297a2656cdcb8 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Wed, 10 Jun 2026 13:20:04 -0300 Subject: [PATCH 14/18] Fix typing --- phpcs.xml.dist | 5 + src/ContentFetcher.php | 6 ++ src/Croct.php | 8 ++ src/EvaluationOptions.php | 23 +++- src/Evaluator.php | 2 + src/FetchOptions.php | 33 +++++- src/FetchResponse.php | 18 +++- src/HttpContentFetcher.php | 17 ++- src/HttpEvaluator.php | 8 +- src/Plug.php | 8 ++ src/VaryingResponseObserver.php | 10 ++ tests/EvaluationOptionsTest.php | 5 +- tests/FetchOptionsTest.php | 5 +- tests/FetchResponseTest.php | 10 +- tests/Fixtures/VirtualFilesystem.php | 149 ++++++++++++++++++++++++++ tests/VaryingResponseObserverTest.php | 15 ++- 16 files changed, 308 insertions(+), 14 deletions(-) create mode 100644 tests/Fixtures/VirtualFilesystem.php diff --git a/phpcs.xml.dist b/phpcs.xml.dist index dbb4e03..1caf8b3 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -25,6 +25,11 @@ src/ApiKey.php
+ + + tests/Fixtures/VirtualFilesystem.php + + src tests diff --git a/src/ContentFetcher.php b/src/ContentFetcher.php index 625ac5e..28c0034 100644 --- a/src/ContentFetcher.php +++ b/src/ContentFetcher.php @@ -16,6 +16,12 @@ interface ContentFetcher * * Returns the configured fallback if the fetch fails, otherwise it throws. * + * @template F = never + * + * @param FetchOptions|null $options + * + * @return FetchResponse, F> + * * @throws ContentException If the request fails without a fallback. */ public function fetch(string $slotId, ?FetchOptions $options = null): FetchResponse; diff --git a/src/Croct.php b/src/Croct.php index eaab890..76f9bf6 100644 --- a/src/Croct.php +++ b/src/Croct.php @@ -160,6 +160,8 @@ public static function fromEnvironment(IdentityStore $storage): self /** * Evaluates a CQL query against the visitor's context. * + * @param EvaluationOptions|null $options + * * @throws CroctException If the query is invalid or the request fails without a fallback. */ public function evaluate(string $query, ?EvaluationOptions $options = null): mixed @@ -170,6 +172,12 @@ public function evaluate(string $query, ?EvaluationOptions $options = null): mix /** * Fetches the personalized content of a slot. * + * @template F = never + * + * @param FetchOptions|null $options + * + * @return FetchResponse, F> + * * @throws CroctException If the request fails without a fallback. */ public function fetchContent(string $slotId, ?FetchOptions $options = null): FetchResponse diff --git a/src/EvaluationOptions.php b/src/EvaluationOptions.php index 7bb011e..d7dc92f 100644 --- a/src/EvaluationOptions.php +++ b/src/EvaluationOptions.php @@ -9,18 +9,22 @@ * * Build it through the fluent API, starting from the empty options and deriving copies with the * with-methods. + * + * @template-covariant TFallback The fallback result type, or `mixed` when no fallback is set. */ final class EvaluationOptions { /** @var array */ private array $attributes; + /** @var TFallback */ private mixed $fallback; private bool $fallbackProvided; /** * @param array $attributes + * @param TFallback $fallback */ private function __construct(array $attributes, mixed $fallback, bool $fallbackProvided) { @@ -31,16 +35,23 @@ private function __construct(array $attributes, mixed $fallback, bool $fallbackP /** * Creates an empty set of options. + * + * @return self */ public static function empty(): self { - return new self([], null, false); + /** @var self $options */ + $options = new self([], null, false); + + return $options; } /** * Returns a copy with the given custom attributes, replacing any existing ones. * * @param array $attributes + * + * @return self */ public function withAttributes(array $attributes): self { @@ -49,6 +60,8 @@ public function withAttributes(array $attributes): self /** * Returns a copy with the given custom attribute added. + * + * @return self */ public function withAttribute(string $name, mixed $value): self { @@ -62,6 +75,12 @@ public function withAttribute(string $name, mixed $value): self * Returns a copy with a fallback result to return if the evaluation fails. * * Without a fallback, a failed evaluation throws an exception. + * + * @template T + * + * @param T $fallback + * + * @return self */ public function withFallback(mixed $fallback): self { @@ -88,6 +107,8 @@ public function hasFallback(): bool /** * Gets the fallback result returned when the evaluation fails. + * + * @return TFallback */ public function getFallback(): mixed { diff --git a/src/Evaluator.php b/src/Evaluator.php index 378991f..d32c56b 100644 --- a/src/Evaluator.php +++ b/src/Evaluator.php @@ -16,6 +16,8 @@ interface Evaluator * * Returns the configured fallback if the evaluation fails, otherwise it throws. * + * @param EvaluationOptions|null $options + * * @throws EvaluationException If the query is invalid or the request fails without a fallback. */ public function evaluate(string $query, ?EvaluationOptions $options = null): mixed; diff --git a/src/FetchOptions.php b/src/FetchOptions.php index b34137b..3a767e5 100644 --- a/src/FetchOptions.php +++ b/src/FetchOptions.php @@ -9,6 +9,8 @@ * * Build it through the fluent API, starting from the empty options and deriving copies with the * with-methods. + * + * @template-covariant TFallback The fallback content type, or `never` when no fallback is set. */ final class FetchOptions { @@ -25,10 +27,12 @@ final class FetchOptions private bool $fallbackProvided; + /** @var TFallback */ private mixed $fallback; /** * @param array $attributes + * @param TFallback $fallback */ private function __construct( ?string $preferredLocale, @@ -50,14 +54,21 @@ private function __construct( /** * Creates an empty set of options. + * + * @return self */ public static function empty(): self { - return new self(null, null, false, false, [], false, null); + /** @var self $options */ + $options = new self(null, null, false, false, [], false, null); + + return $options; } /** * Returns a copy that requests content in the given locale. + * + * @return self */ public function withPreferredLocale(string $preferredLocale): self { @@ -66,6 +77,8 @@ public function withPreferredLocale(string $preferredLocale): self /** * Returns a copy that requests the given content version. + * + * @return self */ public function withVersion(int|string $version): self { @@ -74,6 +87,8 @@ public function withVersion(int|string $version): self /** * Returns a copy that fetches statically generated content (server-side only). + * + * @return self */ public function withStatic(bool $static = true): self { @@ -82,6 +97,8 @@ public function withStatic(bool $static = true): self /** * Returns a copy that includes the content schema in the response metadata. + * + * @return self */ public function withSchema(bool $includeSchema = true): self { @@ -92,6 +109,8 @@ public function withSchema(bool $includeSchema = true): self * Returns a copy with the given custom attributes, replacing any existing ones. * * @param array $attributes + * + * @return self */ public function withAttributes(array $attributes): self { @@ -100,6 +119,8 @@ public function withAttributes(array $attributes): self /** * Returns a copy with the given custom attribute added. + * + * @return self */ public function withAttribute(string $name, mixed $value): self { @@ -114,6 +135,12 @@ public function withAttribute(string $name, mixed $value): self * * Without a fallback, a failed fetch throws an exception. The fallback may be any value, * including null, which is treated as a provided fallback rather than the absence of one. + * + * @template T + * + * @param T $content + * + * @return self */ public function withFallback(mixed $content): self { @@ -184,6 +211,8 @@ public function hasFallback(): bool /** * Gets the fallback content returned when the fetch fails. + * + * @return TFallback */ public function getFallback(): mixed { @@ -194,6 +223,8 @@ public function getFallback(): mixed * Returns a copy with the given fields overridden, keeping the rest. * * @param array|null $attributes + * + * @return self */ private function copy( ?string $preferredLocale = null, diff --git a/src/FetchResponse.php b/src/FetchResponse.php index 40ea455..610dad1 100644 --- a/src/FetchResponse.php +++ b/src/FetchResponse.php @@ -8,13 +8,20 @@ /** * Result of fetching the content of a slot. + * + * @template-covariant TContent The content type returned on success. + * @template-covariant TFallback The fallback type returned when the fetch fails. */ final class FetchResponse { + /** @var TContent|TFallback */ private mixed $content; private ?SlotMetadata $metadata; + /** + * @param TContent|TFallback $content + */ public function __construct(mixed $content, ?SlotMetadata $metadata = null) { $this->content = $content; @@ -22,7 +29,9 @@ public function __construct(mixed $content, ?SlotMetadata $metadata = null) } /** - * Gets the slot content. + * Gets the slot content, or the fallback when the fetch failed. + * + * @return TContent|TFallback */ public function getContent(): mixed { @@ -41,6 +50,8 @@ public function getMetadata(): ?SlotMetadata /** * Creates a response from the decoded API payload. + * + * @return self, never> */ public static function fromResponse(mixed $data): self { @@ -57,6 +68,9 @@ public static function fromResponse(mixed $data): self } } - return new self($content, $metadata); + /** @var self, never> $response */ + $response = new self($content, $metadata); + + return $response; } } diff --git a/src/HttpContentFetcher.php b/src/HttpContentFetcher.php index 8f71ec1..650b5d6 100644 --- a/src/HttpContentFetcher.php +++ b/src/HttpContentFetcher.php @@ -38,6 +38,13 @@ public function __construct( $this->contentProvider = $contentProvider ?? new NullContentProvider(); } + /** + * @template F = never + * + * @param FetchOptions|null $options + * + * @return FetchResponse, F> + */ public function fetch(string $slotId, ?FetchOptions $options = null): FetchResponse { $options ??= FetchOptions::empty(); @@ -92,13 +99,19 @@ public function fetch(string $slotId, ?FetchOptions $options = null): FetchRespo return FetchResponse::fromResponse($this->client->send($endpoint, $payload, $headers)); } catch (ApiException $exception) { if ($options->hasFallback()) { - return new FetchResponse($options->getFallback()); + /** @var FetchResponse, F> $response */ + $response = new FetchResponse($options->getFallback()); + + return $response; } $content = $this->contentProvider->getContent($slotId); if ($content !== null) { - return new FetchResponse($content); + /** @var FetchResponse, F> $response */ + $response = new FetchResponse($content); + + return $response; } throw new ContentException($exception->getMessage(), 0, $exception); diff --git a/src/HttpEvaluator.php b/src/HttpEvaluator.php index 9b04638..eddbc7c 100644 --- a/src/HttpEvaluator.php +++ b/src/HttpEvaluator.php @@ -29,6 +29,9 @@ public function __construct(ApiClient $client, RequestContext $context, ?Identit $this->identity = $identity; } + /** + * @param EvaluationOptions|null $options + */ public function evaluate(string $query, ?EvaluationOptions $options = null): mixed { // Reject oversized queries before reaching the API, and never mask the misuse with a fallback. @@ -44,12 +47,11 @@ public function evaluate(string $query, ?EvaluationOptions $options = null): mix ); } - $options ??= EvaluationOptions::empty(); $context = $this->context; $payload = ['query' => $query]; - $evaluationContext = $context->toEvaluationContext($options->getAttributes()); + $evaluationContext = $context->toEvaluationContext($options?->getAttributes() ?? []); if ($evaluationContext !== []) { $payload['context'] = $evaluationContext; @@ -65,7 +67,7 @@ public function evaluate(string $query, ?EvaluationOptions $options = null): mix try { return $this->client->send(self::ENDPOINT, $payload, $headers); } catch (ApiException $exception) { - if ($options->hasFallback()) { + if ($options !== null && $options->hasFallback()) { return $options->getFallback(); } diff --git a/src/Plug.php b/src/Plug.php index 405d73f..2bf634d 100644 --- a/src/Plug.php +++ b/src/Plug.php @@ -46,6 +46,8 @@ public function anonymize(): void; /** * Evaluates a CQL query against the visitor's context. * + * @param EvaluationOptions|null $options + * * @throws CroctException If the query is invalid or the request fails without a fallback. */ public function evaluate(string $query, ?EvaluationOptions $options = null): mixed; @@ -53,6 +55,12 @@ public function evaluate(string $query, ?EvaluationOptions $options = null): mix /** * Fetches the personalized content of a slot. * + * @template F = never + * + * @param FetchOptions|null $options + * + * @return FetchResponse, F> + * * @throws CroctException If the request fails without a fallback. */ public function fetchContent(string $slotId, ?FetchOptions $options = null): FetchResponse; diff --git a/src/VaryingResponseObserver.php b/src/VaryingResponseObserver.php index b14cf8d..a099e36 100644 --- a/src/VaryingResponseObserver.php +++ b/src/VaryingResponseObserver.php @@ -54,6 +54,9 @@ public function getPlugOptions(): array return $this->plug->getPlugOptions(); } + /** + * @param EvaluationOptions|null $options + */ public function evaluate(string $query, ?EvaluationOptions $options = null): mixed { ($this->notify)(); @@ -61,6 +64,13 @@ public function evaluate(string $query, ?EvaluationOptions $options = null): mix return $this->plug->evaluate($query, $options); } + /** + * @template F = never + * + * @param FetchOptions|null $options + * + * @return FetchResponse, F> + */ public function fetchContent(string $slotId, ?FetchOptions $options = null): FetchResponse { if (!($options?->isStatic() ?? false)) { diff --git a/tests/EvaluationOptionsTest.php b/tests/EvaluationOptionsTest.php index 2d1b823..056e237 100644 --- a/tests/EvaluationOptionsTest.php +++ b/tests/EvaluationOptionsTest.php @@ -25,7 +25,10 @@ public function testEmptyHasNoAttributes(): void #[TestDox('Carry a fallback distinct from an unset one, even when null.')] public function testCarriesFallback(): void { - $options = EvaluationOptions::empty()->withFallback(null); + /** @var mixed $fallback */ + $fallback = null; + + $options = EvaluationOptions::empty()->withFallback($fallback); self::assertTrue($options->hasFallback()); self::assertNull($options->getFallback()); diff --git a/tests/FetchOptionsTest.php b/tests/FetchOptionsTest.php index bc190dd..c62610e 100644 --- a/tests/FetchOptionsTest.php +++ b/tests/FetchOptionsTest.php @@ -51,7 +51,10 @@ public function testDistinguishesNullFallback(): void { self::assertFalse(FetchOptions::empty()->hasFallback()); - $options = FetchOptions::empty()->withFallback(null); + /** @var mixed $fallback */ + $fallback = null; + + $options = FetchOptions::empty()->withFallback($fallback); self::assertTrue($options->hasFallback()); self::assertNull($options->getFallback()); diff --git a/tests/FetchResponseTest.php b/tests/FetchResponseTest.php index f8cef3f..33bf7c1 100644 --- a/tests/FetchResponseTest.php +++ b/tests/FetchResponseTest.php @@ -17,7 +17,10 @@ final class FetchResponseTest extends TestCase #[TestDox('Exposes the content and metadata given to the constructor.')] public function testExposesContentAndMetadata(): void { - $response = new FetchResponse(['title' => 'Hello'], new SlotMetadata('1')); + /** @var array $content */ + $content = ['title' => 'Hello']; + + $response = new FetchResponse($content, new SlotMetadata('1')); self::assertSame(['title' => 'Hello'], $response->getContent()); self::assertSame('1', $response->getMetadata()?->getVersion()); @@ -26,7 +29,10 @@ public function testExposesContentAndMetadata(): void #[TestDox('Defaults to no metadata for a bare content value.')] public function testDefaultsToNoMetadata(): void { - $response = new FetchResponse('fallback'); + /** @var string $content */ + $content = 'fallback'; + + $response = new FetchResponse($content); self::assertSame('fallback', $response->getContent()); self::assertNull($response->getMetadata()); diff --git a/tests/Fixtures/VirtualFilesystem.php b/tests/Fixtures/VirtualFilesystem.php new file mode 100644 index 0000000..c65f0a3 --- /dev/null +++ b/tests/Fixtures/VirtualFilesystem.php @@ -0,0 +1,149 @@ + + */ + private static array $files = []; + + /** @var resource|null */ + public $context; + + private string $contents = ''; + + private int $position = 0; + + public static function setUp(): void + { + self::$files = []; + + if (!\in_array(self::PROTOCOL, \stream_get_wrappers(), true)) { + \stream_wrapper_register(self::PROTOCOL, self::class); + } + } + + public static function tearDown(): void + { + self::$files = []; + + if (\in_array(self::PROTOCOL, \stream_get_wrappers(), true)) { + \stream_wrapper_unregister(self::PROTOCOL); + } + } + + /** + * Resolves a path within the virtual filesystem, defaulting to its root. + */ + public static function path(string $path = ''): string + { + $root = self::PROTOCOL . '://root'; + + return $path === '' ? $root : $root . '/' . \ltrim($path, '/'); + } + + public static function write(string $path, string $contents): void + { + self::$files[self::normalize($path)] = $contents; + } + + public static function writeUnreadable(string $path): void + { + self::$files[self::normalize($path)] = null; + } + + public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool + { + $contents = self::$files[self::normalize($path)] ?? null; + + if ($contents === null) { + return false; + } + + $this->contents = $contents; + $this->position = 0; + + return true; + } + + public function stream_read(int $count): string + { + $chunk = \substr($this->contents, $this->position, $count); + $this->position += \strlen($chunk); + + return $chunk; + } + + public function stream_eof(): bool + { + return $this->position >= \strlen($this->contents); + } + + /** + * @return array + */ + public function stream_stat(): array + { + return self::stat(\strlen($this->contents)); + } + + public function stream_close(): void + { + // Nothing to release: the contents live in memory for the stream's lifetime. + } + + /** + * @return array|false + */ + public function url_stat(string $path, int $flags): array|false + { + $normalized = self::normalize($path); + + if (!\array_key_exists($normalized, self::$files)) { + return false; + } + + $contents = self::$files[$normalized]; + + return self::stat($contents === null ? 0 : \strlen($contents)); + } + + private static function normalize(string $path): string + { + return \str_replace('/./', '/', $path); + } + + /** + * @return array + */ + private static function stat(int $size): array + { + return [ + 'dev' => 0, + 'ino' => 0, + 'mode' => 0100644, + 'nlink' => 1, + 'uid' => 0, + 'gid' => 0, + 'rdev' => 0, + 'size' => $size, + 'atime' => 0, + 'mtime' => 0, + 'ctime' => 0, + 'blksize' => -1, + 'blocks' => -1, + ]; + } +} diff --git a/tests/VaryingResponseObserverTest.php b/tests/VaryingResponseObserverTest.php index 3c3fa71..033f58c 100644 --- a/tests/VaryingResponseObserverTest.php +++ b/tests/VaryingResponseObserverTest.php @@ -125,6 +125,9 @@ public function getPlugOptions(): array return ['appId' => 'app']; } + /** + * @param EvaluationOptions|null $options + */ public function evaluate(string $query, ?EvaluationOptions $options = null): mixed { $this->calls[] = 'evaluate'; @@ -132,11 +135,21 @@ public function evaluate(string $query, ?EvaluationOptions $options = null): mix return true; } + /** + * @template F = never + * + * @param FetchOptions|null $options + * + * @return FetchResponse, F> + */ public function fetchContent(string $slotId, ?FetchOptions $options = null): FetchResponse { $this->calls[] = 'fetchContent'; - return new FetchResponse(['title' => 'Hello']); + /** @var FetchResponse, F> $response */ + $response = new FetchResponse(['title' => 'Hello']); + + return $response; } public function identify(string $userId): void From c7bc63e36ac7abe94a71a91514dd564523989da3 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Thu, 11 Jun 2026 13:20:01 -0300 Subject: [PATCH 15/18] Fix issues --- src/Content/ArrayContentProvider.php | 2 +- src/Content/ContentProvider.php | 5 +- ...rovider.php => DefaultContentProvider.php} | 91 ++++++++++++------- src/Content/NullContentProvider.php | 2 +- src/ContentFetcher.php | 3 + src/Croct.php | 15 ++- src/EvaluationOptions.php | 6 +- src/FetchOptions.php | 35 +------ src/HttpContentFetcher.php | 33 ++++++- src/Plug.php | 3 + ...est.php => DefaultContentProviderTest.php} | 38 +++++--- tests/CroctTest.php | 27 ------ tests/EvaluationOptionsTest.php | 10 +- tests/FetchOptionsTest.php | 18 ++-- tests/Fixtures/InstalledContentProvider.php | 18 ---- tests/HttpContentFetcherTest.php | 57 ++++++++++-- tests/HttpEvaluatorTest.php | 4 +- tests/VaryingResponseObserverTest.php | 2 +- 18 files changed, 199 insertions(+), 170 deletions(-) rename src/Content/{SlotsContentProvider.php => DefaultContentProvider.php} (63%) rename tests/Content/{SlotsContentProviderTest.php => DefaultContentProviderTest.php} (74%) delete mode 100644 tests/Fixtures/InstalledContentProvider.php diff --git a/src/Content/ArrayContentProvider.php b/src/Content/ArrayContentProvider.php index 315be27..61f3647 100644 --- a/src/Content/ArrayContentProvider.php +++ b/src/Content/ArrayContentProvider.php @@ -23,7 +23,7 @@ public function __construct(array $content) /** * @return array|null */ - public function getContent(string $id): ?array + public function getContent(string $id, ?string $language = null): ?array { return $this->content[$id] ?? null; } diff --git a/src/Content/ContentProvider.php b/src/Content/ContentProvider.php index e849e4d..a4d9211 100644 --- a/src/Content/ContentProvider.php +++ b/src/Content/ContentProvider.php @@ -12,9 +12,10 @@ interface ContentProvider /** * Gets the content of a slot. * - * @param string $id The ID of the slot. + * @param string $id The ID of the slot. + * @param string|null $language The preferred language, or null for the default. * * @return array|null The content of the slot, or null when none is available. */ - public function getContent(string $id): ?array; + public function getContent(string $id, ?string $language = null): ?array; } diff --git a/src/Content/SlotsContentProvider.php b/src/Content/DefaultContentProvider.php similarity index 63% rename from src/Content/SlotsContentProvider.php rename to src/Content/DefaultContentProvider.php index cd57692..44bc868 100644 --- a/src/Content/SlotsContentProvider.php +++ b/src/Content/DefaultContentProvider.php @@ -9,17 +9,31 @@ /** * A content provider backed by the `slots.json` file written by the Croct CLI. * - * Reads the project's `croct.json` to locate `slots.json` and determine the - * default locale, then serves the latest version of each slot's content as a - * fallback. It requires no code generation: the data files are committed to the - * project and read at runtime. + * Serves the latest version of each slot's content as a fallback, resolving the language at + * lookup time the same way as the CLI-generated resolver: the requested language, then the + * project's default locale, then nothing. */ -final class SlotsContentProvider extends ArrayContentProvider +final class DefaultContentProvider implements ContentProvider { private const CONFIG_FILE = 'croct.json'; private const CONTENT_FILE = 'slots.json'; + /** @var array>> */ + private array $slots; + + private ?string $defaultLocale; + + /** + * @param array>> $slots The latest content of each + * slot, keyed by ID then locale. + */ + public function __construct(array $slots, ?string $defaultLocale = null) + { + $this->slots = $slots; + $this->defaultLocale = $defaultLocale; + } + /** * Builds a provider from the project's committed content files. * @@ -49,18 +63,36 @@ public static function fromProject(?string $projectDirectory = null): ?self return null; } - return new self(self::resolve($content, self::getDefaultLocale($configuration))); + return new self(self::resolve($content), self::getDefaultLocale($configuration)); + } + + /** + * @return array|null + */ + public function getContent(string $id, ?string $language = null): ?array + { + $localized = $this->slots[$id] ?? null; + + if ($localized === null) { + return null; + } + + // Mirror the CLI-generated resolver: the requested language, then the default, then nothing. + foreach ([$language ?? $this->defaultLocale, $this->defaultLocale] as $candidate) { + if ($candidate !== null && isset($localized[$candidate])) { + return $localized[$candidate]; + } + } + + return null; } /** - * Reduces the versioned, localized content to the latest version of each slot - * in the given locale, keyed by slot ID. - * * @param array $content * - * @return array> + * @return array>> */ - private static function resolve(array $content, ?string $locale): array + private static function resolve(array $content): array { $resolved = []; @@ -75,10 +107,10 @@ private static function resolve(array $content, ?string $locale): array continue; } - $value = self::getLocalizedContent($localized, $locale); + $byLocale = self::filterLocalized($localized); - if ($value !== null) { - $resolved[(string) $id] = $value; + if ($byLocale !== []) { + $resolved[(string) $id] = $byLocale; } } @@ -113,34 +145,25 @@ private static function getLatestContent(array $versions): ?array } /** + * Keeps only the locales mapped to a content object, keyed by locale. + * * @param array $localized * - * @return array|null + * @return array> */ - private static function getLocalizedContent(array $localized, ?string $locale): ?array + private static function filterLocalized(array $localized): array { - $value = null; + $result = []; - if ($locale !== null && \is_array($localized[$locale] ?? null)) { - $value = $localized[$locale]; - } else { - foreach ($localized as $candidate) { - if (\is_array($candidate)) { - $value = $candidate; - - break; - } + foreach ($localized as $locale => $value) { + if (\is_array($value)) { + /** @var array $content */ + $content = $value; + $result[(string) $locale] = $content; } } - if (!\is_array($value)) { - return null; - } - - /** @var array $content */ - $content = $value; - - return $content; + return $result; } /** diff --git a/src/Content/NullContentProvider.php b/src/Content/NullContentProvider.php index c3f2579..079fa66 100644 --- a/src/Content/NullContentProvider.php +++ b/src/Content/NullContentProvider.php @@ -12,7 +12,7 @@ final class NullContentProvider implements ContentProvider /** * @return array|null */ - public function getContent(string $id): ?array + public function getContent(string $id, ?string $language = null): ?array { return null; } diff --git a/src/ContentFetcher.php b/src/ContentFetcher.php index 28c0034..1995899 100644 --- a/src/ContentFetcher.php +++ b/src/ContentFetcher.php @@ -18,10 +18,13 @@ interface ContentFetcher * * @template F = never * + * @param string $slotId The slot ID, optionally versioned as `slot-id@version` + * (e.g. `home-banner@2`). * @param FetchOptions|null $options * * @return FetchResponse, F> * + * @throws \InvalidArgumentException If the slot ID is malformed. * @throws ContentException If the request fails without a fallback. */ public function fetch(string $slotId, ?FetchOptions $options = null): FetchResponse; diff --git a/src/Croct.php b/src/Croct.php index 76f9bf6..57cca01 100644 --- a/src/Croct.php +++ b/src/Croct.php @@ -6,8 +6,8 @@ use Composer\InstalledVersions; use Croct\Plug\Content\ContentProvider; +use Croct\Plug\Content\DefaultContentProvider; use Croct\Plug\Content\NullContentProvider; -use Croct\Plug\Content\SlotsContentProvider; use Croct\Plug\Exception\ConfigurationException; use Croct\Plug\Exception\CroctException; use Http\Discovery\Exception\NotFoundException; @@ -29,8 +29,6 @@ */ final class Croct implements Plug { - private const DEFAULT_CONTENT_PROVIDER_CLASS = 'Croct\\Content\\GeneratedContentProvider'; - public const DEFAULT_BASE_ENDPOINT_URL = 'https://api.croct.io'; public const DEFAULT_TOKEN_DURATION = 86400; @@ -174,10 +172,13 @@ public function evaluate(string $query, ?EvaluationOptions $options = null): mix * * @template F = never * + * @param string $slotId The slot ID, optionally versioned as `slot-id@version` + * (e.g. `home-banner@2`). * @param FetchOptions|null $options * * @return FetchResponse, F> * + * @throws \InvalidArgumentException If the slot ID is malformed. * @throws CroctException If the request fails without a fallback. */ public function fetchContent(string $slotId, ?FetchOptions $options = null): FetchResponse @@ -244,7 +245,7 @@ private static function getEnv(string $name): ?string * Discovers the content provider for the project. * * Prefers the content committed by the CLI as `slots.json`, falling back to a - * generated provider class when present, or a null provider otherwise. + * null provider when none is available. */ private static function discoverContentProvider(): ContentProvider { @@ -252,11 +253,7 @@ private static function discoverContentProvider(): ContentProvider static $provider = null; if ($provider === null) { - /** @var ContentProvider $provider **/ - $provider = SlotsContentProvider::fromProject() - ?? (\class_exists(self::DEFAULT_CONTENT_PROVIDER_CLASS) - ? new (self::DEFAULT_CONTENT_PROVIDER_CLASS)() - : new NullContentProvider()); + $provider = DefaultContentProvider::fromProject() ?? new NullContentProvider(); } return $provider; diff --git a/src/EvaluationOptions.php b/src/EvaluationOptions.php index d7dc92f..5fbe00a 100644 --- a/src/EvaluationOptions.php +++ b/src/EvaluationOptions.php @@ -7,7 +7,7 @@ /** * Immutable options for evaluating a CQL query. * - * Build it through the fluent API, starting from the empty options and deriving copies with the + * Build it through the fluent API, starting from the default options and deriving copies with the * with-methods. * * @template-covariant TFallback The fallback result type, or `mixed` when no fallback is set. @@ -34,11 +34,11 @@ private function __construct(array $attributes, mixed $fallback, bool $fallbackP } /** - * Creates an empty set of options. + * Creates the default set of options, with nothing set. * * @return self */ - public static function empty(): self + public static function default(): self { /** @var self $options */ $options = new self([], null, false); diff --git a/src/FetchOptions.php b/src/FetchOptions.php index 3a767e5..602a7b7 100644 --- a/src/FetchOptions.php +++ b/src/FetchOptions.php @@ -7,7 +7,7 @@ /** * Immutable options for fetching the content of a slot. * - * Build it through the fluent API, starting from the empty options and deriving copies with the + * Build it through the fluent API, starting from the default options and deriving copies with the * with-methods. * * @template-covariant TFallback The fallback content type, or `never` when no fallback is set. @@ -16,8 +16,6 @@ final class FetchOptions { private ?string $preferredLocale; - private int|string|null $version; - private bool $static; private bool $includeSchema; @@ -36,7 +34,6 @@ final class FetchOptions */ private function __construct( ?string $preferredLocale, - int|string|null $version, bool $static, bool $includeSchema, array $attributes, @@ -44,7 +41,6 @@ private function __construct( mixed $fallback, ) { $this->preferredLocale = $preferredLocale; - $this->version = $version; $this->static = $static; $this->includeSchema = $includeSchema; $this->attributes = $attributes; @@ -53,14 +49,14 @@ private function __construct( } /** - * Creates an empty set of options. + * Creates the default set of options, with nothing set. * * @return self */ - public static function empty(): self + public static function default(): self { /** @var self $options */ - $options = new self(null, null, false, false, [], false, null); + $options = new self(null, false, false, [], false, null); return $options; } @@ -75,16 +71,6 @@ public function withPreferredLocale(string $preferredLocale): self return $this->copy(preferredLocale: $preferredLocale); } - /** - * Returns a copy that requests the given content version. - * - * @return self - */ - public function withVersion(int|string $version): self - { - return $this->copy(version: $version); - } - /** * Returns a copy that fetches statically generated content (server-side only). * @@ -146,7 +132,6 @@ public function withFallback(mixed $content): self { return new self( $this->preferredLocale, - $this->version, $this->static, $this->includeSchema, $this->attributes, @@ -165,16 +150,6 @@ public function getPreferredLocale(): ?string return $this->preferredLocale; } - /** - * Gets the requested content version. - * - * @return int|string|null The version, or null for the latest. - */ - public function getVersion(): int|string|null - { - return $this->version; - } - /** * Checks whether statically generated content is requested. */ @@ -228,14 +203,12 @@ public function getFallback(): mixed */ private function copy( ?string $preferredLocale = null, - int|string|null $version = null, ?bool $static = null, ?bool $includeSchema = null, ?array $attributes = null, ): self { return new self( $preferredLocale ?? $this->preferredLocale, - $version ?? $this->version, $static ?? $this->static, $includeSchema ?? $this->includeSchema, $attributes ?? $this->attributes, diff --git a/src/HttpContentFetcher.php b/src/HttpContentFetcher.php index 650b5d6..53ebb2e 100644 --- a/src/HttpContentFetcher.php +++ b/src/HttpContentFetcher.php @@ -47,16 +47,16 @@ public function __construct( */ public function fetch(string $slotId, ?FetchOptions $options = null): FetchResponse { - $options ??= FetchOptions::empty(); + $options ??= FetchOptions::default(); $context = $this->context; $static = $options->isStatic(); - $payload = ['slotId' => $slotId]; + [$id, $version] = self::parseSlotId($slotId); - $version = $options->getVersion(); + $payload = ['slotId' => $id]; if ($version !== null) { - $payload['version'] = (string) $version; + $payload['version'] = $version; } $locale = $options->getPreferredLocale() ?? $context->getPreferredLocale(); @@ -105,7 +105,7 @@ public function fetch(string $slotId, ?FetchOptions $options = null): FetchRespo return $response; } - $content = $this->contentProvider->getContent($slotId); + $content = $this->contentProvider->getContent($id, $locale); if ($content !== null) { /** @var FetchResponse, F> $response */ @@ -117,4 +117,27 @@ public function fetch(string $slotId, ?FetchOptions $options = null): FetchRespo throw new ContentException($exception->getMessage(), 0, $exception); } } + + /** + * Splits a slot ID into its identifier and optional version. + * + * The version is encoded as a suffix on the slot ID, as in `home-banner@2`. It must be a + * positive integer or the literal `latest`, which is the default and thus carries no version. + * + * @return array{string, string|null} The slot identifier and the version, or null for the latest. + * + * @throws \InvalidArgumentException If the slot ID is malformed. + */ + private static function parseSlotId(string $slotId): array + { + $pattern = '/^(?[a-z0-9]+(?:-[a-z0-9]+)*)(?:@(?[1-9][0-9]*|latest))?$/'; + + if (\preg_match($pattern, $slotId, $matches) !== 1) { + throw new \InvalidArgumentException(\sprintf('Malformed slot ID "%s".', $slotId)); + } + + $version = $matches['version'] ?? ''; + + return [$matches['id'], $version === '' || $version === 'latest' ? null : $version]; + } } diff --git a/src/Plug.php b/src/Plug.php index 2bf634d..bc3c5e3 100644 --- a/src/Plug.php +++ b/src/Plug.php @@ -57,10 +57,13 @@ public function evaluate(string $query, ?EvaluationOptions $options = null): mix * * @template F = never * + * @param string $slotId The slot ID, optionally versioned as `slot-id@version` + * (e.g. `home-banner@2`). * @param FetchOptions|null $options * * @return FetchResponse, F> * + * @throws \InvalidArgumentException If the slot ID is malformed. * @throws CroctException If the request fails without a fallback. */ public function fetchContent(string $slotId, ?FetchOptions $options = null): FetchResponse; diff --git a/tests/Content/SlotsContentProviderTest.php b/tests/Content/DefaultContentProviderTest.php similarity index 74% rename from tests/Content/SlotsContentProviderTest.php rename to tests/Content/DefaultContentProviderTest.php index 3195c07..04c8a81 100644 --- a/tests/Content/SlotsContentProviderTest.php +++ b/tests/Content/DefaultContentProviderTest.php @@ -4,15 +4,15 @@ namespace Croct\Plug\Tests\Content; -use Croct\Plug\Content\SlotsContentProvider; +use Croct\Plug\Content\DefaultContentProvider; use Croct\Plug\Tests\Fixtures\VirtualFilesystem; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; -#[CoversClass(SlotsContentProvider::class)] -#[TestDox('A slots content provider')] -final class SlotsContentProviderTest extends TestCase +#[CoversClass(DefaultContentProvider::class)] +#[TestDox('A default content provider')] +final class DefaultContentProviderTest extends TestCase { protected function setUp(): void { @@ -28,13 +28,13 @@ protected function tearDown(): void public function testDefaultsToTheInstalledRootPackage(): void { // The library's own root has no croct.json, so discovery returns null. - self::assertNull(SlotsContentProvider::fromProject()); + self::assertNull(DefaultContentProvider::fromProject()); } #[TestDox('Returns null when the configuration file is missing.')] public function testReturnsNullWhenConfigurationIsMissing(): void { - self::assertNull(SlotsContentProvider::fromProject(VirtualFilesystem::path())); + self::assertNull(DefaultContentProvider::fromProject(VirtualFilesystem::path())); } #[TestDox('Returns null when the configuration is not a JSON object.')] @@ -42,7 +42,7 @@ public function testReturnsNullWhenConfigurationIsNotAnObject(): void { $this->write('croct.json', '42'); - self::assertNull(SlotsContentProvider::fromProject(VirtualFilesystem::path())); + self::assertNull(DefaultContentProvider::fromProject(VirtualFilesystem::path())); } #[TestDox('Returns null when the content file is missing.')] @@ -50,7 +50,7 @@ public function testReturnsNullWhenContentIsMissing(): void { $this->write('croct.json', '{}'); - self::assertNull(SlotsContentProvider::fromProject(VirtualFilesystem::path())); + self::assertNull(DefaultContentProvider::fromProject(VirtualFilesystem::path())); } #[TestDox('Returns null when the configuration cannot be read.')] @@ -60,7 +60,7 @@ public function testReturnsNullWhenConfigurationCannotBeRead(): void // The file reports as readable but fails to open; the resulting warning // is silenced so the failure path can be asserted. - $provider = @SlotsContentProvider::fromProject(VirtualFilesystem::path()); + $provider = @DefaultContentProvider::fromProject(VirtualFilesystem::path()); self::assertNull($provider); } @@ -105,12 +105,19 @@ public function testResolvesLatestVersionInDefaultLocale(): void ], ]); - $provider = SlotsContentProvider::fromProject(VirtualFilesystem::path()); + $provider = DefaultContentProvider::fromProject(VirtualFilesystem::path()); self::assertNotNull($provider); + // Defaults to the project's default locale when no language is requested. self::assertSame(['title' => 'Latest'], $provider->getContent('home-hero')); - // Falls back to the first available locale when the default is absent. - self::assertSame(['label' => 'Comprar'], $provider->getContent('cta')); + // Serves the requested language when available. + self::assertSame(['title' => 'Recente'], $provider->getContent('home-hero', 'pt')); + // Falls back to the default locale when the requested language is absent. + self::assertSame(['title' => 'Latest'], $provider->getContent('home-hero', 'fr')); + // Serves an explicitly requested language even when the default locale is absent. + self::assertSame(['label' => 'Comprar'], $provider->getContent('cta', 'pt')); + // Gives up when neither the requested language nor the default locale is available. + self::assertNull($provider->getContent('cta')); self::assertNull($provider->getContent('missing')); } @@ -154,13 +161,16 @@ public function testSkipsMalformedEntriesWithoutDefaultLocale(): void ], ]); - $provider = SlotsContentProvider::fromProject(VirtualFilesystem::path()); + $provider = DefaultContentProvider::fromProject(VirtualFilesystem::path()); self::assertNotNull($provider); self::assertNull($provider->getContent('not-versions')); self::assertNull($provider->getContent('no-valid-latest')); self::assertNull($provider->getContent('no-localized-array')); - self::assertSame(['k' => 'v2'], $provider->getContent('valid')); + // Resolves the latest valid version for the requested language. + self::assertSame(['k' => 'v2'], $provider->getContent('valid', 'en')); + // Without a default locale, an unspecified language resolves to nothing. + self::assertNull($provider->getContent('valid')); } /** diff --git a/tests/CroctTest.php b/tests/CroctTest.php index 056147f..d7adaae 100644 --- a/tests/CroctTest.php +++ b/tests/CroctTest.php @@ -12,7 +12,6 @@ use Croct\Plug\IdentityStore; use Croct\Plug\InMemoryIdentityStore; use Croct\Plug\RequestContext; -use Croct\Plug\Tests\Fixtures\InstalledContentProvider; use Croct\Plug\Token; use Croct\Plug\Uuid; use Http\Discovery\Psr18ClientDiscovery; @@ -185,32 +184,6 @@ public function testRejectsMissingEnvironment(): void Croct::fromEnvironment(new InMemoryIdentityStore()); } - #[PreserveGlobalState(false)] - #[RunInSeparateProcess] - #[TestDox('Falls back to the discovered content provider when one is installed.')] - public function testUsesDiscoveredContentProvider(): void - { - \class_alias(InstalledContentProvider::class, 'Croct\\Content\\GeneratedContentProvider'); - - $factory = new Psr17Factory(); - $mock = new MockClient(); - $mock->addResponse($factory->createResponse(500)); - - $storage = new InMemoryIdentityStore(Uuid::parse(self::CLIENT_ID)); - - $croct = Croct::plug( - appId: self::APP_ID, - apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), - storage: $storage, - context: new RequestContext(), - httpClient: $mock, - requestFactory: $factory, - streamFactory: $factory, - ); - - self::assertSame(['title' => 'Generated default'], $croct->fetchContent('home-hero')->getContent()); - } - #[PreserveGlobalState(false)] #[RunInSeparateProcess] #[TestDox('Reports a missing transport when no HTTP client can be discovered.')] diff --git a/tests/EvaluationOptionsTest.php b/tests/EvaluationOptionsTest.php index 056e237..6f775b1 100644 --- a/tests/EvaluationOptionsTest.php +++ b/tests/EvaluationOptionsTest.php @@ -16,7 +16,7 @@ final class EvaluationOptionsTest extends TestCase #[TestDox('Default to no attributes or a fallback.')] public function testEmptyHasNoAttributes(): void { - $options = EvaluationOptions::empty(); + $options = EvaluationOptions::default(); self::assertSame([], $options->getAttributes()); self::assertFalse($options->hasFallback()); @@ -28,7 +28,7 @@ public function testCarriesFallback(): void /** @var mixed $fallback */ $fallback = null; - $options = EvaluationOptions::empty()->withFallback($fallback); + $options = EvaluationOptions::default()->withFallback($fallback); self::assertTrue($options->hasFallback()); self::assertNull($options->getFallback()); @@ -37,7 +37,7 @@ public function testCarriesFallback(): void #[TestDox('Add attributes one at a time.')] public function testAddsAttributes(): void { - $options = EvaluationOptions::empty() + $options = EvaluationOptions::default() ->withAttribute('plan', 'pro') ->withAttribute('seats', 5); @@ -47,7 +47,7 @@ public function testAddsAttributes(): void #[TestDox('Replace all attributes when set as a whole.')] public function testReplacesAttributes(): void { - $options = EvaluationOptions::empty() + $options = EvaluationOptions::default() ->withAttribute('plan', 'pro') ->withAttributes(['seats' => 5]); @@ -57,7 +57,7 @@ public function testReplacesAttributes(): void #[TestDox('Do not mutate the original instance.')] public function testWithMethodsAreImmutable(): void { - $options = EvaluationOptions::empty(); + $options = EvaluationOptions::default(); $options->withAttribute('plan', 'pro'); diff --git a/tests/FetchOptionsTest.php b/tests/FetchOptionsTest.php index c62610e..04d0a27 100644 --- a/tests/FetchOptionsTest.php +++ b/tests/FetchOptionsTest.php @@ -16,10 +16,9 @@ final class FetchOptionsTest extends TestCase #[TestDox('Default to empty.')] public function testEmptyHasNoOptions(): void { - $options = FetchOptions::empty(); + $options = FetchOptions::default(); self::assertNull($options->getPreferredLocale()); - self::assertNull($options->getVersion()); self::assertFalse($options->isStatic()); self::assertFalse($options->includesSchema()); self::assertSame([], $options->getAttributes()); @@ -29,16 +28,14 @@ public function testEmptyHasNoOptions(): void #[TestDox('Build up immutably through the fluent API.')] public function testBuildsOptionsFluently(): void { - $options = FetchOptions::empty() + $options = FetchOptions::default() ->withPreferredLocale('en-us') - ->withVersion(2) ->withStatic() ->withSchema() ->withAttribute('plan', 'pro') ->withFallback(['headline' => 'Welcome']); self::assertSame('en-us', $options->getPreferredLocale()); - self::assertSame(2, $options->getVersion()); self::assertTrue($options->isStatic()); self::assertTrue($options->includesSchema()); self::assertSame(['plan' => 'pro'], $options->getAttributes()); @@ -49,12 +46,12 @@ public function testBuildsOptionsFluently(): void #[TestDox('Distinguish a null fallback from no fallback.')] public function testDistinguishesNullFallback(): void { - self::assertFalse(FetchOptions::empty()->hasFallback()); + self::assertFalse(FetchOptions::default()->hasFallback()); /** @var mixed $fallback */ $fallback = null; - $options = FetchOptions::empty()->withFallback($fallback); + $options = FetchOptions::default()->withFallback($fallback); self::assertTrue($options->hasFallback()); self::assertNull($options->getFallback()); @@ -63,12 +60,11 @@ public function testDistinguishesNullFallback(): void #[TestDox('Do not mutate the original instance.')] public function testWithMethodsAreImmutable(): void { - $options = FetchOptions::empty(); + $options = FetchOptions::default(); - $options->withPreferredLocale('en-us')->withVersion(3)->withStatic()->withSchema(); + $options->withPreferredLocale('en-us')->withStatic()->withSchema(); self::assertNull($options->getPreferredLocale()); - self::assertNull($options->getVersion()); self::assertFalse($options->isStatic()); self::assertFalse($options->includesSchema()); } @@ -76,7 +72,7 @@ public function testWithMethodsAreImmutable(): void #[TestDox('Replace all attributes when set as a whole.')] public function testReplacesAttributes(): void { - $options = FetchOptions::empty() + $options = FetchOptions::default() ->withAttribute('a', 1) ->withAttributes(['b' => 2]); diff --git a/tests/Fixtures/InstalledContentProvider.php b/tests/Fixtures/InstalledContentProvider.php deleted file mode 100644 index 9763a44..0000000 --- a/tests/Fixtures/InstalledContentProvider.php +++ /dev/null @@ -1,18 +0,0 @@ - ['title' => 'Generated default']]); - } -} diff --git a/tests/HttpContentFetcherTest.php b/tests/HttpContentFetcherTest.php index d62424c..fd821f3 100644 --- a/tests/HttpContentFetcherTest.php +++ b/tests/HttpContentFetcherTest.php @@ -8,6 +8,7 @@ use Croct\Plug\Content\ArrayContentProvider; use Croct\Plug\Content\ContentProvider; use Croct\Plug\Content\ContentSource; +use Croct\Plug\Content\DefaultContentProvider; use Croct\Plug\Exception\ContentException; use Croct\Plug\FetchOptions; use Croct\Plug\HttpContentFetcher; @@ -59,8 +60,8 @@ public function testFetchesContent(): void $fetcher = $this->createFetcher($mock, $factory, new RequestContext(url: 'https://example.com/')); $response = $fetcher->fetch( - 'home-hero', - FetchOptions::empty()->withPreferredLocale('en-us')->withVersion(2), + 'home-hero@2', + FetchOptions::default()->withPreferredLocale('en-us'), ); self::assertSame(['title' => 'Hello'], $response->getContent()); @@ -108,7 +109,7 @@ public function testIncludesSchemaWhenRequested(): void $fetcher = $this->createFetcher($mock, $factory); - $response = $fetcher->fetch('home-hero', FetchOptions::empty()->withSchema()); + $response = $fetcher->fetch('home-hero', FetchOptions::default()->withSchema()); self::assertSame(['type' => 'structure'], $response->getMetadata()?->getSchema()); @@ -143,7 +144,7 @@ public function testFetchesStaticContent(): void ); $this->createFetcher($mock, $factory, $context, identity: $identity) - ->fetch('home-hero', FetchOptions::empty()->withStatic()); + ->fetch('home-hero', FetchOptions::default()->withStatic()); $request = $mock->getLastRequest(); @@ -210,7 +211,7 @@ public function testReturnsFallbackOnFailure(): void $mock->addResponse($factory->createResponse(500)); $response = $this->createFetcher($mock, $factory) - ->fetch('home-hero', FetchOptions::empty()->withFallback(['title' => 'Default'])); + ->fetch('home-hero', FetchOptions::default()->withFallback(['title' => 'Default'])); self::assertSame(['title' => 'Default'], $response->getContent()); } @@ -254,11 +255,55 @@ public function testExplicitFallbackWinsOverProvider(): void $provider = new ArrayContentProvider(['home-hero' => ['title' => 'Generated']]); $response = $this->createFetcher($mock, $factory, contentProvider: $provider) - ->fetch('home-hero', FetchOptions::empty()->withFallback(['title' => 'Explicit'])); + ->fetch('home-hero', FetchOptions::default()->withFallback(['title' => 'Explicit'])); self::assertSame(['title' => 'Explicit'], $response->getContent()); } + #[TestDox('Looks up the content provider by the slot ID without its version.')] + public function testFallsBackToContentProviderForVersionedSlot(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse($factory->createResponse(500)); + + $provider = new ArrayContentProvider(['home-hero' => ['title' => 'Generated']]); + + $response = $this->createFetcher($mock, $factory, contentProvider: $provider)->fetch('home-hero@2'); + + self::assertSame(['title' => 'Generated'], $response->getContent()); + } + + #[TestDox('Forwards the preferred locale to the content provider on fallback.')] + public function testForwardsPreferredLocaleToContentProvider(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->addResponse($factory->createResponse(500)); + + $provider = new DefaultContentProvider( + ['home-hero' => ['en' => ['title' => 'Hello'], 'pt-br' => ['title' => 'OlĂĄ']]], + 'en', + ); + + $response = $this->createFetcher($mock, $factory, contentProvider: $provider) + ->fetch('home-hero@2', FetchOptions::default()->withPreferredLocale('pt-br')); + + self::assertSame(['title' => 'OlĂĄ'], $response->getContent()); + } + + #[TestDox('Rejects a malformed slot ID.')] + public function testRejectsMalformedSlotId(): void + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Malformed slot ID "home hero".'); + + $this->createFetcher($mock, $factory)->fetch('home hero'); + } + private function createFetcher( MockClient $client, Psr17Factory $factory, diff --git a/tests/HttpEvaluatorTest.php b/tests/HttpEvaluatorTest.php index e45b5f9..8d9b348 100644 --- a/tests/HttpEvaluatorTest.php +++ b/tests/HttpEvaluatorTest.php @@ -52,7 +52,7 @@ public function testEvaluatesQuery(): void $result = $evaluator->evaluate( 'user is returning', - EvaluationOptions::empty()->withAttribute('plan', 'pro'), + EvaluationOptions::default()->withAttribute('plan', 'pro'), ); self::assertTrue($result); @@ -221,7 +221,7 @@ public function testReturnsFallbackOnFailure(): void new RequestContext(), ); - $result = $evaluator->evaluate('???', EvaluationOptions::empty()->withFallback(false)); + $result = $evaluator->evaluate('???', EvaluationOptions::default()->withFallback(false)); self::assertFalse($result); } diff --git a/tests/VaryingResponseObserverTest.php b/tests/VaryingResponseObserverTest.php index 033f58c..1b01b16 100644 --- a/tests/VaryingResponseObserverTest.php +++ b/tests/VaryingResponseObserverTest.php @@ -78,7 +78,7 @@ public function testDoesNotVaryOnStaticContentFetch(): void self::assertSame( ['title' => 'Hello'], - $plug->fetchContent('home-hero', FetchOptions::empty()->withStatic())->getContent(), + $plug->fetchContent('home-hero', FetchOptions::default()->withStatic())->getContent(), ); self::assertSame(0, $calls); From bb1bf926ae78646d9531c0997d87f2f8ecf5a08a Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Thu, 11 Jun 2026 13:25:27 -0300 Subject: [PATCH 16/18] Fix issues --- src/CroctScriptResponse.php | 10 +++++++++- src/EvaluationOptions.php | 2 +- src/FetchOptions.php | 2 +- src/HttpContentFetcher.php | 2 +- tests/EvaluationOptionsTest.php | 10 +++++----- tests/FetchOptionsTest.php | 12 ++++++------ tests/HttpContentFetcherTest.php | 12 ++++++------ tests/HttpEvaluatorTest.php | 4 ++-- tests/VaryingResponseObserverTest.php | 2 +- 9 files changed, 32 insertions(+), 24 deletions(-) diff --git a/src/CroctScriptResponse.php b/src/CroctScriptResponse.php index 9d7ea89..a8e1901 100644 --- a/src/CroctScriptResponse.php +++ b/src/CroctScriptResponse.php @@ -26,19 +26,27 @@ public function __construct(int $statusCode, array $headers, string $content) $this->content = $content; } + /** + * Gets the HTTP status code of the captured response. + */ public function getStatusCode(): int { return $this->statusCode; } /** - * @return array + * Gets the response headers. + * + * @return array The headers, keyed by name. */ public function getHeaders(): array { return $this->headers; } + /** + * Gets the raw response body relayed to the client. + */ public function getContent(): string { return $this->content; diff --git a/src/EvaluationOptions.php b/src/EvaluationOptions.php index 5fbe00a..caf0300 100644 --- a/src/EvaluationOptions.php +++ b/src/EvaluationOptions.php @@ -38,7 +38,7 @@ private function __construct(array $attributes, mixed $fallback, bool $fallbackP * * @return self */ - public static function default(): self + public static function defaults(): self { /** @var self $options */ $options = new self([], null, false); diff --git a/src/FetchOptions.php b/src/FetchOptions.php index 602a7b7..a7bf1f8 100644 --- a/src/FetchOptions.php +++ b/src/FetchOptions.php @@ -53,7 +53,7 @@ private function __construct( * * @return self */ - public static function default(): self + public static function defaults(): self { /** @var self $options */ $options = new self(null, false, false, [], false, null); diff --git a/src/HttpContentFetcher.php b/src/HttpContentFetcher.php index 53ebb2e..a2b2498 100644 --- a/src/HttpContentFetcher.php +++ b/src/HttpContentFetcher.php @@ -47,7 +47,7 @@ public function __construct( */ public function fetch(string $slotId, ?FetchOptions $options = null): FetchResponse { - $options ??= FetchOptions::default(); + $options ??= FetchOptions::defaults(); $context = $this->context; $static = $options->isStatic(); diff --git a/tests/EvaluationOptionsTest.php b/tests/EvaluationOptionsTest.php index 6f775b1..15a18aa 100644 --- a/tests/EvaluationOptionsTest.php +++ b/tests/EvaluationOptionsTest.php @@ -16,7 +16,7 @@ final class EvaluationOptionsTest extends TestCase #[TestDox('Default to no attributes or a fallback.')] public function testEmptyHasNoAttributes(): void { - $options = EvaluationOptions::default(); + $options = EvaluationOptions::defaults(); self::assertSame([], $options->getAttributes()); self::assertFalse($options->hasFallback()); @@ -28,7 +28,7 @@ public function testCarriesFallback(): void /** @var mixed $fallback */ $fallback = null; - $options = EvaluationOptions::default()->withFallback($fallback); + $options = EvaluationOptions::defaults()->withFallback($fallback); self::assertTrue($options->hasFallback()); self::assertNull($options->getFallback()); @@ -37,7 +37,7 @@ public function testCarriesFallback(): void #[TestDox('Add attributes one at a time.')] public function testAddsAttributes(): void { - $options = EvaluationOptions::default() + $options = EvaluationOptions::defaults() ->withAttribute('plan', 'pro') ->withAttribute('seats', 5); @@ -47,7 +47,7 @@ public function testAddsAttributes(): void #[TestDox('Replace all attributes when set as a whole.')] public function testReplacesAttributes(): void { - $options = EvaluationOptions::default() + $options = EvaluationOptions::defaults() ->withAttribute('plan', 'pro') ->withAttributes(['seats' => 5]); @@ -57,7 +57,7 @@ public function testReplacesAttributes(): void #[TestDox('Do not mutate the original instance.')] public function testWithMethodsAreImmutable(): void { - $options = EvaluationOptions::default(); + $options = EvaluationOptions::defaults(); $options->withAttribute('plan', 'pro'); diff --git a/tests/FetchOptionsTest.php b/tests/FetchOptionsTest.php index 04d0a27..ca565c4 100644 --- a/tests/FetchOptionsTest.php +++ b/tests/FetchOptionsTest.php @@ -16,7 +16,7 @@ final class FetchOptionsTest extends TestCase #[TestDox('Default to empty.')] public function testEmptyHasNoOptions(): void { - $options = FetchOptions::default(); + $options = FetchOptions::defaults(); self::assertNull($options->getPreferredLocale()); self::assertFalse($options->isStatic()); @@ -28,7 +28,7 @@ public function testEmptyHasNoOptions(): void #[TestDox('Build up immutably through the fluent API.')] public function testBuildsOptionsFluently(): void { - $options = FetchOptions::default() + $options = FetchOptions::defaults() ->withPreferredLocale('en-us') ->withStatic() ->withSchema() @@ -46,12 +46,12 @@ public function testBuildsOptionsFluently(): void #[TestDox('Distinguish a null fallback from no fallback.')] public function testDistinguishesNullFallback(): void { - self::assertFalse(FetchOptions::default()->hasFallback()); + self::assertFalse(FetchOptions::defaults()->hasFallback()); /** @var mixed $fallback */ $fallback = null; - $options = FetchOptions::default()->withFallback($fallback); + $options = FetchOptions::defaults()->withFallback($fallback); self::assertTrue($options->hasFallback()); self::assertNull($options->getFallback()); @@ -60,7 +60,7 @@ public function testDistinguishesNullFallback(): void #[TestDox('Do not mutate the original instance.')] public function testWithMethodsAreImmutable(): void { - $options = FetchOptions::default(); + $options = FetchOptions::defaults(); $options->withPreferredLocale('en-us')->withStatic()->withSchema(); @@ -72,7 +72,7 @@ public function testWithMethodsAreImmutable(): void #[TestDox('Replace all attributes when set as a whole.')] public function testReplacesAttributes(): void { - $options = FetchOptions::default() + $options = FetchOptions::defaults() ->withAttribute('a', 1) ->withAttributes(['b' => 2]); diff --git a/tests/HttpContentFetcherTest.php b/tests/HttpContentFetcherTest.php index fd821f3..d59a36c 100644 --- a/tests/HttpContentFetcherTest.php +++ b/tests/HttpContentFetcherTest.php @@ -61,7 +61,7 @@ public function testFetchesContent(): void $response = $fetcher->fetch( 'home-hero@2', - FetchOptions::default()->withPreferredLocale('en-us'), + FetchOptions::defaults()->withPreferredLocale('en-us'), ); self::assertSame(['title' => 'Hello'], $response->getContent()); @@ -109,7 +109,7 @@ public function testIncludesSchemaWhenRequested(): void $fetcher = $this->createFetcher($mock, $factory); - $response = $fetcher->fetch('home-hero', FetchOptions::default()->withSchema()); + $response = $fetcher->fetch('home-hero', FetchOptions::defaults()->withSchema()); self::assertSame(['type' => 'structure'], $response->getMetadata()?->getSchema()); @@ -144,7 +144,7 @@ public function testFetchesStaticContent(): void ); $this->createFetcher($mock, $factory, $context, identity: $identity) - ->fetch('home-hero', FetchOptions::default()->withStatic()); + ->fetch('home-hero', FetchOptions::defaults()->withStatic()); $request = $mock->getLastRequest(); @@ -211,7 +211,7 @@ public function testReturnsFallbackOnFailure(): void $mock->addResponse($factory->createResponse(500)); $response = $this->createFetcher($mock, $factory) - ->fetch('home-hero', FetchOptions::default()->withFallback(['title' => 'Default'])); + ->fetch('home-hero', FetchOptions::defaults()->withFallback(['title' => 'Default'])); self::assertSame(['title' => 'Default'], $response->getContent()); } @@ -255,7 +255,7 @@ public function testExplicitFallbackWinsOverProvider(): void $provider = new ArrayContentProvider(['home-hero' => ['title' => 'Generated']]); $response = $this->createFetcher($mock, $factory, contentProvider: $provider) - ->fetch('home-hero', FetchOptions::default()->withFallback(['title' => 'Explicit'])); + ->fetch('home-hero', FetchOptions::defaults()->withFallback(['title' => 'Explicit'])); self::assertSame(['title' => 'Explicit'], $response->getContent()); } @@ -287,7 +287,7 @@ public function testForwardsPreferredLocaleToContentProvider(): void ); $response = $this->createFetcher($mock, $factory, contentProvider: $provider) - ->fetch('home-hero@2', FetchOptions::default()->withPreferredLocale('pt-br')); + ->fetch('home-hero@2', FetchOptions::defaults()->withPreferredLocale('pt-br')); self::assertSame(['title' => 'OlĂĄ'], $response->getContent()); } diff --git a/tests/HttpEvaluatorTest.php b/tests/HttpEvaluatorTest.php index 8d9b348..bcf20a8 100644 --- a/tests/HttpEvaluatorTest.php +++ b/tests/HttpEvaluatorTest.php @@ -52,7 +52,7 @@ public function testEvaluatesQuery(): void $result = $evaluator->evaluate( 'user is returning', - EvaluationOptions::default()->withAttribute('plan', 'pro'), + EvaluationOptions::defaults()->withAttribute('plan', 'pro'), ); self::assertTrue($result); @@ -221,7 +221,7 @@ public function testReturnsFallbackOnFailure(): void new RequestContext(), ); - $result = $evaluator->evaluate('???', EvaluationOptions::default()->withFallback(false)); + $result = $evaluator->evaluate('???', EvaluationOptions::defaults()->withFallback(false)); self::assertFalse($result); } diff --git a/tests/VaryingResponseObserverTest.php b/tests/VaryingResponseObserverTest.php index 1b01b16..9d31ced 100644 --- a/tests/VaryingResponseObserverTest.php +++ b/tests/VaryingResponseObserverTest.php @@ -78,7 +78,7 @@ public function testDoesNotVaryOnStaticContentFetch(): void self::assertSame( ['title' => 'Hello'], - $plug->fetchContent('home-hero', FetchOptions::default()->withStatic())->getContent(), + $plug->fetchContent('home-hero', FetchOptions::defaults()->withStatic())->getContent(), ); self::assertSame(0, $calls); From 9eadb7c27558541b39e64ec1c30aade0146d5d44 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Thu, 11 Jun 2026 13:33:45 -0300 Subject: [PATCH 17/18] Rename method --- src/Content/ArrayContentProvider.php | 2 +- src/Content/ContentProvider.php | 2 +- src/Content/DefaultContentProvider.php | 2 +- src/Content/NullContentProvider.php | 2 +- src/HttpContentFetcher.php | 2 +- tests/Content/ArrayContentProviderTest.php | 4 ++-- tests/Content/DefaultContentProviderTest.php | 22 ++++++++++---------- tests/Content/NullContentProviderTest.php | 2 +- 8 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/Content/ArrayContentProvider.php b/src/Content/ArrayContentProvider.php index 61f3647..b3c8b9c 100644 --- a/src/Content/ArrayContentProvider.php +++ b/src/Content/ArrayContentProvider.php @@ -23,7 +23,7 @@ public function __construct(array $content) /** * @return array|null */ - public function getContent(string $id, ?string $language = null): ?array + public function getSlotContent(string $id, ?string $language = null): ?array { return $this->content[$id] ?? null; } diff --git a/src/Content/ContentProvider.php b/src/Content/ContentProvider.php index a4d9211..09c850c 100644 --- a/src/Content/ContentProvider.php +++ b/src/Content/ContentProvider.php @@ -17,5 +17,5 @@ interface ContentProvider * * @return array|null The content of the slot, or null when none is available. */ - public function getContent(string $id, ?string $language = null): ?array; + public function getSlotContent(string $id, ?string $language = null): ?array; } diff --git a/src/Content/DefaultContentProvider.php b/src/Content/DefaultContentProvider.php index 44bc868..1768e3a 100644 --- a/src/Content/DefaultContentProvider.php +++ b/src/Content/DefaultContentProvider.php @@ -69,7 +69,7 @@ public static function fromProject(?string $projectDirectory = null): ?self /** * @return array|null */ - public function getContent(string $id, ?string $language = null): ?array + public function getSlotContent(string $id, ?string $language = null): ?array { $localized = $this->slots[$id] ?? null; diff --git a/src/Content/NullContentProvider.php b/src/Content/NullContentProvider.php index 079fa66..540c038 100644 --- a/src/Content/NullContentProvider.php +++ b/src/Content/NullContentProvider.php @@ -12,7 +12,7 @@ final class NullContentProvider implements ContentProvider /** * @return array|null */ - public function getContent(string $id, ?string $language = null): ?array + public function getSlotContent(string $id, ?string $language = null): ?array { return null; } diff --git a/src/HttpContentFetcher.php b/src/HttpContentFetcher.php index a2b2498..3b3c1ef 100644 --- a/src/HttpContentFetcher.php +++ b/src/HttpContentFetcher.php @@ -105,7 +105,7 @@ public function fetch(string $slotId, ?FetchOptions $options = null): FetchRespo return $response; } - $content = $this->contentProvider->getContent($id, $locale); + $content = $this->contentProvider->getSlotContent($id, $locale); if ($content !== null) { /** @var FetchResponse, F> $response */ diff --git a/tests/Content/ArrayContentProviderTest.php b/tests/Content/ArrayContentProviderTest.php index 0b98f0b..ee9750e 100644 --- a/tests/Content/ArrayContentProviderTest.php +++ b/tests/Content/ArrayContentProviderTest.php @@ -18,12 +18,12 @@ public function testReturnsMappedContent(): void { $provider = new ArrayContentProvider(['home-hero' => ['title' => 'Hello']]); - self::assertSame(['title' => 'Hello'], $provider->getContent('home-hero')); + self::assertSame(['title' => 'Hello'], $provider->getSlotContent('home-hero')); } #[TestDox('Returns null for an unknown slot ID.')] public function testReturnsNullForUnknownSlot(): void { - self::assertNull((new ArrayContentProvider([]))->getContent('missing')); + self::assertNull((new ArrayContentProvider([]))->getSlotContent('missing')); } } diff --git a/tests/Content/DefaultContentProviderTest.php b/tests/Content/DefaultContentProviderTest.php index 04c8a81..80db983 100644 --- a/tests/Content/DefaultContentProviderTest.php +++ b/tests/Content/DefaultContentProviderTest.php @@ -109,16 +109,16 @@ public function testResolvesLatestVersionInDefaultLocale(): void self::assertNotNull($provider); // Defaults to the project's default locale when no language is requested. - self::assertSame(['title' => 'Latest'], $provider->getContent('home-hero')); + self::assertSame(['title' => 'Latest'], $provider->getSlotContent('home-hero')); // Serves the requested language when available. - self::assertSame(['title' => 'Recente'], $provider->getContent('home-hero', 'pt')); + self::assertSame(['title' => 'Recente'], $provider->getSlotContent('home-hero', 'pt')); // Falls back to the default locale when the requested language is absent. - self::assertSame(['title' => 'Latest'], $provider->getContent('home-hero', 'fr')); + self::assertSame(['title' => 'Latest'], $provider->getSlotContent('home-hero', 'fr')); // Serves an explicitly requested language even when the default locale is absent. - self::assertSame(['label' => 'Comprar'], $provider->getContent('cta', 'pt')); + self::assertSame(['label' => 'Comprar'], $provider->getSlotContent('cta', 'pt')); // Gives up when neither the requested language nor the default locale is available. - self::assertNull($provider->getContent('cta')); - self::assertNull($provider->getContent('missing')); + self::assertNull($provider->getSlotContent('cta')); + self::assertNull($provider->getSlotContent('missing')); } #[TestDox('Skips malformed entries and resolves without a default locale.')] @@ -164,13 +164,13 @@ public function testSkipsMalformedEntriesWithoutDefaultLocale(): void $provider = DefaultContentProvider::fromProject(VirtualFilesystem::path()); self::assertNotNull($provider); - self::assertNull($provider->getContent('not-versions')); - self::assertNull($provider->getContent('no-valid-latest')); - self::assertNull($provider->getContent('no-localized-array')); + self::assertNull($provider->getSlotContent('not-versions')); + self::assertNull($provider->getSlotContent('no-valid-latest')); + self::assertNull($provider->getSlotContent('no-localized-array')); // Resolves the latest valid version for the requested language. - self::assertSame(['k' => 'v2'], $provider->getContent('valid', 'en')); + self::assertSame(['k' => 'v2'], $provider->getSlotContent('valid', 'en')); // Without a default locale, an unspecified language resolves to nothing. - self::assertNull($provider->getContent('valid')); + self::assertNull($provider->getSlotContent('valid')); } /** diff --git a/tests/Content/NullContentProviderTest.php b/tests/Content/NullContentProviderTest.php index 863dafa..7415785 100644 --- a/tests/Content/NullContentProviderTest.php +++ b/tests/Content/NullContentProviderTest.php @@ -16,6 +16,6 @@ final class NullContentProviderTest extends TestCase #[TestDox('Has no content for any slot.')] public function testHasNoContent(): void { - self::assertNull((new NullContentProvider())->getContent('home-hero')); + self::assertNull((new NullContentProvider())->getSlotContent('home-hero')); } } From 7b6cecb121fc44f36414c0d164eb56076c26f046 Mon Sep 17 00:00:00 2001 From: Marcos Passos Date: Thu, 11 Jun 2026 15:08:33 -0300 Subject: [PATCH 18/18] Improve DX --- composer.json | 3 +- composer.lock | 735 +++++++++++++++++++++++------------- src/CookieStorage.php | 24 ++ src/Croct.php | 95 ++++- tests/CookieStorageTest.php | 38 ++ tests/CroctTest.php | 167 ++++++++ 6 files changed, 787 insertions(+), 275 deletions(-) diff --git a/composer.json b/composer.json index 1561159..dbe62a3 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,8 @@ "psr/http-factory": "^1.0", "psr/http-message": "^1.1 || ^2.0", "psr/log": "^2.0 || ^3.0", - "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", + "vlucas/phpdotenv": "^5.6" }, "require-dev": { "ext-simplexml": "*", diff --git a/composer.lock b/composer.lock index d386fb7..ffdceda 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,70 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "74746d27ce51ed7e1bf2f7cb872354dd", + "content-hash": "bd1a6b30ce6094528891a2baa2ecc834", "packages": [ + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:43:20+00:00" + }, { "name": "php-http/discovery", "version": "1.20.0", @@ -85,6 +147,81 @@ }, "time": "2024-10-02T11:20:13+00:00" }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, { "name": "psr/http-client", "version": "1.0.3", @@ -341,10 +478,346 @@ "psr-16", "simple-cache" ], - "support": { - "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" - }, - "time": "2021-10-29T13:26:27+00:00" + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+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-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.3", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "955e7815d677a3eaa7075231212f2110983adecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:49:13+00:00" } ], "packages-dev": [ @@ -6752,89 +7225,6 @@ ], "time": "2026-05-29T05:06:50+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-deepclone", "version": "v1.37.0", @@ -7089,175 +7479,6 @@ ], "time": "2026-05-25T13:48:31+00:00" }, - { - "name": "symfony/polyfill-mbstring", - "version": "v1.38.2", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", - "shasum": "" - }, - "require": { - "ext-iconv": "*", - "php": ">=7.2" - }, - "provide": { - "ext-mbstring": "*" - }, - "suggest": { - "ext-mbstring": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for the Mbstring extension", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-05-27T06:59:30+00:00" - }, - { - "name": "symfony/polyfill-php80", - "version": "v1.37.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-04-10T16:19:22+00:00" - }, { "name": "symfony/polyfill-php84", "version": "v1.38.1", diff --git a/src/CookieStorage.php b/src/CookieStorage.php index 87b0ae1..cb651e4 100644 --- a/src/CookieStorage.php +++ b/src/CookieStorage.php @@ -15,6 +15,8 @@ */ final class CookieStorage implements IdentityStore { + private static ?self $instance = null; + private ?Uuid $clientId; private ?Token $userToken; @@ -46,6 +48,28 @@ public static function fromGlobals(?CookieConfiguration $configuration = null, ? return self::fromArray($cookies, $configuration, $now); } + /** + * Returns the process-wide instance built from the current request's cookies. + * + * The instance is created once from the request cookies and reused, so the session can update + * it and the cookies emitted afterwards reflect those changes without passing it around. + * + * In long-running runtimes (e.g. RoadRunner, Swoole), call reset() between requests to avoid + * leaking session state across them. + */ + public static function global(): self + { + return self::$instance ??= self::fromGlobals(); + } + + /** + * Clears the process-wide instance returned by global(). + */ + public static function reset(): void + { + self::$instance = null; + } + /** * Creates an instance from the cookies of a server request. */ diff --git a/src/Croct.php b/src/Croct.php index 57cca01..72ac550 100644 --- a/src/Croct.php +++ b/src/Croct.php @@ -10,6 +10,7 @@ use Croct\Plug\Content\NullContentProvider; use Croct\Plug\Exception\ConfigurationException; use Croct\Plug\Exception\CroctException; +use Dotenv\Dotenv; use Http\Discovery\Exception\NotFoundException; use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; @@ -131,28 +132,54 @@ public static function plug( /** * Creates an instance from the CROCT_* environment variables. * + * Reads from the process environment ($_ENV, $_SERVER, and getenv()), defaulting to the + * process-wide cookie storage when none is given. + * * @throws ConfigurationException If required variables are missing or no transport is available. */ - public static function fromEnvironment(IdentityStore $storage): self + public static function fromEnvironment(?IdentityStore $storage = null): self { - $appId = self::getEnv('CROCT_APP_ID'); - $apiKey = self::getEnv('CROCT_API_KEY'); + return self::build(self::getEnv(...), $storage ?? CookieStorage::global()); + } - if ($appId === null || $apiKey === null) { - throw new ConfigurationException( - 'The CROCT_APP_ID and CROCT_API_KEY environment variables are required.', - ); + /** + * Creates an instance from the CROCT_* variables in a .env file. + * + * Reads the .env file in the given directory (defaulting to the current working directory) + * without modifying the process environment, falling back to the process environment for any + * variable absent from the file. Defaults to the process-wide cookie storage when none is given. + * + * @throws ConfigurationException If required variables are missing or no transport is available. + */ + public static function fromDotenv(?string $directory = null, ?IdentityStore $storage = null): self + { + if ($directory === null) { + $cwd = \getcwd(); + $directory = $cwd === false ? '.' : $cwd; } - $tokenDuration = self::getEnv('CROCT_TOKEN_DURATION'); + $values = Dotenv::createArrayBacked($directory)->safeLoad(); - return self::plug( - appId: $appId, - apiKey: $apiKey, - storage: $storage, - baseEndpointUrl: self::getEnv('CROCT_BASE_ENDPOINT_URL') ?? self::DEFAULT_BASE_ENDPOINT_URL, - tokenDuration: $tokenDuration !== null ? (int) $tokenDuration : self::DEFAULT_TOKEN_DURATION, - ); + $resolve = static function (string $name) use ($values): ?string { + $value = $values[$name] ?? null; + + return \is_string($value) && $value !== '' ? $value : self::getEnv($name); + }; + + return self::build($resolve, $storage ?? CookieStorage::global()); + } + + /** + * Emits the session cookies of the process-wide cookie storage. + * + * Convenience for CookieStorage::global()->emit(); must be called before any output is sent. + * + * @param (callable(string, string, array): bool)|null $emitter + * The function used to send each cookie. Defaults to PHP's setcookie(). + */ + public static function emitCookies(?callable $emitter = null): void + { + CookieStorage::global()->emit($emitter); } /** @@ -230,13 +257,47 @@ public function getPlugOptions(): array } /** - * Reads an environment variable. + * Builds an instance from a resolver of CROCT_* configuration variables. + * + * @param callable(string): (string|null) $source Resolves a variable by name, or null if unset. + * + * @throws ConfigurationException If required variables are missing or no transport is available. + */ + private static function build(callable $source, IdentityStore $storage): self + { + $appId = $source('CROCT_APP_ID'); + $apiKey = $source('CROCT_API_KEY'); + + if ($appId === null || $apiKey === null) { + throw new ConfigurationException( + 'The CROCT_APP_ID and CROCT_API_KEY variables are required. Set them in the ' + . 'environment, load them from a .env file with Croct::fromDotenv(), or pass them ' + . 'directly to Croct::plug().', + ); + } + + $tokenDuration = $source('CROCT_TOKEN_DURATION'); + + return self::plug( + appId: $appId, + apiKey: $apiKey, + storage: $storage, + baseEndpointUrl: $source('CROCT_BASE_ENDPOINT_URL') ?? self::DEFAULT_BASE_ENDPOINT_URL, + tokenDuration: $tokenDuration !== null ? (int) $tokenDuration : self::DEFAULT_TOKEN_DURATION, + ); + } + + /** + * Reads a variable from the process environment. + * + * Checks $_ENV and $_SERVER before getenv() so variables set by the SAPI (e.g. Apache SetEnv + * or PHP-FPM env) are seen even when they are not exported to getenv(). * * @return string|null The value, or null when it is unset or empty. */ private static function getEnv(string $name): ?string { - $value = \getenv($name); + $value = $_ENV[$name] ?? $_SERVER[$name] ?? \getenv($name); return \is_string($value) && $value !== '' ? $value : null; } diff --git a/tests/CookieStorageTest.php b/tests/CookieStorageTest.php index b7ce291..f130e51 100644 --- a/tests/CookieStorageTest.php +++ b/tests/CookieStorageTest.php @@ -180,4 +180,42 @@ public function testReadsFromServerRequest(): void self::assertSame(self::CLIENT_ID, $storage->getClientId()?->toString()); } + + #[TestDox('Reuses the process-wide instance built from the request cookies.')] + public function testGlobalReturnsMemoizedInstance(): void + { + $originalCookie = $_COOKIE; + $_COOKIE = ['ct.client_id' => self::CLIENT_ID]; + + try { + CookieStorage::reset(); + + $first = CookieStorage::global(); + + self::assertSame($first, CookieStorage::global()); + self::assertSame(self::CLIENT_ID, $first->getClientId()?->toString()); + } finally { + CookieStorage::reset(); + $_COOKIE = $originalCookie; + } + } + + #[TestDox('Rebuilds the process-wide instance after a reset.')] + public function testResetClearsTheGlobalInstance(): void + { + $originalCookie = $_COOKIE; + $_COOKIE = []; + + try { + CookieStorage::reset(); + + $first = CookieStorage::global(); + CookieStorage::reset(); + + self::assertNotSame($first, CookieStorage::global()); + } finally { + CookieStorage::reset(); + $_COOKIE = $originalCookie; + } + } } diff --git a/tests/CroctTest.php b/tests/CroctTest.php index d7adaae..6bb97a3 100644 --- a/tests/CroctTest.php +++ b/tests/CroctTest.php @@ -12,6 +12,7 @@ use Croct\Plug\IdentityStore; use Croct\Plug\InMemoryIdentityStore; use Croct\Plug\RequestContext; +use Croct\Plug\Tests\Fixtures\VirtualFilesystem; use Croct\Plug\Token; use Croct\Plug\Uuid; use Http\Discovery\Psr18ClientDiscovery; @@ -32,6 +33,60 @@ final class CroctTest extends TestCase private const CLIENT_ID = '11111111-2222-4333-8444-555555555555'; + private const CROCT_VARIABLES = [ + 'CROCT_APP_ID', + 'CROCT_API_KEY', + 'CROCT_BASE_ENDPOINT_URL', + 'CROCT_TOKEN_DURATION', + ]; + + /** @var array */ + private array $env; + + /** @var array */ + private array $server; + + /** @var array */ + private array $cookie; + + private string $cwd; + + protected function setUp(): void + { + VirtualFilesystem::setUp(); + + $this->env = $_ENV; + $this->server = $_SERVER; + $this->cookie = $_COOKIE; + + $cwd = \getcwd(); + $this->cwd = $cwd === false ? '.' : $cwd; + + // Start each test from a clean slate so reads are deterministic regardless of the shell. + foreach (self::CROCT_VARIABLES as $name) { + unset($_ENV[$name], $_SERVER[$name]); + \putenv($name); + } + + CookieStorage::reset(); + } + + protected function tearDown(): void + { + \chdir($this->cwd); + + $_ENV = $this->env; + $_SERVER = $this->server; + $_COOKIE = $this->cookie; + + foreach (self::CROCT_VARIABLES as $name) { + \putenv($name); + } + + VirtualFilesystem::tearDown(); + CookieStorage::reset(); + } + #[TestDox('Evaluates queries with the resolved client ID and token.')] public function testEvaluateUsesResolvedSession(): void { @@ -203,6 +258,118 @@ public function testReportsMissingTransport(): void ); } + #[TestDox('Builds from the environment using the global cookie storage by default.')] + public function testFromEnvironmentDefaultsToGlobalStorage(): void + { + \putenv('CROCT_APP_ID=' . self::APP_ID); + \putenv('CROCT_API_KEY=' . EcKeyFactory::IDENTIFIER); + $_COOKIE = ['ct.client_id' => self::CLIENT_ID]; + + $croct = Croct::fromEnvironment(); + + self::assertSame(self::CLIENT_ID, $croct->getClientId()); + self::assertSame(self::CLIENT_ID, CookieStorage::global()->getClientId()?->toString()); + } + + #[TestDox('Reads the required variables from the $_SERVER superglobal.')] + public function testReadsEnvironmentFromServerSuperglobal(): void + { + $_SERVER['CROCT_APP_ID'] = self::APP_ID; + $_SERVER['CROCT_API_KEY'] = EcKeyFactory::IDENTIFIER; + + $croct = Croct::fromEnvironment(new InMemoryIdentityStore(Uuid::parse(self::CLIENT_ID))); + + self::assertSame(self::APP_ID, $croct->getAppId()); + } + + #[TestDox('Can be built from a .env file.')] + public function testCreatesFromDotenv(): void + { + VirtualFilesystem::write( + VirtualFilesystem::path('.env'), + 'CROCT_APP_ID=' . self::APP_ID . "\n" + . 'CROCT_API_KEY=' . EcKeyFactory::IDENTIFIER . "\n", + ); + + $croct = Croct::fromDotenv( + VirtualFilesystem::path(), + new InMemoryIdentityStore(Uuid::parse(self::CLIENT_ID)), + ); + + self::assertSame(self::APP_ID, $croct->getAppId()); + self::assertSame(self::CLIENT_ID, $croct->getClientId()); + } + + #[TestDox('Reads the .env file without modifying the process environment.')] + public function testDotenvDoesNotModifyEnvironment(): void + { + VirtualFilesystem::write( + VirtualFilesystem::path('.env'), + 'CROCT_APP_ID=' . self::APP_ID . "\n" + . 'CROCT_API_KEY=' . EcKeyFactory::IDENTIFIER . "\n" + . "UNRELATED_VARIABLE=should-not-leak\n", + ); + + Croct::fromDotenv(VirtualFilesystem::path(), new InMemoryIdentityStore()); + + self::assertFalse(\getenv('CROCT_APP_ID')); + self::assertArrayNotHasKey('CROCT_APP_ID', $_ENV); + self::assertArrayNotHasKey('CROCT_APP_ID', $_SERVER); + self::assertFalse(\getenv('UNRELATED_VARIABLE')); + self::assertArrayNotHasKey('UNRELATED_VARIABLE', $_ENV); + self::assertArrayNotHasKey('UNRELATED_VARIABLE', $_SERVER); + } + + #[TestDox('Falls back to the process environment for variables absent from the .env file.')] + public function testDotenvFallsBackToProcessEnvironment(): void + { + VirtualFilesystem::write(VirtualFilesystem::path('.env'), 'CROCT_APP_ID=' . self::APP_ID . "\n"); + + // The API key lives only in the process environment, not in the file. + $_SERVER['CROCT_API_KEY'] = EcKeyFactory::IDENTIFIER; + + $croct = Croct::fromDotenv( + VirtualFilesystem::path(), + new InMemoryIdentityStore(Uuid::parse(self::CLIENT_ID)), + ); + + self::assertSame(self::APP_ID, $croct->getAppId()); + } + + #[TestDox('Defaults to the current working directory and the global cookie storage.')] + public function testDotenvDefaultsToCurrentDirectoryAndGlobalStorage(): void + { + // The tests directory has no .env, so the values come from the process environment. + \chdir(__DIR__); + $_SERVER['CROCT_APP_ID'] = self::APP_ID; + $_SERVER['CROCT_API_KEY'] = EcKeyFactory::IDENTIFIER; + $_COOKIE = ['ct.client_id' => self::CLIENT_ID]; + + $croct = Croct::fromDotenv(); + + self::assertSame(self::APP_ID, $croct->getAppId()); + self::assertSame(self::CLIENT_ID, $croct->getClientId()); + self::assertSame(self::CLIENT_ID, CookieStorage::global()->getClientId()?->toString()); + } + + #[TestDox('Emits the global cookie storage cookies through the given emitter.')] + public function testEmitsCookiesFromGlobalStorage(): void + { + $_COOKIE = ['ct.client_id' => self::CLIENT_ID]; + + // Prime the process-wide storage from the request cookies. + CookieStorage::global(); + + $emitted = []; + Croct::emitCookies(static function (string $name, string $value, array $options) use (&$emitted): bool { + $emitted[$name] = $value; + + return true; + }); + + self::assertSame(self::CLIENT_ID, $emitted['ct.client_id'] ?? null); + } + private function createCroct(MockClient $client, ?IdentityStore $storage = null): Croct { $factory = new Psr17Factory();