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