diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bb0aa27 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI +on: + push: + branches: + - main + pull_request: ~ + workflow_dispatch: ~ + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: json + coverage: none + - name: Get Composer Cache Directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: lint-composer-${{ hashFiles('**/composer.json') }} + restore-keys: lint-composer- + - name: Install dependencies + run: composer install --prefer-dist + - name: Run simple-phpunit version check + run: ./vendor/bin/simple-phpunit --version + - name: Run code style check + run: composer cs-fixer -- --dry-run --diff + - name: Run static analysis + run: composer phpstan + + tests: + name: Tests (PHP ${{ matrix.php }}) + needs: lint + runs-on: ubuntu-latest + strategy: + matrix: + php: + - '8.4' + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: json + coverage: none + - name: Get Composer Cache Directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ matrix.php }}-composer- + - name: Install dependencies + run: composer install --prefer-dist + - name: Run tests + run: composer test diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index f4fdde0..4496053 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -33,6 +33,7 @@ 'phpdoc_align' => [ 'align' => 'left' ], + 'ordered_class_elements' => true, 'yoda_style' => false, ]) ->setFinder($finder); diff --git a/composer.json b/composer.json index da65366..d49daf9 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,10 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.91", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-symfony": "^2.0", "symfony/phpunit-bridge": "^8.0" }, "suggest": { @@ -41,9 +45,18 @@ "symfony/security": "For using authentication and exception handling in your APIs." }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "phpstan/extension-installer": true + } }, "scripts": { - "test": "vendor/bin/simple-phpunit" + "test": "./vendor/bin/simple-phpunit", + "check": [ + "@cs-fixer", + "@phpstan" + ], + "cs-fixer": "php -d memory_limit=-1 ./vendor/bin/php-cs-fixer fix", + "phpstan": "php -d memory_limit=-1 ./vendor/bin/phpstan analyse" } } diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..5497f3e --- /dev/null +++ b/composer.lock @@ -0,0 +1,6537 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "96b2ae5fe2b0935dd62375d826549cb9", + "packages": [ + { + "name": "devizzent/cebe-php-openapi", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/DEVizzent/cebe-php-openapi.git", + "reference": "af42b77f339b6b2920b65bae5df748e47391e11d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DEVizzent/cebe-php-openapi/zipball/af42b77f339b6b2920b65bae5df748e47391e11d", + "reference": "af42b77f339b6b2920b65bae5df748e47391e11d", + "shasum": "" + }, + "require": { + "ext-json": "*", + "justinrainbow/json-schema": "^5.2 || ^6.0", + "php": ">=7.1.0", + "symfony/yaml": "^3.4 || ^4 || ^5 || ^6 || ^7" + }, + "conflict": { + "symfony/yaml": "3.4.0 - 3.4.4 || 4.0.0 - 4.4.17 || 5.0.0 - 5.1.9 || 5.2.0" + }, + "replace": { + "cebe/php-openapi": "1.7.0" + }, + "require-dev": { + "apis-guru/openapi-directory": "1.0.0", + "cebe/indent": "*", + "mermade/openapi3-examples": "1.0.0", + "oai/openapi-specification-3.0": "3.0.3", + "oai/openapi-specification-3.1": "3.1.0", + "phpstan/phpstan": "^0.12.0", + "phpunit/phpunit": "^6.5 || ^7.5 || ^8.5 || ^9.4 || ^11.4" + }, + "bin": [ + "bin/php-openapi" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.6.x-dev" + } + }, + "autoload": { + "psr-4": { + "cebe\\openapi\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Carsten Brandt", + "email": "mail@cebe.cc", + "homepage": "https://cebe.cc/", + "role": "Creator" + }, + { + "name": "Vicent Valls", + "email": "vizzent@gmail.com" + } + ], + "description": "Read and write OpenAPI yaml/json files and make the content accessable in PHP objects.", + "homepage": "https://github.com/DEVizzent/cebe-php-openapi#readme", + "keywords": [ + "openapi" + ], + "support": { + "issues": "https://github.com/DEVizzent/cebe-php-openapi/issues", + "source": "https://github.com/DEVizzent/cebe-php-openapi" + }, + "time": "2025-01-16T11:12:34+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.5", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=13" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^13", + "phpstan/phpstan": "1.4.10 || 2.1.11", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + }, + "time": "2025-04-07T20:06:18+00:00" + }, + { + "name": "justinrainbow/json-schema", + "version": "6.6.1", + "source": { + "type": "git", + "url": "https://github.com/jsonrainbow/json-schema.git", + "reference": "fd8e5c6b1badb998844ad34ce0abcd71a0aeb396" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/fd8e5c6b1badb998844ad34ce0abcd71a0aeb396", + "reference": "fd8e5c6b1badb998844ad34ce0abcd71a0aeb396", + "shasum": "" + }, + "require": { + "ext-json": "*", + "marc-mabe/php-enum": "^4.0", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.3.0", + "json-schema/json-schema-test-suite": "^23.2", + "marc-mabe/php-enum-phpstan": "^2.0", + "phpspec/prophecy": "^1.19", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^8.5" + }, + "bin": [ + "bin/validate-json" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "JsonSchema\\": "src/JsonSchema/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bruno Prieto Reis", + "email": "bruno.p.reis@gmail.com" + }, + { + "name": "Justin Rainbow", + "email": "justin.rainbow@gmail.com" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Robert Schönthal", + "email": "seroscho@googlemail.com" + } + ], + "description": "A library to validate a json schema.", + "homepage": "https://github.com/jsonrainbow/json-schema", + "keywords": [ + "json", + "schema" + ], + "support": { + "issues": "https://github.com/jsonrainbow/json-schema/issues", + "source": "https://github.com/jsonrainbow/json-schema/tree/6.6.1" + }, + "time": "2025-11-07T18:30:29+00:00" + }, + { + "name": "league/openapi-psr7-validator", + "version": "0.22", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/openapi-psr7-validator.git", + "reference": "a665e220d0bba68411efc1899ed5022fd9b10113" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/openapi-psr7-validator/zipball/a665e220d0bba68411efc1899ed5022fd9b10113", + "reference": "a665e220d0bba68411efc1899ed5022fd9b10113", + "shasum": "" + }, + "require": { + "devizzent/cebe-php-openapi": "^1.0", + "ext-json": "*", + "league/uri": "^6.3 || ^7.0", + "php": ">=7.2", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-middleware": "^1.0", + "respect/validation": "^1.1.3 || ^2.0", + "riverline/multipart-parser": "^2.0.3", + "symfony/polyfill-php80": "^1.27", + "webmozart/assert": "^1.4" + }, + "require-dev": { + "doctrine/coding-standard": "^8.0", + "guzzlehttp/psr7": "^2.0", + "hansott/psr7-cookies": "^3.0.2 || ^4.0", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1", + "phpstan/phpstan-phpunit": "^1", + "phpstan/phpstan-webmozart-assert": "^1", + "phpunit/phpunit": "^7 || ^8 || ^9", + "symfony/cache": "^5.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\OpenAPIValidation\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Validate PSR-7 messages against OpenAPI (3.0.2) specifications expressed in YAML or JSON", + "homepage": "https://github.com/thephpleague/openapi-psr7-validator", + "keywords": [ + "http", + "openapi", + "psr7", + "validation" + ], + "support": { + "issues": "https://github.com/thephpleague/openapi-psr7-validator/issues", + "source": "https://github.com/thephpleague/openapi-psr7-validator/tree/0.22" + }, + "time": "2024-01-10T19:11:09+00:00" + }, + { + "name": "league/uri", + "version": "7.6.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "f625804987a0a9112d954f9209d91fec52182344" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/f625804987a0a9112d954f9209d91fec52182344", + "reference": "f625804987a0a9112d954f9209d91fec52182344", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.6", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", + "league/uri-components": "Needed to easily manipulate URI objects components", + "league/uri-polyfill": "Needed to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle WHATWG URL", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.6.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2025-11-18T12:17:23+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.6.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "ccbfb51c0445298e7e0b7f4481b942f589665368" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/ccbfb51c0445298e7e0b7f4481b942f589665368", + "reference": "ccbfb51c0445298e7e0b7f4481b942f589665368", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle WHATWG URL", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.6.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2025-11-18T12:17:23+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": "nelmio/api-doc-bundle", + "version": "v5.8.1", + "source": { + "type": "git", + "url": "https://github.com/nelmio/NelmioApiDocBundle.git", + "reference": "16e139b812320dc33c971dc21627068fcc1ed34c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/16e139b812320dc33c971dc21627068fcc1ed34c", + "reference": "16e139b812320dc33c971dc21627068fcc1ed34c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "phpdocumentor/reflection-docblock": "^5.0", + "phpdocumentor/type-resolver": "^1.8.2", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "psr/container": "^1.0 || ^2.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/config": "^6.4 || ^7.2", + "symfony/console": "^6.4 || ^7.2", + "symfony/dependency-injection": "^6.4 || ^7.2", + "symfony/deprecation-contracts": "^2.1 || ^3", + "symfony/framework-bundle": "^6.4 || ^7.2", + "symfony/http-foundation": "^6.4 || ^7.2", + "symfony/http-kernel": "^6.4 || ^7.2", + "symfony/options-resolver": "^6.4 || ^7.2", + "symfony/property-info": "^6.4 || ^7.2", + "symfony/routing": "^6.4 || ^7.2", + "symfony/type-info": "^7.2", + "zircote/swagger-php": "^4.11.1 || ^5.0" + }, + "conflict": { + "zircote/swagger-php": "4.8.7 || 5.5.0" + }, + "require-dev": { + "api-platform/core": "^3.2", + "friendsofphp/php-cs-fixer": "^3.52", + "friendsofsymfony/rest-bundle": "^3.2.0", + "jms/serializer": "^3.32", + "jms/serializer-bundle": "^5.5", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "phpunit/phpunit": "^10.5", + "symfony/asset": "^6.4 || ^7.2", + "symfony/browser-kit": "^6.4 || ^7.2", + "symfony/cache": "^6.4 || ^7.2", + "symfony/dom-crawler": "^6.4 || ^7.2", + "symfony/expression-language": "^6.4 || ^7.2", + "symfony/finder": "^6.4 || ^7.2", + "symfony/form": "^6.4 || ^7.2", + "symfony/phpunit-bridge": "^6.4 || ^7.2", + "symfony/property-access": "^6.4 || ^7.2", + "symfony/security-csrf": "^6.4 || ^7.2", + "symfony/security-http": "^6.4 || ^7.2", + "symfony/serializer": "^6.4 || ^7.2", + "symfony/stopwatch": "^6.4 || ^7.2", + "symfony/templating": "^6.4 || ^7.2", + "symfony/translation": "^6.4 || ^7.2", + "symfony/twig-bundle": "^6.4 || ^7.2", + "symfony/uid": "^6.4 || ^7.2", + "symfony/validator": "^6.4 || ^7.2", + "willdurand/hateoas-bundle": "^2.7", + "willdurand/negotiation": "^3.0" + }, + "suggest": { + "api-platform/core": "For using an API oriented framework.", + "friendsofsymfony/rest-bundle": "For using the parameters annotations.", + "jms/serializer-bundle": "For describing your models.", + "symfony/asset": "For using the Swagger UI.", + "symfony/cache": "For using a PSR-6 compatible cache implementation with the API doc generator.", + "symfony/form": "For describing your form type models.", + "symfony/monolog-bundle": "For using a PSR-3 compatible logger implementation with the API PHP describer.", + "symfony/security-csrf": "For using csrf protection tokens in forms.", + "symfony/serializer": "For describing your models.", + "symfony/twig-bundle": "For using the Swagger UI.", + "symfony/validator": "For describing the validation constraints in your models.", + "willdurand/hateoas-bundle": "For extracting HATEOAS metadata." + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-4.x": "4.x-dev", + "dev-5.x": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Nelmio\\ApiDocBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://github.com/nelmio/NelmioApiDocBundle/contributors" + } + ], + "description": "Generates documentation for your REST API from attributes", + "keywords": [ + "api", + "doc", + "documentation", + "rest" + ], + "support": { + "issues": "https://github.com/nelmio/NelmioApiDocBundle/issues", + "source": "https://github.com/nelmio/NelmioApiDocBundle/tree/v5.8.1" + }, + "funding": [ + { + "url": "https://github.com/DjordyKoert", + "type": "github" + } + ], + "time": "2025-11-14T20:06:10+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": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "5.6.6", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^1.7", + "phpstan/phpdoc-parser": "^1.7|^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" + }, + "time": "2025-12-22T21:13:58+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "1.12.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.3 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^1.18|^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpunit": "^9.5", + "rector/rector": "^0.13.9", + "vimeo/psalm": "^4.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + }, + "time": "2025-11-21T15:09:14+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495", + "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0" + }, + "time": "2025-08-30T15:50:23+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/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/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "respect/stringifier", + "version": "0.2.0", + "source": { + "type": "git", + "url": "https://github.com/Respect/Stringifier.git", + "reference": "e55af3c8aeaeaa2abb5fa47a58a8e9688cc23b59" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Respect/Stringifier/zipball/e55af3c8aeaeaa2abb5fa47a58a8e9688cc23b59", + "reference": "e55af3c8aeaeaa2abb5fa47a58a8e9688cc23b59", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.8", + "malukenho/docheader": "^0.1.7", + "phpunit/phpunit": "^6.4" + }, + "type": "library", + "autoload": { + "files": [ + "src/stringify.php" + ], + "psr-4": { + "Respect\\Stringifier\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Respect/Stringifier Contributors", + "homepage": "https://github.com/Respect/Stringifier/graphs/contributors" + } + ], + "description": "Converts any value to a string", + "homepage": "http://respect.github.io/Stringifier/", + "keywords": [ + "respect", + "stringifier", + "stringify" + ], + "support": { + "issues": "https://github.com/Respect/Stringifier/issues", + "source": "https://github.com/Respect/Stringifier/tree/0.2.0" + }, + "time": "2017-12-29T19:39:25+00:00" + }, + { + "name": "respect/validation", + "version": "2.4.4", + "source": { + "type": "git", + "url": "https://github.com/Respect/Validation.git", + "reference": "f13f10f19978aea33af2a102a2f58f2db1e63619" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Respect/Validation/zipball/f13f10f19978aea33af2a102a2f58f2db1e63619", + "reference": "f13f10f19978aea33af2a102a2f58f2db1e63619", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "respect/stringifier": "^0.2.0", + "symfony/polyfill-mbstring": "^1.2" + }, + "require-dev": { + "egulias/email-validator": "^3.0", + "giggsey/libphonenumber-for-php-lite": "^8.13 || ^9.0", + "malukenho/docheader": "^1.0", + "mikey179/vfsstream": "^1.6", + "phpstan/phpstan": "^1.9", + "phpstan/phpstan-deprecation-rules": "^1.1", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.6", + "psr/http-message": "^1.0", + "respect/coding-standard": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "suggest": { + "egulias/email-validator": "Improves the Email rule if available", + "ext-bcmath": "Arbitrary Precision Mathematics", + "ext-fileinfo": "File Information", + "ext-mbstring": "Multibyte String Functions", + "giggsey/libphonenumber-for-php-lite": "Enables the phone rule if available" + }, + "type": "library", + "autoload": { + "psr-4": { + "Respect\\Validation\\": "library/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Respect/Validation Contributors", + "homepage": "https://github.com/Respect/Validation/graphs/contributors" + } + ], + "description": "The most awesome validation engine ever created for PHP", + "homepage": "http://respect.github.io/Validation/", + "keywords": [ + "respect", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/Respect/Validation/issues", + "source": "https://github.com/Respect/Validation/tree/2.4.4" + }, + "time": "2025-06-07T00:07:21+00:00" + }, + { + "name": "riverline/multipart-parser", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/Riverline/multipart-parser.git", + "reference": "1410f23a8fd416a0cf5c8867ea9c95544016c831" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Riverline/multipart-parser/zipball/1410f23a8fd416a0cf5c8867ea9c95544016c831", + "reference": "1410f23a8fd416a0cf5c8867ea9c95544016c831", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=5.6.0" + }, + "require-dev": { + "laminas/laminas-diactoros": "^1.8.7 || ^2.11.1", + "phpunit/phpunit": "^5.7 || ^9.0", + "psr/http-message": "^1.0", + "symfony/psr-http-message-bridge": "^1.1 || ^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Riverline\\MultiPartParser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Romain Cambien", + "email": "romain@cambien.net" + }, + { + "name": "Riverline", + "homepage": "http://www.riverline.fr" + } + ], + "description": "One class library to parse multipart content with encoding and charset support.", + "keywords": [ + "http", + "multipart", + "parser" + ], + "support": { + "issues": "https://github.com/Riverline/multipart-parser/issues", + "source": "https://github.com/Riverline/multipart-parser/tree/2.2.0" + }, + "time": "2025-04-29T08:38:14+00:00" + }, + { + "name": "symfony/cache", + "version": "v7.3.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "1277a1ec61c8d93ea61b2a59738f1deb9bfb6701" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/1277a1ec61c8d93ea61b2a59738f1deb9bfb6701", + "reference": "1277a1ec61c8d93ea61b2a59738f1deb9bfb6701", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^6.4|^7.0" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/dependency-injection": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/var-dumper": "<6.4" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/filesystem": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v7.3.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-30T13:22:58+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "5d68a57d66910405e5c0b63d6f0af941e66fc868" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/5d68a57d66910405e5c0b63d6f0af941e66fc868", + "reference": "5d68a57d66910405e5c0b63d6f0af941e66fc868", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-03-13T15:25:07+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/config", + "version": "v7.3.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "9d18eba95655a3152ae4c1d53c6cc34eb4d4a0b7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/9d18eba95655a3152ae4c1d53c6cc34eb4d4a0b7", + "reference": "9d18eba95655a3152ae4c1d53c6cc34eb4d4a0b7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.1", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "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": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v7.3.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-02T08:04:43+00:00" + }, + { + "name": "symfony/console", + "version": "v7.3.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "c28ad91448f86c5f6d9d2c70f0cf68bf135f252a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/c28ad91448f86c5f6d9d2c70f0cf68bf135f252a", + "reference": "c28ad91448f86c5f6d9d2c70f0cf68bf135f252a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.3.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-04T01:21:42+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v7.3.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "98af8bb46c56aedd9dd5a7f0414fc72bf2dcfe69" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/98af8bb46c56aedd9dd5a7f0414fc72bf2dcfe69", + "reference": "98af8bb46c56aedd9dd5a7f0414fc72bf2dcfe69", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.5", + "symfony/var-exporter": "^6.4.20|^7.2.5" + }, + "conflict": { + "ext-psr": "<1.1|>=2", + "symfony/config": "<6.4", + "symfony/finder": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "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": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v7.3.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-31T10:11:11+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-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.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.3.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "bbe40bfab84323d99dab491b716ff142410a92a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/bbe40bfab84323d99dab491b716ff142410a92a8", + "reference": "bbe40bfab84323d99dab491b716ff142410a92a8", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/var-dumper": "^6.4|^7.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.3.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-31T19:12:50+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.3.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b7dc69e71de420ac04bc9ab830cf3ffebba48191", + "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/error-handler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-08-13T11:49:31+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v7.3.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "e9bcfd7837928ab656276fe00464092cc9e1826a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/e9bcfd7837928ab656276fe00464092cc9e1826a", + "reference": "e9bcfd7837928ab656276fe00464092cc9e1826a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.3.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-05T09:52:27+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.3.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "9f696d2f1e340484b4683f7853b273abff94421f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/9f696d2f1e340484b4683f7853b273abff94421f", + "reference": "9f696d2f1e340484b4683f7853b273abff94421f", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.3.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-15T18:45:57+00:00" + }, + { + "name": "symfony/framework-bundle", + "version": "v7.3.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/framework-bundle.git", + "reference": "cabfdfa82bc4f75d693a329fe263d96937636b77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/cabfdfa82bc4f75d693a329fe263d96937636b77", + "reference": "cabfdfa82bc4f75d693a329fe263d96937636b77", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.2", + "symfony/cache": "^6.4|^7.0", + "symfony/config": "^7.3", + "symfony/dependency-injection": "^7.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^7.3", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/filesystem": "^7.1", + "symfony/finder": "^6.4|^7.0", + "symfony/http-foundation": "^7.3", + "symfony/http-kernel": "^7.2", + "symfony/polyfill-mbstring": "~1.0", + "symfony/routing": "^6.4|^7.0" + }, + "conflict": { + "doctrine/persistence": "<1.3", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/asset": "<6.4", + "symfony/asset-mapper": "<6.4", + "symfony/clock": "<6.4", + "symfony/console": "<6.4", + "symfony/dom-crawler": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/json-streamer": ">=7.4", + "symfony/lock": "<6.4", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/object-mapper": ">=7.4", + "symfony/property-access": "<6.4", + "symfony/property-info": "<6.4", + "symfony/runtime": "<6.4.13|>=7.0,<7.1.6", + "symfony/scheduler": "<6.4.4|>=7.0.0,<7.0.4", + "symfony/security-core": "<6.4", + "symfony/security-csrf": "<7.2", + "symfony/serializer": "<7.2.5", + "symfony/stopwatch": "<6.4", + "symfony/translation": "<7.3", + "symfony/twig-bridge": "<6.4", + "symfony/twig-bundle": "<6.4", + "symfony/validator": "<6.4", + "symfony/web-profiler-bundle": "<6.4", + "symfony/webhook": "<7.2", + "symfony/workflow": "<7.3.0-beta2" + }, + "require-dev": { + "doctrine/persistence": "^1.3|^2|^3", + "dragonmantank/cron-expression": "^3.1", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "seld/jsonlint": "^1.10", + "symfony/asset": "^6.4|^7.0", + "symfony/asset-mapper": "^6.4|^7.0", + "symfony/browser-kit": "^6.4|^7.0", + "symfony/clock": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/css-selector": "^6.4|^7.0", + "symfony/dom-crawler": "^6.4|^7.0", + "symfony/dotenv": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/form": "^6.4|^7.0", + "symfony/html-sanitizer": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/json-streamer": "7.3.*", + "symfony/lock": "^6.4|^7.0", + "symfony/mailer": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/notifier": "^6.4|^7.0", + "symfony/object-mapper": "^v7.3.0-beta2", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/process": "^6.4|^7.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/rate-limiter": "^6.4|^7.0", + "symfony/scheduler": "^6.4.4|^7.0.4", + "symfony/security-bundle": "^6.4|^7.0", + "symfony/semaphore": "^6.4|^7.0", + "symfony/serializer": "^7.2.5", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/string": "^6.4|^7.0", + "symfony/translation": "^7.3", + "symfony/twig-bundle": "^6.4|^7.0", + "symfony/type-info": "^7.1.8", + "symfony/uid": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/web-link": "^6.4|^7.0", + "symfony/webhook": "^7.2", + "symfony/workflow": "^7.3", + "symfony/yaml": "^6.4|^7.0", + "twig/twig": "^3.12" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\FrameworkBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/framework-bundle/tree/v7.3.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-30T09:42:24+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.3.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "db488a62f98f7a81d5746f05eea63a74e55bb7c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/db488a62f98f7a81d5746f05eea63a74e55bb7c4", + "reference": "db488a62f98f7a81d5746f05eea63a74e55bb7c4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-mbstring": "~1.1", + "symfony/polyfill-php83": "^1.27" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5", + "symfony/clock": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/rate-limiter": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "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": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.3.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-08T16:41:12+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.3.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "10b8e9b748ea95fa4539c208e2487c435d3c87ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/10b8e9b748ea95fa4539c208e2487c435d3c87ce", + "reference": "10b8e9b748ea95fa4539c208e2487c435d3c87ce", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0", + "symfony/event-dispatcher": "^7.3", + "symfony/http-foundation": "^7.3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0", + "symfony/clock": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/css-selector": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/dom-crawler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0", + "symfony/property-access": "^7.1", + "symfony/routing": "^6.4|^7.0", + "symfony/serializer": "^7.1", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/translation": "^6.4|^7.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0", + "symfony/var-exporter": "^6.4|^7.0", + "twig/twig": "^3.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.3.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-12T11:38:40+00:00" + }, + { + "name": "symfony/messenger", + "version": "v7.3.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/messenger.git", + "reference": "58a7efa3bebadbe4cdd8f7577c5856f0e3ea3978" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/messenger/zipball/58a7efa3bebadbe4cdd8f7577c5856f0e3ea3978", + "reference": "58a7efa3bebadbe4cdd8f7577c5856f0e3ea3978", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/clock": "^6.4|^7.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<7.2", + "symfony/event-dispatcher": "<6.4", + "symfony/event-dispatcher-contracts": "<2.5", + "symfony/framework-bundle": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/lock": "<6.4", + "symfony/serializer": "<6.4" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/console": "^7.2", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/rate-limiter": "^6.4|^7.0", + "symfony/routing": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Samuel Roze", + "email": "samuel.roze@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps applications send and receive messages to/from other applications or via message queues", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/messenger/tree/v7.3.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-06T11:17:34+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v7.3.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/0ff2f5c3df08a395232bbc3c2eb7e84912df911d", + "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "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/v7.3.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-08-05T10:16:07+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.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": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.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": "2025-06-27T09:58:17+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.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": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.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": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "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.33.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": "2025-01-02T08:10:11+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "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\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.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": "2025-07-08T02:45:35+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.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": "2025-06-24T13:30:11+00:00" + }, + { + "name": "symfony/property-info", + "version": "v7.3.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "0b346ed259dc5da43535caf243005fe7d4b0f051" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/0b346ed259dc5da43535caf243005fe7d4b0f051", + "reference": "0b346ed259dc5da43535caf243005fe7d4b0f051", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/string": "^6.4|^7.0", + "symfony/type-info": "^7.3.5" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/cache": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/serializer": "<6.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v7.3.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-05T22:12:41+00:00" + }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/03f2f72319e7acaf2a9f6fcbe30ef17eec51594f", + "reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/http-message": "^1.0|^2.0", + "symfony/http-foundation": "^6.4|^7.0" + }, + "conflict": { + "php-http/discovery": "<1.15", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "php-http/discovery": "^1.15", + "psr/log": "^1.1.4|^2|^3", + "symfony/browser-kit": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "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": "PSR HTTP message bridge", + "homepage": "https://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-26T08:57:56+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.3.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "c97abe725f2a1a858deca629a6488c8fc20c3091" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/c97abe725f2a1a858deca629a6488c8fc20c3091", + "reference": "c97abe725f2a1a858deca629a6488c8fc20c3091", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "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": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.3.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-05T07:57:47+00:00" + }, + { + "name": "symfony/serializer", + "version": "v7.3.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "ba2e50a5f2870c93f0f47ca1a4e56e4bbe274035" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/ba2e50a5f2870c93f0f47ca1a4e56e4bbe274035", + "reference": "ba2e50a5f2870c93f0f47ca1a4e56e4bbe274035", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-php84": "^1.30" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/dependency-injection": "<6.4", + "symfony/property-access": "<6.4", + "symfony/property-info": "<6.4", + "symfony/uid": "<6.4", + "symfony/validator": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^3.2|^4.0|^5.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/dependency-injection": "^7.2", + "symfony/error-handler": "^6.4|^7.0", + "symfony/filesystem": "^6.4|^7.0", + "symfony/form": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.1.8", + "symfony/uid": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0", + "symfony/var-exporter": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "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": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v7.3.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-08T11:26:21+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:30:57+00:00" + }, + { + "name": "symfony/string", + "version": "v7.3.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "f96476035142921000338bad71e5247fbc138872" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/f96476035142921000338bad71e5247fbc138872", + "reference": "f96476035142921000338bad71e5247fbc138872", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1", + "symfony/http-client": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.3.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-09-11T14:36:48+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T13:41:35+00:00" + }, + { + "name": "symfony/type-info", + "version": "v7.3.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "8b36f41421160db56914f897b57eaa6a830758b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/8b36f41421160db56914f897b57eaa6a830758b3", + "reference": "8b36f41421160db56914f897b57eaa6a830758b3", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v7.3.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-16T12:30:12+00:00" + }, + { + "name": "symfony/validator", + "version": "v7.3.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/validator.git", + "reference": "8290a095497c3fe5046db21888d1f75b54ddf39d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/validator/zipball/8290a095497c3fe5046db21888d1f75b54ddf39d", + "reference": "8290a095497c3fe5046db21888d1f75b54ddf39d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php83": "^1.27", + "symfony/translation-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/lexer": "<1.1", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<7.0", + "symfony/expression-language": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/intl": "<6.4", + "symfony/property-info": "<6.4", + "symfony/translation": "<6.4.3|>=7.0,<7.0.3", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3|^4", + "symfony/cache": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/string": "^6.4|^7.0", + "symfony/translation": "^6.4.3|^7.0.3", + "symfony/type-info": "^7.1.8", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Validator\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/Resources/bin/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to validate values", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/validator/tree/v7.3.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-08T16:29:29+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.3.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/476c4ae17f43a9a36650c69879dcf5b1e6ae724d", + "reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/uid": "^6.4|^7.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.3.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-09-27T09:00:46+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v7.3.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "0f020b544a30a7fe8ba972e53ee48a74c0bc87f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/0f020b544a30a7fe8ba972e53ee48a74c0bc87f4", + "reference": "0f020b544a30a7fe8ba972e53ee48a74c0bc87f4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "symfony/property-access": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v7.3.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-09-11T10:12:26+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "24dd4de28d2e3988b311751ac49e684d783e2345" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345", + "reference": "24dd4de28d2e3988b311751ac49e684d783e2345", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "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": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-12-04T18:11:45+00:00" + }, + { + "name": "webmozart/assert", + "version": "1.12.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^7.2 || ^8.0" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/1.12.1" + }, + "time": "2025-10-29T15:56:20+00:00" + }, + { + "name": "zircote/swagger-php", + "version": "5.7.3", + "source": { + "type": "git", + "url": "https://github.com/zircote/swagger-php.git", + "reference": "4d0d3086d7c876626167d198cec285e98d3629dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zircote/swagger-php/zipball/4d0d3086d7c876626167d198cec285e98d3629dc", + "reference": "4d0d3086d7c876626167d198cec285e98d3629dc", + "shasum": "" + }, + "require": { + "ext-json": "*", + "nikic/php-parser": "^4.19 || ^5.0", + "php": ">=7.4", + "phpstan/phpdoc-parser": "^2.0", + "psr/log": "^1.1 || ^2.0 || ^3.0", + "symfony/deprecation-contracts": "^2 || ^3", + "symfony/finder": "^5.0 || ^6.0 || ^7.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0" + }, + "conflict": { + "symfony/process": ">=6, <6.4.14" + }, + "require-dev": { + "composer/package-versions-deprecated": "^1.11", + "doctrine/annotations": "^2.0", + "friendsofphp/php-cs-fixer": "^3.62.0", + "phpstan/phpstan": "^1.6 || ^2.0", + "phpunit/phpunit": "^9.0", + "rector/rector": "^1.0 || ^2.0", + "vimeo/psalm": "^4.30 || ^5.0" + }, + "suggest": { + "doctrine/annotations": "^2.0", + "radebatz/type-info-extras": "^1.0.2" + }, + "bin": [ + "bin/openapi" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "OpenApi\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Robert Allen", + "email": "zircote@gmail.com" + }, + { + "name": "Bob Fanger", + "email": "bfanger@gmail.com", + "homepage": "https://bfanger.nl" + }, + { + "name": "Martin Rademacher", + "email": "mano@radebatz.net", + "homepage": "https://radebatz.net" + } + ], + "description": "Generate interactive documentation for your RESTful API using PHP attributes (preferred) or PHPDoc annotations", + "homepage": "https://github.com/zircote/swagger-php", + "keywords": [ + "api", + "json", + "rest", + "service discovery" + ], + "support": { + "issues": "https://github.com/zircote/swagger-php/issues", + "source": "https://github.com/zircote/swagger-php/tree/5.7.3" + }, + "time": "2025-11-17T20:56:13+00:00" + } + ], + "packages-dev": [ + { + "name": "clue/ndjson-react", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\NDJson\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "friendsofphp/php-cs-fixer", + "version": "v3.91.0", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "c4a25f20390337789c26b693ae46faa125040352" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/c4a25f20390337789c26b693ae46faa125040352", + "reference": "c4a25f20390337789c26b693ae46faa125040352", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ext-filter": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.3", + "php": "^7.4 || ^8.0", + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.33", + "symfony/polyfill-php80": "^1.33", + "symfony/polyfill-php81": "^1.33", + "symfony/polyfill-php84": "^1.33", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.7", + "infection/infection": "^0.31.0", + "justinrainbow/json-schema": "^6.5", + "keradus/cli-executor": "^2.2", + "mikey179/vfsstream": "^1.6.12", + "php-coveralls/php-coveralls": "^2.9", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.6", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.6", + "phpunit/phpunit": "^9.6.25 || ^10.5.53 || ^11.5.34", + "symfony/var-dumper": "^5.4.48 || ^6.4.24 || ^7.3.2 || ^8.0", + "symfony/yaml": "^5.4.45 || ^6.4.24 || ^7.3.2 || ^8.0" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." + }, + "bin": [ + "php-cs-fixer" + ], + "type": "application", + "autoload": { + "psr-4": { + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/Fixer/Internal/*" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", + "keywords": [ + "Static code analysis", + "fixer", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.91.0" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2025-11-28T22:07:42+00:00" + }, + { + "name": "phpstan/extension-installer", + "version": "1.4.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/extension-installer.git", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/extension-installer/zipball/85e90b3942d06b2326fba0403ec24fe912372936", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "php": "^7.2 || ^8.0", + "phpstan/phpstan": "^1.9.0 || ^2.0" + }, + "require-dev": { + "composer/composer": "^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2.0", + "phpstan/phpstan-strict-rules": "^0.11 || ^0.12 || ^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPStan\\ExtensionInstaller\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPStan\\ExtensionInstaller\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Composer plugin for automatic installation of PHPStan extensions", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/extension-installer/issues", + "source": "https://github.com/phpstan/extension-installer/tree/1.4.3" + }, + "time": "2024-09-04T20:21:43+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.1.33", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9e800e6bee7d5bd02784d4c6069b48032d16224f", + "reference": "9e800e6bee7d5bd02784d4c6069b48032d16224f", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2025-12-05T10:24:31+00:00" + }, + { + "name": "phpstan/phpstan-phpunit", + "version": "2.0.11", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-phpunit.git", + "reference": "5e30669bef866eff70db8b58d72a5c185aa82414" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/5e30669bef866eff70db8b58d72a5c185aa82414", + "reference": "5e30669bef866eff70db8b58d72a5c185aa82414", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.32" + }, + "conflict": { + "phpunit/phpunit": "<7.0" + }, + "require-dev": { + "nikic/php-parser": "^5", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPUnit extensions and rules for PHPStan", + "support": { + "issues": "https://github.com/phpstan/phpstan-phpunit/issues", + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.11" + }, + "time": "2025-12-19T09:05:35+00:00" + }, + { + "name": "phpstan/phpstan-symfony", + "version": "2.0.12", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-symfony.git", + "reference": "a46dd92eaf15146cd932d897a272e59cd4108ce2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/a46dd92eaf15146cd932d897a272e59cd4108ce2", + "reference": "a46dd92eaf15146cd932d897a272e59cd4108ce2", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.13" + }, + "conflict": { + "symfony/framework-bundle": "<3.0" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-phpunit": "^2.0.8", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "psr/container": "1.1.2", + "symfony/config": "^5.4 || ^6.1", + "symfony/console": "^5.4 || ^6.1", + "symfony/dependency-injection": "^5.4 || ^6.1", + "symfony/form": "^5.4 || ^6.1", + "symfony/framework-bundle": "^5.4 || ^6.1", + "symfony/http-foundation": "^5.4 || ^6.1", + "symfony/messenger": "^5.4", + "symfony/polyfill-php80": "^1.24", + "symfony/serializer": "^5.4", + "symfony/service-contracts": "^2.2.0" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lukáš Unger", + "email": "looky.msc@gmail.com", + "homepage": "https://lookyman.net" + } + ], + "description": "Symfony Framework extensions and rules for PHPStan", + "support": { + "issues": "https://github.com/phpstan/phpstan-symfony/issues", + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.12" + }, + "time": "2026-01-23T09:04:33+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.6", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "1721e2b93d89b745664353b9cfc8f155ba8a6159" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/1721e2b93d89b745664353b9cfc8f155ba8a6159", + "reference": "1721e2b93d89b745664353b9cfc8f155ba8a6159", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.6" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-01-01T16:37:48+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:46+00:00" + }, + { + "name": "symfony/phpunit-bridge", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/phpunit-bridge.git", + "reference": "51b2adaf2cdb00cdab11e6b593e37ef76358e161" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/51b2adaf2cdb00cdab11e6b593e37ef76358e161", + "reference": "51b2adaf2cdb00cdab11e6b593e37ef76358e161", + "shasum": "" + }, + "require": { + "php": ">=8.1.0" + }, + "require-dev": { + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4.3|^7.0.3|^8.0" + }, + "bin": [ + "bin/simple-phpunit" + ], + "type": "symfony-bridge", + "extra": { + "thanks": { + "url": "https://github.com/sebastianbergmann/phpunit", + "name": "phpunit/phpunit" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Bridge\\PhpUnit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/bin/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides utilities for PHPUnit, especially user deprecation notices management", + "homepage": "https://symfony.com", + "keywords": [ + "testing" + ], + "support": { + "source": "https://github.com/symfony/phpunit-bridge/tree/v8.0.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": "2025-10-29T07:48:08+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.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": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/process", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "a0a750500c4ce900d69ba4e9faf16f82c10ee149" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/a0a750500c4ce900d69ba4e9faf16f82c10ee149", + "reference": "a0a750500c4ce900d69ba4e9faf16f82c10ee149", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v8.0.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": "2025-10-16T16:25:44+00:00" + }, + { + "name": "symfony/stopwatch", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "67df1914c6ccd2d7b52f70d40cf2aea02159d942" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/67df1914c6ccd2d7b52f70d40cf2aea02159d942", + "reference": "67df1914c6ccd2d7b52f70d40cf2aea02159d942", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a way to profile code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v8.0.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": "2025-08-04T07:36:47+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^8.4", + "ext-json": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config/routing.php b/config/routing.php index 6374f40..4edf9eb 100644 --- a/config/routing.php +++ b/config/routing.php @@ -4,9 +4,8 @@ use Stixx\OpenApiCommandBundle\Routing\Loader\AttributeDirectoryLoaderDecorator; use Stixx\OpenApiCommandBundle\Routing\Loader\CommandRouteClassLoader; -use Stixx\OpenApiCommandBundle\Routing\NelmioAreaRoutes; +use Stixx\OpenApiCommandBundle\Routing\NelmioAreaRoutesChecker; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; - use function Symfony\Component\DependencyInjection\Loader\Configurator\param; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; @@ -18,7 +17,7 @@ ->private(); $services - ->set(NelmioAreaRoutes::class) + ->set(NelmioAreaRoutesChecker::class) ->arg('$routesLocator', service('stixx_openapi_command.nelmio.routes_locator')); $services diff --git a/phpstan.dist.neon b/phpstan.dist.neon new file mode 100644 index 0000000..eeb00c5 --- /dev/null +++ b/phpstan.dist.neon @@ -0,0 +1,6 @@ +parameters: + level: max + paths: + - src/ + - tests/ + treatPhpDocTypesAsCertain: false diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..ab9ec47 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,29 @@ + + + + + + + + + ./tests/Unit + + + ./tests/Functional + + + + + + ./src/ + + + diff --git a/src/Controller/ArgumentResolver/CommandValueResolver.php b/src/Controller/ArgumentResolver/CommandValueResolver.php index 2522d39..9e21823 100644 --- a/src/Controller/ArgumentResolver/CommandValueResolver.php +++ b/src/Controller/ArgumentResolver/CommandValueResolver.php @@ -20,18 +20,20 @@ use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; -use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Exception\NotEncodableValueException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; -use Symfony\Component\Serializer\SerializerInterface; final readonly class CommandValueResolver implements ValueResolverInterface { public function __construct( - private SerializerInterface $serializer, + private DenormalizerInterface $serializer, ) { } + /** + * @return iterable + */ public function resolve(Request $request, ArgumentMetadata $argument): iterable { $type = $this->resolveTargetClass($request, $argument); @@ -76,14 +78,22 @@ private function resolveTargetClass(Request $request, ArgumentMetadata $argument private function hasRequestBody(Request $request): bool { - return $request->getContent() !== null && trim((string) $request->getContent()) !== ''; + $content = $request->getContent(); + + return $content !== '' && trim((string) $content) !== ''; } private function assertJsonContentType(Request $request): void { - $header = $request->headers->get('Content-Type', ''); - $first = (string) (current(HeaderUtils::split($header, ',')) ?: ''); - $mediaType = strtolower(trim((string) (current(HeaderUtils::split($first, ';')) ?: ''))); + $contentType = $request->headers->get('Content-Type'); + + if ($contentType === null) { + throw new BadRequestHttpException('Unsupported Content-Type. Expecting application/json'); + } + + $parts = HeaderUtils::split($contentType, ';,'); + /** @var array> $parts */ + $mediaType = strtolower(trim($parts[0][0] ?? '')); $isJson = ($mediaType === 'application/json') || str_ends_with($mediaType, '+json'); if (!$isJson) { @@ -114,10 +124,9 @@ static function ($value, $key): bool { $queryData = $request->query->all(); $filteredQuery = array_filter( $queryData, - static function ($value, $key): bool { - return is_string($key) && (is_scalar($value) || $value === null); - }, - ARRAY_FILTER_USE_BOTH + static function ($value): bool { + return is_scalar($value); + } ); return array_replace($routeData, $filteredQuery); @@ -129,26 +138,29 @@ static function ($value, $key): bool { private function decodeJsonBodyToArray(Request $request): array { try { + /** @var array $decoded */ $decoded = json_decode((string) $request->getContent(), true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $exception) { throw new BadRequestHttpException('Invalid JSON body: '.$exception->getMessage(), $exception); } - return is_array($decoded) ? $decoded : []; + return $decoded; } + /** + * @param array $data + */ private function denormalizeToType(array $data, string $type): object { - $format = null; + try { + $object = $this->serializer->denormalize($data, $type); - if (!$this->serializer instanceof DenormalizerInterface) { - $data = json_encode($data, JSON_THROW_ON_ERROR); - $format = JsonEncoder::FORMAT; - } + if (!is_object($object)) { + throw new NotNormalizableValueException(sprintf('Expected object, got %s', get_debug_type($object))); + } - try { - return $this->serializer->denormalize($data, $type, $format); - } catch (NotEncodableValueException $exception) { + return $object; + } catch (NotEncodableValueException|NotNormalizableValueException $exception) { throw new BadRequestHttpException('Unable to map request to command: '.$exception->getMessage(), $exception); } } diff --git a/src/DependencyInjection/Compiler/CollectNelmioApiDocRoutesPass.php b/src/DependencyInjection/Compiler/CollectNelmioApiDocRoutesPass.php index 256e7bd..df0cf65 100644 --- a/src/DependencyInjection/Compiler/CollectNelmioApiDocRoutesPass.php +++ b/src/DependencyInjection/Compiler/CollectNelmioApiDocRoutesPass.php @@ -26,6 +26,7 @@ public function process(ContainerBuilder $container): void return; } + /** @var list $areas */ $areas = (array) $container->getParameter('nelmio_api_doc.areas'); $map = []; diff --git a/src/DependencyInjection/Loader/CommandTaggedRouteLoader.php b/src/DependencyInjection/Loader/CommandTaggedRouteLoader.php deleted file mode 100644 index 73955c3..0000000 --- a/src/DependencyInjection/Loader/CommandTaggedRouteLoader.php +++ /dev/null @@ -1,103 +0,0 @@ -> $taggedRoutes - */ - public function __construct( - private readonly array $taggedRoutes = [], - ) { - parent::__construct(); - } - - public function load(mixed $resource, ?string $type = null): RouteCollection - { - if ($this->loaded) { - return new RouteCollection(); - } - - $collection = new RouteCollection(); - - foreach ($this->taggedRoutes as $meta) { - $class = (string) ($meta['class'] ?? ''); - $path = (string) ($meta['path'] ?? ''); - - if ($class === '' || $path === '') { - continue; - } - - $defaults = array_merge((array) ($meta['defaults'] ?? []), [ - '_controller' => CommandController::class, - '_command_class' => $class, - ]); - - $route = new Route( - path: $path, - defaults: $defaults, - requirements: (array) ($meta['requirements'] ?? []), - options: (array) ($meta['options'] ?? []), - host: $meta['host'] ?? null, - schemes: (array) ($meta['schemes'] ?? []), - methods: (array) ($meta['methods'] ?? []), - condition: $meta['condition'] ?? null, - ); - - $name = (string) ($meta['name'] ?? '') ?: $this->generateRouteName($class); - $final = $this->ensureUniqueName($collection, $name); - $collection->add($final, $route); - } - - $this->loaded = true; - - return $collection; - } - - public function supports(mixed $resource, ?string $type = null): bool - { - return $type === self::TYPE; - } - - private function generateRouteName(string $class): string - { - $base = strtolower(preg_replace('/[^A-Za-z0-9_]+/', '_', ltrim(strrchr($class, '\\') ?: $class, '\\'))); - - return 'command_'.$base; - } - - private function ensureUniqueName(RouteCollection $collection, string $name): string - { - if (null === $collection->get($name)) { - return $name; - } - - $i = 2; - while (null !== $collection->get($name.'_'.$i)) { - ++$i; - } - - return $name.'_'.$i; - } -} diff --git a/src/DependencyInjection/StixxOpenApiCommandExtension.php b/src/DependencyInjection/StixxOpenApiCommandExtension.php index f22947b..5451bd8 100644 --- a/src/DependencyInjection/StixxOpenApiCommandExtension.php +++ b/src/DependencyInjection/StixxOpenApiCommandExtension.php @@ -27,8 +27,11 @@ public function load(array $configs, ContainerBuilder $container): void $configuration = new Configuration(); $config = $this->processConfiguration($configuration, $configs); - $container->setParameter('stixx_openapi_command.validation.enabled', $config['validation']['enabled']); - $container->setParameter('stixx_openapi_command.validation.groups', $config['validation']['groups']); + /** @var array{enabled: bool, groups: list} $validationConfig */ + $validationConfig = $config['validation']; + + $container->setParameter('stixx_openapi_command.validation.enabled', $validationConfig['enabled']); + $container->setParameter('stixx_openapi_command.validation.groups', $validationConfig['groups']); $container ->registerForAutoconfiguration(ResponderInterface::class) diff --git a/src/EventSubscriber/ApiExceptionSubscriber.php b/src/EventSubscriber/ApiExceptionSubscriber.php index 385d6a1..5f18259 100644 --- a/src/EventSubscriber/ApiExceptionSubscriber.php +++ b/src/EventSubscriber/ApiExceptionSubscriber.php @@ -15,18 +15,19 @@ use Stixx\OpenApiCommandBundle\Exception\ApiProblemException; use Stixx\OpenApiCommandBundle\Exception\ExceptionToApiProblemTransformerInterface; -use Stixx\OpenApiCommandBundle\Routing\NelmioAreaRoutes; +use Stixx\OpenApiCommandBundle\Routing\NelmioAreaRoutesChecker; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\KernelEvents; -use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; final readonly class ApiExceptionSubscriber implements EventSubscriberInterface { public function __construct( - private NelmioAreaRoutes $nelmioAreaRoutes, - private SerializerInterface $serializer, + private NelmioAreaRoutesChecker $nelmioAreaRoutesChecker, + private NormalizerInterface $normalizer, private ExceptionToApiProblemTransformerInterface $exceptionTransformer, ) { } @@ -44,7 +45,7 @@ public function onKernelException(ExceptionEvent $event): void return; } - if (!$this->nelmioAreaRoutes->isApiRoute($event->getRequest())) { + if (!$this->nelmioAreaRoutesChecker->isApiRoute($event->getRequest())) { return; } @@ -54,7 +55,7 @@ public function onKernelException(ExceptionEvent $event): void $throwable = $this->exceptionTransformer->transform($throwable); } - $payload = $this->serializer->normalize($throwable, 'json'); + $payload = $this->normalizer->normalize($throwable, JsonEncoder::FORMAT); $response = new JsonResponse($payload, $throwable->getStatusCode(), array_merge([ 'Content-Type' => 'application/problem+json', diff --git a/src/EventSubscriber/RequestValidatorSubscriber.php b/src/EventSubscriber/RequestValidatorSubscriber.php index 5f51fc0..2d8e5b7 100644 --- a/src/EventSubscriber/RequestValidatorSubscriber.php +++ b/src/EventSubscriber/RequestValidatorSubscriber.php @@ -13,7 +13,7 @@ namespace Stixx\OpenApiCommandBundle\EventSubscriber; -use Stixx\OpenApiCommandBundle\Routing\NelmioAreaRoutes; +use Stixx\OpenApiCommandBundle\Routing\NelmioAreaRoutesChecker; use Stixx\OpenApiCommandBundle\Validator\RequestValidatorChain; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\KernelEvent; @@ -23,7 +23,7 @@ { public function __construct( private RequestValidatorChain $requestValidatorChain, - private NelmioAreaRoutes $nelmioAreaRoutes, + private NelmioAreaRoutesChecker $nelmioAreaRoutes, ) { } diff --git a/src/Exception/ApiProblemException.php b/src/Exception/ApiProblemException.php index baa59ea..13bf1f4 100644 --- a/src/Exception/ApiProblemException.php +++ b/src/Exception/ApiProblemException.php @@ -21,8 +21,12 @@ final class ApiProblemException extends RuntimeException implements HttpExceptionInterface { + /** @var array */ + private readonly array $headers; + /** * @param array>|ConstraintViolationListInterface|null $violations + * @param array $headers */ public function __construct( private readonly int $statusCode, @@ -32,8 +36,9 @@ public function __construct( private readonly ?string $instance = null, private readonly array|ConstraintViolationListInterface|null $violations = null, ?Throwable $previous = null, - private readonly array $headers = [], + array $headers = [], ) { + $this->headers = $headers; parent::__construct($detail ?? $title, 0, $previous); } @@ -42,9 +47,15 @@ public function getStatusCode(): int return $this->statusCode; } + /** + * @return array + */ public function getHeaders(): array { - return $this->headers; + /** @var array $headers */ + $headers = $this->headers; + + return $headers; } public function getTitle(): string diff --git a/src/Exception/DefaultExceptionToApiProblemTransformer.php b/src/Exception/DefaultExceptionToApiProblemTransformer.php index c9a639f..b1d0aa9 100644 --- a/src/Exception/DefaultExceptionToApiProblemTransformer.php +++ b/src/Exception/DefaultExceptionToApiProblemTransformer.php @@ -49,12 +49,13 @@ public function transform(Throwable $throwable): ApiProblemException statusCode: $throwable->getStatusCode(), title: $this->defaultTitleForStatus($throwable->getStatusCode()), type: 'about:blank', - detail: $throwable->getMessage() ?? null, + detail: $throwable->getMessage(), previous: $throwable, + /* @phpstan-ignore-next-line */ headers: $throwable->getHeaders() ), default => ApiProblemException::serverError( - detail: $throwable->getMessage() ?? null, + detail: $throwable->getMessage(), ), }; } diff --git a/src/Response/ResponseStatusResolver.php b/src/Response/ResponseStatusResolver.php index 407fc56..2c13c6f 100644 --- a/src/Response/ResponseStatusResolver.php +++ b/src/Response/ResponseStatusResolver.php @@ -74,13 +74,10 @@ private function findOperationAttributeForMethod(object $command, string $method private function first2xxFromOperation(Operation $operation): ?int { - $responses = $operation->responses ?? []; + $responses = $operation->responses; foreach ($responses as $response) { - $code = $response->response ?? null; - if ($code === null) { - continue; - } + $code = $response->response; if (is_string($code) && ctype_digit($code)) { $code = (int) $code; diff --git a/src/RouteDescriber/CommandRouteDescriber.php b/src/RouteDescriber/CommandRouteDescriber.php index 89e9235..f84828a 100644 --- a/src/RouteDescriber/CommandRouteDescriber.php +++ b/src/RouteDescriber/CommandRouteDescriber.php @@ -62,6 +62,7 @@ public function describe(OA\OpenApi $api, Route $route, ReflectionMethod $reflec } $pathItem = Util::getPath($api, $this->normalizePath($route->getPath())); + /** @var class-string $commandClass */ $classReflector = new ReflectionClass($commandClass); $context = $this->createContextForCommandClass($classReflector, $pathItem); @@ -89,6 +90,8 @@ private function getSupportedHttpMethods(Route $route): array } /** + * @param ReflectionClass $reflection + * * @return OA\AbstractAnnotation[] */ private function getAttributesAsAnnotation(ReflectionClass $reflection, Context $context): array @@ -114,12 +117,16 @@ private function resolveCommandClass(Route $route): ?string return $commandClass; } + /** + * @param ReflectionClass $classReflector + */ private function createContextForCommandClass(ReflectionClass $classReflector, OA\PathItem $pathItem): Context { $context = Util::createContext(['nested' => $pathItem], $pathItem->_context); $context->namespace = $classReflector->getNamespaceName(); $context->class = $classReflector->getShortName(); - $context->filename = $classReflector->getFileName(); + $filename = $classReflector->getFileName(); + $context->filename = is_string($filename) ? $filename : null; return $context; } @@ -147,7 +154,7 @@ private function processClassAnnotations(OA\OpenApi $api, OA\PathItem $pathItem, // Ensure the Command's operationId, when provided, overwrites any existing value. // To avoid swagger-php "Multiple definitions" warnings, directly set it on the // target operation and clear it from the annotation before merging. - if (property_exists($annotation, 'operationId') && Generator::UNDEFINED !== $annotation->operationId) { + if (Generator::UNDEFINED !== $annotation->operationId) { $operation->operationId = $annotation->operationId; $annotation->operationId = Generator::UNDEFINED; } @@ -159,6 +166,7 @@ private function processClassAnnotations(OA\OpenApi $api, OA\PathItem $pathItem, if ($annotation instanceof OA\Tag) { $annotation->validate(); + /* @phpstan-ignore-next-line */ $mergeProperties->tags[] = $annotation->name; $tag = Util::getTag($api, $annotation->name); diff --git a/src/Routing/Loader/AttributeDirectoryLoaderDecorator.php b/src/Routing/Loader/AttributeDirectoryLoaderDecorator.php index 2f29f6c..11b6483 100644 --- a/src/Routing/Loader/AttributeDirectoryLoaderDecorator.php +++ b/src/Routing/Loader/AttributeDirectoryLoaderDecorator.php @@ -29,9 +29,12 @@ public function __construct( ) { } - public function load(mixed $resource, ?string $type = null): ?RouteCollection + public function load(mixed $resource, ?string $type = null): RouteCollection { - $collection = $this->inner->load($resource, $type) ?? new RouteCollection(); + $collection = $this->inner->load($resource, $type); + if (!$collection instanceof RouteCollection) { + $collection = new RouteCollection(); + } if ($this->augmented) { return $collection; diff --git a/src/Routing/Loader/CommandRouteClassLoader.php b/src/Routing/Loader/CommandRouteClassLoader.php index 85c7873..c1eae9d 100644 --- a/src/Routing/Loader/CommandRouteClassLoader.php +++ b/src/Routing/Loader/CommandRouteClassLoader.php @@ -15,6 +15,7 @@ use OpenApi\Annotations\Operation; use OpenApi\Attributes as OA; +use OpenApi\Generator; use ReflectionAttribute; use ReflectionClass; use ReflectionMethod; @@ -27,7 +28,7 @@ final class CommandRouteClassLoader extends AttributeClassLoader { /** - * @param list $controllerClasses + * @param array $controllerClasses */ public function __construct( ?string $env = null, @@ -108,14 +109,20 @@ public function load(mixed $class, ?string $type = null): RouteCollection return $collection; } + /** + * @param ReflectionClass $class + */ protected function configureRoute(Route $route, ReflectionClass $class, ReflectionMethod $method, object $attr): void { } + /** + * @param ReflectionClass $class + */ private function defaultNameFromClass(ReflectionClass $class): string { $short = $class->getShortName(); - $base = strtolower(preg_replace('/[^A-Za-z0-9_]+/', '_', $short)); + $base = strtolower(preg_replace('/[^A-Za-z0-9_]+/', '_', $short) ?? ''); return 'command_'.$base; } @@ -139,8 +146,8 @@ private function ensureUniqueName(RouteCollection $collection, string $name): st */ private function methodsFromOperation(Operation $operation): array { - $method = property_exists($operation, 'method') ? ($operation->method ?? '') : ''; - if ($method !== '') { + $method = $operation->method; + if ($method !== Generator::UNDEFINED && $method !== '') { return [strtoupper($method)]; } @@ -156,10 +163,13 @@ private function methodsFromOperation(Operation $operation): array }; } + /** + * @param ReflectionClass $class + */ private function routeNameFromOperation(Operation $operation, ReflectionClass $class): string { - $operationId = $operation->operationId ?? ''; - if ($operationId !== '') { + $operationId = $operation->operationId; + if ($operationId !== Generator::UNDEFINED && $operationId !== '') { return $operationId; } @@ -168,7 +178,7 @@ private function routeNameFromOperation(Operation $operation, ReflectionClass $c private function controllerFromVendorExtension(Operation $operation): ?string { - $x = $operation->x ?? null; + $x = $operation->x; if (is_array($x)) { $controller = $x['controller'] ?? null; if (is_string($controller) && $controller !== '') { @@ -179,6 +189,9 @@ private function controllerFromVendorExtension(Operation $operation): ?string return null; } + /** + * @param ReflectionClass $reflectionClass + */ private function resolveClassLevelController(ReflectionClass $reflectionClass): ?string { $attrs = $reflectionClass->getAttributes(CommandObject::class, ReflectionAttribute::IS_INSTANCEOF); @@ -186,7 +199,7 @@ private function resolveClassLevelController(ReflectionClass $reflectionClass): return null; } - $commandObject = $attrs[0]?->newInstance(); + $commandObject = $attrs[0]->newInstance(); if (!$commandObject instanceof CommandObject) { return null; } diff --git a/src/Routing/Loader/CommandRouteDirectoryLoader.php b/src/Routing/Loader/CommandRouteDirectoryLoader.php index 28daa29..a392d7b 100644 --- a/src/Routing/Loader/CommandRouteDirectoryLoader.php +++ b/src/Routing/Loader/CommandRouteDirectoryLoader.php @@ -27,6 +27,6 @@ public function __construct(FileLocatorInterface $locator, CommandRouteClassLoad public function supports(mixed $resource, ?string $type = null): bool { - return $type === self::TYPE; + return is_string($resource) && $type === self::TYPE; } } diff --git a/src/Routing/NelmioAreaRoutes.php b/src/Routing/NelmioAreaRoutesChecker.php similarity index 81% rename from src/Routing/NelmioAreaRoutes.php rename to src/Routing/NelmioAreaRoutesChecker.php index 85e4e7b..461e29a 100644 --- a/src/Routing/NelmioAreaRoutes.php +++ b/src/Routing/NelmioAreaRoutesChecker.php @@ -17,16 +17,19 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\RouteCollection; -final readonly class NelmioAreaRoutes +final readonly class NelmioAreaRoutesChecker { + /** + * @param ServiceLocator $routesLocator + */ public function __construct(private ServiceLocator $routesLocator) { } public function isApiRoute(Request $request): bool { - $routeName = (string) $request->attributes->get('_route', ''); - if ($routeName === '') { + $routeName = $request->attributes->get('_route', ''); + if (!is_string($routeName) || $routeName === '') { return false; } diff --git a/src/Serializer/Normalizer/ApiProblemNormalizer.php b/src/Serializer/Normalizer/ApiProblemNormalizer.php index 5faf44e..5bdbbcf 100644 --- a/src/Serializer/Normalizer/ApiProblemNormalizer.php +++ b/src/Serializer/Normalizer/ApiProblemNormalizer.php @@ -26,6 +26,9 @@ public function __construct(private readonly bool $debug) { } + /** + * @param array $context + */ public function supportsNormalization($data, ?string $format = null, array $context = []): bool { return $data instanceof ApiProblemException; @@ -33,6 +36,7 @@ public function supportsNormalization($data, ?string $format = null, array $cont /** * @param ApiProblemException $data + * @param array $context * * @return array{type:string,title:string,status:int,detail?:string,instance?:string,violations?:mixed} */ diff --git a/src/Serializer/Normalizer/ConstraintViolationListNormalizer.php b/src/Serializer/Normalizer/ConstraintViolationListNormalizer.php index f3d489c..c9a0e7f 100644 --- a/src/Serializer/Normalizer/ConstraintViolationListNormalizer.php +++ b/src/Serializer/Normalizer/ConstraintViolationListNormalizer.php @@ -32,6 +32,9 @@ final class ConstraintViolationListNormalizer implements NormalizerInterface, No { use NormalizerAwareTrait; + /** + * @param array $context + */ public function supportsNormalization($data, ?string $format = null, array $context = []): bool { return $data instanceof ConstraintViolationListInterface; @@ -39,18 +42,23 @@ public function supportsNormalization($data, ?string $format = null, array $cont /** * @param ConstraintViolationListInterface $data + * @param array $context * - * @return array> + * @return list */ public function normalize($data, ?string $format = null, array $context = []): array { $out = []; + /** @var ConstraintViolationInterface $violation */ foreach ($data as $violation) { - if ($violation instanceof ConstraintViolationInterface) { - $out[] = $this->normalizer->normalize($violation, $format, $context); + $normalized = $this->normalizer->normalize($violation, $format, $context); + if (is_array($normalized)) { + /* @var array $normalized */ + $out[] = $normalized; } } + /* @var list $out */ return $out; } diff --git a/src/Serializer/Normalizer/ConstraintViolationNormalizer.php b/src/Serializer/Normalizer/ConstraintViolationNormalizer.php index ba5007c..3ad66b5 100644 --- a/src/Serializer/Normalizer/ConstraintViolationNormalizer.php +++ b/src/Serializer/Normalizer/ConstraintViolationNormalizer.php @@ -33,6 +33,9 @@ final class ConstraintViolationNormalizer implements NormalizerInterface */ private array $constMapCache = []; + /** + * @param array $context + */ public function supportsNormalization($data, ?string $format = null, array $context = []): bool { return $data instanceof ConstraintViolationInterface; @@ -40,8 +43,9 @@ public function supportsNormalization($data, ?string $format = null, array $cont /** * @param ConstraintViolationInterface $data + * @param array $context * - * @return array{propertyPath:?string,message:string,code:?string,constraint:?string,error:?string} + * @return array{propertyPath: string|null, message: string, code: string|null, constraint: string|null, error: string|null} */ public function normalize($data, ?string $format = null, array $context = []): array { @@ -58,6 +62,7 @@ public function normalize($data, ?string $format = null, array $context = []): a $constraintName = $reflectionClass->getShortName(); if ($code !== null) { + /** @var ReflectionClass $reflectionClass */ $map = $this->constMapCache[$reflectionClass->getName()] ??= $this->buildConstantMap($reflectionClass); $errorName = $map[$code] ?? null; } @@ -65,7 +70,7 @@ public function normalize($data, ?string $format = null, array $context = []): a return [ 'propertyPath' => $violation->getPropertyPath() ?: null, - 'message' => $violation->getMessage(), + 'message' => (string) $violation->getMessage(), 'code' => $code, 'constraint' => $constraintName, 'error' => $errorName, @@ -81,6 +86,8 @@ public function getSupportedTypes(?string $format): array } /** + * @param ReflectionClass $reflectionClass + * * @return array */ private function buildConstantMap(ReflectionClass $reflectionClass): array diff --git a/src/StixxOpenApiCommandBundle.php b/src/StixxOpenApiCommandBundle.php index 2d652e2..7ce7357 100644 --- a/src/StixxOpenApiCommandBundle.php +++ b/src/StixxOpenApiCommandBundle.php @@ -29,10 +29,16 @@ public function build(ContainerBuilder $container): void public function getContainerExtension(): ?ExtensionInterface { - if (!isset($this->extension)) { - $this->extension = $this->createContainerExtension(); + if (null === $this->extension) { + $extension = $this->createContainerExtension(); + + if ($extension instanceof ExtensionInterface) { + $this->extension = $extension; + } else { + $this->extension = false; + } } - return $this->extension; + return $this->extension ?: null; } } diff --git a/tests/Functional/.gitignore b/tests/Functional/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/tests/Mock/CallCountValidator.php b/tests/Mock/CallCountValidator.php new file mode 100644 index 0000000..a085d11 --- /dev/null +++ b/tests/Mock/CallCountValidator.php @@ -0,0 +1,30 @@ + + */ + public array $calls = []; + + public function validate(Request $request): void + { + $this->calls[] = $request; + } +} diff --git a/tests/Mock/Command/CreateItemCommand.php b/tests/Mock/Command/CreateItemCommand.php new file mode 100644 index 0000000..85996fe --- /dev/null +++ b/tests/Mock/Command/CreateItemCommand.php @@ -0,0 +1,83 @@ + + */ + public array $calls = []; + + public function describe(ArgumentMetadata $argumentMetadata, OA\Operation $operation): void + { + $this->calls[] = [$argumentMetadata, $operation]; + } +} diff --git a/tests/Mock/Routing/src/AnnotatedCommand.php b/tests/Mock/Routing/src/AnnotatedCommand.php new file mode 100644 index 0000000..74086dc --- /dev/null +++ b/tests/Mock/Routing/src/AnnotatedCommand.php @@ -0,0 +1,21 @@ +class); + self::assertSame(CommandController::class, $attribute->controller); + } + + public function testConstructorCustomValues(): void + { + // Act + $attribute = new CommandObject(class: 'App\Command\MyCommand', controller: 'App\Controller\CustomController'); + + // Assert + self::assertSame('App\Command\MyCommand', $attribute->class); + self::assertSame('App\Controller\CustomController', $attribute->controller); + } +} diff --git a/tests/Unit/Controller/ArgumentResolver/CommandValueResolverTest.php b/tests/Unit/Controller/ArgumentResolver/CommandValueResolverTest.php new file mode 100644 index 0000000..e0438fa --- /dev/null +++ b/tests/Unit/Controller/ArgumentResolver/CommandValueResolverTest.php @@ -0,0 +1,225 @@ +serializer = $this->createMock(DenormalizerInterface::class); + $this->resolver = new CommandValueResolver($this->serializer); + } + + public function testResolveReturnsEmptyWhenNoAttribute(): void + { + // Arrange + $request = new Request(); + $argument = new ArgumentMetadata('command', stdClass::class, false, false, null); + + // Act + $result = $this->resolver->resolve($request, $argument); + + // Assert + $this->assertSame([], iterator_to_array($result)); + } + + public function testResolveReturnsEmptyWhenTypeCannotBeResolved(): void + { + // Arrange + $request = new Request(); + $attribute = new CommandObject(class: null); + $argument = new ArgumentMetadata('command', null, false, false, null, false, [$attribute]); + + // Act + $result = $this->resolver->resolve($request, $argument); + + // Assert + $this->assertSame([], iterator_to_array($result)); + } + + /** + * @param array $expectedPayload + */ + #[DataProvider('provideResolveRequests')] + public function testResolveRequests(Request $request, ArgumentMetadata $argument, array $expectedPayload, string $expectedType): void + { + // Arrange + $expectedObject = new stdClass(); + + $this->serializer->expects($this->once()) + ->method('denormalize') + ->with($expectedPayload, $expectedType) + ->willReturn($expectedObject); + + // Act + $result = $this->resolver->resolve($request, $argument); + $actual = iterator_to_array($result); + + // Assert + $this->assertCount(1, $actual); + $this->assertSame($expectedObject, $actual[0]); + } + + /** + * @return iterable, string}> + */ + public static function provideResolveRequests(): iterable + { + yield 'with attribute class' => [ + new Request(), + new ArgumentMetadata('command', null, false, false, null, false, [new CommandObject(class: stdClass::class)]), + [], + stdClass::class, + ]; + + yield 'with argument type' => [ + new Request(), + new ArgumentMetadata('command', stdClass::class, false, false, null, false, [new CommandObject()]), + [], + stdClass::class, + ]; + + yield 'with route class' => [ + new Request(attributes: ['_command_class' => stdClass::class]), + new ArgumentMetadata('command', 'object', false, false, null, false, [new CommandObject()]), + [], + stdClass::class, + ]; + + yield 'with request body only' => [ + self::createJsonRequest('{"foo":"bar"}'), + new ArgumentMetadata('command', stdClass::class, false, false, null, false, [new CommandObject()]), + ['foo' => 'bar'], + stdClass::class, + ]; + + yield 'with parameters only' => [ + new Request( + query: ['queryParam' => 'queryValue'], + attributes: ['routeParam' => 'routeValue', '_internal' => 'ignore'] + ), + new ArgumentMetadata('command', stdClass::class, false, false, null, false, [new CommandObject()]), + ['routeParam' => 'routeValue', 'queryParam' => 'queryValue'], + stdClass::class, + ]; + + yield 'with precedence' => [ + self::createJsonRequest( + '{"key":"bodyValue", "other":"bodyOther", "unique":"bodyUnique"}', + ['key' => 'queryValue'], + ['key' => 'routeValue', 'other' => 'routeOther'] + ), + new ArgumentMetadata('command', stdClass::class, false, false, null, false, [new CommandObject()]), + [ + 'key' => 'queryValue', + 'other' => 'routeOther', + 'unique' => 'bodyUnique', + ], + stdClass::class, + ]; + + yield 'merges payload route and query' => [ + self::createJsonRequest( + '{"body":"value", "override":"body"}', + ['query' => 'value', 'override' => 'query'], + ['route' => 'value', 'override' => 'route', '_not_included' => 'secret'] + ), + new ArgumentMetadata('command', stdClass::class, false, false, null, false, [new CommandObject()]), + [ + 'body' => 'value', + 'override' => 'query', + 'route' => 'value', + 'query' => 'value', + ], + stdClass::class, + ]; + } + + #[DataProvider('provideValidationFailures')] + public function testResolveValidationFailures(Request $request, string $expectedMessage): void + { + // Arrange + $attribute = new CommandObject(class: stdClass::class); + $argument = new ArgumentMetadata('command', null, false, false, null, false, [$attribute]); + + // Assert + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage($expectedMessage); + + // Act + iterator_to_array($this->resolver->resolve($request, $argument)); + } + + /** + * @return iterable + */ + public static function provideValidationFailures(): iterable + { + yield 'unsupported content type' => [ + new Request(server: ['HTTP_CONTENT_TYPE' => 'text/plain'], content: '{"foo":"bar"}'), + 'Unsupported Content-Type. Expecting application/json', + ]; + + yield 'invalid JSON' => [ + self::createJsonRequest('{invalid}'), + 'Invalid JSON body', + ]; + } + + public function testResolveThrowsOnDenormalizationFailure(): void + { + // Arrange + $request = new Request(); + $attribute = new CommandObject(class: stdClass::class); + $argument = new ArgumentMetadata('command', null, false, false, null, false, [$attribute]); + + $this->serializer->expects($this->once()) + ->method('denormalize') + ->willThrowException(new NotEncodableValueException('Fail')); + + // Assert + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('Unable to map request to command: Fail'); + + // Act + iterator_to_array($this->resolver->resolve($request, $argument)); + } + + /** + * @param array $query + * @param array $attributes + */ + private static function createJsonRequest(string $content, array $query = [], array $attributes = []): Request + { + $request = new Request($query, [], $attributes, [], [], [], $content); + $request->headers->set('Content-Type', 'application/json'); + + return $request; + } +} diff --git a/tests/Unit/Controller/CommandControllerTest.php b/tests/Unit/Controller/CommandControllerTest.php new file mode 100644 index 0000000..6bc31dc --- /dev/null +++ b/tests/Unit/Controller/CommandControllerTest.php @@ -0,0 +1,185 @@ +createMock(ValidatorInterface::class); + $violations = $this->createMock(ConstraintViolationListInterface::class); + + $violations->method('count')->willReturn(0); + $validator->expects(self::once()) + ->method('validate') + ->with($command, null, ['Default']) + ->willReturn($violations); + + $result = ['ok' => true]; + $envelope = new Envelope($command, [new HandledStamp($result, 'handler')]); + + $messageBus = $this->createMock(MessageBusInterface::class); + $messageBus->expects(self::once()) + ->method('dispatch') + ->with($command) + ->willReturn($envelope); + + $statusResolver = $this->createMock(StatusResolverInterface::class); + $statusResolver->expects(self::once()) + ->method('resolve') + ->with($request, $command) + ->willReturn(201); + + $responder = $this->createMock(ResponderInterface::class); + $responder->expects(self::once()) + ->method('respond') + ->with($result, 201) + ->willReturn(new Response((string) json_encode($result), 201, ['Content-Type' => 'application/json'])); + + // Act + $controller = new CommandController($messageBus, $validator, $statusResolver, $responder); + $response = $controller($request, $command); + + // Assert + self::assertSame(201, $response->getStatusCode()); + self::assertSame(json_encode($result), $response->getContent()); + self::assertSame('application/json', $response->headers->get('content-type')); + } + + public function testInvokeSkipsValidationWhenDisabled(): void + { + // Arrange + $command = new ExampleCommand(); + $request = new Request(); + + $validator = $this->createMock(ValidatorInterface::class); + $validator->expects(self::never()) + ->method('validate'); + + $result = ['done' => 1]; + $envelope = new Envelope($command, [new HandledStamp($result, 'handler')]); + + $messageBus = $this->createMock(MessageBusInterface::class); + $messageBus->method('dispatch') + ->with($command) + ->willReturn($envelope); + + $statusResolver = $this->createMock(StatusResolverInterface::class); + $statusResolver->method('resolve') + ->with($request, $command) + ->willReturn(200); + + $responder = $this->createMock(ResponderInterface::class); + $responder->method('respond') + ->with($result, 200) + ->willReturn(new Response((string) json_encode($result), 200)); + + // Act + $controller = new CommandController($messageBus, $validator, $statusResolver, $responder, validationEnabled: false); + $response = $controller($request, $command); + + // Assert + self::assertSame(200, $response->getStatusCode()); + self::assertSame(json_encode($result), $response->getContent()); + } + + public function testInvokeThrowsApiProblemExceptionWhenValidationFails(): void + { + // Arrange + $this->expectException(ApiProblemException::class); + + $command = new ExampleCommand(); + $request = new Request(); + + $violations = $this->createMock(ConstraintViolationListInterface::class); + $violations->method('count') + ->willReturn(2); + + $validator = $this->createMock(ValidatorInterface::class); + $validator->method('validate') + ->willReturn($violations); + + $messageBus = $this->createMock(MessageBusInterface::class); + $messageBus->expects(self::never()) + ->method('dispatch'); + + $statusResolver = $this->createMock(StatusResolverInterface::class); + $statusResolver->expects(self::never()) + ->method('resolve'); + + $responder = $this->createMock(ResponderInterface::class); + + // Act + $controller = new CommandController($messageBus, $validator, $statusResolver, $responder, validationEnabled: true); + $controller($request, $command); + } + + public function testInvokeRethrowsPreviousExceptionFromHandlerFailedException(): void + { + // Arrange + $command = new ExampleCommand(); + $request = new Request(); + + $validator = $this->createMock(ValidatorInterface::class); + $violations = $this->createMock(ConstraintViolationListInterface::class); + + $violations->method('count') + ->willReturn(0); + $validator->method('validate') + ->willReturn($violations); + + $exception = new HandlerFailedException( + new Envelope($command), + [new RuntimeException('boom')] + ); + + $messageBus = $this->createMock(MessageBusInterface::class); + $messageBus->method('dispatch') + ->willThrowException($exception); + + $statusResolver = $this->createMock(StatusResolverInterface::class); + $responder = $this->createMock(ResponderInterface::class); + + // Act + $controller = new CommandController($messageBus, $validator, $statusResolver, $responder, validationEnabled: true); + + // Assert + try { + $controller($request, $command); + self::fail('Expected RuntimeException to be thrown'); + } catch (RuntimeException $e) { + self::assertSame('boom', $e->getMessage()); + } + } +} diff --git a/tests/Unit/DependencyInjection/Compiler/CollectControllerClassesPassTest.php b/tests/Unit/DependencyInjection/Compiler/CollectControllerClassesPassTest.php new file mode 100644 index 0000000..a89d8e2 --- /dev/null +++ b/tests/Unit/DependencyInjection/Compiler/CollectControllerClassesPassTest.php @@ -0,0 +1,49 @@ +addTag('controller.service_arguments'); + $container->setDefinition('controller1', $controller1); + + $controller2 = new Definition('NonExistentClass'); + $controller2->addTag('controller.service_arguments'); + $container->setDefinition('controller2', $controller2); + + $otherService = new Definition(stdClass::class); + $container->setDefinition('other_service', $otherService); + + $pass = new CollectControllerClassesPass(); + + // Act + $pass->process($container); + + // Assert + self::assertTrue($container->hasParameter('stixx_openapi_command.controller_classes')); + self::assertSame([stdClass::class => true], $container->getParameter('stixx_openapi_command.controller_classes')); + } +} diff --git a/tests/Unit/DependencyInjection/Compiler/CollectNelmioApiDocRoutesPassTest.php b/tests/Unit/DependencyInjection/Compiler/CollectNelmioApiDocRoutesPassTest.php new file mode 100644 index 0000000..a1c6685 --- /dev/null +++ b/tests/Unit/DependencyInjection/Compiler/CollectNelmioApiDocRoutesPassTest.php @@ -0,0 +1,65 @@ +setParameter('nelmio_api_doc.areas', ['default', 'internal']); + + $container->setDefinition('nelmio_api_doc.routes.default', new Definition(stdClass::class)); + // 'nelmio_api_doc.routes.internal' is missing + + $pass = new CollectNelmioApiDocRoutesPass(); + + // Act + $pass->process($container); + + // Assert + self::assertTrue($container->hasDefinition('stixx_openapi_command.nelmio.routes_locator')); + $definition = $container->getDefinition('stixx_openapi_command.nelmio.routes_locator'); + + self::assertSame(ServiceLocator::class, $definition->getClass()); + self::assertTrue($definition->hasTag('container.service_locator')); + + $expectedMap = [ + 'default' => new Reference('nelmio_api_doc.routes.default'), + ]; + self::assertEquals([$expectedMap], $definition->getArguments()); + } + + public function testProcessWithoutParameter(): void + { + // Arrange + $container = new ContainerBuilder(); + $pass = new CollectNelmioApiDocRoutesPass(); + + // Act + $pass->process($container); + + // Assert + self::assertFalse($container->hasDefinition('stixx_openapi_command.nelmio.routes_locator')); + } +} diff --git a/tests/Unit/DependencyInjection/ConfigurationTest.php b/tests/Unit/DependencyInjection/ConfigurationTest.php new file mode 100644 index 0000000..7ab0620 --- /dev/null +++ b/tests/Unit/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,59 @@ +processConfiguration($configuration, []); + + // Assert + $expected = [ + 'validation' => [ + 'enabled' => true, + 'groups' => ['Default'], + ], + ]; + self::assertSame($expected, $config); + } + + public function testCustomConfig(): void + { + // Arrange + $configuration = new Configuration(); + $processor = new Processor(); + $customConfig = [ + 'validation' => [ + 'enabled' => false, + 'groups' => ['Custom', 'Special'], + ], + ]; + + // Act + $config = $processor->processConfiguration($configuration, [$customConfig]); + + // Assert + self::assertSame($customConfig, $config); + } +} diff --git a/tests/Unit/DependencyInjection/StixxOpenApiCommandExtensionTest.php b/tests/Unit/DependencyInjection/StixxOpenApiCommandExtensionTest.php new file mode 100644 index 0000000..a2b0352 --- /dev/null +++ b/tests/Unit/DependencyInjection/StixxOpenApiCommandExtensionTest.php @@ -0,0 +1,54 @@ +load([], $container); + + // Assert + self::assertTrue($container->hasParameter('stixx_openapi_command.validation.enabled')); + self::assertTrue($container->getParameter('stixx_openapi_command.validation.enabled')); + self::assertSame(['Default'], $container->getParameter('stixx_openapi_command.validation.groups')); + + $autoconfigured = $container->getAutoconfiguredInstanceof(); + self::assertArrayHasKey(ResponderInterface::class, $autoconfigured); + self::assertTrue($autoconfigured[ResponderInterface::class]->hasTag(ResponderInterface::TAG_NAME)); + + self::assertArrayHasKey(ValidatorInterface::class, $autoconfigured); + self::assertTrue($autoconfigured[ValidatorInterface::class]->hasTag(ValidatorInterface::TAG_NAME)); + } + + public function testGetAlias(): void + { + // Arrange + $extension = new StixxOpenApiCommandExtension(); + + // Act & Assert + self::assertSame('stixx_openapi_command', $extension->getAlias()); + } +} diff --git a/tests/Unit/EventSubscriber/AbstractEventSubscriberTestCase.php b/tests/Unit/EventSubscriber/AbstractEventSubscriberTestCase.php new file mode 100644 index 0000000..75e8834 --- /dev/null +++ b/tests/Unit/EventSubscriber/AbstractEventSubscriberTestCase.php @@ -0,0 +1,55 @@ + + */ + public static function skipRequestProvider(): iterable + { + yield 'sub-request' => [ + HttpKernelInterface::SUB_REQUEST, + 'api_route', + 'api_route', + ]; + + yield 'main request non-api route' => [ + HttpKernelInterface::MAIN_REQUEST, + 'some_api', + 'not_api', + ]; + } + + protected function createNelmioAreaRoutesWithRouteName(string $routeName): NelmioAreaRoutesChecker + { + $collection = new RouteCollection(); + $collection->add($routeName, new Route('/'.$routeName)); + + /** @var ServiceLocator $locator */ + $locator = new ServiceLocator([ + 'default' => static fn () => $collection, + ]); + + return new NelmioAreaRoutesChecker($locator); + } +} diff --git a/tests/Unit/EventSubscriber/ApiExceptionSubscriberTest.php b/tests/Unit/EventSubscriber/ApiExceptionSubscriberTest.php new file mode 100644 index 0000000..289f517 --- /dev/null +++ b/tests/Unit/EventSubscriber/ApiExceptionSubscriberTest.php @@ -0,0 +1,156 @@ +createMock(KernelInterface::class); + $request = new Request(); + $throwable = new RuntimeException('boom'); + $event = new ExceptionEvent($kernel, $request, $requestType, $throwable); + + $routes = $this->createNelmioAreaRoutesWithRouteName($areaRoute); + $request->attributes->set('_route', $requestRoute); + + $normalizer = $this->createMock(NormalizerInterface::class); + $transformer = $this->createMock(ExceptionToApiProblemTransformerInterface::class); + $subscriber = new ApiExceptionSubscriber($routes, $normalizer, $transformer); + + // Act + $subscriber->onKernelException($event); + + // Assert + self::assertFalse($event->isPropagationStopped()); + self::assertNull($event->getResponse()); + } + + public function testUsesApiProblemExceptionAsIsAndBuildsProblemJsonResponse(): void + { + // Arrange + $kernel = $this->createMock(KernelInterface::class); + $request = new Request(); + + $violations = [ + ['constraint' => 'x', 'message' => 'y'], + ]; + $apiProblem = ApiProblemException::badRequest( + detail: 'bad', + violations: $violations + ); + + $event = new ExceptionEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $apiProblem); + + $routes = $this->createNelmioAreaRoutesWithRouteName('api_problem'); + $request->attributes->set('_route', 'api_problem'); + + $normalizer = $this->createMock(NormalizerInterface::class); + $normalizer->expects(self::once()) + ->method('normalize') + ->willReturn(['detail' => 'bad', 'violations' => $violations, 'status' => 400, 'title' => 'The request body contains errors', 'type' => 'about:blank']); + + $transformer = $this->createMock(ExceptionToApiProblemTransformerInterface::class); + $subscriber = new ApiExceptionSubscriber($routes, $normalizer, $transformer); + + // Act + $subscriber->onKernelException($event); + + // Assert + self::assertTrue($event->isPropagationStopped()); + $response = $event->getResponse(); + self::assertNotNull($response); + self::assertSame(400, $response->getStatusCode()); + self::assertSame('application/problem+json', $response->headers->get('Content-Type')); + + $content = $response->getContent(); + self::assertIsString($content); + /** @var array $data */ + $data = json_decode($content, true); + self::assertSame('about:blank', $data['type']); + self::assertSame('The request body contains errors', $data['title']); + self::assertSame(400, $data['status']); + self::assertSame('bad', $data['detail']); + self::assertSame($violations, $data['violations']); + } + + #[DataProvider('exceptionMappingProvider')] + public function testMapsExceptionToApiProblem(Throwable $throwable, string $routeName, int $expectedStatus, string $expectedTitle): void + { + // Arrange + $kernel = $this->createMock(KernelInterface::class); + $request = new Request(); + $event = new ExceptionEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $throwable); + + $routes = $this->createNelmioAreaRoutesWithRouteName($routeName); + $request->attributes->set('_route', $routeName); + + $apiProblem = new ApiProblemException($expectedStatus, $expectedTitle); + + $transformer = $this->createMock(ExceptionToApiProblemTransformerInterface::class); + $transformer->expects(self::once()) + ->method('transform') + ->with($throwable) + ->willReturn($apiProblem); + + $normalizer = $this->createMock(NormalizerInterface::class); + $normalizer->expects(self::once()) + ->method('normalize') + ->with($apiProblem, 'json') + ->willReturn(['title' => $expectedTitle, 'status' => $expectedStatus, 'type' => 'about:blank']); + + $subscriber = new ApiExceptionSubscriber($routes, $normalizer, $transformer); + + // Act + $subscriber->onKernelException($event); + + // Assert + self::assertTrue($event->isPropagationStopped()); + $response = $event->getResponse(); + self::assertNotNull($response); + self::assertSame($expectedStatus, $response->getStatusCode()); + + $content = $response->getContent(); + self::assertIsString($content); + /** @var array $data */ + $data = json_decode($content, true); + self::assertSame($expectedTitle, $data['title']); + self::assertSame($expectedStatus, $data['status']); + self::assertSame('about:blank', $data['type']); + } + + /** + * @return iterable + */ + public static function exceptionMappingProvider(): iterable + { + yield 'forbidden from access denied' => [new AccessDeniedHttpException(), 'api_forbidden', 403, 'Forbidden']; + yield 'server error from generic exception' => [new RuntimeException('oops'), 'api_error', 500, 'An error occurred.']; + } +} diff --git a/tests/Unit/EventSubscriber/RequestValidatorSubscriberTest.php b/tests/Unit/EventSubscriber/RequestValidatorSubscriberTest.php new file mode 100644 index 0000000..fb2a9d3 --- /dev/null +++ b/tests/Unit/EventSubscriber/RequestValidatorSubscriberTest.php @@ -0,0 +1,68 @@ +createMock(KernelInterface::class); + $request = new Request(); + $request->attributes->set('_route', $requestRoute); + $event = new KernelEvent($kernel, $request, $requestType); + + $callCountValidator = new CallCountValidator(); + $requestValidatorChain = new RequestValidatorChain([$callCountValidator]); + $routes = $this->createNelmioAreaRoutesWithRouteName($areaRoute); + $subscriber = new RequestValidatorSubscriber($requestValidatorChain, $routes); + + // Act + $subscriber->validateRequest($event); + + // Assert + self::assertSame([], $callCountValidator->calls); + } + + public function testValidatesMainApiRequest(): void + { + // Arrange + $kernel = $this->createMock(KernelInterface::class); + $request = new Request(); + $request->attributes->set('_route', 'api_ok'); + $event = new KernelEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST); + + $callCountValidator = new CallCountValidator(); + $requestValidatorChain = new RequestValidatorChain([$callCountValidator]); + $routes = $this->createNelmioAreaRoutesWithRouteName('api_ok'); + $subscriber = new RequestValidatorSubscriber($requestValidatorChain, $routes); + + // Act + $subscriber->validateRequest($event); + + // Assert + self::assertCount(1, $callCountValidator->calls); + self::assertSame($request, $callCountValidator->calls[0]); + } +} diff --git a/tests/Unit/Exception/ApiProblemExceptionTest.php b/tests/Unit/Exception/ApiProblemExceptionTest.php new file mode 100644 index 0000000..12ef43a --- /dev/null +++ b/tests/Unit/Exception/ApiProblemExceptionTest.php @@ -0,0 +1,162 @@ + 'name', 'message' => 'Required']]; + $headers = ['X-Foo' => 'bar']; + + // Act + $exception = new ApiProblemException( + statusCode: 422, + title: 'Unprocessable Entity', + type: 'https://example.com/errors/unprocessable', + detail: 'Invalid payload', + instance: '/orders/123', + violations: $violations, + previous: null, + headers: $headers, + ); + + // Assert + self::assertSame(422, $exception->getStatusCode()); + self::assertSame('Unprocessable Entity', $exception->getTitle()); + self::assertSame('https://example.com/errors/unprocessable', $exception->getType()); + self::assertSame('Invalid payload', $exception->getDetail()); + self::assertSame('/orders/123', $exception->getInstance()); + self::assertSame($violations, $exception->getViolations()); + self::assertSame($headers, $exception->getHeaders()); + self::assertSame('Invalid payload', $exception->getMessage()); + } + + public function testMessageDefaultsToTitleWhenNoDetailProvided(): void + { + // Arrange + $title = 'Something went wrong'; + + // Act + $exception = new ApiProblemException(statusCode: 418, title: $title); + + // Assert + self::assertSame($title, $exception->getTitle()); + self::assertNull($exception->getDetail()); + self::assertSame($title, $exception->getMessage()); + } + + public function testHeadersAreReturned(): void + { + // Arrange + $headers = ['Retry-After' => '120']; + + // Act + $exception = new ApiProblemException(statusCode: 503, headers: $headers); + + // Assert + self::assertSame($headers, $exception->getHeaders()); + } + + #[DataProvider('exceptionProvider')] + public function testSimpleFactoriesCreateExpectedProblems(callable $factory, int $expectedStatus, string $expectedTitle, ?string $detail): void + { + // Act + /** @var ApiProblemException $exception */ + $exception = $factory($detail); + + // Assert + self::assertSame($expectedStatus, $exception->getStatusCode()); + self::assertSame($expectedTitle, $exception->getTitle()); + self::assertSame('about:blank', $exception->getType()); + self::assertSame($detail, $exception->getDetail()); + + $expectedMessage = $detail ?? $expectedTitle; + self::assertSame($expectedMessage, $exception->getMessage()); + } + + /** + * @return iterable + */ + public static function exceptionProvider(): iterable + { + yield 'unauthenticated default detail' => [ + static fn (?string $detail) => ApiProblemException::unauthenticated($detail), + Response::HTTP_UNAUTHORIZED, + 'Unauthenticated', + null, + ]; + + yield 'forbidden with custom detail' => [ + static fn (?string $detail) => ApiProblemException::forbidden($detail), + Response::HTTP_FORBIDDEN, + 'Forbidden', + 'nope', + ]; + + yield 'not found default detail' => [ + static fn (?string $detail) => ApiProblemException::notFound($detail), + Response::HTTP_NOT_FOUND, + 'An error occurred.', + null, + ]; + + yield 'server error with custom detail' => [ + static fn (?string $detail) => ApiProblemException::serverError($detail), + Response::HTTP_INTERNAL_SERVER_ERROR, + 'An error occurred.', + 'boom', + ]; + } + + public function testBadRequestIncludesViolationsArray(): void + { + // Arrange + $violations = [ + ['constraint' => 'c1', 'message' => 'm1'], + ['constraint' => 'c2', 'message' => 'm2'], + ]; + + // Act + $exception = ApiProblemException::badRequest(detail: 'bad body', violations: $violations); + + // Assert + self::assertSame(Response::HTTP_BAD_REQUEST, $exception->getStatusCode()); + self::assertSame('The request body contains errors', $exception->getTitle()); + self::assertSame('about:blank', $exception->getType()); + self::assertSame('bad body', $exception->getDetail()); + self::assertSame($violations, $exception->getViolations()); + } + + public function testBadRequestAcceptsConstraintViolationList(): void + { + // Arrange + $list = $this->createMock(ConstraintViolationListInterface::class); + + // Act + $exception = ApiProblemException::badRequest(violations: $list); + + // Assert + self::assertSame(Response::HTTP_BAD_REQUEST, $exception->getStatusCode()); + self::assertSame('The request body contains errors', $exception->getTitle()); + self::assertSame($list, $exception->getViolations()); + } +} diff --git a/tests/Unit/Exception/DefaultExceptionToApiProblemTransformerTest.php b/tests/Unit/Exception/DefaultExceptionToApiProblemTransformerTest.php new file mode 100644 index 0000000..5a6e2ee --- /dev/null +++ b/tests/Unit/Exception/DefaultExceptionToApiProblemTransformerTest.php @@ -0,0 +1,135 @@ +transformer = new DefaultExceptionToApiProblemTransformer(); + } + + /** + * @param array>|null $expectedViolations + */ + #[DataProvider('exceptionProvider')] + public function testTransform(Throwable $exception, int $expectedStatus, string $expectedTitle, ?string $expectedDetail = null, ?array $expectedViolations = null): void + { + // Act + $apiProblem = $this->transformer->transform($exception); + + // Assert + self::assertSame($expectedStatus, $apiProblem->getStatusCode()); + self::assertSame($expectedTitle, $apiProblem->getTitle()); + if ($expectedDetail !== null) { + self::assertSame($expectedDetail, $apiProblem->getDetail()); + } + if ($expectedViolations !== null) { + self::assertSame($expectedViolations, $apiProblem->getViolations()); + } + } + + /** + * @return iterable>|null}> + */ + public static function exceptionProvider(): iterable + { + yield 'AccessDeniedHttpException' => [ + new AccessDeniedHttpException('Forbidden message'), + Response::HTTP_FORBIDDEN, + 'Forbidden', + 'You are not allowed to perform this action.', + ]; + + yield 'NotFoundHttpException' => [ + new NotFoundHttpException('Not found message'), + Response::HTTP_NOT_FOUND, + 'An error occurred.', + 'The requested resource could not be found.', + ]; + + $openApiValidationFailed = new OpenApiValidationFailed('OpenAPI validation failed'); + yield 'OpenApiValidationFailed' => [ + $openApiValidationFailed, + Response::HTTP_BAD_REQUEST, + 'The request body contains errors', + 'OpenAPI validation failed', + [[ + 'constraint' => 'openapi_request_validation', + 'message' => 'OpenAPI validation failed', + ]], + ]; + + $renderInvalidArgumentException = new RenderInvalidArgumentException('Invalid argument'); + yield 'RenderInvalidArgumentException' => [ + $renderInvalidArgumentException, + Response::HTTP_BAD_REQUEST, + 'The request body contains errors', + 'Invalid argument', + [[ + 'constraint' => 'invalid_argument', + 'message' => 'Invalid argument', + ]], + ]; + + yield 'HttpExceptionInterface - 400' => [ + new HttpException(Response::HTTP_BAD_REQUEST, 'Custom bad request'), + Response::HTTP_BAD_REQUEST, + 'Bad Request', + 'Custom bad request', + ]; + + yield 'HttpExceptionInterface - 401' => [ + new HttpException(Response::HTTP_UNAUTHORIZED, 'Custom unauthorized'), + Response::HTTP_UNAUTHORIZED, + 'Unauthorized', + 'Custom unauthorized', + ]; + + yield 'HttpExceptionInterface - 403' => [ + new HttpException(Response::HTTP_FORBIDDEN, 'Custom forbidden'), + Response::HTTP_FORBIDDEN, + 'Forbidden', + 'Custom forbidden', + ]; + + yield 'HttpExceptionInterface - 500' => [ + new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Custom server error'), + Response::HTTP_INTERNAL_SERVER_ERROR, + 'An error occurred.', + 'Custom server error', + ]; + + yield 'Default case (generic Exception)' => [ + new Exception('Something went wrong'), + Response::HTTP_INTERNAL_SERVER_ERROR, + 'An error occurred.', + 'Something went wrong', + ]; + } +} diff --git a/tests/Unit/Responder/JsonResponderTest.php b/tests/Unit/Responder/JsonResponderTest.php new file mode 100644 index 0000000..df44d81 --- /dev/null +++ b/tests/Unit/Responder/JsonResponderTest.php @@ -0,0 +1,89 @@ + 'bar']; + $status = 200; + + // Act + $response = $responder->respond($result, $status); + + // Assert + self::assertInstanceOf(JsonResponse::class, $response); + self::assertSame(json_encode($result), $response->getContent()); + self::assertSame($status, $response->getStatusCode()); + } + + #[DataProvider('supportsProvider')] + public function testSupports(mixed $result, bool $expected): void + { + // Arrange + $responder = new JsonResponder(); + + // Act + $actual = $responder->supports($result); + + // Assert + self::assertSame($expected, $actual); + } + + /** + * @return iterable + */ + public static function supportsProvider(): iterable + { + yield 'JsonSerializable' => [ + new class () implements JsonSerializable { + public function jsonSerialize(): mixed + { + return []; + } + }, + true, + ]; + + yield 'array' => [ + [], + false, + ]; + + yield 'object' => [ + new stdClass(), + false, + ]; + + yield 'string' => [ + 'foo', + false, + ]; + + yield 'null' => [ + null, + false, + ]; + } +} diff --git a/tests/Unit/Responder/JsonSerializedResponderTest.php b/tests/Unit/Responder/JsonSerializedResponderTest.php new file mode 100644 index 0000000..ec4c067 --- /dev/null +++ b/tests/Unit/Responder/JsonSerializedResponderTest.php @@ -0,0 +1,110 @@ +createMock(SerializerInterface::class); + $responder = new JsonSerializedResponder($serializer); + $result = ['foo' => 'bar']; + $status = 201; + $json = '{"foo":"bar"}'; + + $serializer->expects(self::once()) + ->method('serialize') + ->with($result, JsonEncoder::FORMAT) + ->willReturn($json); + + // Act + $response = $responder->respond($result, $status); + + // Assert + self::assertInstanceOf(JsonResponse::class, $response); + self::assertSame($json, $response->getContent()); + self::assertSame($status, $response->getStatusCode()); + } + + #[DataProvider('supportsProvider')] + public function testSupports(mixed $result, bool $expected): void + { + // Arrange + $serializer = $this->createMock(SerializerInterface::class); + $responder = new JsonSerializedResponder($serializer); + + // Act + $actual = $responder->supports($result); + + // Assert + self::assertSame($expected, $actual); + } + + /** + * @return iterable + */ + public static function supportsProvider(): iterable + { + yield 'object' => [ + new stdClass(), + true, + ]; + + yield 'array' => [ + ['foo' => 'bar'], + true, + ]; + + yield 'Traversable' => [ + new ArrayIterator([]), + true, + ]; + + yield 'JsonSerializable' => [ + new class () implements JsonSerializable { + public function jsonSerialize(): mixed + { + return []; + } + }, + false, + ]; + + yield 'string' => [ + 'foo', + false, + ]; + + yield 'int' => [ + 123, + false, + ]; + + yield 'null' => [ + null, + false, + ]; + } +} diff --git a/tests/Unit/Responder/NullableResponderTest.php b/tests/Unit/Responder/NullableResponderTest.php new file mode 100644 index 0000000..b8fef45 --- /dev/null +++ b/tests/Unit/Responder/NullableResponderTest.php @@ -0,0 +1,89 @@ +respond(null, $status); + + // Assert + self::assertSame('', $response->getContent()); + self::assertSame($status, $response->getStatusCode()); + } + + #[DataProvider('supportsProvider')] + public function testSupports(mixed $result, bool $expected): void + { + // Arrange + $responder = new NullableResponder(); + + // Act + $actual = $responder->supports($result); + + // Assert + self::assertSame($expected, $actual); + } + + /** + * @return iterable + */ + public static function supportsProvider(): iterable + { + yield 'null' => [ + null, + true, + ]; + + yield 'empty string' => [ + '', + true, + ]; + + yield 'false' => [ + false, + true, + ]; + + yield 'zero' => [ + 0, + true, + ]; + + yield 'empty array' => [ + [], + false, + ]; + + yield 'non-empty string' => [ + 'foo', + false, + ]; + + yield 'non-empty array' => [ + ['foo'], + false, + ]; + } +} diff --git a/tests/Unit/Responder/ResponderChainTest.php b/tests/Unit/Responder/ResponderChainTest.php new file mode 100644 index 0000000..a19a16e --- /dev/null +++ b/tests/Unit/Responder/ResponderChainTest.php @@ -0,0 +1,79 @@ +createMock(ResponderInterface::class); + $responder1->expects(self::once())->method('supports')->with($result)->willReturn(false); + $responder1->expects(self::never())->method('respond'); + + $responder2 = $this->createMock(ResponderInterface::class); + $responder2->expects(self::once())->method('supports')->with($result)->willReturn(true); + $responder2->expects(self::once())->method('respond')->with($result, $status)->willReturn($expectedResponse); + + $responder3 = $this->createMock(ResponderInterface::class); + $responder3->expects(self::never())->method('supports'); + + $chain = new ResponderChain([$responder1, $responder2, $responder3]); + + // Act + $actualResponse = $chain->respond($result, $status); + + // Assert + self::assertSame($expectedResponse, $actualResponse); + } + + public function testRespondThrowsExceptionWhenNoResponderSupportsResult(): void + { + // Arrange + $result = 'foo'; + $status = 200; + + $responder = $this->createMock(ResponderInterface::class); + $responder->expects(self::once())->method('supports')->with($result)->willReturn(false); + + $chain = new ResponderChain([$responder]); + + // Assert + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('No supported responder found.'); + + // Act + $chain->respond($result, $status); + } + + public function testSupportsAlwaysReturnsTrue(): void + { + // Arrange + $chain = new ResponderChain([]); + + // Act & Assert + self::assertTrue($chain->supports('anything')); + self::assertTrue($chain->supports(null)); + } +} diff --git a/tests/Unit/Response/ResponseStatusResolverTest.php b/tests/Unit/Response/ResponseStatusResolverTest.php new file mode 100644 index 0000000..b709a51 --- /dev/null +++ b/tests/Unit/Response/ResponseStatusResolverTest.php @@ -0,0 +1,116 @@ +setMethod($method); + $command = new ExampleCommand(); + + // Act + $status = $resolver->resolve($request, $command); + + // Assert + self::assertSame($expected, $status); + } + + /** + * @return iterable + */ + public static function defaultMappingProvider(): iterable + { + yield 'GET => 200' => ['GET', 200]; + yield 'POST => 201' => ['POST', 201]; + yield 'DELETE => 204' => ['DELETE', 204]; + yield 'PUT => 200' => ['PUT', 200]; + yield 'PATCH => 200' => ['PATCH', 200]; + yield 'HEAD => 200' => ['HEAD', 200]; + yield 'OPTIONS => 200' => ['OPTIONS', 200]; + yield 'TRACE => 200' => ['TRACE', 200]; + yield 'Unknown => 200' => ['FOO', 200]; + } + + #[DataProvider('attributeResolutionProvider')] + public function testResolvesFromOpenApiOperationAttribute(Request $request, object $command, int $expected): void + { + // Arrange + $resolver = new ResponseStatusResolver(); + + // Act + $status = $resolver->resolve($request, $command); + + // Assert + self::assertSame($expected, $status); + } + + /** + * @return iterable + */ + public static function attributeResolutionProvider(): iterable + { + $post = new Request(); + $post->setMethod('POST'); + yield 'POST uses first 2xx (201)' => [$post, new PostWithResponsesCommand(), 201]; + + $delete = new Request(); + $delete->setMethod('DELETE'); + yield 'DELETE casts string code 204 to int' => [$delete, new DeleteWithStringResponseCommand(), 204]; + } + + #[DataProvider('nonApplicableAttributesProvider')] + public function testIgnoresNonMatchingOrNon2xxAttributesAndFallsBack(Request $request, object $command, int $expected): void + { + // Arrange + $resolver = new ResponseStatusResolver(); + + // Act + $status = $resolver->resolve($request, $command); + + // Assert + self::assertSame($expected, $status); + } + + /** + * @return iterable + */ + public static function nonApplicableAttributesProvider(): iterable + { + $get = new Request(); + $get->setMethod('GET'); + yield 'GET ignores POST attribute on command' => [$get, new PostWithResponsesCommand(), 200]; + + $get2 = new Request(); + $get2->setMethod('GET'); + yield 'GET with non-2xx responses falls back to 200' => [$get2, new GetWithNon2xxResponsesCommand(), 200]; + + $post = new Request(); + $post->setMethod('POST'); + yield 'POST ignores GET attribute and returns 201 default' => [$post, new GetWithNon2xxResponsesCommand(), 201]; + } +} diff --git a/tests/Unit/RouteDescriber/CommandRouteDescriberTest.php b/tests/Unit/RouteDescriber/CommandRouteDescriberTest.php new file mode 100644 index 0000000..d5f9be1 --- /dev/null +++ b/tests/Unit/RouteDescriber/CommandRouteDescriberTest.php @@ -0,0 +1,198 @@ +|object|string $controller + * + * @return ArgumentMetadata[] + */ + public function createArgumentMetadata(array|object|string $controller, ?ReflectionFunctionAbstract $reflector = null): array + { + return []; + } + }; + $commandRouteDescriber = $this->createCommandRouteDescriber($argumentMetadataFactory, $inlineParameterDescriber); + $reflection = $this->createControllerReflection(); + + // Act + $commandRouteDescriber->describe($api, $route, $reflection); + + // Assert + self::assertSame(Generator::UNDEFINED, $api->paths); + self::assertSame([], $inlineParameterDescriber->calls); + } + + public function testDescribeBuildsOperationMergesAnnotationsAndRunsInlineDescribers(): void + { + // Arrange + $api = new OA\OpenApi([]); + $route = new Route('/items', defaults: [ + '_command_class' => MockCommand\CreateItemCommand::class, + ], methods: ['POST']); + + $inlineParameterDescriber = new FakeInlineParameterDescriber(); + $argumentMetadataFactory = $this->createArgumentMetadataFactory(MockCommand\CreateItemCommand::class); + $commandRouteDescriber = $this->createCommandRouteDescriber($argumentMetadataFactory, $inlineParameterDescriber); + $reflection = $this->createControllerReflection(); + + // Act + $commandRouteDescriber->describe($api, $route, $reflection); + + // Assert + $operation = $this->getOperationFor($api, '/items', 'POST'); + self::assertNotNull($operation, 'POST operation should be created'); + self::assertSame('create_item_full', $operation->operationId); + + self::assertIsArray($operation->tags); + self::assertContains('items', $operation->tags); + + self::assertIsArray($operation->responses); + self::assertNotEmpty($operation->responses); + $response = $operation->responses[0]; + self::assertSame(201, $response->response); + + $tag = Util::getTag($api, 'items'); + self::assertSame('items', $tag->name); + + self::assertCount(1, $inlineParameterDescriber->calls); + [$argMeta, $op] = $inlineParameterDescriber->calls[0]; + self::assertSame($operation, $op); + } + + #[DataProvider('commandsProvider')] + public function testDescriberUsesFullFeaturedCommandsViaProvider( + string $httpMethod, + string $routePath, + string $commandClass, + string $expectedOperationId, + int $expectedFirstResponseCode, + string $expectedTag, + ): void { + // Arrange + $api = new OA\OpenApi([]); + $route = new Route($routePath, defaults: [ + '_command_class' => $commandClass, + ], methods: [$httpMethod]); + + $inlineParameterDescriber = new FakeInlineParameterDescriber(); + $argumentMetadataFactory = $this->createArgumentMetadataFactory($commandClass); + $commandRouteDescriber = $this->createCommandRouteDescriber($argumentMetadataFactory, $inlineParameterDescriber); + $reflection = $this->createControllerReflection(); + + // Act + $commandRouteDescriber->describe($api, $route, $reflection); + + // Assert + $operation = $this->getOperationFor($api, $routePath, $httpMethod); + + self::assertNotNull($operation, sprintf('Operation for %s should be created', $httpMethod)); + self::assertSame($expectedOperationId, $operation->operationId); + + self::assertIsArray($operation->responses); + self::assertNotEmpty($operation->responses); + $firstResponse = $operation->responses[0]; + self::assertSame($expectedFirstResponseCode, $firstResponse->response); + + self::assertIsArray($operation->tags); + self::assertContains($expectedTag, $operation->tags); + + self::assertCount(1, $inlineParameterDescriber->calls); + } + + /** + * @return iterable + */ + public static function commandsProvider(): iterable + { + yield 'GET full featured' => ['GET', '/items', MockCommand\GetItemsCommand::class, 'list_items_full', 200, 'items']; + yield 'POST full featured' => ['POST', '/items', MockCommand\CreateItemCommand::class, 'create_item_full', 201, 'items']; + yield 'PUT full featured' => ['PUT', '/items/{id}', MockCommand\ReplaceItemCommand::class, 'replace_item_full', 200, 'admin']; + yield 'PATCH full featured' => ['PATCH', '/items/{id}', MockCommand\UpdateItemCommand::class, 'update_item_full', 200, 'admin']; + yield 'DELETE full featured' => ['DELETE', '/items/{id}', MockCommand\DeleteItemCommand::class, 'delete_item_full', 204, 'admin']; + } + + private function createControllerReflection(): ReflectionMethod + { + $controller = new class () { + public function __invoke(object $command): void + { + } + }; + + return new ReflectionMethod($controller, '__invoke'); + } + + private function createArgumentMetadataFactory(string $class): ArgumentMetadataFactoryInterface + { + return new readonly class ($class) implements ArgumentMetadataFactoryInterface { + public function __construct(private string $class) + { + } + + /** + * @param array|object|string $controller + * + * @return ArgumentMetadata[] + */ + public function createArgumentMetadata(array|object|string $controller, ?ReflectionFunctionAbstract $reflector = null): array + { + return [new ArgumentMetadata('cmd', $this->class, false, false, null)]; + } + }; + } + + private function createCommandRouteDescriber(ArgumentMetadataFactoryInterface $factory, FakeInlineParameterDescriber $inline): CommandRouteDescriber + { + return new CommandRouteDescriber($factory, [$inline]); + } + + private function getOperationFor(OA\OpenApi $api, string $routePath, string $httpMethod): ?OA\Operation + { + $pathItem = Util::getPath($api, $routePath); + + return match (strtolower($httpMethod)) { + 'get' => $pathItem->get, + 'post' => $pathItem->post, + 'put' => $pathItem->put, + 'patch' => $pathItem->patch, + 'delete' => $pathItem->delete, + default => null, + }; + } +} diff --git a/tests/Unit/Routing/Loader/AttributeDirectoryLoaderDecoratorTest.php b/tests/Unit/Routing/Loader/AttributeDirectoryLoaderDecoratorTest.php new file mode 100644 index 0000000..db1fb9d --- /dev/null +++ b/tests/Unit/Routing/Loader/AttributeDirectoryLoaderDecoratorTest.php @@ -0,0 +1,72 @@ +projectDir = dirname(__DIR__, 3).'/Mock/Routing'; + $this->inner = $this->createMock(AttributeDirectoryLoader::class); + } + + public function testLoadAugmentsOnlyOnceByScanningSrc(): void + { + // Arrange + $this->inner->expects(self::exactly(2)) + ->method('load') + ->with($this->anything(), $this->anything()) + ->willReturnOnConsecutiveCalls(new RouteCollection(), new RouteCollection()); + + $locator = new FileLocator([$this->projectDir]); + $commandClassLoader = new CommandRouteClassLoader(); + $decorator = new AttributeDirectoryLoaderDecorator($this->inner, $locator, $commandClassLoader, $this->projectDir); + + // Act & Assert + $first = $decorator->load('ignored'); + $route = array_keys($first->all()); + self::assertContains('api_test', $route, 'Expected route from AnnotatedCommand to be merged'); + + $second = $decorator->load('ignored'); + self::assertCount(0, $second->all(), 'Second load returns inner collection without augmentation'); + } + + public function testSupportsDelegatesToInner(): void + { + // Arrange + $this->inner->expects(self::once()) + ->method('supports') + ->with('resource', 'attribute') + ->willReturn(true); + + $locator = new FileLocator([$this->projectDir]); + $decorator = new AttributeDirectoryLoaderDecorator($this->inner, $locator, new CommandRouteClassLoader(), $this->projectDir); + + // Assert + self::assertTrue($decorator->supports('resource', 'attribute')); + } +} diff --git a/tests/Unit/Routing/Loader/CommandRouteClassLoaderTest.php b/tests/Unit/Routing/Loader/CommandRouteClassLoaderTest.php new file mode 100644 index 0000000..df50264 --- /dev/null +++ b/tests/Unit/Routing/Loader/CommandRouteClassLoaderTest.php @@ -0,0 +1,250 @@ +load(''); + + self::assertCount(0, $collection->all()); + } + + public function testReturnsEmptyForNonExistingClass(): void + { + $loader = new CommandRouteClassLoader(); + + /** @phpstan-ignore-next-line */ + $collection = $loader->load('This\\Class\\DoesNotExist'); + + self::assertCount(0, $collection->all()); + } + + public function testReturnsEmptyForAbstractClass(): void + { + // Avoid redeclaration in case this test runs multiple times in‑process + if (!class_exists(__NAMESPACE__.'\\AbstractTmpCommand')) { + eval(<<<'PHP' + namespace Stixx\OpenApiCommandBundle\Tests\Unit\Routing\Loader; + use OpenApi\Attributes as OA; + abstract class AbstractTmpCommand { #[OA\Get(path: '/abstract')] public static function marker(){} } + PHP); + } + + $loader = new CommandRouteClassLoader(); + /** @var class-string $className */ + $className = self::classNamespace('AbstractTmpCommand'); + $collection = $loader->load($className); + + self::assertCount(0, $collection->all()); + } + + public function testReturnsEmptyWhenNoOperationAttributes(): void + { + if (!class_exists(__NAMESPACE__.'\\NoOpsCommand')) { + eval(<<<'PHP' + namespace Stixx\OpenApiCommandBundle\Tests\Unit\Routing\Loader; + final class NoOpsCommand {} + PHP); + } + + $loader = new CommandRouteClassLoader(); + /** @var class-string $className */ + $className = self::classNamespace('NoOpsCommand'); + $collection = $loader->load($className); + + self::assertCount(0, $collection->all()); + } + + public function testSkipsWhenClassIsInControllerClassesMap(): void + { + if (!class_exists(__NAMESPACE__.'\\MappedCommand')) { + eval(<<<'PHP' + namespace Stixx\OpenApiCommandBundle\Tests\Unit\Routing\Loader; + use OpenApi\Attributes as OA; + #[OA\Get(path: '/mapped')] + final class MappedCommand {} + PHP); + } + + /** @var class-string $fqcn */ + $fqcn = self::classNamespace('MappedCommand'); + $loader = new CommandRouteClassLoader(controllerClasses: [$fqcn => 'SomeController']); + $collection = $loader->load($fqcn); + + self::assertCount(0, $collection->all()); + } + + public function testBuildsRoutesForOperationsAndResolvesMethod(): void + { + if (!class_exists(__NAMESPACE__.'\\MultiOpsCommand')) { + eval(<<<'PHP' + namespace Stixx\OpenApiCommandBundle\Tests\Unit\Routing\Loader; + use OpenApi\Attributes as OA; + #[OA\Get(path: '/a')] + #[OA\Post(path: '/b')] + final class MultiOpsCommand {} + PHP); + } + + $loader = new CommandRouteClassLoader(); + /** @var class-string $className */ + $className = self::classNamespace('MultiOpsCommand'); + $collection = $loader->load($className); + + self::assertCount(2, $collection->all()); + $routes = $collection->all(); + $methods = array_values(array_map(fn ($r) => $r->getMethods(), $routes)); + + // Flatten and sort for predictable assertion + $flat = array_values(array_merge(...$methods)); + sort($flat); + self::assertSame(['GET', 'POST'], $flat); + } + + public function testRouteNameDefaultsAndEnsuresUniqueness(): void + { + if (!class_exists(__NAMESPACE__.'\\TwoGetNoIdCommand')) { + eval(<<<'PHP' + namespace Stixx\OpenApiCommandBundle\Tests\Unit\Routing\Loader; + use OpenApi\Attributes as OA; + #[OA\Get(path: '/one')] + #[OA\Get(path: '/two')] + final class TwoGetNoIdCommand {} + PHP); + } + + /** @var class-string $fqcn */ + $fqcn = self::classNamespace('TwoGetNoIdCommand'); + $loader = new CommandRouteClassLoader(); + $collection = $loader->load($fqcn); + + $names = array_keys($collection->all()); + sort($names); + + // default name is command_{short_class} + $base = 'command_twogetnoidcommand'; + self::assertSame([$base, $base.'_2'], $names); + } + + public function testControllerResolutionOrder(): void + { + // 1) Operation vendor extension x[controller] + if (!class_exists(__NAMESPACE__.'\\OpControllerCmd')) { + eval(<<<'PHP' + namespace Stixx\OpenApiCommandBundle\Tests\Unit\Routing\Loader; + use OpenApi\Attributes as OA; + #[OA\Get(path: '/x', x: ['controller' => \Stixx\OpenApiCommandBundle\Tests\Unit\Routing\Loader\FakeControllerA::class])] + final class OpControllerCmd {} + PHP); + } + + // 2) Class-level CommandObject(controller: ...) + if (!class_exists(__NAMESPACE__.'\\ClassControllerCmd')) { + eval(<<<'PHP' + namespace Stixx\OpenApiCommandBundle\Tests\Unit\Routing\Loader; + use OpenApi\Attributes as OA; + use Stixx\OpenApiCommandBundle\Attribute\CommandObject; + #[CommandObject(controller: \Stixx\OpenApiCommandBundle\Tests\Unit\Routing\Loader\FakeControllerB::class)] + #[OA\Get(path: '/y')] + final class ClassControllerCmd {} + PHP); + } + + // 3) Default controller when none provided + if (!class_exists(__NAMESPACE__.'\\DefaultControllerCmd')) { + eval(<<<'PHP' + namespace Stixx\OpenApiCommandBundle\Tests\Unit\Routing\Loader; + use OpenApi\Attributes as OA; + #[OA\Get(path: '/z')] + final class DefaultControllerCmd {} + PHP); + } + + // Fake controllers for assertions + if (!class_exists(__NAMESPACE__.'\\FakeControllerA')) { + eval('namespace '.__NAMESPACE__.'; final class FakeControllerA {}'); + } + if (!class_exists(__NAMESPACE__.'\\FakeControllerB')) { + eval('namespace '.__NAMESPACE__.'; final class FakeControllerB {}'); + } + + $loader = new CommandRouteClassLoader(); + + /** @var class-string $opName */ + $opName = self::classNamespace('OpControllerCmd'); + $allOp = $loader->load($opName)->all(); + $opCtrlRoute = current($allOp); + self::assertInstanceOf(\Symfony\Component\Routing\Route::class, $opCtrlRoute); + self::assertSame(self::classNamespace('FakeControllerA'), $opCtrlRoute->getDefault('_controller')); + + /** @var class-string $className */ + $className = self::classNamespace('ClassControllerCmd'); + $allClass = $loader->load($className)->all(); + $classCtrlRoute = current($allClass); + self::assertInstanceOf(\Symfony\Component\Routing\Route::class, $classCtrlRoute); + self::assertSame(self::classNamespace('FakeControllerB'), $classCtrlRoute->getDefault('_controller')); + + /** @var class-string $defaultName */ + $defaultName = self::classNamespace('DefaultControllerCmd'); + $allDefault = $loader->load($defaultName)->all(); + $defaultRoute = current($allDefault); + self::assertInstanceOf(\Symfony\Component\Routing\Route::class, $defaultRoute); + self::assertSame(CommandController::class, $defaultRoute->getDefault('_controller')); + } + + public function testOperationIdBecomesRouteName(): void + { + if (!class_exists(__NAMESPACE__.'\\WithOperationId')) { + eval(<<<'PHP' + namespace Stixx\OpenApiCommandBundle\Tests\Unit\Routing\Loader; + use OpenApi\Attributes as OA; + #[OA\Get(path: '/oid', operationId: 'my_op')] + final class WithOperationId {} + PHP); + } + + $loader = new CommandRouteClassLoader(); + /** @var class-string $className */ + $className = self::classNamespace('WithOperationId'); + $collection = $loader->load($className); + $names = array_keys($collection->all()); + + self::assertSame(['my_op'], $names); + } + + private static function classNamespace(string $short): string + { + return __NAMESPACE__.'\\'.$short; + } +} diff --git a/tests/Unit/Routing/Loader/CommandRouteDirectoryLoaderTest.php b/tests/Unit/Routing/Loader/CommandRouteDirectoryLoaderTest.php new file mode 100644 index 0000000..a6c2cea --- /dev/null +++ b/tests/Unit/Routing/Loader/CommandRouteDirectoryLoaderTest.php @@ -0,0 +1,36 @@ +supports(__DIR__, CommandRouteDirectoryLoader::TYPE)); + self::assertFalse($loader->supports(__DIR__, 'attribute')); + self::assertFalse($loader->supports(__DIR__)); + self::assertFalse($loader->supports(123, CommandRouteDirectoryLoader::TYPE)); + } +} diff --git a/tests/Unit/Routing/NelmioAreaRoutesTest.php b/tests/Unit/Routing/NelmioAreaRoutesTest.php new file mode 100644 index 0000000..1e3212d --- /dev/null +++ b/tests/Unit/Routing/NelmioAreaRoutesTest.php @@ -0,0 +1,126 @@ + $locator */ + $locator = new ServiceLocator([]); + $checker = new NelmioAreaRoutesChecker($locator); + + $request = new Request(); + + self::assertFalse($checker->isApiRoute($request)); + } + + public function testReturnsFalseWhenRouteNameIsEmpty(): void + { + /** @var ServiceLocator $locator */ + $locator = new ServiceLocator([]); + $checker = new NelmioAreaRoutesChecker($locator); + + $request = new Request(); + $request->attributes->set('_route', ''); + + self::assertFalse($checker->isApiRoute($request)); + } + + public function testReturnsTrueWhenRouteExistsInAnyArea(): void + { + $collection = new RouteCollection(); + $collection->add('api_route', new Route('/api')); + + /** @var ServiceLocator $locator */ + $locator = new ServiceLocator([ + 'default' => static fn () => $collection, + ]); + + $checker = new NelmioAreaRoutesChecker($locator); + + $request = new Request(); + $request->attributes->set('_route', 'api_route'); + + self::assertTrue($checker->isApiRoute($request)); + } + + public function testReturnsFalseWhenRouteDoesNotExistInAnyArea(): void + { + $collection = new RouteCollection(); + $collection->add('another_route', new Route('/other')); + + /** @var ServiceLocator $locator */ + $locator = new ServiceLocator([ + 'default' => static fn () => $collection, + ]); + + $checker = new NelmioAreaRoutesChecker($locator); + + $request = new Request(); + $request->attributes->set('_route', 'missing_route'); + + self::assertFalse($checker->isApiRoute($request)); + } + + public function testReturnsTrueWhenFoundInSecondArea(): void + { + $first = new RouteCollection(); + $first->add('first_only', new Route('/first')); + + $second = new RouteCollection(); + $second->add('target', new Route('/target')); + + /** @var ServiceLocator $locator */ + $locator = new ServiceLocator([ + 'area_one' => static fn () => $first, + 'area_two' => static fn () => $second, + ]); + + $checker = new NelmioAreaRoutesChecker($locator); + + $request = new Request(); + $request->attributes->set('_route', 'target'); + + self::assertTrue($checker->isApiRoute($request)); + } + + public function testNonRouteCollectionServiceCausesFalse(): void + { + $notARouteCollection = static fn () => (object) ['not' => 'a route collection']; + + $collection = new RouteCollection(); + $collection->add('would_match', new Route('/match')); + + /** @var ServiceLocator $locator */ + $locator = new ServiceLocator([ + 'not_a_route_collection' => $notARouteCollection, + 'collection' => static fn () => $collection, + ]); + + $checker = new NelmioAreaRoutesChecker($locator); + + $request = new Request(); + $request->attributes->set('_route', 'would_match'); + + self::assertFalse($checker->isApiRoute($request)); + } +} diff --git a/tests/Unit/Serializer/Normalizer/ApiProblemNormalizerTest.php b/tests/Unit/Serializer/Normalizer/ApiProblemNormalizerTest.php new file mode 100644 index 0000000..69bb144 --- /dev/null +++ b/tests/Unit/Serializer/Normalizer/ApiProblemNormalizerTest.php @@ -0,0 +1,134 @@ +supportsNormalization($apiProblem)); + self::assertFalse($normalizer->supportsNormalization(new stdClass())); + } + + public function testGetSupportedTypes(): void + { + // Arrange + $normalizer = new ApiProblemNormalizer(false); + + // Act + $types = $normalizer->getSupportedTypes(null); + + // Assert + self::assertSame([ApiProblemException::class => true], $types); + } + + /** + * @param array $expected + */ + #[DataProvider('normalizeProvider')] + public function testNormalize(bool $debug, ApiProblemException $exception, array $expected): void + { + // Arrange + $normalizer = new ApiProblemNormalizer($debug); + + // Act + $result = $normalizer->normalize($exception); + + // Assert + self::assertSame($expected, $result); + } + + /** + * @return iterable}> + */ + public static function normalizeProvider(): iterable + { + yield 'basic' => [ + false, + new ApiProblemException(400, 'Title', 'Type'), + [ + 'type' => 'Type', + 'title' => 'Title', + 'status' => 400, + ], + ]; + + yield 'with instance' => [ + false, + new ApiProblemException(400, 'Title', 'Type', instance: '/instance'), + [ + 'type' => 'Type', + 'title' => 'Title', + 'status' => 400, + 'instance' => '/instance', + ], + ]; + + yield 'with detail (debug false)' => [ + false, + new ApiProblemException(400, 'Title', 'Type', detail: 'Detail'), + [ + 'type' => 'Type', + 'title' => 'Title', + 'status' => 400, + ], + ]; + + yield 'with detail (debug true)' => [ + true, + new ApiProblemException(400, 'Title', 'Type', detail: 'Detail'), + [ + 'type' => 'Type', + 'title' => 'Title', + 'status' => 400, + 'detail' => 'Detail', + ], + ]; + } + + public function testNormalizeWithViolations(): void + { + // Arrange + $violations = [['field' => 'foo', 'message' => 'bar']]; + $exception = ApiProblemException::badRequest(violations: $violations); + $normalizer = new ApiProblemNormalizer(false); + + $innerNormalizer = $this->createMock(NormalizerInterface::class); + $innerNormalizer->expects(self::once()) + ->method('normalize') + ->with($violations, 'json', ['context' => 'foo']) + ->willReturn($violations); + + $normalizer->setNormalizer($innerNormalizer); + + // Act + $result = $normalizer->normalize($exception, 'json', ['context' => 'foo']); + + // Assert + self::assertArrayHasKey('violations', $result); + self::assertSame($violations, $result['violations']); + } +} diff --git a/tests/Unit/Serializer/Normalizer/ConstraintViolationListNormalizerTest.php b/tests/Unit/Serializer/Normalizer/ConstraintViolationListNormalizerTest.php new file mode 100644 index 0000000..f3423dc --- /dev/null +++ b/tests/Unit/Serializer/Normalizer/ConstraintViolationListNormalizerTest.php @@ -0,0 +1,74 @@ +createMock(ConstraintViolationListInterface::class); + + // Act & Assert + self::assertTrue($normalizer->supportsNormalization($list)); + self::assertFalse($normalizer->supportsNormalization(new stdClass())); + } + + public function testGetSupportedTypes(): void + { + // Arrange + $normalizer = new ConstraintViolationListNormalizer(); + + // Act + $types = $normalizer->getSupportedTypes(null); + + // Assert + self::assertSame([ConstraintViolationListInterface::class => true], $types); + } + + public function testNormalize(): void + { + // Arrange + $violation1 = $this->createMock(ConstraintViolationInterface::class); + $violation2 = $this->createMock(ConstraintViolationInterface::class); + $list = new ConstraintViolationList([$violation1, $violation2]); + + $normalizer = new ConstraintViolationListNormalizer(); + $innerNormalizer = $this->createMock(NormalizerInterface::class); + + $innerNormalizer->expects(self::exactly(2)) + ->method('normalize') + ->willReturnMap([ + [$violation1, 'json', [], ['norm1']], + [$violation2, 'json', [], ['norm2']], + ]); + + $normalizer->setNormalizer($innerNormalizer); + + // Act + $result = $normalizer->normalize($list, 'json'); + + // Assert + self::assertEquals([['norm1'], ['norm2']], $result); + } +} diff --git a/tests/Unit/Serializer/Normalizer/ConstraintViolationNormalizerTest.php b/tests/Unit/Serializer/Normalizer/ConstraintViolationNormalizerTest.php new file mode 100644 index 0000000..5d5ef19 --- /dev/null +++ b/tests/Unit/Serializer/Normalizer/ConstraintViolationNormalizerTest.php @@ -0,0 +1,120 @@ +createMock(ConstraintViolationInterface::class); + + // Act & Assert + self::assertTrue($normalizer->supportsNormalization($violation)); + self::assertFalse($normalizer->supportsNormalization(new stdClass())); + } + + public function testGetSupportedTypes(): void + { + // Arrange + $normalizer = new ConstraintViolationNormalizer(); + + // Act + $types = $normalizer->getSupportedTypes(null); + + // Assert + self::assertSame([ConstraintViolationInterface::class => true], $types); + } + + /** + * @param array $expected + */ + #[DataProvider('normalizeProvider')] + public function testNormalize(ConstraintViolationInterface $violation, array $expected): void + { + // Arrange + $normalizer = new ConstraintViolationNormalizer(); + + // Act + $result = $normalizer->normalize($violation); + + // Assert + self::assertSame($expected, $result); + } + + /** + * @return iterable}> + */ + public static function normalizeProvider(): iterable + { + $violation = self::createMockViolation('prop', 'msg', 'CODE123', null); + yield 'basic violation' => [ + $violation, + [ + 'propertyPath' => 'prop', + 'message' => 'msg', + 'code' => 'CODE123', + 'constraint' => null, + 'error' => null, + ], + ]; + + $constraint = new class () extends Constraint { + public const string SOME_ERROR = 'SOME_ERROR_CODE'; + }; + $violation = self::createMockViolation('prop', 'msg', 'SOME_ERROR_CODE', $constraint); + yield 'violation with constraint and mapped error' => [ + $violation, + [ + 'propertyPath' => 'prop', + 'message' => 'msg', + 'code' => 'SOME_ERROR_CODE', + 'constraint' => (new ReflectionClass($constraint))->getShortName(), + 'error' => 'SOME_ERROR', + ], + ]; + + $violation = self::createMockViolation('', 'msg', null, null); + yield 'minimal violation' => [ + $violation, + [ + 'propertyPath' => null, + 'message' => 'msg', + 'code' => null, + 'constraint' => null, + 'error' => null, + ], + ]; + } + + private static function createMockViolation(string $path, string $message, ?string $code, ?Constraint $constraint): ConstraintViolationInterface + { + $mock = (new class ('stub') extends TestCase {})->createMock(ConstraintViolationInterface::class); + $mock->method('getPropertyPath')->willReturn($path); + $mock->method('getMessage')->willReturn($message); + $mock->method('getCode')->willReturn($code); + $mock->method('getConstraint')->willReturn($constraint); + + return $mock; + } +} diff --git a/tests/Unit/Validator/RequestValidatorChainTest.php b/tests/Unit/Validator/RequestValidatorChainTest.php new file mode 100644 index 0000000..7147a42 --- /dev/null +++ b/tests/Unit/Validator/RequestValidatorChainTest.php @@ -0,0 +1,58 @@ +createMock(ValidatorInterface::class); + $v2 = $this->createMock(ValidatorInterface::class); + + $v1->expects(self::once()) + ->method('validate') + ->with($request); + + $v2->expects(self::once()) + ->method('validate') + ->with($request); + + $chain = new RequestValidatorChain([$v1, $v2]); + + // Act + $chain->validate($request); + + // Assert - handled by mock expectations + } + + public function testValidateWithEmptyChain(): void + { + // Arrange + $request = new Request(); + $chain = new RequestValidatorChain([]); + + // Act + $chain->validate($request); + + // Assert + $this->expectNotToPerformAssertions(); + } +} diff --git a/tests/Unit/Validator/RequestValidatorTest.php b/tests/Unit/Validator/RequestValidatorTest.php new file mode 100644 index 0000000..853ad52 --- /dev/null +++ b/tests/Unit/Validator/RequestValidatorTest.php @@ -0,0 +1,107 @@ +createDescriber([]); + + $apiDocGenerator = new ApiDocGenerator([$describer], []); + $validator = new RequestValidator($apiDocGenerator); + + $request = Request::create('/test', 'POST'); + + // Act + $validator->validate($request); + + // Assert + $this->expectNotToPerformAssertions(); + } + + public function testValidateThrowsExceptionOnValidationError(): void + { + // Arrange + $describer = $this->createDescriber([ + new Parameter([ + 'name' => 'X-Required-Header', + 'in' => 'header', + 'required' => true, + 'schema' => new Schema(['schema' => 'header-schema', 'type' => 'string', '_context' => new Context(['version' => '3.0.0'], null)]), + '_context' => new Context(['version' => '3.0.0'], null), + ]), + ]); + + $apiDocGenerator = new ApiDocGenerator([$describer], []); + $validator = new RequestValidator($apiDocGenerator); + + $request = Request::create('/test', 'POST'); + // The request is missing the 'X-Required-Header' defined in the OpenAPI spec above + + // Act & Assert + $this->expectException(ValidationFailed::class); + $validator->validate($request); + } + + /** + * @param array $parameters + */ + private function createDescriber(array $parameters): DescriberInterface + { + return new readonly class ($parameters) implements DescriberInterface { + /** + * @param array $parameters + */ + public function __construct(private array $parameters) + { + } + + public function describe(OpenApi $api): void + { + $api->openapi = '3.0.0'; + $api->info = new Info(['title' => 'Test', 'version' => '1.0.0', '_context' => new Context(['version' => '3.0.0'], null)]); + $api->paths = [ + new PathItem([ + 'path' => '/test', + 'post' => new Post([ + 'parameters' => $this->parameters, + 'responses' => [ + new Response(['response' => '200', 'description' => 'OK', '_context' => new Context(['version' => '3.0.0'], null)]), + ], + '_context' => new Context(['version' => '3.0.0'], null), + ]), + '_context' => new Context(['version' => '3.0.0'], null), + ]), + ]; + } + }; + } +}