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..c494c83 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 + Build

-# 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 4289aee..dbe62a3 100644 --- a/composer.json +++ b/composer.json @@ -1,33 +1,62 @@ { "name": "croct/plug-php", - "description": "A brief description about the project.", - "license": "proprietary", + "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-hash": "*", + "ext-json": "*", + "ext-mbstring": "*", + "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", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", + "vlucas/phpdotenv": "^5.6" }, "require-dev": { - "croct/coding-standard": "^0.4", + "ext-simplexml": "*", + "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", + "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).", + "nyholm/psr7": "A lightweight PSR-7/PSR-17 implementation for building requests.", + "symfony/http-client": "An alternative PSR-18 HTTP client." }, "repositories": [ { @@ -39,19 +68,30 @@ "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 + } + }, + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "psalm": { + "pluginClass": "Croct\\Plug\\Psalm\\ContentStubPlugin" } }, "scripts": { @@ -60,7 +100,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..ffdceda 100644 --- a/composer.lock +++ b/composer.lock @@ -4,127 +4,120 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bbfd140bbbfb9af32d513ba24c2f0f0e", - "packages": [], - "packages-dev": [ + "content-hash": "bd1a6b30ce6094528891a2baa2ecc834", + "packages": [ { - "name": "croct/coding-standard", - "version": "0.4.4", + "name": "graham-campbell/result-type", + "version": "v1.1.4", "source": { "type": "git", - "url": "git@github.com:croct-tech/coding-standard-php.git", - "reference": "7ee8241e988a67a4ba7f1dbbf8b9089967707218" + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/croct-tech/coding-standard-php/zipball/7ee8241e988a67a4ba7f1dbbf8b9089967707218", - "reference": "7ee8241e988a67a4ba7f1dbbf8b9089967707218", - "shasum": "", - "mirrors": [ - { - "url": "https://croct.repo.repman.io/dists/%package%/%version%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" }, "require": { - "php": "^8.1", - "slevomat/coding-standard": "^8.0", - "squizlabs/php_codesniffer": "^3.7" + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" }, "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" + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" }, - "type": "phpcodesniffer-standard", + "type": "library", "autoload": { "psr-4": { - "Croct\\": "src/Croct/" + "GrahamCampbell\\ResultType\\": "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": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" } ], - "description": "A set of Code Sniffer rules applied to all Croct PHP projects.", - "homepage": "https://github.com/croct-tech/coding-standard-php", + "description": "An Implementation Of The Result Type", "keywords": [ - "coding", - "croct", - "phpcs", - "standard" + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" ], "support": { - "source": "https://github.com/croct-tech/coding-standard-php/tree/0.4.4", - "issues": "https://github.com/croct-tech/coding-standard-php/issues" + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" }, - "time": "2023-05-29T03:31:45+00:00" + "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": "dealerdirect/phpcodesniffer-composer-installer", - "version": "v1.0.0", + "name": "php-http/discovery", + "version": "1.20.0", "source": { "type": "git", - "url": "https://github.com/PHPCSStandards/composer-installer.git", - "reference": "4be43904336affa5c2f70744a348312336afd0da" + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/4be43904336affa5c2f70744a348312336afd0da", - "reference": "4be43904336affa5c2f70744a348312336afd0da", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", "shasum": "" }, "require": { - "composer-plugin-api": "^1.0 || ^2.0", - "php": ">=5.4", - "squizlabs/php_codesniffer": "^2.0 || ^3.1.0 || ^4.0" + "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": "*", - "ext-json": "*", - "ext-zip": "*", - "php-parallel-lint/php-parallel-lint": "^1.3.1", - "phpcompatibility/php-compatibility": "^9.0", - "yoast/phpunit-polyfills": "^1.0" + "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": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true }, "autoload": { "psr-4": { - "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" - } + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -132,159 +125,130 @@ ], "authors": [ { - "name": "Franck Nijhof", - "email": "franck.nijhof@dealerdirect.com", - "homepage": "http://www.frenck.nl", - "role": "Developer / IT Manager" - }, - { - "name": "Contributors", - "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" } ], - "description": "PHP_CodeSniffer Standards Composer Installer Plugin", - "homepage": "http://www.dealerdirect.com", + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", "keywords": [ - "PHPCodeSniffer", - "PHP_CodeSniffer", - "code quality", - "codesniffer", - "composer", - "installer", - "phpcbf", - "phpcs", - "plugin", - "qa", - "quality", - "standard", - "standards", - "style guide", - "stylecheck", - "tests" + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" ], "support": { - "issues": "https://github.com/PHPCSStandards/composer-installer/issues", - "source": "https://github.com/PHPCSStandards/composer-installer" + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" }, - "time": "2023-01-05T11:28:13+00:00" + "time": "2024-10-02T11:20:13+00:00" }, { - "name": "ergebnis/composer-normalize", - "version": "2.39.0", + "name": "phpoption/phpoption", + "version": "1.9.5", "source": { "type": "git", - "url": "https://github.com/ergebnis/composer-normalize.git", - "reference": "a878360bc8cb5cb440b9381f72b0aaa125f937c7" + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/composer-normalize/zipball/a878360bc8cb5cb440b9381f72b0aaa125f937c7", - "reference": "a878360bc8cb5cb440b9381f72b0aaa125f937c7", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", "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", - "ext-json": "*", - "justinrainbow/json-schema": "^5.2.12", - "localheinz/diff": "^1.1.1", - "php": "~8.1.0 || ~8.2.0 || ~8.3.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" + "php": "^7.2.5 || ^8.0" }, - "type": "composer-plugin", + "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": { - "class": "Ergebnis\\Composer\\Normalize\\NormalizePlugin", - "composer-normalize": { - "indent-size": 2, - "indent-style": "space" + "bamarni-bin": { + "bin-links": true, + "forward-command": false }, - "plugin-optional": true + "branch-alias": { + "dev-master": "1.9-dev" + } }, "autoload": { "psr-4": { - "Ergebnis\\Composer\\Normalize\\": "src/" + "PhpOption\\": "src/PhpOption/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "Apache-2.0" ], "authors": [ { - "name": "Andreas Möller", - "email": "am@localheinz.com", - "homepage": "https://localheinz.com" + "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": "Provides a composer plugin for normalizing composer.json.", - "homepage": "https://github.com/ergebnis/composer-normalize", + "description": "Option Type for PHP", "keywords": [ - "composer", - "normalize", - "normalizer", - "plugin" + "language", + "option", + "php", + "type" ], "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/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" }, - "time": "2023-10-10T15:43:27+00:00" + "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": "ergebnis/json", - "version": "1.1.0", + "name": "psr/http-client", + "version": "1.0.3", "source": { "type": "git", - "url": "https://github.com/ergebnis/json.git", - "reference": "9f2b9086c43b189d7044a5b6215a931fb6e9125d" + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/json/zipball/9f2b9086c43b189d7044a5b6215a931fb6e9125d", - "reference": "9f2b9086c43b189d7044a5b6215a931fb6e9125d", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", "shasum": "" }, "require": { - "php": "~8.1.0 || ~8.2.0 || ~8.3.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" + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" }, "type": "library", "extra": { - "composer-normalize": { - "indent-size": 2, - "indent-style": "space" + "branch-alias": { + "dev-master": "1.0.x-dev" } }, "autoload": { "psr-4": { - "Ergebnis\\Json\\": "src/" + "Psr\\Http\\Client\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -293,68 +257,50 @@ ], "authors": [ { - "name": "Andreas Möller", - "email": "am@localheinz.com", - "homepage": "https://localheinz.com" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "description": "Provides a Json value object for representing a valid JSON string.", - "homepage": "https://github.com/ergebnis/json", + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", "keywords": [ - "json" + "http", + "http-client", + "psr", + "psr-18" ], "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" + "source": "https://github.com/php-fig/http-client" }, - "time": "2023-10-10T07:57:48+00:00" + "time": "2023-09-23T14:17:50+00:00" }, { - "name": "ergebnis/json-normalizer", - "version": "4.3.0", + "name": "psr/http-factory", + "version": "1.1.0", "source": { "type": "git", - "url": "https://github.com/ergebnis/json-normalizer.git", - "reference": "716fa0a5dcc75fbcb2c1c2e0542b2f56732460bd" + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/json-normalizer/zipball/716fa0a5dcc75fbcb2c1c2e0542b2f56732460bd", - "reference": "716fa0a5dcc75fbcb2c1c2e0542b2f56732460bd", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", "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", - "ext-json": "*", - "justinrainbow/json-schema": "^5.2.12", - "php": "~8.1.0 || ~8.2.0 || ~8.3.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" - }, - "suggest": { - "composer/semver": "If you want to use ComposerJsonNormalizer or VersionConstraintNormalizer" + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, "autoload": { "psr-4": { - "Ergebnis\\Json\\Normalizer\\": "src/" + "Psr\\Http\\Message\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -363,64 +309,52 @@ ], "authors": [ { - "name": "Andreas Möller", - "email": "am@localheinz.com", - "homepage": "https://localheinz.com" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "description": "Provides generic and vendor-specific normalizers for normalizing JSON documents.", - "homepage": "https://github.com/ergebnis/json-normalizer", + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", "keywords": [ - "json", - "normalizer" + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" ], "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" + "source": "https://github.com/php-fig/http-factory" }, - "time": "2023-10-10T15:15:03+00:00" + "time": "2024-04-15T12:06:14+00:00" }, { - "name": "ergebnis/json-pointer", - "version": "3.3.0", + "name": "psr/http-message", + "version": "2.0", "source": { "type": "git", - "url": "https://github.com/ergebnis/json-pointer.git", - "reference": "8e517faefc06b7c761eaa041febef51a9375819a" + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/json-pointer/zipball/8e517faefc06b7c761eaa041febef51a9375819a", - "reference": "8e517faefc06b7c761eaa041febef51a9375819a", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", "shasum": "" }, "require": { - "php": "~8.1.0 || ~8.2.0 || ~8.3.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" + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { - "composer-normalize": { - "indent-size": 2, - "indent-style": "space" + "branch-alias": { + "dev-master": "2.0.x-dev" } }, "autoload": { "psr-4": { - "Ergebnis\\Json\\Pointer\\": "src/" + "Psr\\Http\\Message\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -429,59 +363,51 @@ ], "authors": [ { - "name": "Andreas Möller", - "email": "am@localheinz.com", - "homepage": "https://localheinz.com" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "description": "Provides JSON pointer as a value object.", - "homepage": "https://github.com/ergebnis/json-pointer", + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", "keywords": [ - "RFC6901", - "json", - "pointer" + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" ], "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" + "source": "https://github.com/php-fig/http-message/tree/2.0" }, - "time": "2023-10-10T14:41:06+00:00" + "time": "2023-04-04T09:54:51+00:00" }, { - "name": "ergebnis/json-printer", - "version": "3.4.0", + "name": "psr/log", + "version": "3.0.2", "source": { "type": "git", - "url": "https://github.com/ergebnis/json-printer.git", - "reference": "05841593d72499de4f7ce4034a237c77e470558f" + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/json-printer/zipball/05841593d72499de4f7ce4034a237c77e470558f", - "reference": "05841593d72499de4f7ce4034a237c77e470558f", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", "shasum": "" }, "require": { - "ext-json": "*", - "ext-mbstring": "*", - "php": "~8.1.0 || ~8.2.0 || ~8.3.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" + "php": ">=8.0.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, "autoload": { "psr-4": { - "Ergebnis\\Json\\Printer\\": "src/" + "Psr\\Log\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -490,69 +416,48 @@ ], "authors": [ { - "name": "Andreas Möller", - "email": "am@localheinz.com", - "homepage": "https://localheinz.com" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "description": "Provides a JSON printer, allowing for flexible indentation.", - "homepage": "https://github.com/ergebnis/json-printer", + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", "keywords": [ - "formatter", - "json", - "printer" + "log", + "psr", + "psr-3" ], "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" + "source": "https://github.com/php-fig/log/tree/3.0.2" }, - "time": "2023-10-10T07:42:48+00:00" + "time": "2024-09-11T13:17:53+00:00" }, { - "name": "ergebnis/json-schema-validator", - "version": "4.1.0", + "name": "psr/simple-cache", + "version": "3.0.0", "source": { "type": "git", - "url": "https://github.com/ergebnis/json-schema-validator.git", - "reference": "d568ed85d1cdc2e49d650c2fc234dc2516f3f25b" + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/json-schema-validator/zipball/d568ed85d1cdc2e49d650c2fc234dc2516f3f25b", - "reference": "d568ed85d1cdc2e49d650c2fc234dc2516f3f25b", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", "shasum": "" }, "require": { - "ergebnis/json": "^1.0.1", - "ergebnis/json-pointer": "^3.2.0", - "ext-json": "*", - "justinrainbow/json-schema": "^5.2.12", - "php": "~8.1.0 || ~8.2.0 || ~8.3.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" + "php": ">=8.0.0" }, "type": "library", "extra": { - "composer-normalize": { - "indent-size": 2, - "indent-style": "space" + "branch-alias": { + "dev-master": "3.0.x-dev" } }, "autoload": { "psr-4": { - "Ergebnis\\Json\\SchemaValidator\\": "src/" + "Psr\\SimpleCache\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -561,59 +466,59 @@ ], "authors": [ { - "name": "Andreas Möller", - "email": "am@localheinz.com", - "homepage": "https://localheinz.com" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "description": "Provides a JSON schema validator, building on top of justinrainbow/json-schema.", - "homepage": "https://github.com/ergebnis/json-schema-validator", + "description": "Common interfaces for simple caching", "keywords": [ - "json", - "schema", - "validator" + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" ], "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" + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" }, - "time": "2023-10-10T14:16:57+00:00" + "time": "2021-10-29T13:26:27+00:00" }, { - "name": "justinrainbow/json-schema", - "version": "v5.2.13", + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/justinrainbow/json-schema.git", - "reference": "fbbe7e5d79f618997bc3332a6f49246036c45793" + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/fbbe7e5d79f618997bc3332a6f49246036c45793", - "reference": "fbbe7e5d79f618997bc3332a6f49246036c45793", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": ">=7.2" }, - "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" + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" }, - "bin": [ - "bin/validate-json" - ], "type": "library", "extra": { - "branch-alias": { - "dev-master": "5.0.x-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "JsonSchema\\": "src/JsonSchema/" + "Symfony\\Polyfill\\Ctype\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -622,187 +527,258 @@ ], "authors": [ { - "name": "Bruno Prieto Reis", - "email": "bruno.p.reis@gmail.com" + "name": "Gert de Pagter", + "email": "BackEndTea@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": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "A library to validate a json schema.", - "homepage": "https://github.com/justinrainbow/json-schema", + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", "keywords": [ - "json", - "schema" + "compatibility", + "ctype", + "polyfill", + "portable" ], "support": { - "issues": "https://github.com/justinrainbow/json-schema/issues", - "source": "https://github.com/justinrainbow/json-schema/tree/v5.2.13" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, - "time": "2023-09-26T02:20:38+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": "localheinz/diff", - "version": "1.1.1", + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", "source": { "type": "git", - "url": "https://github.com/localheinz/diff.git", - "reference": "851bb20ea8358c86f677f5f111c4ab031b1c764c" + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/localheinz/diff/zipball/851bb20ea8358c86f677f5f111c4ab031b1c764c", - "reference": "851bb20ea8358c86f677f5f111c4ab031b1c764c", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "ext-iconv": "*", + "php": ">=7.2" }, - "require-dev": { - "phpunit/phpunit": "^7.5 || ^8.0", - "symfony/process": "^4.2 || ^5" + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { - "classmap": [ - "src/" - ] + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } }, "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": "Kore Nordmann", - "email": "mail@kore-nordmann.de" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Fork of sebastian/diff for use with ergebnis/composer-normalize", - "homepage": "https://github.com/localheinz/diff", + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" ], "support": { - "source": "https://github.com/localheinz/diff/tree/main" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2020-07-06T04:49:32+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { - "name": "myclabs/deep-copy", - "version": "1.11.1", + "name": "symfony/polyfill-php80", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c" + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "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", - "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + "php": ">=7.2" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { "files": [ - "src/DeepCopy/deep_copy.php" + "bootstrap.php" ], "psr-4": { - "DeepCopy\\": "src/DeepCopy/" - } + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "Create deep copies (clones) of your objects", + "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": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" + "compatibility", + "polyfill", + "portable", + "shim" ], "support": { - "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.11.1" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "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": "2023-03-08T13:26:56+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { - "name": "nikic/php-parser", - "version": "v4.17.1", + "name": "vlucas/phpdotenv", + "version": "v5.6.3", "source": { "type": "git", - "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d" + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "955e7815d677a3eaa7075231212f2110983adecc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", - "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", "shasum": "" }, "require": { - "ext-tokenizer": "*", - "php": ">=7.0" + "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": { - "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + "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." }, - "bin": [ - "bin/php-parse" - ], "type": "library", "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, "branch-alias": { - "dev-master": "4.9-dev" + "dev-master": "5.6-dev" } }, "autoload": { "psr-4": { - "PhpParser\\": "lib/PhpParser" + "Dotenv\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -811,429 +787,5508 @@ ], "authors": [ { - "name": "Nikita Popov" + "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": "A PHP parser written in PHP", + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", "keywords": [ - "parser", - "php" + "dotenv", + "env", + "environment" ], "support": { - "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.17.1" + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" }, - "time": "2023-08-13T19:53:39+00:00" - }, + "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": [ { - "name": "phar-io/manifest", - "version": "2.0.3", + "name": "amphp/amp", + "version": "v3.1.1", "source": { "type": "git", - "url": "https://github.com/phar-io/manifest.git", - "reference": "97803eca37d319dfa7826cc2437fc020857acb53" + "url": "https://github.com/amphp/amp.git", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53", - "reference": "97803eca37d319dfa7826cc2437fc020857acb53", + "url": "https://api.github.com/repos/amphp/amp/zipball/fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-phar": "*", - "ext-xmlwriter": "*", - "phar-io/version": "^3.0.1", - "php": "^7.2 || ^8.0" + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23.1" }, + "type": "library", "autoload": { - "classmap": [ - "src/" - ] + "files": [ + "src/functions.php", + "src/Future/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" }, { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" } ], - "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", + "keywords": [ + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "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/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v3.1.1" }, - "time": "2021-07-20T11:28:43+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-27T21:42:00+00:00" }, { - "name": "phar-io/version", - "version": "3.2.1", + "name": "amphp/byte-stream", + "version": "v2.1.2", "source": { "type": "git", - "url": "https://github.com/phar-io/version.git", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + "url": "https://github.com/amphp/byte-stream.git", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46", "shasum": "" }, "require": { - "php": "^7.2 || ^8.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": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.22.1" }, "type": "library", "autoload": { - "classmap": [ - "src/" - ] + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\ByteStream\\": "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": "Aaron Piotrowski", + "email": "aaron@trowski.com" }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" + "name": "Niklas Keller", + "email": "me@kelunik.com" } ], - "description": "Library for handling version information and constraints", + "description": "A stream abstraction to make working with non-blocking I/O simple.", + "homepage": "https://amphp.org/byte-stream", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "non-blocking", + "stream" + ], "support": { - "issues": "https://github.com/phar-io/version/issues", - "source": "https://github.com/phar-io/version/tree/3.2.1" + "issues": "https://github.com/amphp/byte-stream/issues", + "source": "https://github.com/amphp/byte-stream/tree/v2.1.2" }, - "time": "2022-02-21T01:04:05+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T17:10:27+00:00" }, { - "name": "phpstan/phpdoc-parser", - "version": "1.24.4", + "name": "amphp/cache", + "version": "v2.0.1", "source": { "type": "git", - "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "6bd0c26f3786cd9b7c359675cb789e35a8e07496" + "url": "https://github.com/amphp/cache.git", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/6bd0c26f3786cd9b7c359675cb789e35a8e07496", - "reference": "6bd0c26f3786cd9b7c359675cb789e35a8e07496", + "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "amphp/amp": "^3", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, "require-dev": { - "doctrine/annotations": "^2.0", - "nikic/php-parser": "^4.15", - "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", - "symfony/process": "^5.2" + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" }, "type": "library", "autoload": { "psr-4": { - "PHPStan\\PhpDocParser\\": [ - "src/" - ] + "Amp\\Cache\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "PHPDoc parser with support for nullable, intersection and generic types", + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A fiber-aware cache API based on Amp and Revolt.", + "homepage": "https://amphp.org/cache", "support": { - "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.24.4" + "issues": "https://github.com/amphp/cache/issues", + "source": "https://github.com/amphp/cache/tree/v2.0.1" }, - "time": "2023-11-26T18:29:22+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:38:06+00:00" }, { - "name": "phpstan/phpstan", - "version": "1.10.46", + "name": "amphp/dns", + "version": "v2.4.0", "source": { "type": "git", - "url": "https://github.com/phpstan/phpstan.git", - "reference": "90d3d25c5b98b8068916bbf08ce42d5cb6c54e70" + "url": "https://github.com/amphp/dns.git", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/90d3d25c5b98b8068916bbf08ce42d5cb6c54e70", - "reference": "90d3d25c5b98b8068916bbf08ce42d5cb6c54e70", + "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", "shasum": "" }, "require": { - "php": "^7.2|^8.0" + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/process": "^2", + "daverandom/libdns": "^2.0.2", + "ext-filter": "*", + "ext-json": "*", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, - "conflict": { - "phpstan/phpstan-shim": "*" + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" }, - "bin": [ - "phpstan", - "phpstan.phar" + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Dns\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "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": "Async DNS resolution for Amp.", + "homepage": "https://github.com/amphp/dns", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "dns", + "resolve" + ], + "support": { + "issues": "https://github.com/amphp/dns/issues", + "source": "https://github.com/amphp/dns/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } ], + "time": "2025-01-19T15:43:40+00:00" + }, + { + "name": "amphp/parallel", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/parallel.git", + "reference": "37f5b2754fadc229c00f9416bd68fb8d04529a81" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parallel/zipball/37f5b2754fadc229c00f9416bd68fb8d04529a81", + "reference": "37f5b2754fadc229c00f9416bd68fb8d04529a81", + "shasum": "" + }, + "require": { + "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": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, "type": "library", "autoload": { "files": [ - "bootstrap.php" - ] + "src/Context/functions.php", + "src/Context/Internal/functions.php", + "src/Ipc/functions.php", + "src/Worker/functions.php" + ], + "psr-4": { + "Amp\\Parallel\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "PHPStan - PHP Static Analysis Tool", + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Parallel processing component for Amp.", + "homepage": "https://github.com/amphp/parallel", "keywords": [ - "dev", - "static analysis" + "async", + "asynchronous", + "concurrent", + "multi-processing", + "multi-threading" + ], + "support": { + "issues": "https://github.com/amphp/parallel/issues", + "source": "https://github.com/amphp/parallel/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-16T16:54:01+00:00" + }, + { + "name": "amphp/parser", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/parser.git", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Parser\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A generator parser to make streaming parsers simple.", + "homepage": "https://github.com/amphp/parser", + "keywords": [ + "async", + "non-blocking", + "parser", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/parser/issues", + "source": "https://github.com/amphp/parser/tree/v1.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:16:53+00:00" + }, + { + "name": "amphp/pipeline", + "version": "v1.2.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/pipeline.git", + "reference": "a044733e080940d1483f56caff0c412ad6982776" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/a044733e080940d1483f56caff0c412ad6982776", + "reference": "a044733e080940d1483f56caff0c412ad6982776", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Pipeline\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Asynchronous iterators and operators.", + "homepage": "https://amphp.org/pipeline", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "iterator", + "non-blocking" + ], + "support": { + "issues": "https://github.com/amphp/pipeline/issues", + "source": "https://github.com/amphp/pipeline/tree/v1.2.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-06T05:37:57+00:00" + }, + { + "name": "amphp/process", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/process.git", + "reference": "583959df17d00304ad7b0b32285373f985935643" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/process/zipball/583959df17d00304ad7b0b32285373f985935643", + "reference": "583959df17d00304ad7b0b32285373f985935643", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Process\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A fiber-aware process manager based on Amp and Revolt.", + "homepage": "https://amphp.org/process", + "support": { + "issues": "https://github.com/amphp/process/issues", + "source": "https://github.com/amphp/process/tree/v2.1.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-05-31T15:11:55+00:00" + }, + { + "name": "amphp/serialization", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/serialization.git", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "ext-json": "*", + "ext-zlib": "*", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Serialization\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Serialization tools for IPC and data storage in PHP.", + "homepage": "https://github.com/amphp/serialization", + "keywords": [ + "async", + "asynchronous", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/amphp/serialization/issues", + "source": "https://github.com/amphp/serialization/tree/v1.1.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-05T15:59:53+00:00" + }, + { + "name": "amphp/socket", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/socket.git", + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/socket/zipball/dadb63c5d3179fd83803e29dfeac27350e619314", + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314", + "shasum": "" + }, + "require": { + "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": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/process": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php", + "src/SocketAddress/functions.php" + ], + "psr-4": { + "Amp\\Socket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", + "homepage": "https://github.com/amphp/socket", + "keywords": [ + "amp", + "async", + "encryption", + "non-blocking", + "sockets", + "tcp", + "tls" + ], + "support": { + "issues": "https://github.com/amphp/socket/issues", + "source": "https://github.com/amphp/socket/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-19T15:09:56+00:00" + }, + { + "name": "amphp/sync", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/sync.git", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Sync\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", + "homepage": "https://github.com/amphp/sync", + "keywords": [ + "async", + "asynchronous", + "mutex", + "semaphore", + "synchronization" + ], + "support": { + "issues": "https://github.com/amphp/sync/issues", + "source": "https://github.com/amphp/sync/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-08-03T19:31:26+00:00" + }, + { + "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": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "croct/coding-standard", + "version": "0.4.5", + "source": { + "type": "git", + "url": "https://github.com/croct-tech/coding-standard-php.git", + "reference": "cdd2d44ac4801137e52d21f27b3be22afe80144a" + }, + "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 + } + ] + }, + "require": { + "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": "^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": { + "Croct\\": "src/Croct/" + } + }, + "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": "Croct", + "email": "lib+coding-standard-php@croct.com", + "homepage": "https://croct.com" + } + ], + "description": "A set of Code Sniffer rules applied to all Croct PHP projects.", + "homepage": "https://github.com/croct-tech/coding-standard-php", + "keywords": [ + "coding", + "croct", + "phpcs", + "standard" + ], + "support": { + "source": "https://github.com/croct-tech/coding-standard-php/tree/0.4.5", + "issues": "https://github.com/croct-tech/coding-standard-php/issues" + }, + "time": "2026-05-06T14:38:44+00:00" + }, + { + "name": "danog/advanced-json-rpc", + "version": "v3.2.3", + "source": { + "type": "git", + "url": "https://github.com/danog/php-advanced-json-rpc.git", + "reference": "ae703ea7b4811797a10590b6078de05b3b33dd91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/danog/php-advanced-json-rpc/zipball/ae703ea7b4811797a10590b6078de05b3b33dd91", + "reference": "ae703ea7b4811797a10590b6078de05b3b33dd91", + "shasum": "" + }, + "require": { + "netresearch/jsonmapper": "^5", + "php": ">=8.1", + "phpdocumentor/reflection-docblock": "^4.3.4 || ^5.0.0 || ^6" + }, + "replace": { + "felixfbecker/php-advanced-json-rpc": "^3" + }, + "require-dev": { + "phpunit/phpunit": "^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "AdvancedJsonRpc\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "ISC" + ], + "authors": [ + { + "name": "Felix Becker", + "email": "felix.b@outlook.com" + }, + { + "name": "Daniil Gentili", + "email": "daniil@daniil.it" + } + ], + "description": "A more advanced JSONRPC implementation", + "support": { + "issues": "https://github.com/danog/php-advanced-json-rpc/issues", + "source": "https://github.com/danog/php-advanced-json-rpc/tree/v3.2.3" + }, + "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" + ], + "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": "dealerdirect/phpcodesniffer-composer-installer", + "version": "v1.2.1", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/composer-installer.git", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.2", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.1.0 || ^4.0" + }, + "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" + }, + "autoload": { + "psr-4": { + "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Franck Nijhof", + "email": "opensource@frenck.dev", + "homepage": "https://frenck.dev", + "role": "Open source developer" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" + } + ], + "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/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/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": "dnoegel/php-xdg-base-dir", + "version": "v0.1.1", + "source": { + "type": "git", + "url": "https://github.com/dnoegel/php-xdg-base-dir.git", + "reference": "8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dnoegel/php-xdg-base-dir/zipball/8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd", + "reference": "8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "require-dev": { + "phpunit/phpunit": "~7.0|~6.0|~5.0|~4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "XdgBaseDir\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "implementation of xdg base directory specification for php", + "support": { + "issues": "https://github.com/dnoegel/php-xdg-base-dir/issues", + "source": "https://github.com/dnoegel/php-xdg-base-dir/tree/v0.1.1" + }, + "time": "2019-12-04T15:06:13+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "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": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "ergebnis/composer-normalize", + "version": "2.52.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/composer-normalize.git", + "reference": "988f83f5e51a42cdd2337e5fcd935432f8dfa33c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/composer-normalize/zipball/988f83f5e51a42cdd2337e5fcd935432f8dfa33c", + "reference": "988f83f5e51a42cdd2337e5fcd935432f8dfa33c", + "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", + "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": { + "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" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Composer\\Normalize\\": "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 composer plugin for normalizing composer.json.", + "homepage": "https://github.com/ergebnis/composer-normalize", + "keywords": [ + "composer", + "normalize", + "normalizer", + "plugin" + ], + "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" + }, + "time": "2026-05-15T15:39:24+00:00" + }, + { + "name": "ergebnis/json", + "version": "1.6.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/json.git", + "reference": "7b56d2b5d9e897e75b43e2e753075a0904c921b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/json/zipball/7b56d2b5d9e897e75b43e2e753075a0904c921b1", + "reference": "7b56d2b5d9e897e75b43e2e753075a0904c921b1", + "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" + }, + "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" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.7-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\Json\\": "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 value object for representing a valid JSON string.", + "homepage": "https://github.com/ergebnis/json", + "keywords": [ + "json" + ], + "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" + }, + "time": "2025-09-06T09:08:45+00:00" + }, + { + "name": "ergebnis/json-normalizer", + "version": "4.10.1", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/json-normalizer.git", + "reference": "77961faf2c651c3f05977b53c6c68e8434febf62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/json-normalizer/zipball/77961faf2c651c3f05977b53c6c68e8434febf62", + "reference": "77961faf2c651c3f05977b53c6c68e8434febf62", + "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" + }, + "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" + }, + "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/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides generic and vendor-specific normalizers for normalizing JSON documents.", + "homepage": "https://github.com/ergebnis/json-normalizer", + "keywords": [ + "json", + "normalizer" + ], + "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" + }, + "time": "2025-09-06T09:18:13+00:00" + }, + { + "name": "ergebnis/json-pointer", + "version": "3.8.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/json-pointer.git", + "reference": "b58c3c468a7ff109fdf9a255f17de29ecbe5276c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/json-pointer/zipball/b58c3c468a7ff109fdf9a255f17de29ecbe5276c", + "reference": "b58c3c468a7ff109fdf9a255f17de29ecbe5276c", + "shasum": "" + }, + "require": { + "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.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": { + "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/", + "license": [ + "MIT" + ], + "authors": [ + { + "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" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "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", + "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": "revolt/event-loop", + "version": "v1.0.9", + "source": { + "type": "git", + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "44061cf513e53c6200372fc935ac42271566295d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/44061cf513e53c6200372fc935ac42271566295d", + "reference": "44061cf513e53c6200372fc935ac42271566295d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Revolt\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "ceesjank@gmail.com" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Rock-solid event loop for concurrent PHP applications.", + "keywords": [ + "async", + "asynchronous", + "concurrency", + "event", + "event-loop", + "non-blocking", + "scheduler" + ], + "support": { + "issues": "https://github.com/revoltphp/event-loop/issues", + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.9" + }, + "time": "2026-05-16T17:55:38+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "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" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "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.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" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "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" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/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": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "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": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "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" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/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": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "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" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" ], "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/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" }, "funding": [ { - "url": "https://github.com/ondrejmirtes", - "type": "github" - }, - { - "url": "https://github.com/phpstan", + "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpstan/phpstan", - "type": "tidelift" } ], - "time": "2023-11-28T14:57:26+00:00" + "time": "2024-07-03T04:57:36+00:00" }, { - "name": "phpstan/phpstan-deprecation-rules", - "version": "1.1.4", + "name": "sebastian/lines-of-code", + "version": "3.0.1", "source": { "type": "git", - "url": "https://github.com/phpstan/phpstan-deprecation-rules.git", - "reference": "089d8a8258ed0aeefdc7b68b6c3d25572ebfdbaa" + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/089d8a8258ed0aeefdc7b68b6c3d25572ebfdbaa", - "reference": "089d8a8258ed0aeefdc7b68b6c3d25572ebfdbaa", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0", - "phpstan/phpstan": "^1.10.3" + "nikic/php-parser": "^5.0", + "php": ">=8.2" }, "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" + "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": "PHPStan rules for detecting usage of deprecated classes, methods, properties, constants and traits.", + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { - "issues": "https://github.com/phpstan/phpstan-deprecation-rules/issues", - "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/1.1.4" + "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" }, - "time": "2023-08-05T09:02:04+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" }, { - "name": "phpstan/phpstan-phpunit", - "version": "1.3.15", + "name": "sebastian/object-enumerator", + "version": "6.0.1", "source": { "type": "git", - "url": "https://github.com/phpstan/phpstan-phpunit.git", - "reference": "70ecacc64fe8090d8d2a33db5a51fe8e88acd93a" + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/70ecacc64fe8090d8d2a33db5a51fe8e88acd93a", - "reference": "70ecacc64fe8090d8d2a33db5a51fe8e88acd93a", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0", - "phpstan/phpstan": "^1.10" - }, - "conflict": { - "phpunit/phpunit": "<7.0" + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" }, "require-dev": { - "nikic/php-parser": "^4.13.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/phpstan-strict-rules": "^1.5.1", - "phpunit/phpunit": "^9.5" + "phpunit/phpunit": "^11.0" }, - "type": "phpstan-extension", + "type": "library", "extra": { - "phpstan": { - "includes": [ - "extension.neon", - "rules.neon" - ] + "branch-alias": { + "dev-main": "6.0-dev" } }, "autoload": { - "psr-4": { - "PHPStan\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], - "description": "PHPUnit extensions and rules for PHPStan", + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { - "issues": "https://github.com/phpstan/phpstan-phpunit/issues", - "source": "https://github.com/phpstan/phpstan-phpunit/tree/1.3.15" + "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" }, - "time": "2023-10-09T18:58:39+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" }, { - "name": "phpstan/phpstan-strict-rules", - "version": "1.5.2", + "name": "sebastian/object-reflector", + "version": "4.0.1", "source": { "type": "git", - "url": "https://github.com/phpstan/phpstan-strict-rules.git", - "reference": "7a50e9662ee9f3942e4aaaf3d603653f60282542" + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/7a50e9662ee9f3942e4aaaf3d603653f60282542", - "reference": "7a50e9662ee9f3942e4aaaf3d603653f60282542", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0", - "phpstan/phpstan": "^1.10.34" + "php": ">=8.2" }, "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" + "phpunit/phpunit": "^11.0" }, - "type": "phpstan-extension", + "type": "library", "extra": { - "phpstan": { - "includes": [ - "rules.neon" - ] + "branch-alias": { + "dev-main": "4.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", + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { - "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", - "source": "https://github.com/phpstan/phpstan-strict-rules/tree/1.5.2" + "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" }, - "time": "2023-10-30T14:35:06+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" }, { - "name": "phpunit/php-code-coverage", - "version": "10.1.9", + "name": "sebastian/recursion-context", + "version": "6.0.3", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "a56a9ab2f680246adcf3db43f38ddf1765774735" + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/a56a9ab2f680246adcf3db43f38ddf1765774735", - "reference": "a56a9ab2f680246adcf3db43f38ddf1765774735", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", "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" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.1" - }, - "suggest": { - "ext-pcov": "PHP extension that provides line coverage", - "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "10.1-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -1248,54 +6303,68 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" } ], - "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": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", "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" + "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": [ { "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-11-23T12:23:20+00:00" + "time": "2025-08-13T04:42:22+00:00" }, { - "name": "phpunit/php-file-iterator", - "version": "4.1.0", + "name": "sebastian/type", + "version": "5.1.3", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", - "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "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": { @@ -1314,53 +6383,54 @@ "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" - ], + "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/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" + "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": "2023-08-31T06:24:48+00:00" + "time": "2025-08-09T06:55:48+00:00" }, { - "name": "phpunit/php-invoker", - "version": "4.0.0", + "name": "sebastian/version", + "version": "5.0.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", - "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", "shasum": "" }, "require": { - "php": ">=8.1" - }, - "require-dev": { - "ext-pcntl": "*", - "phpunit/phpunit": "^10.0" - }, - "suggest": { - "ext-pcntl": "*" + "php": ">=8.2" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -1379,14 +6449,12 @@ "role": "lead" } ], - "description": "Invoke callables with a timeout", - "homepage": "https://github.com/sebastianbergmann/php-invoker/", - "keywords": [ - "process" - ], + "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/php-invoker/issues", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + "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": [ { @@ -1394,1324 +6462,1693 @@ "type": "github" } ], - "time": "2023-02-03T06:56:09+00:00" + "time": "2024-10-09T05:16:32+00:00" }, { - "name": "phpunit/php-text-template", - "version": "3.0.1", + "name": "slevomat/coding-standard", + "version": "8.29.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + "url": "https://github.com/slevomat/coding-standard.git", + "reference": "81fce13c4ef4b53a03e5cfa6ce36afc191c1598e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", - "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/81fce13c4ef4b53a03e5cfa6ce36afc191c1598e", + "reference": "81fce13c4ef4b53a03e5cfa6ce36afc191c1598e", "shasum": "" }, "require": { - "php": ">=8.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": { - "phpunit/phpunit": "^10.0" + "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": "3.0-dev" + "dev-master": "8.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" + "autoload": { + "psr-4": { + "SlevomatCodingStandard\\": "SlevomatCodingStandard/" } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" ], - "description": "Simple template engine.", - "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "description": "Slevomat Coding Standard for PHP_CodeSniffer complements Consistence Coding Standard by providing sniffs with additional checks.", "keywords": [ - "template" + "dev", + "phpcs" ], "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" + "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://tidelift.com/funding/github/packagist/slevomat/coding-standard", + "type": "tidelift" } ], - "time": "2023-08-31T14:07:24+00:00" + "time": "2026-05-07T05:48:08+00:00" }, { - "name": "phpunit/php-timer", - "version": "6.0.0", + "name": "spatie/array-to-xml", + "version": "3.4.4", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + "url": "https://github.com/spatie/array-to-xml.git", + "reference": "88b2f3852a922dd73177a68938f8eb2ec70c7224" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", - "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "url": "https://api.github.com/repos/spatie/array-to-xml/zipball/88b2f3852a922dd73177a68938f8eb2ec70c7224", + "reference": "88b2f3852a922dd73177a68938f8eb2ec70c7224", "shasum": "" }, "require": { - "php": ">=8.1" + "ext-dom": "*", + "php": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "mockery/mockery": "^1.2", + "pestphp/pest": "^1.21", + "spatie/pest-plugin-snapshots": "^1.1" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-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", - "role": "lead" + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://freek.dev", + "role": "Developer" } ], - "description": "Utility class for timing", - "homepage": "https://github.com/sebastianbergmann/php-timer/", + "description": "Convert an array to xml", + "homepage": "https://github.com/spatie/array-to-xml", "keywords": [ - "timer" + "array", + "convert", + "xml" ], "support": { - "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + "source": "https://github.com/spatie/array-to-xml/tree/3.4.4" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", "type": "github" } ], - "time": "2023-02-03T06:57:52+00:00" + "time": "2025-12-15T09:00:41+00:00" }, { - "name": "phpunit/phpunit", - "version": "10.4.2", + "name": "squizlabs/php_codesniffer", + "version": "4.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "cacd8b9dd224efa8eb28beb69004126c7ca1a1a1" + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "0525c73950de35ded110cffafb9892946d7771b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/cacd8b9dd224efa8eb28beb69004126c7ca1a1a1", - "reference": "cacd8b9dd224efa8eb28beb69004126c7ca1a1a1", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0525c73950de35ded110cffafb9892946d7771b5", + "reference": "0525c73950de35ded110cffafb9892946d7771b5", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-json": "*", - "ext-libxml": "*", - "ext-mbstring": "*", - "ext-xml": "*", + "ext-simplexml": "*", + "ext-tokenizer": "*", "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" + "php": ">=7.2.0" }, - "suggest": { - "ext-soap": "To be able to generate mocks based on WSDL files" + "require-dev": { + "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31" }, "bin": [ - "phpunit" + "bin/phpcbf", + "bin/phpcs" ], "type": "library", - "extra": { - "branch-alias": { - "dev-main": "10.4-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" + "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": "The PHP Unit Testing framework.", - "homepage": "https://phpunit.de/", + "description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", "keywords": [ - "phpunit", - "testing", - "xunit" + "phpcs", + "standards", + "static analysis" ], "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" + "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://phpunit.de/sponsors.html", - "type": "custom" + "url": "https://github.com/PHPCSStandards", + "type": "github" }, { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/jrfnl", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" } ], - "time": "2023-10-26T07:21:45+00:00" + "time": "2025-11-10T16:43:36+00:00" }, { - "name": "sebastian/cli-parser", - "version": "2.0.0", + "name": "staabm/side-effects-detector", + "version": "1.0.5", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "efdc130dbbbb8ef0b545a994fd811725c5282cae" + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/efdc130dbbbb8ef0b545a994fd811725c5282cae", - "reference": "efdc130dbbbb8ef0b545a994fd811725c5282cae", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", "shasum": "" }, "require": { - "php": ">=8.1" + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^10.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": "2.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 parsing CLI options", - "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" + "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": "2023-02-03T06:58:15+00:00" + "time": "2024-10-20T05:08:20+00:00" }, { - "name": "sebastian/code-unit", - "version": "2.0.0", + "name": "symfony/cache", + "version": "v7.4.13", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + "url": "https://github.com/symfony/cache.git", + "reference": "4c09e18a92cce126cc0d1155825279fca8cd0673" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", - "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "url": "https://api.github.com/repos/symfony/cache/zipball/4c09e18a92cce126cc0d1155825279fca8cd0673", + "reference": "4c09e18a92cce126cc0d1155825279fca8cd0673", "shasum": "" }, "require": { - "php": ">=8.1" + "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": { - "phpunit/phpunit": "^10.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": "2.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", - "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 PHP code units", - "homepage": "https://github.com/sebastianbergmann/code-unit", + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], "support": { - "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" + "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": "2023-02-03T06:58:43+00:00" + "time": "2026-05-24T08:43:14+00:00" }, { - "name": "sebastian/code-unit-reverse-lookup", - "version": "3.0.0", + "name": "symfony/cache-contracts", + "version": "v3.7.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "225e8a254166bd3442e370c6f50145465db63831" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", - "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/225e8a254166bd3442e370c6f50145465db63831", + "reference": "225e8a254166bd3442e370c6f50145465db63831", "shasum": "" }, "require": { - "php": ">=8.1" - }, - "require-dev": { - "phpunit/phpunit": "^10.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": "3.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": "Looks up which function or method a line of code belongs to", - "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], "support": { - "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" + "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": "2023-02-03T06:59:15+00:00" + "time": "2026-05-05T15:33:14+00:00" }, { - "name": "sebastian/comparator", - "version": "5.0.1", + "name": "symfony/console", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "2db5010a484d53ebf536087a70b4a5423c102372" + "url": "https://github.com/symfony/console.git", + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2db5010a484d53ebf536087a70b4a5423c102372", - "reference": "2db5010a484d53ebf536087a70b4a5423c102372", + "url": "https://api.github.com/repos/symfony/console/zipball/f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-mbstring": "*", - "php": ">=8.1", - "sebastian/diff": "^5.0", - "sebastian/exporter": "^5.0" + "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": "^10.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": "5.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": "Volker Dusch", - "email": "github@wallbash.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { - "name": "Bernhard Schussek", - "email": "bschussek@2bepublished.at" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Provides the functionality to compare PHP values for equality", - "homepage": "https://github.com/sebastianbergmann/comparator", + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", "keywords": [ - "comparator", - "compare", - "equality" + "cli", + "command-line", + "console", + "terminal" ], "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/symfony/console/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": "2023-08-14T13:18:12+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "sebastian/complexity", - "version": "3.1.0", + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "68cfb347a44871f01e33ab0ef8215966432f6957" + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68cfb347a44871f01e33ab0ef8215966432f6957", - "reference": "68cfb347a44871f01e33ab0ef8215966432f6957", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { - "nikic/php-parser": "^4.10", "php": ">=8.1" }, - "require-dev": { - "phpunit/phpunit": "^10.0" - }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { - "dev-main": "3.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": "Library for calculating the complexity of PHP code units", - "homepage": "https://github.com/sebastianbergmann/complexity", + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", "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/symfony/deprecation-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": "2023-09-28T11:50:59+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { - "name": "sebastian/diff", - "version": "5.0.3", + "name": "symfony/filesystem", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "912dc2fbe3e3c1e7873313cc801b100b6c68c87b" + "url": "https://github.com/symfony/filesystem.git", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/912dc2fbe3e3c1e7873313cc801b100b6c68c87b", - "reference": "912dc2fbe3e3c1e7873313cc801b100b6c68c87b", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2", + "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "phpunit/phpunit": "^10.0", - "symfony/process": "^4.2 || ^5" + "symfony/process": "^7.4|^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "5.0-dev" - } - }, "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" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Diff implementation", - "homepage": "https://github.com/sebastianbergmann/diff", - "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" - ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", "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/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": "2023-05-01T07:48:21+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "sebastian/environment", - "version": "6.0.1", + "name": "symfony/options-resolver", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "43c751b41d74f96cbbd4e07b7aec9675651e2951" + "url": "https://github.com/symfony/options-resolver.git", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/43c751b41d74f96cbbd4e07b7aec9675651e2951", - "reference": "43c751b41d74f96cbbd4e07b7aec9675651e2951", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82", + "reference": "88f9c561f678a02d54b897014049fa839e33ff82", "shasum": "" }, "require": { - "php": ">=8.1" - }, - "require-dev": { - "phpunit/phpunit": "^10.0" - }, - "suggest": { - "ext-posix": "*" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "6.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "https://github.com/sebastianbergmann/environment", + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", "keywords": [ - "Xdebug", - "environment", - "hhvm" + "config", + "configuration", + "options" ], "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/symfony/options-resolver/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": "2023-04-11T05:39:26+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "sebastian/exporter", - "version": "5.1.1", + "name": "symfony/polyfill-deepclone", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "64f51654862e0f5e318db7e9dcc2292c63cdbddc" + "url": "https://github.com/symfony/polyfill-deepclone.git", + "reference": "2ca9e9e75ead5174f2b44613a646bdc9338b8eb4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/64f51654862e0f5e318db7e9dcc2292c63cdbddc", - "reference": "64f51654862e0f5e318db7e9dcc2292c63cdbddc", + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/2ca9e9e75ead5174f2b44613a646bdc9338b8eb4", + "reference": "2ca9e9e75ead5174f2b44613a646bdc9338b8eb4", "shasum": "" }, "require": { - "ext-mbstring": "*", - "php": ">=8.1", - "sebastian/recursion-context": "^5.0" + "php": ">=8.1" }, - "require-dev": { - "phpunit/phpunit": "^10.0" + "provide": { + "ext-deepclone": "*" + }, + "suggest": { + "ext-deepclone": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "5.1-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\DeepClone\\": "" + }, "classmap": [ - "src/" + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, - { - "name": "Adam Harvey", - "email": "aharvey@php.net" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Provides the functionality to export PHP variables for visualization", - "homepage": "https://www.github.com/sebastianbergmann/exporter", + "description": "Symfony polyfill for the deepclone extension", + "homepage": "https://symfony.com", "keywords": [ - "export", - "exporter" + "compatibility", + "deepclone", + "polyfill", + "portable", + "shim" ], "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/symfony/polyfill-deepclone/tree/v1.37.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": "2023-09-24T13:22:09+00:00" + "time": "2026-04-26T13:03:27+00:00" }, { - "name": "sebastian/global-state", - "version": "6.0.1", + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "7ea9ead78f6d380d2a667864c132c2f7b83055e4" + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/7ea9ead78f6d380d2a667864c132c2f7b83055e4", - "reference": "7ea9ead78f6d380d2a667864c132c2f7b83055e4", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { - "php": ">=8.1", - "sebastian/object-reflector": "^3.0", - "sebastian/recursion-context": "^5.0" + "php": ">=7.2" }, - "require-dev": { - "ext-dom": "*", - "phpunit/phpunit": "^10.0" + "suggest": { + "ext-intl": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "6.0-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { - "classmap": [ - "src/" - ] + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } }, "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": "Snapshotting of global state", - "homepage": "http://www.github.com/sebastianbergmann/global-state", + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", "keywords": [ - "global state" + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" ], "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/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2023-07-19T07:19:23+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { - "name": "sebastian/lines-of-code", - "version": "2.0.1", + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "649e40d279e243d985aa8fb6e74dd5bb28dc185d" + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/649e40d279e243d985aa8fb6e74dd5bb28dc185d", - "reference": "649e40d279e243d985aa8fb6e74dd5bb28dc185d", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { - "nikic/php-parser": "^4.10", - "php": ">=8.1" + "php": ">=7.2" }, - "require-dev": { - "phpunit/phpunit": "^10.0" + "suggest": { + "ext-intl": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "2.0-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, "classmap": [ - "src/" + "Resources/stubs" ] }, "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": "Library for counting the lines of code in PHP source code", - "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], "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/symfony/polyfill-intl-normalizer/tree/v1.38.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": "2023-08-31T09:25:50+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { - "name": "sebastian/object-enumerator", - "version": "5.0.0", + "name": "symfony/polyfill-php84", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", - "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { - "php": ">=8.1", - "sebastian/object-reflector": "^3.0", - "sebastian/recursion-context": "^5.0" - }, - "require-dev": { - "phpunit/phpunit": "^10.0" + "php": ">=7.2" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "5.0-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, "classmap": [ - "src/" + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Traverses array structures and object graphs to enumerate all referenced objects", - "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], "support": { - "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2023-02-03T07:08:32+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { - "name": "sebastian/object-reflector", - "version": "3.0.0", + "name": "symfony/polyfill-php85", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", - "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", "shasum": "" }, "require": { - "php": ">=8.1" - }, - "require-dev": { - "phpunit/phpunit": "^10.0" + "php": ">=7.2" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.0-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, "classmap": [ - "src/" + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Allows reflection of object attributes, including inherited and non-public ones", - "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], "support": { - "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2023-02-03T07:06:18+00:00" + "time": "2026-05-26T02:25:22+00:00" }, { - "name": "sebastian/recursion-context", - "version": "5.0.0", + "name": "symfony/service-contracts", + "version": "v3.7.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "05909fb5bc7df4c52992396d0116aed689f93712" + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/05909fb5bc7df4c52992396d0116aed689f93712", - "reference": "05909fb5bc7df4c52992396d0116aed689f93712", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" }, - "require-dev": { - "phpunit/phpunit": "^10.0" + "conflict": { + "ext-psr": "<1.1|>=2" }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "3.7-dev" } }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { - "name": "Adam Harvey", - "email": "aharvey@php.net" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Provides functionality to recursively process PHP variables", - "homepage": "https://github.com/sebastianbergmann/recursion-context", + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], "support": { - "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.0" + "source": "https://github.com/symfony/service-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": "2023-02-03T07:05:40+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { - "name": "sebastian/type", - "version": "4.0.0", + "name": "symfony/string", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/type.git", - "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" + "url": "https://github.com/symfony/string.git", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", - "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "4.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Collection of value objects that represent the types of the PHP type system", - "homepage": "https://github.com/sebastianbergmann/type", + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], "support": { - "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" + "source": "https://github.com/symfony/string/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": "2023-02-03T07:10:45+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "sebastian/version", - "version": "4.0.1", + "name": "symfony/var-exporter", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" + "url": "https://github.com/symfony/var-exporter.git", + "reference": "2dd18582c5f6c024db9fc0ff9c76d873af726f34" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", - "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/2dd18582c5f6c024db9fc0ff9c76d873af726f34", + "reference": "2dd18582c5f6c024db9fc0ff9c76d873af726f34", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-deepclone": "^1.37" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "4.0-dev" - } + "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": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Library that helps with managing the version number of Git-hosted PHP projects", - "homepage": "https://github.com/sebastianbergmann/version", + "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": { - "issues": "https://github.com/sebastianbergmann/version/issues", - "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" + "source": "https://github.com/symfony/var-exporter/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": "2023-02-07T11:34:05+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "slevomat/coding-standard", - "version": "8.14.1", + "name": "theseer/tokenizer", + "version": "1.3.1", "source": { "type": "git", - "url": "https://github.com/slevomat/coding-standard.git", - "reference": "fea1fd6f137cc84f9cba0ae30d549615dbc6a926" + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/fea1fd6f137cc84f9cba0ae30d549615dbc6a926", - "reference": "fea1fd6f137cc84f9cba0ae30d549615dbc6a926", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "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" - }, - "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" - }, - "type": "phpcodesniffer-standard", - "extra": { - "branch-alias": { - "dev-master": "8.x-dev" - } + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" }, + "type": "library", "autoload": { - "psr-4": { - "SlevomatCodingStandard\\": "SlevomatCodingStandard/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], - "description": "Slevomat Coding Standard for PHP_CodeSniffer complements Consistence Coding Standard by providing sniffs with additional checks.", - "keywords": [ - "dev", - "phpcs" + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { - "issues": "https://github.com/slevomat/coding-standard/issues", - "source": "https://github.com/slevomat/coding-standard/tree/8.14.1" + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { - "url": "https://github.com/kukulich", + "url": "https://github.com/theseer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/slevomat/coding-standard", - "type": "tidelift" } ], - "time": "2023-10-08T07:28:08+00:00" + "time": "2025-11-17T20:03:58+00:00" }, { - "name": "squizlabs/php_codesniffer", - "version": "3.7.2", + "name": "vimeo/psalm", + "version": "6.16.1", "source": { "type": "git", - "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", - "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879" + "url": "https://github.com/vimeo/psalm.git", + "reference": "f1f5de594dc76faf8784e02d3dc4716c91c6f6ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/ed8e00df0a83aa96acf703f8c2979ff33341f879", - "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879", + "url": "https://api.github.com/repos/vimeo/psalm/zipball/f1f5de594dc76faf8784e02d3dc4716c91c6f6ac", + "reference": "f1f5de594dc76faf8784e02d3dc4716c91c6f6ac", "shasum": "" }, "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/parallel": "^2.3", + "composer-runtime-api": "^2", + "composer/semver": "^1.4 || ^2.0 || ^3.0", + "composer/xdebug-handler": "^2.0 || ^3.0", + "danog/advanced-json-rpc": "^3.1", + "dnoegel/php-xdg-base-dir": "^0.1.1", + "ext-ctype": "*", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", "ext-simplexml": "*", "ext-tokenizer": "*", - "ext-xmlwriter": "*", - "php": ">=5.4.0" + "felixfbecker/language-server-protocol": "^1.5.3", + "fidry/cpu-core-counter": "^0.4.1 || ^0.5.1 || ^1.0.0", + "netresearch/jsonmapper": "^5.0", + "nikic/php-parser": "^5.0.0", + "php": "~8.1.31 || ~8.2.27 || ~8.3.16 || ~8.4.3 || ~8.5.0", + "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0", + "spatie/array-to-xml": "^2.17.0 || ^3.0", + "symfony/console": "^6.0 || ^7.0 || ^8.0", + "symfony/filesystem": "~6.3.12 || ~6.4.3 || ^7.0.3 || ^8.0", + "symfony/polyfill-php84": "^1.31.0" + }, + "provide": { + "psalm/psalm": "self.version" }, "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0" + "amphp/phpunit-util": "^3", + "bamarni/composer-bin-plugin": "^1.4", + "brianium/paratest": "^6.9", + "danog/class-finder": "^0.4.8", + "dg/bypass-finals": "^1.5", + "ext-curl": "*", + "mockery/mockery": "^1.5", + "nunomaduro/mock-final-classes": "^1.1", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpdoc-parser": "^1.6", + "phpunit/phpunit": "^9.6", + "psalm/plugin-mockery": "^1.1", + "psalm/plugin-phpunit": "^0.19", + "slevomat/coding-standard": "^8.4", + "squizlabs/php_codesniffer": "^3.6", + "symfony/process": "^6.0 || ^7.0 || ^8.0" + }, + "suggest": { + "ext-curl": "In order to send data to shepherd", + "ext-igbinary": "^2.0.5 is required, used to serialize caching data" }, "bin": [ - "bin/phpcs", - "bin/phpcbf" - ], - "type": "library", + "psalm", + "psalm-language-server", + "psalm-plugin", + "psalm-refactor", + "psalm-review", + "psalter" + ], + "type": "project", "extra": { "branch-alias": { - "dev-master": "3.x-dev" + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev", + "dev-3.x": "3.x-dev", + "dev-4.x": "4.x-dev", + "dev-5.x": "5.x-dev", + "dev-6.x": "6.x-dev", + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psalm\\": "src/Psalm/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Greg Sherwood", - "role": "lead" + "name": "Matthew Brown" + }, + { + "name": "Daniil Gentili", + "email": "daniil@daniil.it" } ], - "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", - "homepage": "https://github.com/squizlabs/PHP_CodeSniffer", + "description": "A static analysis tool for finding errors in PHP applications", "keywords": [ - "phpcs", - "standards", + "code", + "inspection", + "php", "static analysis" ], "support": { - "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues", - "source": "https://github.com/squizlabs/PHP_CodeSniffer", - "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" + "docs": "https://psalm.dev/docs", + "issues": "https://github.com/vimeo/psalm/issues", + "source": "https://github.com/vimeo/psalm" }, - "time": "2023-02-22T23:07:41+00:00" + "time": "2026-03-19T10:56:09+00:00" }, { - "name": "theseer/tokenizer", - "version": "1.2.2", + "name": "webmozart/assert", + "version": "2.4.0", "source": { "type": "git", - "url": "https://github.com/theseer/tokenizer.git", - "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96" + "url": "https://github.com/webmozarts/assert.git", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b2ad5003ca10d4ee50a12da31de12a5774ba6b96", - "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-tokenizer": "*", - "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" }, "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Webmozart\\Assert\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], - "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], "support": { - "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.2" + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.0" }, - "funding": [ - { - "url": "https://github.com/theseer", - "type": "github" - } - ], - "time": "2023-11-20T00:12:19+00:00" + "time": "2026-05-20T13:07:01+00:00" } ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.0" + "php": "^8.2", + "ext-hash": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "composer-runtime-api": "^2.0" + }, + "platform-dev": { + "ext-simplexml": "*" }, - "platform-dev": [], - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/extension.neon b/extension.neon new file mode 100644 index 0000000..f546dcb --- /dev/null +++ b/extension.neon @@ -0,0 +1,7 @@ +services: + - + class: Croct\Plug\PhpStan\ContentStubFilesExtension + arguments: + workingDirectory: %currentWorkingDirectory% + tags: + - phpstan.stubFilesExtension diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 0200aa9..1caf8b3 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,23 @@ - + + + + + + + + src/ApiKey.php + + + + + tests/Fixtures/VirtualFilesystem.php + src tests diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 57b48b4..a8a3cb0 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -10,13 +10,15 @@ parameters: uncheckedExceptionClasses: - LogicException - ArithmeticError + - Random\RandomException + - JsonException + - Psr\SimpleCache\InvalidArgumentException 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..386aa84 --- /dev/null +++ b/src/ApiClient.php @@ -0,0 +1,26 @@ + $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, array $headers = []): 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..b3c8b9c --- /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 getSlotContent(string $id, ?string $language = null): ?array + { + return $this->content[$id] ?? null; + } +} diff --git a/src/Content/ContentProvider.php b/src/Content/ContentProvider.php new file mode 100644 index 0000000..09c850c --- /dev/null +++ b/src/Content/ContentProvider.php @@ -0,0 +1,21 @@ +|null The content of the slot, or null when none is available. + */ + public function getSlotContent(string $id, ?string $language = null): ?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 @@ +>> */ + 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. + * + * Reads from the given project directory, defaulting to the root package's + * install path. Returns null when the content files cannot be located, so the + * caller can fall back to another provider. + */ + public static function fromProject(?string $projectDirectory = null): ?self + { + $root = $projectDirectory ?? self::getProjectDirectory(); + + $configuration = self::readJson($root . \DIRECTORY_SEPARATOR . self::CONFIG_FILE); + + if ($configuration === null) { + return null; + } + + $content = self::readJson( + $root + . \DIRECTORY_SEPARATOR + . self::getContentDirectory($configuration) + . \DIRECTORY_SEPARATOR + . self::CONTENT_FILE, + ); + + if ($content === null) { + return null; + } + + return new self(self::resolve($content), self::getDefaultLocale($configuration)); + } + + /** + * @return array|null + */ + public function getSlotContent(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; + } + + /** + * @param array $content + * + * @return array>> + */ + private static function resolve(array $content): array + { + $resolved = []; + + foreach ($content as $id => $versions) { + if (!\is_array($versions)) { + continue; + } + + $localized = self::getLatestContent($versions); + + if ($localized === null) { + continue; + } + + $byLocale = self::filterLocalized($localized); + + if ($byLocale !== []) { + $resolved[(string) $id] = $byLocale; + } + } + + 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; + } + + /** + * Keeps only the locales mapped to a content object, keyed by locale. + * + * @param array $localized + * + * @return array> + */ + private static function filterLocalized(array $localized): array + { + $result = []; + + foreach ($localized as $locale => $value) { + if (\is_array($value)) { + /** @var array $content */ + $content = $value; + $result[(string) $locale] = $content; + } + } + + return $result; + } + + /** + * @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/Content/ExperienceMetadata.php b/src/Content/ExperienceMetadata.php new file mode 100644 index 0000000..8722bf9 --- /dev/null +++ b/src/Content/ExperienceMetadata.php @@ -0,0 +1,82 @@ +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..540c038 --- /dev/null +++ b/src/Content/NullContentProvider.php @@ -0,0 +1,19 @@ +|null + */ + public function getSlotContent(string $id, ?string $language = null): ?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..1995899 --- /dev/null +++ b/src/ContentFetcher.php @@ -0,0 +1,31 @@ +|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/Cookie.php b/src/Cookie.php new file mode 100644 index 0000000..e1c6f4d --- /dev/null +++ b/src/Cookie.php @@ -0,0 +1,155 @@ +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..cb651e4 --- /dev/null +++ b/src/CookieStorage.php @@ -0,0 +1,231 @@ +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); + } + + /** + * 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. + */ + 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..72ac550 --- /dev/null +++ b/src/Croct.php @@ -0,0 +1,322 @@ +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, + ?IdentityResolver $identity = null, + ?string $baseEndpointUrl = null, + 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(); + $baseEndpointUrl ??= self::DEFAULT_BASE_ENDPOINT_URL; + + $session = new Session($appId, $key, $storage, $tokenDuration, identity: $identity); + + 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, + ); + + $cookieConfiguration = $storage instanceof CookieStorage + ? $storage->getConfiguration() + : new CookieConfiguration(); + + return new self( + $appId, + $session, + new HttpEvaluator($client, $context, $session), + new HttpContentFetcher( + $client, + $context, + $session, + $contentProvider ?? self::discoverContentProvider(), + ), + $cookieConfiguration, + ); + } + + /** + * 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 = null): self + { + return self::build(self::getEnv(...), $storage ?? CookieStorage::global()); + } + + /** + * 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; + } + + $values = Dotenv::createArrayBacked($directory)->safeLoad(); + + $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); + } + + /** + * 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 + { + return $this->evaluator->evaluate($query, $options); + } + + /** + * Fetches the personalized content of a slot. + * + * @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 + { + 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, + 'disableCidMirroring' => true, + 'cookie' => $this->cookieConfiguration->toBrowserCookies(), + ]; + } + + /** + * 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 = $_ENV[$name] ?? $_SERVER[$name] ?? \getenv($name); + + return \is_string($value) && $value !== '' ? $value : null; + } + + /** + * Discovers the content provider for the project. + * + * Prefers the content committed by the CLI as `slots.json`, falling back to a + * null provider when none is available. + */ + private static function discoverContentProvider(): ContentProvider + { + /** @var ContentProvider|null $provider **/ + static $provider = null; + + if ($provider === null) { + $provider = DefaultContentProvider::fromProject() ?? new NullContentProvider(); + } + + return $provider; + } +} diff --git a/src/CroctScript.php b/src/CroctScript.php new file mode 100644 index 0000000..0765e11 --- /dev/null +++ b/src/CroctScript.php @@ -0,0 +1,57 @@ + */ + 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/CroctScriptProvider.php b/src/CroctScriptProvider.php new file mode 100644 index 0000000..e845b8a --- /dev/null +++ b/src/CroctScriptProvider.php @@ -0,0 +1,123 @@ +httpClient = $httpClient; + $this->requestFactory = $requestFactory; + $this->cache = $cache; + $this->scriptUrl = $scriptUrl; + } + + /** + * @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 + { + $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..a8e1901 --- /dev/null +++ b/src/CroctScriptResponse.php @@ -0,0 +1,54 @@ + */ + 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; + } + + /** + * Gets the HTTP status code of the captured response. + */ + public function getStatusCode(): int + { + return $this->statusCode; + } + + /** + * 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 new file mode 100644 index 0000000..caf0300 --- /dev/null +++ b/src/EvaluationOptions.php @@ -0,0 +1,117 @@ + */ + 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) + { + $this->attributes = $attributes; + $this->fallback = $fallback; + $this->fallbackProvided = $fallbackProvided; + } + + /** + * Creates the default set of options, with nothing set. + * + * @return self + */ + public static function defaults(): self + { + /** @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 + { + return new self($attributes, $this->fallback, $this->fallbackProvided); + } + + /** + * Returns a copy with the given custom attribute added. + * + * @return self + */ + 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. + * + * @template T + * + * @param T $fallback + * + * @return self + */ + 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. + * + * @return TFallback + */ + public function getFallback(): mixed + { + return $this->fallback; + } +} diff --git a/src/Evaluator.php b/src/Evaluator.php new file mode 100644 index 0000000..d32c56b --- /dev/null +++ b/src/Evaluator.php @@ -0,0 +1,24 @@ +|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/Exception/ApiException.php b/src/Exception/ApiException.php new file mode 100644 index 0000000..bf395e9 --- /dev/null +++ b/src/Exception/ApiException.php @@ -0,0 +1,58 @@ +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; + + /** @var TFallback */ + private mixed $fallback; + + /** + * @param array $attributes + * @param TFallback $fallback + */ + private function __construct( + ?string $preferredLocale, + bool $static, + bool $includeSchema, + array $attributes, + bool $fallbackProvided, + mixed $fallback, + ) { + $this->preferredLocale = $preferredLocale; + $this->static = $static; + $this->includeSchema = $includeSchema; + $this->attributes = $attributes; + $this->fallbackProvided = $fallbackProvided; + $this->fallback = $fallback; + } + + /** + * Creates the default set of options, with nothing set. + * + * @return self + */ + public static function defaults(): self + { + /** @var self $options */ + $options = new self(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 + { + return $this->copy(preferredLocale: $preferredLocale); + } + + /** + * Returns a copy that fetches statically generated content (server-side only). + * + * @return self + */ + public function withStatic(bool $static = true): self + { + return $this->copy(static: $static); + } + + /** + * Returns a copy that includes the content schema in the response metadata. + * + * @return self + */ + 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 + * + * @return self + */ + public function withAttributes(array $attributes): self + { + return $this->copy(attributes: $attributes); + } + + /** + * Returns a copy with the given custom attribute added. + * + * @return self + */ + 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. + * + * @template T + * + * @param T $content + * + * @return self + */ + public function withFallback(mixed $content): self + { + return new self( + $this->preferredLocale, + $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; + } + + /** + * 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. + * + * @return TFallback + */ + public function getFallback(): mixed + { + return $this->fallback; + } + + /** + * Returns a copy with the given fields overridden, keeping the rest. + * + * @param array|null $attributes + * + * @return self + */ + private function copy( + ?string $preferredLocale = null, + ?bool $static = null, + ?bool $includeSchema = null, + ?array $attributes = null, + ): self { + return new self( + $preferredLocale ?? $this->preferredLocale, + $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..610dad1 --- /dev/null +++ b/src/FetchResponse.php @@ -0,0 +1,76 @@ +content = $content; + $this->metadata = $metadata; + } + + /** + * Gets the slot content, or the fallback when the fetch failed. + * + * @return TContent|TFallback + */ + 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. + * + * @return self, never> + */ + 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']); + } + } + + /** @var self, never> $response */ + $response = new self($content, $metadata); + + return $response; + } +} diff --git a/src/HttpContentFetcher.php b/src/HttpContentFetcher.php new file mode 100644 index 0000000..3b3c1ef --- /dev/null +++ b/src/HttpContentFetcher.php @@ -0,0 +1,143 @@ +client = $client; + $this->context = $context; + $this->identity = $identity; + $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::defaults(); + $context = $this->context; + $static = $options->isStatic(); + + [$id, $version] = self::parseSlotId($slotId); + + $payload = ['slotId' => $id]; + + if ($version !== null) { + $payload['version'] = $version; + } + + $locale = $options->getPreferredLocale() ?? $context->getPreferredLocale(); + + if ($locale !== null) { + $payload['preferredLocale'] = $locale; + } + + if ($options->includesSchema()) { + $payload['includeSchema'] = true; + } + + // Static content is impersonal: it carries no visitor signals, preview, or page context. + $headers = []; + + if (!$static) { + $previewToken = $context->getPreviewToken(); + + if ($previewToken !== null) { + $payload['previewToken'] = $previewToken; + } + + $evaluationContext = $context->toEvaluationContext($options->getAttributes()); + + 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 = $static ? self::STATIC_ENDPOINT : self::ENDPOINT; + + try { + return FetchResponse::fromResponse($this->client->send($endpoint, $payload, $headers)); + } catch (ApiException $exception) { + if ($options->hasFallback()) { + /** @var FetchResponse, F> $response */ + $response = new FetchResponse($options->getFallback()); + + return $response; + } + + $content = $this->contentProvider->getSlotContent($id, $locale); + + if ($content !== null) { + /** @var FetchResponse, F> $response */ + $response = new FetchResponse($content); + + return $response; + } + + 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/HttpEvaluator.php b/src/HttpEvaluator.php new file mode 100644 index 0000000..eddbc7c --- /dev/null +++ b/src/HttpEvaluator.php @@ -0,0 +1,77 @@ +client = $client; + $this->context = $context; + $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. + $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, + ), + ); + } + + $context = $this->context; + + $payload = ['query' => $query]; + + $evaluationContext = $context->toEvaluationContext($options?->getAttributes() ?? []); + + 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(), + ]; + + try { + return $this->client->send(self::ENDPOINT, $payload, $headers); + } catch (ApiException $exception) { + if ($options !== null && $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/LocaleResolver.php b/src/LocaleResolver.php new file mode 100644 index 0000000..d664bea --- /dev/null +++ b/src/LocaleResolver.php @@ -0,0 +1,16 @@ +workingDirectory = $workingDirectory; + } + + /** + * @return list + */ + public function getFiles(): array + { + $path = $this->workingDirectory . \DIRECTORY_SEPARATOR . self::STUB_PATH; + + return \is_file($path) ? [$path] : []; + } +} diff --git a/src/Plug.php b/src/Plug.php new file mode 100644 index 0000000..bc3c5e3 --- /dev/null +++ b/src/Plug.php @@ -0,0 +1,70 @@ + + */ + 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. + * + * @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; + + /** + * Fetches the personalized content of a slot. + * + * @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/src/Psalm/ContentStubPlugin.php b/src/Psalm/ContentStubPlugin.php new file mode 100644 index 0000000..9111782 --- /dev/null +++ b/src/Psalm/ContentStubPlugin.php @@ -0,0 +1,46 @@ +baseDirectory = $baseDirectory; + } + + public function __invoke(PluginRegistration $registration, ?SimpleXMLElement $config = null): void + { + if ($this->baseDirectory === null) { + return; + } + + $path = $this->baseDirectory . \DIRECTORY_SEPARATOR . self::STUB_PATH; + + if (\is_file($path)) { + $registration->addStubFile($path); + } + } +} diff --git a/src/PsrApiClient.php b/src/PsrApiClient.php new file mode 100644 index 0000000..483e3fa --- /dev/null +++ b/src/PsrApiClient.php @@ -0,0 +1,114 @@ +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(); + } + + /** + * @param array $payload + * @param array $headers + */ + public function send(string $path, array $payload, array $headers = []): 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)); + + foreach ($headers as $name => $value) { + if ($value !== null) { + $request = $request->withHeader($name, $value); + } + } + + 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.', $status); + } + + if ($status >= 400) { + throw ApiException::fromProblem($status, \is_array($data) ? $data : null); + } + + return $data; + } +} diff --git a/src/RequestContext.php b/src/RequestContext.php new file mode 100644 index 0000000..4a23d39 --- /dev/null +++ b/src/RequestContext.php @@ -0,0 +1,225 @@ +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); + + /** @var array $query */ + $query = $_GET; + + return new self( + 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, + ); + } + + /** + * 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(); + + $query = $request->getQueryParams(); + + return new self( + 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, + ); + } + + /** + * 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 = []; + + if ($this->url !== null) { + $page = ['url' => $this->url]; + + if ($this->referrer !== null) { + $page['referrer'] = $this->referrer; + } + + $context['page'] = $page; + } + + if ($attributes !== []) { + $context['attributes'] = $attributes; + } + + return $context; + } + + /** + * 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. + */ + public 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. + */ + 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..45cbc85 --- /dev/null +++ b/src/Session.php @@ -0,0 +1,167 @@ +appId = $appId; + $this->apiKey = $apiKey; + $this->store = $store; + $this->tokenDuration = $tokenDuration; + $this->now = $now; + $this->signTokens = $signTokens ?? $apiKey->hasPrivateKey(); + $this->identity = $identity; + } + + /** + * 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->resolveToken($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()); + } + + /** + * Resolves the token to use, reissuing the stored one when it is absent or no longer usable. + */ + private function resolveToken(?Token $token): Token + { + $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. + $tokenAppId = $token->getApplicationId(); + + if ($tokenAppId !== null && $tokenAppId !== $this->appId) { + return $this->issueToken($userId); + } + + // 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)) { + $tokenId = $token->getTokenId(); + + return $this->issueToken( + $token->getSubject(), + $tokenId !== null && Uuid::isValid($tokenId) ? $tokenId : null, + ); + } + + 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..a099e36 --- /dev/null +++ b/src/VaryingResponseObserver.php @@ -0,0 +1,96 @@ +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 + { + return $this->plug->getPlugOptions(); + } + + /** + * @param EvaluationOptions|null $options + */ + public function evaluate(string $query, ?EvaluationOptions $options = null): mixed + { + ($this->notify)(); + + 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)) { + ($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..2434e42 --- /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'); + + $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); + } +} diff --git a/tests/Content/ArrayContentProviderTest.php b/tests/Content/ArrayContentProviderTest.php new file mode 100644 index 0000000..ee9750e --- /dev/null +++ b/tests/Content/ArrayContentProviderTest.php @@ -0,0 +1,29 @@ + ['title' => 'Hello']]); + + self::assertSame(['title' => 'Hello'], $provider->getSlotContent('home-hero')); + } + + #[TestDox('Returns null for an unknown slot ID.')] + public function testReturnsNullForUnknownSlot(): void + { + self::assertNull((new ArrayContentProvider([]))->getSlotContent('missing')); + } +} diff --git a/tests/Content/DefaultContentProviderTest.php b/tests/Content/DefaultContentProviderTest.php new file mode 100644 index 0000000..80db983 --- /dev/null +++ b/tests/Content/DefaultContentProviderTest.php @@ -0,0 +1,186 @@ +write('croct.json', '42'); + + self::assertNull(DefaultContentProvider::fromProject(VirtualFilesystem::path())); + } + + #[TestDox('Returns null when the content file is missing.')] + public function testReturnsNullWhenContentIsMissing(): void + { + $this->write('croct.json', '{}'); + + self::assertNull(DefaultContentProvider::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 = @DefaultContentProvider::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 = DefaultContentProvider::fromProject(VirtualFilesystem::path()); + + self::assertNotNull($provider); + // Defaults to the project's default locale when no language is requested. + self::assertSame(['title' => 'Latest'], $provider->getSlotContent('home-hero')); + // Serves the requested language when available. + 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->getSlotContent('home-hero', 'fr')); + // Serves an explicitly requested language even when the default locale is absent. + self::assertSame(['label' => 'Comprar'], $provider->getSlotContent('cta', 'pt')); + // Gives up when neither the requested language nor the default locale is available. + self::assertNull($provider->getSlotContent('cta')); + self::assertNull($provider->getSlotContent('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 = DefaultContentProvider::fromProject(VirtualFilesystem::path()); + + self::assertNotNull($provider); + 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->getSlotContent('valid', 'en')); + // Without a default locale, an unspecified language resolves to nothing. + self::assertNull($provider->getSlotContent('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/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..7415785 --- /dev/null +++ b/tests/Content/NullContentProviderTest.php @@ -0,0 +1,21 @@ +getSlotContent('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..f130e51 --- /dev/null +++ b/tests/CookieStorageTest.php @@ -0,0 +1,221 @@ +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 + { + $originalCookie = $_COOKIE; + $_COOKIE = ['ct.client_id' => self::CLIENT_ID]; + + try { + $storage = CookieStorage::fromGlobals(); + + self::assertSame(self::CLIENT_ID, $storage->getClientId()?->toString()); + } finally { + $_COOKIE = $originalCookie; + } + } + + #[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()); + } + + #[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/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/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/CroctTest.php b/tests/CroctTest.php new file mode 100644 index 0000000..6bb97a3 --- /dev/null +++ b/tests/CroctTest.php @@ -0,0 +1,388 @@ + */ + 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 + { + $factory = new Psr17Factory(); + $mock = new MockClient(); + $mock->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, + '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('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(), + ); + } + + #[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(); + $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..15a18aa --- /dev/null +++ b/tests/EvaluationOptionsTest.php @@ -0,0 +1,66 @@ +getAttributes()); + self::assertFalse($options->hasFallback()); + } + + #[TestDox('Carry a fallback distinct from an unset one, even when null.')] + public function testCarriesFallback(): void + { + /** @var mixed $fallback */ + $fallback = null; + + $options = EvaluationOptions::defaults()->withFallback($fallback); + + self::assertTrue($options->hasFallback()); + self::assertNull($options->getFallback()); + } + + #[TestDox('Add attributes one at a time.')] + public function testAddsAttributes(): void + { + $options = EvaluationOptions::defaults() + ->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::defaults() + ->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::defaults(); + + $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..ca565c4 --- /dev/null +++ b/tests/FetchOptionsTest.php @@ -0,0 +1,81 @@ +getPreferredLocale()); + 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::defaults() + ->withPreferredLocale('en-us') + ->withStatic() + ->withSchema() + ->withAttribute('plan', 'pro') + ->withFallback(['headline' => 'Welcome']); + + self::assertSame('en-us', $options->getPreferredLocale()); + 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::defaults()->hasFallback()); + + /** @var mixed $fallback */ + $fallback = null; + + $options = FetchOptions::defaults()->withFallback($fallback); + + self::assertTrue($options->hasFallback()); + self::assertNull($options->getFallback()); + } + + #[TestDox('Do not mutate the original instance.')] + public function testWithMethodsAreImmutable(): void + { + $options = FetchOptions::defaults(); + + $options->withPreferredLocale('en-us')->withStatic()->withSchema(); + + self::assertNull($options->getPreferredLocale()); + self::assertFalse($options->isStatic()); + self::assertFalse($options->includesSchema()); + } + + #[TestDox('Replace all attributes when set as a whole.')] + public function testReplacesAttributes(): void + { + $options = FetchOptions::defaults() + ->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..33bf7c1 --- /dev/null +++ b/tests/FetchResponseTest.php @@ -0,0 +1,70 @@ + $content */ + $content = ['title' => 'Hello']; + + $response = new FetchResponse($content, 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 + { + /** @var string $content */ + $content = 'fallback'; + + $response = new FetchResponse($content); + + 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/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/HttpContentFetcherTest.php b/tests/HttpContentFetcherTest.php new file mode 100644 index 0000000..d59a36c --- /dev/null +++ b/tests/HttpContentFetcherTest.php @@ -0,0 +1,329 @@ +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@2', + FetchOptions::defaults()->withPreferredLocale('en-us'), + ); + + 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::defaults()->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 and omits the visitor signals 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' => []]))), + ); + + // 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::defaults()->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.')] + 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::defaults()->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::defaults()->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::defaults()->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, + ?RequestContext $context = null, + ?ContentProvider $contentProvider = null, + ?IdentityStore $identity = null, + ): HttpContentFetcher { + $context ??= new RequestContext(); + + return new HttpContentFetcher( + new PsrApiClient( + httpClient: $client, + requestFactory: $factory, + streamFactory: $factory, + apiKey: ApiKey::of(EcKeyFactory::IDENTIFIER), + baseEndpointUrl: 'https://api.croct.io', + ), + $context, + identity: $identity, + contentProvider: $contentProvider, + ); + } +} diff --git a/tests/HttpEvaluatorTest.php b/tests/HttpEvaluatorTest.php new file mode 100644 index 0000000..bcf20a8 --- /dev/null +++ b/tests/HttpEvaluatorTest.php @@ -0,0 +1,228 @@ +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::defaults()->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('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 + { + $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::defaults()->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/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); + } +} diff --git a/tests/PsrApiClientTest.php b/tests/PsrApiClientTest.php new file mode 100644 index 0000000..2b6e11a --- /dev/null +++ b/tests/PsrApiClientTest.php @@ -0,0 +1,224 @@ +addResponse( + $factory->createResponse(200)->withBody($factory->createStream((string) \json_encode(['ok' => true]))), + ); + + $apiKey = ApiKey::of(EcKeyFactory::IDENTIFIER); + + $client = new PsrApiClient( + httpClient: $mock, + requestFactory: $factory, + streamFactory: $factory, + apiKey: $apiKey, + baseEndpointUrl: 'https://api.croct.io', + version: '1.0.0', + ); + + $result = $client->send( + 'external/web/evaluate', + ['query' => 'true'], + ['X-Client-Id' => 'client-1', 'X-Client-Ip' => '8.8.8.8'], + ); + + 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('client-1', $request->getHeaderLine('X-Client-Id')); + self::assertSame('8.8.8.8', $request->getHeaderLine('X-Client-Ip')); + self::assertSame(['query' => 'true'], \json_decode((string) $request->getBody(), true)); + } + + #[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, + ); + + $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')); + 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', []); + } + + #[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', []); + 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', []); + } + + #[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', []); + } + + #[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"]); + } +} diff --git a/tests/RequestContextTest.php b/tests/RequestContextTest.php new file mode 100644 index 0000000..155565c --- /dev/null +++ b/tests/RequestContextTest.php @@ -0,0 +1,186 @@ +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('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 + { + $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()); + } + + #[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()); + } + + #[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 + */ + 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 + { + $originalServer = $_SERVER; + $originalQuery = $_GET; + + $_SERVER = $server; + $_GET = $query; + + try { + return $callback(); + } finally { + $_SERVER = $originalServer; + $_GET = $originalQuery; + } + } +} diff --git a/tests/SessionTest.php b/tests/SessionTest.php new file mode 100644 index 0000000..88132f3 --- /dev/null +++ b/tests/SessionTest.php @@ -0,0 +1,270 @@ +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('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::assertTrue($session->getUserToken()->isAnonymous()); + self::assertTrue($session->getUserToken()->isValidNow(200000)); + } + + #[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, identity: $this->resolver('user-3')); + + 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('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 + { + [$sessionKey] = EcKeyFactory::create(); + + $token = $this->createSession(null, null, $sessionKey, identity: $this->resolver(''))->getUserToken(); + + self::assertTrue($token->isSigned()); + self::assertTrue($token->isAnonymous()); + } + + private function createSession( + ?Uuid $clientId, + ?Token $userToken = null, + ?ApiKey $apiKey = null, + int $now = 1000, + ?IdentityResolver $identity = null, + ): 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, + identity: $identity, + ); + } + + private function resolver(?string $userId): IdentityResolver + { + $identity = $this->createMock(IdentityResolver::class); + $identity->method('getUserId')->willReturn($userId); + + return $identity; + } +} 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..9d31ced --- /dev/null +++ b/tests/VaryingResponseObserverTest.php @@ -0,0 +1,166 @@ +createPlug(); + + $calls = 0; + $plug = new VaryingResponseObserver($inner, static function () use (&$calls): void { + ++$calls; + }); + + self::assertSame('cid', $plug->getClientId()); + self::assertSame('tok', $plug->getUserToken()); + self::assertTrue($plug->evaluate('user is returning')); + self::assertSame(['title' => 'Hello'], $plug->fetchContent('home-hero')->getContent()); + + $plug->identify('user-1'); + $plug->anonymize(); + + self::assertSame(6, $calls); + self::assertSame( + [ + 'getClientId', + 'getUserToken', + 'evaluate', + 'fetchContent', + 'identify', + 'anonymize', + ], + $inner->calls, + ); + } + + #[TestDox('Does not run the callback for visitor-independent reads.')] + public function testDoesNotVaryOnStaticReads(): void + { + $inner = $this->createPlug(); + + $calls = 0; + $plug = new VaryingResponseObserver($inner, static function () use (&$calls): void { + ++$calls; + }); + + self::assertSame('app', $plug->getAppId()); + self::assertSame(['appId' => 'app'], $plug->getPlugOptions()); + + self::assertSame(0, $calls); + self::assertSame(['getAppId', 'getPlugOptions'], $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::defaults()->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']; + } + + /** + * @param EvaluationOptions|null $options + */ + public function evaluate(string $query, ?EvaluationOptions $options = null): mixed + { + $this->calls[] = 'evaluate'; + + return true; + } + + /** + * @template F = never + * + * @param FetchOptions|null $options + * + * @return FetchResponse, F> + */ + public function fetchContent(string $slotId, ?FetchOptions $options = null): FetchResponse + { + $this->calls[] = 'fetchContent'; + + /** @var FetchResponse, F> $response */ + $response = new FetchResponse(['title' => 'Hello']); + + return $response; + } + + public function identify(string $userId): void + { + $this->calls[] = 'identify'; + } + + public function anonymize(): void + { + $this->calls[] = 'anonymize'; + } + }; + } +}