diff --git a/.gitattributes b/.gitattributes index c633c0256911d..c7aefa05ef8be 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,5 +7,7 @@ /src/Symfony/Component/Translation/Bridge export-ignore /src/Symfony/Component/Emoji/Resources/data/* linguist-generated=true /src/Symfony/Component/Intl/Resources/data/*/* linguist-generated=true +/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/* linguist-generated=true +/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/* linguist-generated=true /src/Symfony/**/.github/workflows/close-pull-request.yml linguist-generated=true /src/Symfony/**/.github/PULL_REQUEST_TEMPLATE.md linguist-generated=true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3d21822287b6b..5f2d77a453eaf 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,6 @@ | Q | A | ------------- | --- -| Branch? | 7.3 for features / 6.4, 7.1, and 7.2 for bug fixes +| Branch? | 7.3 for features / 6.4, and 7.2 for bug fixes | Bug fix? | yes/no | New feature? | yes/no | Deprecations? | yes/no diff --git a/.github/expected-missing-return-types.diff b/.github/expected-missing-return-types.diff index 30ac60ab98ad7..9faed9a44dd73 100644 --- a/.github/expected-missing-return-types.diff +++ b/.github/expected-missing-return-types.diff @@ -177,6 +177,23 @@ diff --git a/src/Symfony/Component/DependencyInjection/Extension/PrependExtensio - public function prepend(ContainerBuilder $container); + public function prepend(ContainerBuilder $container): void; } +diff --git a/src/Symfony/Component/Emoji/EmojiTransliterator.php b/src/Symfony/Component/Emoji/EmojiTransliterator.php +--- a/src/Symfony/Component/Emoji/EmojiTransliterator.php ++++ b/src/Symfony/Component/Emoji/EmojiTransliterator.php +@@ -88,5 +88,5 @@ final class EmojiTransliterator extends \Transliterator + */ + #[\ReturnTypeWillChange] +- public function getErrorCode(): int|false ++ public function getErrorCode(): int + { + return isset($this->transliterator) ? $this->transliterator->getErrorCode() : 0; +@@ -97,5 +97,5 @@ final class EmojiTransliterator extends \Transliterator + */ + #[\ReturnTypeWillChange] +- public function getErrorMessage(): string|false ++ public function getErrorMessage(): string + { + return isset($this->transliterator) ? $this->transliterator->getErrorMessage() : ''; diff --git a/src/Symfony/Component/EventDispatcher/EventSubscriberInterface.php b/src/Symfony/Component/EventDispatcher/EventSubscriberInterface.php --- a/src/Symfony/Component/EventDispatcher/EventSubscriberInterface.php +++ b/src/Symfony/Component/EventDispatcher/EventSubscriberInterface.php @@ -593,23 +610,6 @@ diff --git a/src/Symfony/Component/VarDumper/Dumper/DataDumperInterface.php b/sr - public function dump(Data $data); + public function dump(Data $data): ?string; } -diff --git a/src/Symfony/Component/VarExporter/Tests/ProxyHelperTest.php b/src/Symfony/Component/VarExporter/Tests/ProxyHelperTest.php ---- a/src/Symfony/Component/VarExporter/Tests/ProxyHelperTest.php -+++ b/src/Symfony/Component/VarExporter/Tests/ProxyHelperTest.php -@@ -172,5 +172,5 @@ class ProxyHelperTest extends TestCase - { - yield 'not type hinted __unserialize method' => [new class { -- public function __unserialize($array) -+ public function __unserialize($array): void - { - } -@@ -192,5 +192,5 @@ class ProxyHelperTest extends TestCase - - yield 'type hinted __unserialize method' => [new class { -- public function __unserialize(array $array) -+ public function __unserialize(array $array): void - { - } diff --git a/src/Symfony/Contracts/Translation/LocaleAwareInterface.php b/src/Symfony/Contracts/Translation/LocaleAwareInterface.php --- a/src/Symfony/Contracts/Translation/LocaleAwareInterface.php +++ b/src/Symfony/Contracts/Translation/LocaleAwareInterface.php diff --git a/.github/get-modified-packages.php b/.github/get-modified-packages.php index 11478cbe935c0..24de414fdd266 100644 --- a/.github/get-modified-packages.php +++ b/.github/get-modified-packages.php @@ -22,7 +22,7 @@ function getPackageType(string $packageDir): string return match (true) { str_contains($packageDir, 'Symfony/Bridge/') => 'bridge', str_contains($packageDir, 'Symfony/Bundle/') => 'bundle', - preg_match('@Symfony/Component/[^/]+/Bridge/@', $packageDir) => 'component_bridge', + 1 === preg_match('@Symfony/Component/[^/]+/Bridge/@', $packageDir) => 'component_bridge', str_contains($packageDir, 'Symfony/Component/') => 'component', str_contains($packageDir, 'Symfony/Contracts/') => 'contract', str_ends_with($packageDir, 'Symfony/Contracts') => 'contracts', diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 0265b619c8be6..a2a3f8853882a 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -19,7 +19,7 @@ jobs: tests: name: Integration - runs-on: Ubuntu-20.04 + runs-on: ubuntu-24.04 strategy: matrix: @@ -81,6 +81,26 @@ jobs: REDIS_MASTER_HOST: redis REDIS_MASTER_SET: redis_sentinel REDIS_SENTINEL_QUORUM: 1 + redis-primary: + image: bitnami/redis:latest + ports: + - 16381:6379 + env: + ALLOW_EMPTY_PASSWORD: "yes" + REDIS_REPLICATION_MODE: "master" + options: >- + --name=redis-primary + redis-replica: + image: bitnami/redis:latest + ports: + - 16382:6379 + env: + ALLOW_EMPTY_PASSWORD: "yes" + REDIS_REPLICATION_MODE: "slave" + REDIS_MASTER_HOST: redis-primary + REDIS_MASTER_PORT_NUMBER: "6379" + options: >- + --name=redis-replica memcached: image: memcached:1.6.5 ports: @@ -152,7 +172,7 @@ jobs: run: | echo "::group::apt-get update" sudo wget -O - https://packages.couchbase.com/clients/c/repos/deb/couchbase.key | sudo apt-key add - - echo "deb https://packages.couchbase.com/clients/c/repos/deb/ubuntu2004 focal focal/main" | sudo tee /etc/apt/sources.list.d/couchbase.list + echo "deb https://packages.couchbase.com/clients/c/repos/deb/ubuntu2404 noble noble/main" | sudo tee /etc/apt/sources.list.d/couchbase.list sudo apt-get update echo "::endgroup::" @@ -239,6 +259,7 @@ jobs: REDIS_CLUSTER_HOSTS: 'localhost:7000 localhost:7001 localhost:7002 localhost:7003 localhost:7004 localhost:7005' REDIS_SENTINEL_HOSTS: 'unreachable-host:26379 localhost:26379 localhost:26379' REDIS_SENTINEL_SERVICE: redis_sentinel + REDIS_REPLICATION_HOSTS: 'localhost:16382 localhost:16381' MESSENGER_REDIS_DSN: redis://127.0.0.1:7006/messages MESSENGER_AMQP_DSN: amqp://localhost/%2f/messages MESSENGER_SQS_DSN: "sqs://localhost:4566/messages?sslmode=disable&poll_timeout=0.01" diff --git a/.github/workflows/intl-data-tests.yml b/.github/workflows/intl-data-tests.yml index a02bd73ac5b8f..193b3dd1df14d 100644 --- a/.github/workflows/intl-data-tests.yml +++ b/.github/workflows/intl-data-tests.yml @@ -36,7 +36,7 @@ permissions: jobs: tests: name: Intl/Emoji data - runs-on: Ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Checkout diff --git a/.github/workflows/package-tests.yml b/.github/workflows/package-tests.yml index bc6f8eec683c7..55d1c82e3661a 100644 --- a/.github/workflows/package-tests.yml +++ b/.github/workflows/package-tests.yml @@ -11,7 +11,7 @@ permissions: jobs: verify: name: Verify Packages - runs-on: Ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.github/workflows/phpunit-bridge.yml b/.github/workflows/phpunit-bridge.yml index fd169dfae782d..56360c4c3d80a 100644 --- a/.github/workflows/phpunit-bridge.yml +++ b/.github/workflows/phpunit-bridge.yml @@ -22,7 +22,7 @@ permissions: jobs: lint: name: Lint PhpUnitBridge - runs-on: Ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Checkout @@ -35,4 +35,4 @@ jobs: php-version: "7.2" - name: Lint - run: find ./src/Symfony/Bridge/PhpUnit -name '*.php' | grep -v -e /Tests/ -e ForV7 -e ForV8 -e ForV9 -e ConstraintLogicTrait | parallel -j 4 php -l {} + run: find ./src/Symfony/Bridge/PhpUnit -name '*.php' | grep -v -e /Tests/ -e /Attribute/ -e /Extension/ -e /Metadata/ -e ForV7 -e ForV8 -e ForV9 -e ConstraintLogicTrait | parallel -j 4 php -l {} diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml index a165d0c7dc126..33a5f58b44c6a 100644 --- a/.github/workflows/psalm.yml +++ b/.github/workflows/psalm.yml @@ -17,7 +17,7 @@ permissions: jobs: psalm: name: Psalm - runs-on: Ubuntu-20.04 + runs-on: ubuntu-24.04 env: php-version: '8.2' diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index c2929a461dfef..a82202d055cc9 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -14,7 +14,7 @@ permissions: read-all jobs: analysis: name: Scorecards analysis - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: # Needed to upload the results to code-scanning dashboard. security-events: write @@ -26,38 +26,45 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@a12a3943b4bdde767164f792f33f40b04645d846 # v3.0.0 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@3e15ea8318eee9b333819ec77a36aca8d39df13e # v1.1.1 + uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 with: results_file: results.sarif results_format: sarif - # (Optional) Read-only PAT token. Uncomment the `repo_token` line below if: + # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: # - you want to enable the Branch-Protection check on a *public* repository, or - # - you are installing Scorecards on a *private* repository - # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. - # repo_token: ${{ secrets.SCORECARD_READ_TOKEN }} - - # Publish the results for public repositories to enable scorecard badges. For more details, see - # https://github.com/ossf/scorecard-action#publishing-results. - # For private repositories, `publish_results` will automatically be set to `false`, regardless - # of the value entered here. + # - you are installing Scorecard on a *private* repository + # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional. + # repo_token: ${{ secrets.SCORECARD_TOKEN }} + + # Public repositories: + # - Publish results to OpenSSF REST API for easy access by consumers + # - Allows the repository to include the Scorecard badge. + # - See https://github.com/ossf/scorecard-action#publishing-results. + # For private repositories: + # - `publish_results` will always be set to `false`, regardless + # of the value entered here. publish_results: true + # (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore + # file_mode: git + # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535 # v3.0.0 + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: SARIF file path: results.sarif retention-days: 5 - # Upload the results to GitHub's code scanning dashboard. + # Upload the results to GitHub's code scanning dashboard (optional). + # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@5f532563584d71fdef14ee64d17bafb34f751ce5 # v1.0.26 + uses: github/codeql-action/upload-sarif@v3 with: sarif_file: results.sarif diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 5b77bf4d6539b..bf81825134aed 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -33,11 +33,14 @@ jobs: mode: low-deps - php: '8.3' - php: '8.4' + # brotli and zstd extensions are optional, when not present the commands will be used instead, + # we must test both scenarios + extensions: amqp,apcu,brotli,igbinary,intl,mbstring,memcached,redis,relay,zstd - php: '8.5' #mode: experimental fail-fast: false - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Checkout @@ -54,6 +57,12 @@ jobs: extensions: "${{ matrix.extensions || env.extensions }}" tools: flex + - name: Install optional commands + if: matrix.php == '8.4' + run: | + sudo apt-get update + sudo apt-get install zopfli + - name: Configure environment run: | git config --global user.email "" @@ -130,10 +139,6 @@ jobs: echo SYMFONY_REQUIRE=">=$([ '${{ matrix.mode }}' = low-deps ] && echo 5.4 || echo $SYMFONY_VERSION)" >> $GITHUB_ENV [[ "${{ matrix.mode }}" = *-deps ]] && mv composer.json.phpunit composer.json || true - if [[ "${{ matrix.mode }}" = low-deps ]]; then - echo SYMFONY_PHPUNIT_REQUIRE="nikic/php-parser:^4.18" >> $GITHUB_ENV - fi - - name: Install dependencies run: | echo "::group::composer update" diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index c5351e435dea2..f3e25eaa66a0d 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -13,14 +13,19 @@ exit(0); } -$fileHeaderComment = <<<'EOF' -This file is part of the Symfony package. +$fileHeaderParts = [ + <<<'EOF' + This file is part of the Symfony package. -(c) Fabien Potencier + (c) Fabien Potencier -For the full copyright and license information, please view the LICENSE -file that was distributed with this source code. -EOF; + EOF, + <<<'EOF' + + For the full copyright and license information, please view the LICENSE + file that was distributed with this source code. + EOF, +]; return (new PhpCsFixer\Config()) // @see https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/pull/7777 @@ -31,7 +36,16 @@ '@Symfony' => true, '@Symfony:risky' => true, 'protected_to_private' => false, - 'header_comment' => ['header' => $fileHeaderComment], + 'header_comment' => [ + 'header' => implode('', $fileHeaderParts), + 'validator' => implode('', [ + '/', + preg_quote($fileHeaderParts[0], '/'), + '(?P.*)??', + preg_quote($fileHeaderParts[1], '/'), + '/s', + ]) + ], ]) ->setRiskyAllowed(true) ->setFinder( @@ -56,6 +70,8 @@ ->notPath('Symfony/Component/ErrorHandler/Tests/DebugClassLoaderTest.php') // stop removing spaces on the end of the line in strings ->notPath('Symfony/Component/Messenger/Tests/Command/FailedMessagesShowCommandTest.php') + // disable to not apply `native_function_invocation` rule, as we explicitly break it for testability reason, ref https://github.com/symfony/symfony/pull/59195 + ->notPath('Symfony/Component/Mailer/Transport/NativeTransportFactory.php') // auto-generated proxies ->notPath('Symfony/Component/Cache/Traits/RelayProxy.php') ->notPath('Symfony/Component/Cache/Traits/Redis5Proxy.php') diff --git a/CHANGELOG-7.2.md b/CHANGELOG-7.2.md index fbc5b88c33e11..93c489ae487bd 100644 --- a/CHANGELOG-7.2.md +++ b/CHANGELOG-7.2.md @@ -7,6 +7,92 @@ in 7.2 minor versions. To get the diff for a specific change, go to https://github.com/symfony/symfony/commit/XXX where XXX is the change hash To get the diff between two versions, go to https://github.com/symfony/symfony/compare/v7.2.0...v7.2.1 +* 7.2.6 (2025-05-02) + + * bug #60288 [VarExporter] dump default value for property hooks if present (xabbuh) + * bug #60267 [Contracts] Fix `ServiceMethodsSubscriberTrait` for nullable service (StevenRenaux) + * bug #60268 [Contracts] Fix `ServiceSubscriberTrait` for nullable service (StevenRenaux) + * bug #60256 [Mailer][Postmark] drop the `Date` header using the API transport (xabbuh) + * bug #60258 [VarExporter] Fix: Use correct closure call for property-specific logic in $notByRef (Hakayashii, denjas) + * bug #60269 [Notifier] [Discord] Fix value limits (norkunas) + * bug #60270 [Validator] [WordCount] Treat 0 as one character word and do not exclude it (sidz) + * bug #60248 [Messenger] Revert " Add call to `gc_collect_cycles()` after each message is handled" (jwage) + * bug #60236 [String] Support nexus -> nexuses pluralization (KorvinSzanto) + * bug #60238 [Lock] read (possible) error from Redis instance where evalSha() was called (xabbuh) + * bug #60194 [Workflow] Fix dispatch of entered event when the subject is already in this marking (lyrixx) + * bug #60174 [PhpUnitBridge] properly clean up mocked features after tests have run (xabbuh) + * bug #60172 [Cache] Fix invalidating on save failures with Array|ApcuAdapter (nicolas-grekas) + * bug #60122 [Cache] ArrayAdapter serialization exception clean $expiries (bastien-wink) + * bug #60167 [Cache] Fix proxying third party PSR-6 cache items (Dmitry Danilson) + * bug #60165 [HttpKernel] Do not ignore enum in controller arguments when it has an `#[Autowire]` attribute (ruudk) + * bug #60168 [Console] Correctly convert `SIGSYS` to its name (cs278) + * bug #60166 [Security] fix(security): fix OIDC user identifier (vincentchalamon) + * bug #60124 [Validator] : fix url validation when punycode is on tld but not on domain (joelwurtz) + * bug #60137 [Config] ResourceCheckerConfigCache metadata unserialize emits warning (Colin Michoudet) + * bug #60057 [Mailer] Fix `Trying to access array offset on value of type null` error by adding null checking (khushaalan) + * bug #60094 [DoctrineBridge] Fix support for entities that leverage native lazy objects (nicolas-grekas) + * bug #60094 [DoctrineBridge] Fix support for entities that leverage native lazy objects (nicolas-grekas) + +* 7.2.5 (2025-03-28) + + * bug #60054 [Form] Use duplicate_preferred_choices to set value of ChoiceType (aleho) + * bug #60026 [Serializer] Fix ObjectNormalizer default context with named serializers (HypeMC) + * bug #60030 [Cache][DoctrineBridge][HttpFoundation][Lock][Messenger] use `Table::addPrimaryKeyConstraint()` with Doctrine DBAL 4.3+ (xabbuh) + * bug #59844 [TypeInfo] Fix `isSatisfiedBy` not traversing type tree (mtarld) + * bug #59858 Update `JsDelivrEsmResolver::IMPORT_REGEX` to support dynamic imports (natepage) + * bug #60019 [HttpKernel] Fix `TraceableEventDispatcher` when the `Stopwatch` service has been reset (lyrixx) + * bug #59975 [HttpKernel] Only remove `E_WARNING` from error level during kernel init (fritzmg) + * bug #59988 [FrameworkBundle] Remove redundant `name` attribute from `default_context` (HypeMC) + * bug #59963 [TypeInfo] Fix ``@var`` tag reading for promoted properties (mtarld) + * bug #59949 [Process] Use a pipe for stderr in pty mode to avoid mixed output between stdout and stderr (joelwurtz) + * bug #59940 [Cache] Fix missing cache data in profiler (dcmbrs) + * bug #59965 [VarExporter] Fix support for hooks and asymmetric visibility (nicolas-grekas) + * bug #59924 Extract no type ``@param`` annotation with `PhpStanExtractor` (thomasdubuffet) + * bug #59908 [Messenger] Reduce keepalive request noise (ro0NL) + * bug #59874 [Console] fix progress bar messing output in section when there is an EOL (joelwurtz) + * bug #59888 [PhpUnitBridge] don't trigger "internal" deprecations for PHPUnit Stub objects (xabbuh) + * bug #59830 [Yaml] drop comments while lexing unquoted strings (xabbuh) + * bug #59884 [VarExporter] Fix support for asymmetric visibility (nicolas-grekas) + * bug #59881 [VarExporter] Fix support for abstract properties (nicolas-grekas) + * bug #59841 [Cache] fix cache data collector on late collect (dcmbrs) + +* 7.2.4 (2025-02-26) + + * bug #59198 [Messenger] Filter out non-consumable receivers when registering `ConsumeMessagesCommand` (wazum) + * bug #59781 [Mailer] fix multiple transports default injection (fkropfhamer) + * bug #59836 [Mailer][Postmark] Set CID for attachments when it exists (IssamRaouf) + * bug #59829 [FrameworkBundle] Disable the keys normalization of the CSRF form field attributes (sukei) + * bug #59840 Fix PHP warning in GetSetMethodNormalizer when a "set()" method is defined (Pepperoni1337) + * bug #59818 [TypeInfo] Fix create union with nullable type (mtarld) + * bug #59810 [DependencyInjection] Defer check for circular references instead of skipping them (biozshock) + * bug #59811 [Validator] Synchronize IBAN formats (alexandre-daubois) + * bug #59796 [Mime] use address for body at `PathHeader` (tinect) + * bug #59803 [Semaphore] allow redis cluster/sentinel dsn (smoench) + * bug #59779 [DomCrawler] Bug #43921 Check for null parent nodes in the case of orphaned branches (ttk) + * bug #59776 [WebProfilerBundle] fix rendering notifier message options (xabbuh) + * bug #59769 Enable `JSON_PRESERVE_ZERO_FRACTION` in `jsonRequest` method (raffaelecarelle) + * bug #59774 [TwigBridge] Fix compatibility with Twig 3.21 (alexandre-daubois) + * bug #59761 [VarExporter] Fix lazy objects with hooked properties (nicolas-grekas) + * bug #59763 [HttpClient] Don't send any default content-type when the body is empty (nicolas-grekas) + * bug #59747 [Translation] check empty notes (davidvancl) + * bug #59751 [Cache] Tests for Redis Replication with cache (DemigodCode) + * bug #59752 [BrowserKit] Fix submitting forms with empty file fields (nicolas-grekas) + * bug #59742 [Notifier] [BlueSky] Change the value returned as the message ID (javiereguiluz) + * bug #59033 [WebProfilerBundle] Fix interception for non conventional redirects (Huluti) + * bug #59713 [DependencyInjection] Do not preload functions (biozshock) + * bug #59723 [DependencyInjection] Fix cloned lazy services not sharing their dependencies when dumped with PhpDumper (pvandommelen) + * bug #59727 [HttpClient] Fix activity tracking leading to negative timeout errors (nicolas-grekas) + * bug #59728 [Form][FrameworkBundle] Use auto-configuration to make the default CSRF token id apply only to the app; not to bundles (nicolas-grekas) + * bug #59262 [DependencyInjection] Fix env default processor with scalar node (tBibaut) + * bug #59699 [Serializer] Handle default context in named Serializer (HypeMC) + * bug #59640 [Security] Return null instead of empty username to fix deprecation notice (phasdev) + * bug #59661 [Lock] Fix Predis error handling (HypeMC) + * bug #59596 [Mime] use `isRendered` method to ensure we can avoid rendering an email twice (walva) + * bug #59689 [HttpClient] Fix buffering AsyncResponse with no passthru (nicolas-grekas) + * bug #59654 [HttpClient] Fix uploading files > 2GB (nicolas-grekas) + * bug #59648 [HttpClient] Fix retrying requests with `Psr18Client` and NTLM connections (nicolas-grekas, ajgarlag) + * bug #59681 [TypeInfo] Fix promoted property phpdoc reading (mtarld) + * 7.2.3 (2025-01-29) * bug #58889 [Serializer] Handle default context in Serializer (Valmonzo) diff --git a/CHANGELOG-7.3.md b/CHANGELOG-7.3.md new file mode 100644 index 0000000000000..b88c6a58f068c --- /dev/null +++ b/CHANGELOG-7.3.md @@ -0,0 +1,212 @@ +CHANGELOG for 7.3.x +=================== + +This changelog references the relevant changes (bug and security fixes) done +in 7.3 minor versions. + +To get the diff for a specific change, go to https://github.com/symfony/symfony/commit/XXX where XXX is the change hash +To get the diff between two versions, go to https://github.com/symfony/symfony/compare/v7.3.0...v7.3.1 + +* 7.3.0-BETA2 (2025-05-10) + + * bug #58643 [SecurityBundle] Use Composer `InstalledVersions` to check if flex is installed (andyexeter) + * feature #54276 [Workflow] Add support for executing custom workflow definition validators during the container compilation (lyrixx) + * feature #52981 [FrameworkBundle] Make `ValidatorCacheWarmer` and `SerializeCacheWarmer` use `kernel.build_dir` instead of `kernel.cache_dir` (Okhoshi) + * feature #54384 [TwigBundle] Use `kernel.build_dir` to store the templates known at build time (Okhoshi) + * bug #60275 [DoctrineBridge] Fix UniqueEntityValidator Stringable identifiers (GiuseppeArcuti, wkania) + * feature #59602 [Console] `#[Option]` rules & restrictions (kbond) + * feature #60389 [Console] Add support for `SignalableCommandInterface` with invokable commands (HypeMC) + * bug #60293 [Messenger] fix asking users to select an option if `--force` option is used in `messenger:failed:retry` command (W0rma) + * bug #60392 [DependencyInjection][FrameworkBundle] Fix precedence of `App\Kernel` alias and ignore `container.excluded` tag on synthetic services (nicolas-grekas) + * bug #60379 [Security] Avoid failing when PersistentRememberMeHandler handles a malformed cookie (Seldaek) + * bug #60308 [Messenger] Fix integration with newer versions of Pheanstalk (HypeMC) + * bug #60373 [FrameworkBundle] Ensure `Email` class exists before using it (Kocal) + * bug #60365 [FrameworkBundle] ensure that all supported e-mail validation modes can be configured (xabbuh) + * bug #60350 [Security][LoginLink] Throw `InvalidLoginLinkException` on invalid parameters (davidszkiba) + * bug #60366 [Console] Set description as first parameter to `Argument` and `Option` attributes (alamirault) + * bug #60361 [Console] Ensure overriding `Command::execute()` keeps priority over `__invoke()` (GromNaN) + * feature #60028 [ObjectMapper] Condition to target a specific class (soyuka) + * feature #60344 [Console] Use kebab-case for auto-guessed input arguments/options names (chalasr) + * bug #60340 [String] fix EmojiTransliterator return type compatibility with PHP 8.5 (xabbuh) + * bug #60322 [FrameworkBundle] drop the limiters option for non-compound rater limiters (xabbuh) + +* 7.3.0-BETA1 (2025-05-02) + + * feature #60232 Add PHP config support for routing (fabpot) + * feature #60102 [HttpFoundation] Add `UriSigner::verify()` that throws named exceptions (kbond) + * feature #60222 [FrameworkBundle][HttpFoundation] Add Clock support for `UriSigner` (kbond) + * feature #60226 [Uid] Add component-specific exception classes (rela589n) + * feature #60163 [TwigBridge] Allow attachment name to be set for inline images (aleho) + * feature #60186 [DependencyInjection] Add "when" argument to #[AsAlias] (Zuruuh) + * feature #60195 [Workflow] Deprecate `Event::getWorkflow()` method (lyrixx) + * feature #60193 [Workflow] Add a link to mermaid.live from the profiler (lyrixx) + * feature #60188 [JsonPath] Add two utils methods to `JsonPath` builder (alexandre-daubois) + * feature #60018 [Messenger] Reset peak memory usage for each message (TimWolla) + * feature #60155 [FrameworkBundle][RateLimiter] compound rate limiter config (kbond) + * feature #60171 [FrameworkBundle][RateLimiter] deprecate `RateLimiterFactory` alias (kbond) + * feature #60139 [Runtime] Support extra dot-env files (natepage) + * feature #60140 Notifier mercure7.3 (ernie76) + * feature #59762 [Config] Add `NodeDefinition::docUrl()` (alexandre-daubois) + * feature #60099 [FrameworkBundle][RateLimiter] default `lock_factory` to `auto` (kbond) + * feature #60112 [DoctrineBridge] Improve exception message when `EntityValueResolver` gets no mapping information (MatTheCat) + * feature #60103 [Console] Mark `AsCommand` attribute as ``@final`` (Somrlik, GromNaN) + * feature #60069 [FrameworkBundle] Deprecate setting the `collect_serializer_data` to `false` (mtarld) + * feature #60087 [TypeInfo] add TypeFactoryTrait::arrayKey() (xabbuh) + * feature #42124 [Messenger] Add `$stamps` parameter to `HandleTrait::handle` (alexander-schranz) + * feature #58200 [Notifier] Deprecate sms77 Notifier bridge (MrYamous) + * feature #58380 [WebProfilerBundle] Update the logic that minimizes the toolbar (javiereguiluz) + * feature #60039 [TwigBridge] Collect all deprecations with `lint:twig` command (Fan2Shrek) + * feature #60081 [FrameworkBundle] Enable controller service with `#[Route]` attribute (GromNaN) + * feature #60076 [Console] Deprecate returning a non-int value from a `\Closure` function set via `Command::setCode()` (yceruto) + * feature #59655 [JsonPath] Add the component (alexandre-daubois) + * feature #58805 [TwigBridge][Validator] Add the Twig constraint and its validator (sfmok) + * feature #54275 [Messenger] [Amqp] Add default exchange support (ilyachase) + * feature #60052 [Mailer][TwigBridge] Revert "Add support for translatable objects" (kbond) + * feature #59967 [Mailer][TwigBridge] Add support for translatable subject (norkunas) + * feature #58654 [FrameworkBundle] Binding for Object Mapper component (soyuka) + * feature #60040 [Messenger] Use newer version of Beanstalkd bridge library (HypeMC) + * feature #52748 [TwigBundle] Enable `#[AsTwigFilter]`, `#[AsTwigFunction]` and `#[AsTwigTest]` attributes to configure runtime extensions (GromNaN) + * feature #59831 [Mailer][Mime] Refactor S/MIME encryption handling in `SMimeEncryptionListener` (Spomky) + * feature #59981 [TypeInfo] Add `ArrayShapeType::$sealed` (mtarld) + * feature #51741 [ObjectMapper] Object to Object mapper component (soyuka) + * feature #57309 [FrameworkBundle][HttpKernel] Allow configuring the logging channel per type of exceptions (Arkalo2) + * feature #60007 [Security] Add methods param in IsCsrfTokenValid attribute (Oviglo) + * feature #59900 [DoctrineBridge] add new `DatePointType` Doctrine type (garak) + * feature #59904 [Routing] Add alias in `{foo:bar}` syntax in route parameter (eltharin) + * feature #59978 [Messenger] Add `--class-filter` option to the `messenger:failed:remove` command (arnaud-deabreu) + * feature #60024 [Console] Add support for invokable commands in `LockableTrait` (yceruto) + * feature #59813 [Cache] Enable namespace-based invalidation by prefixing keys with backend-native namespace separators (nicolas-grekas) + * feature #59902 [PropertyInfo] Deprecate `Type` (mtarld, chalasr) + * feature #59890 [VarExporter] Leverage native lazy objects (nicolas-grekas) + * feature #54545 [DoctrineBridge] Add argument to `EntityValueResolver` to set type aliases (NanoSector) + * feature #60011 [DependencyInjection] Enable multiple attribute autoconfiguration callbacks on the same class (GromNaN) + * feature #60020 [FrameworkBundle] Make `ServicesResetter` autowirable (lyrixx) + * feature #59929 [RateLimiter] Add `CompoundRateLimiterFactory` (kbond) + * feature #59993 [Form] Add input with `string` value in `MoneyType` (StevenRenaux) + * feature #59987 [FrameworkBundle] Auto-exclude DI extensions, test cases, entities and messenger messages (nicolas-grekas) + * feature #59827 [TypeInfo] Add `ArrayShapeType` class (mtarld) + * feature #59909 [FrameworkBundle] Add `--method` option to `debug:router` command (santysisi) + * feature #59913 [DependencyInjection] Leverage native lazy objects for lazy services (nicolas-grekas) + * feature #53425 [Translation] Allow default parameters (Jean-Beru) + * feature #59464 [AssetMapper] Add `--dry-run` option on `importmap:require` command (chadyred) + * feature #59880 [Yaml] Add the `Yaml::DUMP_FORCE_DOUBLE_QUOTES_ON_VALUES` flag to enforce double quotes around string values (dkarlovi) + * feature #59922 [Routing] Add `MONGODB_ID` to requirement patterns (GromNaN) + * feature #59842 [TwigBridge] Add Twig `field_id()` form helper (Legendary4226) + * feature #59869 [Cache] Add support for `valkey:` / `valkeys:` schemes (nicolas-grekas) + * feature #59862 [Messenger] Allow to close the transport connection (andrew-demb) + * feature #59857 [Cache] Add `\Relay\Cluster` support (dorrogeray) + * feature #59863 [JsonEncoder] Rename the component to `JsonStreamer` (mtarld) + * feature #52749 [Serializer] Add discriminator map to debug commmand output (jschaedl) + * feature #59871 [Form] Add support for displaying nested options in `DebugCommand` (yceruto) + * feature #58769 [ErrorHandler] Add a command to dump static error pages (pyrech) + * feature #54932 [Security][SecurityBundle] OIDC discovery (vincentchalamon) + * feature #58485 [Validator] Add `filenameCharset` and `filenameCountUnit` options to `File` constraint (IssamRaouf) + * feature #59828 [Serializer] Add `defaultType` to `DiscriminatorMap` (alanpoulain) + * feature #59570 [Notifier][Webhook] Add Smsbox support (alanzarli) + * feature #50027 [Security] OAuth2 Introspection Endpoint (RFC7662) (Spomky) + * feature #57686 [Config] Allow using an enum FQCN with `EnumNode` (alexandre-daubois) + * feature #59588 [Console] Add a Tree Helper + multiple Styles (smnandre) + * feature #59618 [OptionsResolver] Deprecate defining nested options via `setDefault()` use `setOptions()` instead (yceruto) + * feature #59805 [Security] Improve DX of recent additions (nicolas-grekas) + * feature #59822 [Messenger] Add options to specify SQS queue attributes and tags (TrePe0) + * feature #59290 [JsonEncoder] Replace normalizers by value transformers (mtarld) + * feature #59800 [Validator] Add support for closures in `When` (alexandre-daubois) + * feature #59814 [Framework] Deprecate the `framework.validation.cache` config option (alexandre-daubois) + * feature #59804 [TypeInfo] Add type alias support (mtarld) + * feature #59150 [Security] Allow using a callable with `#[IsGranted]` (alexandre-daubois) + * feature #59789 [Notifier] [Bluesky] Return the record CID as additional info (javiereguiluz) + * feature #59526 [Messenger] [AMQP] Add TransportMessageIdStamp logic for AMQP (AurelienPillevesse) + * feature #59771 [Security] Add ability for voters to explain their vote (nicolas-grekas) + * feature #59768 [Messenger][Process] add `fromShellCommandline` to `RunProcessMessage` (Staormin) + * feature #59377 [Notifier] Add Matrix bridge (chii0815) + * feature #58488 [Serializer] Fix deserializing XML Attributes into string properties (Hanmac) + * feature #59657 [Console] Add markdown format to Table (amenk) + * feature #59274 [Validator] Allow Unique constraint validation on all elements (Jean-Beru) + * feature #59704 [DependencyInjection] Add `Definition::addExcludedTag()` and `ContainerBuilder::findExcludedServiceIds()` for auto-discovering value-objects (GromNaN) + * feature #49750 [FrameworkBundle] Allow to pass signals to `StopWorkerOnSignalsListener` in XML config and as plain strings (alexandre-daubois) + * feature #59479 [Mailer] [Smtp] Add DSN param to enforce TLS/STARTTLS (ssddanbrown) + * feature #59562 [Security] Support hashing the hashed password using crc32c when putting the user in the session (nicolas-grekas) + * feature #58501 [Mailer] Add configuration for dkim and smime signers (elias-playfinder, eliasfernandez) + * feature #52181 [Security] Ability to add roles in `form_login_ldap` by ldap group (Spomky) + * feature #59712 [DependencyInjection] Don't skip classes with private constructor when autodiscovering (nicolas-grekas) + * feature #50797 [FrameworkBundle][Validator] Add `framework.validation.disable_translation` option (alexandre-daubois) + * feature #49652 [Messenger] Add `bury_on_reject` option to Beanstalkd bridge (HypeMC) + * feature #51744 [Security] Add a normalization step for the user-identifier in firewalls (Spomky) + * feature #54141 [Messenger] Introduce `DeduplicateMiddleware` (VincentLanglet) + * feature #58546 [Scheduler] Add MessageHandler result to the `PostRunEvent` (bartholdbos) + * feature #58743 [HttpFoundation] Streamlining server event streaming (yceruto) + * feature #58939 [RateLimiter] Add `RateLimiterFactoryInterface` (alexandre-daubois) + * feature #58717 [HttpKernel] Support `Uid` in `#[MapQueryParameter]` (seb-jean) + * feature #59634 [Validator] Add support for the `otherwise` option in the `When` constraint (alexandre-daubois) + * feature #59670 [Serializer] Add `NumberNormalizer` (valtzu) + * feature #59679 [Scheduler] Normalize `TriggerInterface` as `string` (valtzu) + * feature #59641 [Serializer] register named normalizer & denormalizer aliases (mathroc) + * feature #59682 [Security] Deprecate UserInterface & TokenInterface's `eraseCredentials()` (chalasr, nicolas-grekas) + * feature #59667 [Notifier] [Bluesky] Allow to attach website preview card (ppoulpe) + * feature #58300 [Security][SecurityBundle] Show user account status errors (core23) + * feature #59630 [FrameworkBundle] Add support for info on `ArrayNodeDefinition::canBeEnabled()` and `ArrayNodeDefinition::canBeDisabled()` (alexandre-daubois) + * feature #59612 [Mailer] Add attachments support for Sweego Mailer Bridge (welcoMattic) + * feature #59302 [TypeInfo] Deprecate `CollectionType` as list and not as array (mtarld) + * feature #59481 [Notifier] Add SentMessage additional info (mRoca) + * feature #58819 [Routing] Allow aliases in `#[Route]` attribute (damienfern) + * feature #59004 [AssetMapper] Detect import with a sequence parser (smnandre) + * feature #59601 [Messenger] Add keepalive support (silasjoisten) + * feature #59536 [JsonEncoder] Allow to warm up object and list (mtarld) + * feature #59565 [Console] Deprecating Command getDefaultName and getDefaultDescription methods (yceruto) + * feature #59473 [Console] Add broader support for command "help" definition (yceruto) + * feature #54744 [Validator] deprecate the use of option arrays to configure validation constraints (xabbuh) + * feature #59493 [Console] Invokable command adjustments (yceruto) + * feature #59482 [Mailer] [Smtp] Add DSN option to make SocketStream bind to IPv4 (quilius) + * feature #57721 [Security][SecurityBundle] Add encryption support to OIDC tokens (Spomky) + * feature #58599 [Serializer] Add xml context option to ignore empty attributes (qdequippe) + * feature #59368 [TypeInfo] Add `TypeFactoryTrait::fromValue` method (mtarld) + * feature #59401 [JsonEncoder] Add `JsonEncodable` attribute (mtarld) + * feature #59123 [WebProfilerBundle] Extend web profiler listener & config for replace on ajax requests (chr-hertel) + * feature #59477 [Mailer][Notifier] Add and use `Dsn::getBooleanOption()` (OskarStark) + * feature #59474 [Console] Invokable command deprecations (yceruto) + * feature #59340 [Console] Add support for invokable commands and input attributes (yceruto) + * feature #59035 [VarDumper] Add casters for object-converted resources (alexandre-daubois) + * feature #59225 [FrameworkBundle] Always display service arguments & deprecate `--show-arguments` option for `debug:container` (Florian-Merle) + * feature #59384 [PhpUnitBridge] Enable configuring mock namespaces with attributes (HypeMC) + * feature #59370 [HttpClient] Allow using HTTP/3 with the `CurlHttpClient` (MatTheCat) + * feature #50334 [FrameworkBundle][PropertyInfo] Wire the `ConstructorExtractor` class (HypeMC) + * feature #59354 [OptionsResolver] Support union of types (VincentLanglet) + * feature #58542 [Validator] Add `Slug` constraint (raffaelecarelle) + * feature #59286 [Serializer] Deprecate the `CompiledClassMetadataFactory` (mtarld) + * feature #59257 [DependencyInjection] Support `@>` as a shorthand for `!service_closure` in YamlFileLoader (chx) + * feature #58545 [String] Add `AbstractString::pascal()` method (raffaelecarelle) + * feature #58559 [Validator] [DateTime] Add `format` to error messages (sauliusnord) + * feature #58564 [HttpKernel] Let Monolog handle the creation of log folder for improved readonly containers handling (shyim) + * feature #59360 [Messenger] Implement `KeepaliveReceiverInterface` in Redis bridge (HypeMC) + * feature #58698 [Mailer] Add AhaSend Bridge (farhadhf) + * feature #57632 [PropertyInfo] Add `PropertyDescriptionExtractorInterface` to `PhpStanExtractor` (mtarld) + * feature #58786 [Notifier] [Brevo][SMS] Brevo sms notifier add options (ikerib) + * feature #59273 [Messenger] Add `BeanstalkdPriorityStamp` to Beanstalkd bridge (HypeMC) + * feature #58761 [Mailer] [Amazon] Add support for custom headers in ses+api (StudioMaX) + * feature #54939 [Mailer] Add `retry_period` option for email transport (Sébastien Despont, fabpot) + * feature #59068 [HttpClient] Add IPv6 support to NativeHttpClient (dmitrii-baranov-tg) + * feature #59088 [DependencyInjection] Make `#[AsTaggedItem]` repeatable (alexandre-daubois) + * feature #59301 [Cache][HttpKernel] Add a `noStore` argument to the `#` attribute (smnandre) + * feature #59315 [Yaml] Add compact nested mapping support to `Dumper` (gr8b) + * feature #59325 [Config] Add `ifFalse()` (OskarStark) + * feature #58243 [Yaml] Add support for dumping `null` as an empty value by using the `Yaml::DUMP_NULL_AS_EMPTY` flag (alexandre-daubois) + * feature #59291 [TypeInfo] Add `accepts` method (mtarld) + * feature #59265 [Validator] Validate SVG ratio in Image validator (maximecolin) + * feature #59129 [SecurityBundle][TwigBridge] Add `is_granted_for_user()` function (natewiebe13) + * feature #59254 [JsonEncoder] Remove chunk size definition (mtarld) + * feature #59022 [HttpFoundation] Generate url-safe hashes for signed urls (valtzu) + * feature #59177 [JsonEncoder] Add native lazyghost support (mtarld) + * feature #59192 [PropertyInfo] Add non-*-int missing types for PhpStanExtractor (wuchen90) + * feature #58515 [FrameworkBundle][JsonEncoder] Wire services (mtarld) + * feature #59157 [HttpKernel] [MapQueryString] added key argument to MapQueryString attribute (feymo) + * feature #59154 [HttpFoundation] Support iterable of string in `StreamedResponse` (mtarld) + * feature #51718 [Serializer] [JsonEncoder] Introducing the component (mtarld) + * feature #58946 [Console] Add support of millisecondes for `formatTime` (SebLevDev) + * feature #48142 [Security][SecurityBundle] User authorization checker (natewiebe13) + * feature #59075 [Uid] Add ``@return` non-empty-string` annotations to `AbstractUid` and relevant functions (niravpateljoin) + * feature #59114 [ErrorHandler] support non-empty-string/non-empty-list when patching return types (xabbuh) + * feature #59020 [AssetMapper] add support for assets pre-compression (dunglas) + * feature #58651 [Mailer][Notifier] Add webhooks signature verification on Sweego bridges (welcoMattic) + * feature #59026 [VarDumper] Add caster for Socket instances (nicolas-grekas) + * feature #58989 [VarDumper] Add caster for `AddressInfo` objects (nicolas-grekas) + diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index d0472fa4bd167..ee2cb2a40889b 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -12,8 +12,8 @@ The Symfony Connect username in parenthesis allows to get more information - Robin Chalas (chalas_r) - Tobias Schultze (tobion) - Grégoire Pineau (lyrixx) - - Thomas Calvet (fancyweb) - Alexandre Daubois (alexandre-daubois) + - Thomas Calvet (fancyweb) - Christophe Coevoet (stof) - Wouter de Jong (wouterj) - Jordi Boggiano (seldaek) @@ -38,8 +38,8 @@ The Symfony Connect username in parenthesis allows to get more information - Samuel ROZE (sroze) - Pascal Borreli (pborreli) - Romain Neutron - - Joseph Bielawski (stloyd) - Kevin Bond (kbond) + - Joseph Bielawski (stloyd) - Drak (drak) - Abdellatif Ait boudad (aitboudad) - Lukas Kahwe Smith (lsmith) @@ -50,13 +50,13 @@ The Symfony Connect username in parenthesis allows to get more information - Benjamin Eberlei (beberlei) - Igor Wiedler - Jan Schädlich (jschaedl) - - Mathieu Lechat (mat_the_cat) - Mathias Arlaud (mtarld) + - Mathieu Lechat (mat_the_cat) - Simon André (simonandre) + - Vincent Langlet (deviling) - Matthias Pigulla (mpdude) - Gabriel Ostrolucký (gadelat) - Jonathan Wage (jwage) - - Vincent Langlet (deviling) - Valentin Udaltsov (vudaltsov) - Grégoire Paris (greg0ire) - Alexandre Salomé (alexandresalome) @@ -65,23 +65,23 @@ The Symfony Connect username in parenthesis allows to get more information - Dany Maillard (maidmaid) - Eriksen Costa - Diego Saint Esteben (dosten) + - Dariusz Ruminski - stealth35 ‏ (stealth35) - Alexander Mols (asm89) - Gábor Egyed (1ed) - Francis Besset (francisbesset) + - Mathieu Santostefano (welcomattic) - Titouan Galopin (tgalopin) - Pierre du Plessis (pierredup) - David Maicher (dmaicher) - Tomasz Kowalczyk (thunderer) - - Mathieu Santostefano (welcomattic) - Bulat Shakirzyanov (avalanche123) - Iltar van der Berg - Miha Vrhovnik (mvrhov) - Gary PEGEOT (gary-p) + - Alexander Schranz (alexander-schranz) - Saša Stamenković (umpirsky) - Allison Guilhem (a_guilhem) - - Alexander Schranz (alexander-schranz) - - Dariusz Ruminski - Mathieu Piot (mpiot) - Vasilij Duško (staff) - Sarah Khalil (saro0h) @@ -94,34 +94,36 @@ The Symfony Connect username in parenthesis allows to get more information - Vladimir Reznichenko (kalessil) - Peter Rehm (rpet) - Henrik Bjørnskov (henrikbjorn) - - David Buchmann (dbu) - Ruud Kamphuis (ruudk) + - David Buchmann (dbu) - Andrej Hudec (pulzarraider) - - Jáchym Toušek (enumag) - Tomas Norkūnas (norkunas) + - Jáchym Toušek (enumag) + - Hubert Lenoir (hubert_lenoir) - Christian Raue - Eric Clemmons (ericclemmons) - Denis (yethee) + - Alex Pott - Michel Weimerskirch (mweimerskirch) - Issei Murasawa (issei_m) - Arnout Boks (aboks) - Douglas Greenshields (shieldo) - Frank A. Fiebig (fafiebig) - Baldini - - Alex Pott - Fran Moreno (franmomu) - - Hubert Lenoir (hubert_lenoir) + - Antoine Makdessi (amakdessi) - Charles Sarrazin (csarrazi) - Henrik Westphal (snc) - Dariusz Górecki (canni) - - Antoine Makdessi (amakdessi) - Ener-Getick - Graham Campbell (graham) + - Joel Wurtz (brouznouf) + - Massimiliano Arione (garak) - Tugdual Saunier (tucksaun) - Lee McDermott - Brandon Turner - - Massimiliano Arione (garak) - Luis Cordova (cordoval) + - Phil E. Taylor (philetaylor) - Konstantin Myakshin (koc) - Daniel Holmes (dholmes) - Julien Falque (julienfalque) @@ -129,10 +131,9 @@ The Symfony Connect username in parenthesis allows to get more information - Bart van den Burg (burgov) - Vasilij Dusko | CREATION - Jordan Alliot (jalliot) - - Phil E. Taylor (philetaylor) - Théo FIDRY - - Joel Wurtz (brouznouf) - John Wards (johnwards) + - Valtteri R (valtzu) - Yanick Witschi (toflar) - Antoine Hérault (herzult) - Konstantin.Myakshin @@ -144,7 +145,6 @@ The Symfony Connect username in parenthesis allows to get more information - Tac Tacelosky (tacman1123) - gnito-org - Tim Nagel (merk) - - Valtteri R (valtzu) - Chris Wilkinson (thewilkybarkid) - Jérôme Vasseur (jvasseur) - Peter Kokot (peterkokot) @@ -165,20 +165,24 @@ The Symfony Connect username in parenthesis allows to get more information - Przemysław Bogusz (przemyslaw-bogusz) - Colin Frei - excelwebzone + - Florent Morselli (spomky_) - Paráda József (paradajozsef) - Maximilian Beckers (maxbeckers) - Baptiste Clavié (talus) - Alexander Schwenn (xelaris) + - Maxime Helias (maxhelias) - Fabien Pennequin (fabienpennequin) - Dāvis Zālītis (k0d3r1s) - Gordon Franke (gimler) - Malte Schlüter (maltemaltesich) + - soyuka - jeremyFreeAgent (jeremyfreeagent) - Michael Babker (mbabker) + - Alexis Lefebvre + - Christopher Hertel (chertel) - Joshua Thijssen - Vasilij Dusko - Daniel Wehner (dawehner) - - Maxime Helias (maxhelias) - Robert Schönthal (digitalkaoz) - Smaine Milianni (ismail1432) - Hugo Alliaume (kocal) @@ -187,7 +191,6 @@ The Symfony Connect username in parenthesis allows to get more information - noniagriconomie - Eric GELOEN (gelo) - Gabriel Caruso - - Christopher Hertel (chertel) - Stefano Sala (stefano.sala) - Ion Bazan (ionbazan) - Niels Keurentjes (curry684) @@ -195,7 +198,6 @@ The Symfony Connect username in parenthesis allows to get more information - Jhonny Lidfors (jhonne) - Juti Noppornpitak (shiroyuki) - Gregor Harlan (gharlan) - - Alexis Lefebvre - Anthony MARTIN - Sebastian Hörl (blogsh) - Tigran Azatyan (tigranazatyan) @@ -206,6 +208,7 @@ The Symfony Connect username in parenthesis allows to get more information - Thomas Landauer (thomas-landauer) - Arnaud Kleinpeter (nanocom) - Guilherme Blanco (guilhermeblanco) + - David Prévot (taffit) - Saif Eddin Gmati (azjezz) - Farhad Safarov (safarov) - SpacePossum @@ -213,14 +216,13 @@ The Symfony Connect username in parenthesis allows to get more information - Andreas Braun - Pablo Godel (pgodel) - Alessandro Chitolina (alekitto) + - Jan Rosier (rosier) - Rafael Dohms (rdohms) - Roman Martinuk (a2a4) - jwdeitch - - David Prévot (taffit) - Jérôme Parmentier (lctrs) - Ahmed TAILOULOUTE (ahmedtai) - Simon Berger - - soyuka - Jérémy Derussé - Matthieu Napoli (mnapoli) - Bob van de Vijver (bobvandevijver) @@ -234,9 +236,9 @@ The Symfony Connect username in parenthesis allows to get more information - George Mponos (gmponos) - Richard Shank (iampersistent) - Roland Franssen :) + - Fritz Michael Gschwantner (fritzmg) - Romain Monteil (ker0x) - Sergey (upyx) - - Florent Morselli (spomky_) - Marco Pivetta (ocramius) - Antonio Pauletich (x-coder264) - Vincent Touzet (vincenttouzet) @@ -251,7 +253,6 @@ The Symfony Connect username in parenthesis allows to get more information - Oleg Voronkovich - Helmer Aaviksoo - Alessandro Lai (jean85) - - Jan Rosier (rosier) - 77web - Gocha Ossinkine (ossinkine) - Jesse Rushlow (geeshoe) @@ -265,7 +266,6 @@ The Symfony Connect username in parenthesis allows to get more information - Artur Kotyrba - Wouter J - Tyson Andre - - Fritz Michael Gschwantner (fritzmg) - GDIBass - Samuel NELA (snela) - Baptiste Leduc (korbeil) @@ -277,6 +277,7 @@ The Symfony Connect username in parenthesis allows to get more information - Sébastien Alfaiate (seb33300) - James Halsall (jaitsu) - Christian Scheb + - Alex Hofbauer (alexhofbauer) - Mikael Pajunen - Warnar Boekkooi (boekkooi) - Justin Hileman (bobthecow) @@ -285,6 +286,7 @@ The Symfony Connect username in parenthesis allows to get more information - Clément JOBEILI (dator) - Andreas Möller (localheinz) - Marek Štípek (maryo) + - matlec - Daniel Espendiller - Arnaud PETITPAS (apetitpa) - Michael Käfer (michael_kaefer) @@ -294,27 +296,34 @@ The Symfony Connect username in parenthesis allows to get more information - Richard Miller - Quynh Xuan Nguyen (seriquynh) - Victor Bocharsky (bocharsky_bw) + - Asis Pattisahusiwa - Aleksandar Jakovljevic (ajakov) - Mario A. Alvarez Garcia (nomack84) - Thomas Rabaix (rande) - D (denderello) - DQNEO - Chi-teck + - Marko Kaznovac (kaznovac) + - Stiven Llupa (sllupa) - Andre Rømcke (andrerom) - Bram Leeda (bram123) - Patrick Landolt (scube) - Karoly Gossler (connorhu) - Timo Bakx (timobakx) - Giorgio Premi + - Alan Poulain (alanpoulain) - Ruben Gonzalez (rubenrua) - Benjamin Dulau (dbenjamin) - Markus Fasselt (digilist) - Denis Brumann (dbrumann) - mcfedr (mcfedr) + - Loick Piera (pyrech) - Remon van de Kamp - Mathieu Lemoine (lemoinem) - Christian Schmidt - Andreas Hucks (meandmymonkey) + - Artem Lopata + - Indra Gunawan (indragunawan) - Noel Guilbert (noel) - Bastien Jaillot (bastnic) - Soner Sayakci @@ -322,19 +331,19 @@ The Symfony Connect username in parenthesis allows to get more information - Stepan Anchugov (kix) - bronze1man - sun (sun) + - Filippo Tessarotto (slamdunk) - Larry Garfield (crell) - Leo Feyer - Nikolay Labinskiy (e-moe) - - Asis Pattisahusiwa - Martin Schuhfuß (usefulthink) - apetitpa - Guilliam Xavier - Pierre Minnieur (pminnieur) - Dominique Bongiraud - - Stiven Llupa (sllupa) - Hugo Monteiro (monteiro) - Dmitrii Poddubnyi (karser) - Julien Pauli + - Jonathan H. Wage - Michael Lee (zerustech) - Florian Lonqueu-Brochard (florianlb) - Joe Bennett (kralos) @@ -349,66 +358,63 @@ The Symfony Connect username in parenthesis allows to get more information - John Kary (johnkary) - Võ Xuân Tiến (tienvx) - fd6130 (fdtvui) + - Antonio J. García Lagar (ajgarlag) - Priyadi Iman Nurcahyo (priyadi) - - Alan Poulain (alanpoulain) - Oleg Andreyev (oleg.andreyev) - Maciej Malarz (malarzm) - Marcin Sikoń (marphi) - Michele Orselli (orso) + - Arjen van der Meijden - Sven Paulus (subsven) - - Indra Gunawan (indragunawan) - Peter Kruithof (pkruithof) - - Alex Hofbauer (alexhofbauer) - Maxime Veber (nek-) - Valentine Boineau (valentineboineau) - Rui Marinho (ruimarinho) - - Filippo Tessarotto (slamdunk) - Jeroen Noten (jeroennoten) - Possum - Jérémie Augustin (jaugustin) - Edi Modrić (emodric) - Pascal Montoya - - Loick Piera (pyrech) - Julien Brochet - François Pluchino (francoispluchino) - Tristan Darricau (tristandsensio) - Jan Sorgalla (jsor) - henrikbjorn - Marcel Beerta (mazen) + - Evert Harmeling (evertharmeling) - Mantis Development - - Marko Kaznovac (kaznovac) - Hidde Wieringa (hiddewie) - dFayet - Rob Frawley 2nd (robfrawley) - Renan (renanbr) - - Jonathan H. Wage - Nikita Konstantinov (unkind) - Dariusz - Daniel Gorgan - Francois Zaninotto + - Aurélien Pillevesse (aurelienpillevesse) - Daniel Tschinder - Christian Schmidt - Alexander Kotynia (olden) + - Matthieu Lempereur (mryamous) - Elnur Abdurrakhimov (elnur) - Manuel Reinhard (sprain) - Zan Baldwin (zanbaldwin) - Tim Goudriaan (codedmonkey) - - Antonio J. García Lagar (ajgarlag) - BoShurik - Quentin Devos - Adam Prager (padam87) - Benoît Burnichon (bburnichon) - maxime.steinhausser + - Iker Ibarguren (ikerib) - Roman Ring (inori) - Xavier Montaña Carreras (xmontana) - - Arjen van der Meijden - Romaric Drigon (romaricdrigon) - Sylvain Fabre (sylfabre) - Xavier Perez - Arjen Brouwer (arjenjb) - - Artem Lopata - Patrick McDougle (patrick-mcdougle) - Arnt Gulbrandsen + - Michel Roca (mroca) - Marc Weistroff (futurecat) - Michał (bambucha15) - Danny Berger (dpb587) @@ -421,6 +427,7 @@ The Symfony Connect username in parenthesis allows to get more information - Sullivan SENECHAL (soullivaneuh) - Uwe Jäger (uwej711) - javaDeveloperKid + - Chris Smith (cs278) - W0rma - Lynn van der Berg (kjarli) - Michaël Perrin (michael.perrin) @@ -433,7 +440,6 @@ The Symfony Connect username in parenthesis allows to get more information - Philipp Cordes (corphi) - Chekote - Thomas Adam - - Evert Harmeling (evertharmeling) - Anderson Müller - jdhoek - Jurica Vlahoviček (vjurica) @@ -457,7 +463,6 @@ The Symfony Connect username in parenthesis allows to get more information - renanbr - Sébastien Lavoie (lavoiesl) - Alex Rock (pierstoval) - - Matthieu Lempereur (mryamous) - Wodor Wodorski - Beau Simensen (simensen) - Magnus Nordlander (magnusnordlander) @@ -473,10 +478,10 @@ The Symfony Connect username in parenthesis allows to get more information - Pascal Luna (skalpa) - Wouter Van Hecke - Baptiste Lafontaine (magnetik) - - Iker Ibarguren (ikerib) - Michael Hirschler (mvhirsch) - Michael Holm (hollo) - Robert Meijers + - roman joly (eltharin) - Blanchon Vincent (blanchonvincent) - Cédric Anne - Christian Schmidt @@ -485,9 +490,9 @@ The Symfony Connect username in parenthesis allows to get more information - Bohan Yang (brentybh) - Vilius Grigaliūnas - Jordane VASPARD (elementaire) - - Chris Smith (cs278) - Thomas Bisignani (toma) - Florian Klein (docteurklein) + - Pierre Ambroise (dotordu) - Raphaël Geffroy (raphael-geffroy) - Damien Alexandre (damienalexandre) - Manuel Kießling (manuelkiessling) @@ -511,6 +516,8 @@ The Symfony Connect username in parenthesis allows to get more information - Ismael Ambrosi (iambrosi) - Craig Duncan (duncan3dc) - Emmanuel BORGES + - Mathieu Rochette (mathroc) + - Karoly Negyesi (chx) - Aurelijus Valeiša (aurelijus) - Jan Decavele (jandc) - Gustavo Piltcher @@ -536,7 +543,8 @@ The Symfony Connect username in parenthesis allows to get more information - Ahmed Raafat - Philippe Segatori - Thibaut Cheymol (tcheymol) - - Aurélien Pillevesse (aurelienpillevesse) + - Vincent Chalamon + - Raffaele Carelle - Erin Millard - Matthew Lewinski (lewinski) - Islam Israfilov (islam93) @@ -560,6 +568,7 @@ The Symfony Connect username in parenthesis allows to get more information - Kai Dederichs - Pavel Kirpitsov (pavel-kirpichyov) - Artur Eshenbrener + - Issam Raouf (iraouf) - Harm van Tilborg (hvt) - Thomas Perez (scullwm) - Gwendolen Lynch @@ -568,6 +577,7 @@ The Symfony Connect username in parenthesis allows to get more information - mondrake (mondrake) - Yaroslav Kiliba - FORT Pierre-Louis (plfort) + - Jan Böhmer - Terje Bråten - Gonzalo Vilaseca (gonzalovilaseca) - Tarmo Leppänen (tarlepp) @@ -575,6 +585,7 @@ The Symfony Connect username in parenthesis allows to get more information - Daniel STANCU - Kristen Gilden - Robbert Klarenbeek (robbertkl) + - Dalibor Karlović - Hamza Makraz (makraz) - Eric Masoero (eric-masoero) - Vitalii Ekert (comrade42) @@ -583,7 +594,6 @@ The Symfony Connect username in parenthesis allows to get more information - hossein zolfi (ocean) - Alexander Menshchikov - Clément Gautier (clementgautier) - - roman joly (eltharin) - James Gilliland (neclimdul) - Sanpi (sanpi) - Eduardo Gulias (egulias) @@ -600,11 +610,13 @@ The Symfony Connect username in parenthesis allows to get more information - Kirill chEbba Chebunin - Pol Dellaiera (drupol) - Alex (aik099) + - Kieran Brahney - Fabien Villepinte - SiD (plbsid) - Greg Thornton (xdissent) - Alex Bowers - - Michel Roca (mroca) + - Kev + - kor3k kor3k (kor3k) - Costin Bereveanu (schniper) - Andrii Dembitskyi - Gasan Guseynov (gassan) @@ -622,7 +634,6 @@ The Symfony Connect username in parenthesis allows to get more information - Saif Eddin G - Endre Fejes - Tobias Naumann (tna) - - Mathieu Rochette (mathroc) - Daniel Beyer - Ivan Sarastov (isarastov) - flack (flack) @@ -634,7 +645,6 @@ The Symfony Connect username in parenthesis allows to get more information - Kai - Alain Hippolyte (aloneh) - Grenier Kévin (mcsky_biig) - - Karoly Negyesi (chx) - Xavier HAUSHERR - Albert Jessurum (ajessu) - Romain Pierre @@ -650,7 +660,9 @@ The Symfony Connect username in parenthesis allows to get more information - a.dmitryuk - Anthon Pang (robocoder) - Julien Galenski (ruian) + - Benjamin Morel - Ben Scott (bpscott) + - Shyim - Pablo Lozano (arkadis) - Brian King - quentin neyrat (qneyrat) @@ -663,12 +675,15 @@ The Symfony Connect username in parenthesis allows to get more information - Ahmed Ghanem (ahmedghanem00) - Valentin Jonovs - geoffrey + - Quentin Dequippe (qdequippe) - Benoit Galati (benoitgalati) - Benjamin (yzalis) - Jeanmonod David (jeanmonod) - Webnet team (webnet) + - Christian Gripp (core23) - Tobias Bönner - Nicolas Rigaud + - PHAS Developer - Ben Ramsey (ramsey) - Berny Cantos (xphere81) - Antonio Jose Cerezo (ajcerezo) @@ -678,6 +693,7 @@ The Symfony Connect username in parenthesis allows to get more information - Lescot Edouard (idetox) - Dennis Fridrich (dfridrich) - Mohammad Emran Hasan (phpfour) + - Florian Merle (florian-merle) - Dmitriy Mamontov (mamontovdmitriy) - Jan Schumann - Matheo Daninos (mathdns) @@ -691,12 +707,15 @@ The Symfony Connect username in parenthesis allows to get more information - Yi-Jyun Pan - Sergey Melesh (sergex) - Greg Anderson + - Arnaud De Abreu (arnaud-deabreu) - lancergr - Benjamin Zaslavsky (tiriel) - Tri Pham (phamuyentri) - Angelov Dejan (angelov) - Ivan Nikolaev (destillat) - Gildas Quéméner (gquemener) + - Ioan Ovidiu Enache (ionutenache) + - Mokhtar Tlili (sf-djuba) - Maxim Dovydenok (dovydenok-maxim) - Laurent Masforné (heisenberg) - Claude Khedhiri (ck-developer) @@ -734,10 +753,10 @@ The Symfony Connect username in parenthesis allows to get more information - Vitaliy Tverdokhlib (vitaliytv) - Ariel Ferrandini (aferrandini) - BASAK Semih (itsemih) - - Jan Böhmer - Dirk Pahl (dirkaholic) - Cédric Lombardot (cedriclombardot) - Jérémy REYNAUD (babeuloula) + - Faizan Akram Dar (faizanakram) - Arkadius Stefanski (arkadius) - Jonas Flodén (flojon) - AnneKir @@ -745,6 +764,7 @@ The Symfony Connect username in parenthesis allows to get more information - Arnaud POINTET (oipnet) - Tristan Pouliquen - Miro Michalicka + - Hans Mackowiak - M. Vondano - Dominik Zogg - Maximilian Zumbansen @@ -755,6 +775,7 @@ The Symfony Connect username in parenthesis allows to get more information - François Dume (franek) - Jerzy Lekowski (jlekowski) - Raulnet + - Petrisor Ciprian Daniel - Oleksiy (alexndlm) - William Arslett (warslett) - Giso Stallenberg (gisostallenberg) @@ -776,7 +797,6 @@ The Symfony Connect username in parenthesis allows to get more information - Patrick Reimers (preimers) - Brayden Williams (redstar504) - insekticid - - Kieran Brahney - Jérémy M (th3mouk) - Trent Steel (trsteel88) - boombatower @@ -799,7 +819,6 @@ The Symfony Connect username in parenthesis allows to get more information - Matthew Grasmick - Miroslav Šustek (sustmi) - Pablo Díez (pablodip) - - Kev - Kevin McBride - Sergio Santoro - Jonas Elfering @@ -810,7 +829,6 @@ The Symfony Connect username in parenthesis allows to get more information - nikos.sotiropoulos - BENOIT POLASZEK (bpolaszek) - Eduardo Oliveira (entering) - - kor3k kor3k (kor3k) - Oleksii Zhurbytskyi - Bilge - Anatoly Pashin (b1rdex) @@ -827,6 +845,7 @@ The Symfony Connect username in parenthesis allows to get more information - Jérôme Vieilledent (lolautruche) - Roman Anasal - Filip Procházka (fprochazka) + - Sergey Panteleev - Jeroen Thora (bolle) - Markus Lanthaler (lanthaler) - Gigino Chianese (sajito) @@ -843,10 +862,12 @@ The Symfony Connect username in parenthesis allows to get more information - Andrew Udvare (audvare) - siganushka (siganushka) - alexpods + - Quentin Schuler (sukei) - Adam Szaraniec - Dariusz Ruminski - - Pierre Ambroise (dotordu) + - Bahman Mehrdad (bahman) - Romain Gautier (mykiwi) + - Link1515 - Matthieu Bontemps - Erik Trapman - De Cock Xavier (xdecock) @@ -860,6 +881,7 @@ The Symfony Connect username in parenthesis allows to get more information - Fabrice Bernhard (fabriceb) - Matthijs van den Bos (matthijs) - Markus S. (staabm) + - PatNowak - Bhavinkumar Nakrani (bhavin4u) - Jaik Dean (jaikdean) - Krzysztof Piasecki (krzysztek) @@ -915,9 +937,7 @@ The Symfony Connect username in parenthesis allows to get more information - Markus Staab - Forfarle (forfarle) - Johnny Robeson (johnny) - - Shyim - Disquedur - - Benjamin Morel - Guilherme Ferreira - Geoffrey Tran (geoff) - Jannik Zschiesche @@ -933,12 +953,10 @@ The Symfony Connect username in parenthesis allows to get more information - Pierre-Emmanuel Tanguy (petanguy) - Julien Maulny - Gennadi Janzen - - Quentin Dequippe (qdequippe) - johan Vlaar - Paul Oms - James Hemery - wuchen90 - - PHAS Developer - Wouter van der Loop (toppy-hennie) - Ninos - julien57 @@ -972,7 +990,6 @@ The Symfony Connect username in parenthesis allows to get more information - Ricky Su (ricky) - scyzoryck - Kyle Evans (kevans91) - - Ioan Ovidiu Enache (ionutenache) - Max Rath (drak3) - Cristoforo Cervino (cristoforocervino) - marie @@ -982,16 +999,18 @@ The Symfony Connect username in parenthesis allows to get more information - Noémi Salaün (noemi-salaun) - Sinan Eldem (sineld) - Gennady Telegin + - Benedikt Lenzen (demigodcode) - ampaze - Alexandre Dupuy (satchette) - Michel Hunziker - Malte Blättermann - - Arnaud De Abreu (arnaud-deabreu) + - Ilya Levin (ilyachase) - Simeon Kolev (simeon_kolev9) - Joost van Driel (j92) - Jonas Elfering - Mihai Stancu - Nahuel Cuesta (ncuesta) + - Santiago San Martin - Chris Boden (cboden) - EStyles (insidestyles) - Christophe Villeger (seragan) @@ -1005,6 +1024,7 @@ The Symfony Connect username in parenthesis allows to get more information - Maxime Douailin - Jean Pasdeloup - Maxime COLIN (maximecolin) + - Loïc Ovigne (oviglo) - Lorenzo Millucci (lmillucci) - Javier López (loalf) - Reinier Kip @@ -1032,7 +1052,6 @@ The Symfony Connect username in parenthesis allows to get more information - Rodrigo Aguilera - Vladimir Varlamov (iamvar) - Aurimas Niekis (gcds) - - Vincent Chalamon - Matthieu Calie (matth--) - Sem Schidler (xvilo) - Benjamin Schoch (bschoch) @@ -1049,7 +1068,6 @@ The Symfony Connect username in parenthesis allows to get more information - Andy Palmer (andyexeter) - Andrew Neil Forster (krciga22) - Stefan Warman (warmans) - - Faizan Akram Dar (faizanakram) - Tristan Maindron (tmaindron) - Behnoush Norouzali (behnoush) - Marko H. Tamminen (gzumba) @@ -1085,6 +1103,7 @@ The Symfony Connect username in parenthesis allows to get more information - Kevin SCHNEKENBURGER - Geordie - Fabien Salles (blacked) + - Tim Düsterhus - Andreas Erhard (andaris) - alexpozzi - Michael Devery (mickadoo) @@ -1096,6 +1115,7 @@ The Symfony Connect username in parenthesis allows to get more information - Luca Saba (lucasaba) - Sascha Grossenbacher (berdir) - Guillaume Aveline + - nathanpage - Robin Lehrmann - Szijarto Tamas - Thomas P @@ -1152,7 +1172,6 @@ The Symfony Connect username in parenthesis allows to get more information - Chris Jones (magikid) - Massimiliano Braglia (massimilianobraglia) - Thijs-jan Veldhuizen (tjveldhuizen) - - Petrisor Ciprian Daniel - Richard Quadling - James Hudson (mrthehud) - Raphaëll Roussel @@ -1183,7 +1202,6 @@ The Symfony Connect username in parenthesis allows to get more information - Gert de Pagter - Julien DIDIER (juliendidier) - Ворожцов Максим (myks92) - - Dalibor Karlović - Randy Geraads - Kevin van Sonsbeek (kevin_van_sonsbeek) - Simo Heinonen (simoheinonen) @@ -1198,6 +1216,7 @@ The Symfony Connect username in parenthesis allows to get more information - Arun Philip - Pascal Helfenstein - Jesper Skytte (greew) + - NanoSector - Petar Obradović - Baldur Rensch (brensch) - Carl Casbolt (carlcasbolt) @@ -1215,8 +1234,8 @@ The Symfony Connect username in parenthesis allows to get more information - Travis Carden (traviscarden) - mfettig - Besnik Br - - Issam Raouf (iraouf) - Simon Mönch + - Valmonzo - Sherin Bloemendaal - Jose Gonzalez - Jonathan (jlslew) @@ -1224,6 +1243,7 @@ The Symfony Connect username in parenthesis allows to get more information - aegypius - Ilia (aliance) - Christian Stoller (naitsirch) + - COMBROUSE Dimitri - Dave Marshall (davedevelopment) - Jakub Kulhan (jakubkulhan) - Paweł Niedzielski (steveb) @@ -1250,6 +1270,7 @@ The Symfony Connect username in parenthesis allows to get more information - michaelwilliams - Alexandre Parent - 1emming + - Eric Abouaf (neyric) - Nykopol (nykopol) - Thibault Richard (t-richard) - Jordan Deitch @@ -1268,10 +1289,10 @@ The Symfony Connect username in parenthesis allows to get more information - shubhalgupta - Felds Liscia (felds) - Benjamin Lebon - - Sergey Panteleev - Alexander Grimalovsky (flying) - Andrew Hilobok (hilobok) - Noah Heck (myesain) + - Sébastien JEAN (sebastien76) - Christian Soronellas (theunic) - Max Baldanza - Volodymyr Panivko @@ -1286,6 +1307,7 @@ The Symfony Connect username in parenthesis allows to get more information - izzyp - Jeroen Fiege (fieg) - Martin (meckhardt) + - Wu (wu-agriconomie) - Marcel Hernandez - Evan C - buffcode @@ -1332,12 +1354,10 @@ The Symfony Connect username in parenthesis allows to get more information - James Michael DuPont - Tinjo Schöni - Carlos Buenosvinos (carlosbuenosvinos) - - Christian Gripp (core23) - Jake (jakesoft) - Rustam Bakeev (nommyde) - Vincent CHALAMON - Ivan Kurnosov - - Bahman Mehrdad (bahman) - Christopher Hall (mythmakr) - Patrick Dawkins (pjcdawkins) - Paul Kamer (pkamer) @@ -1361,7 +1381,6 @@ The Symfony Connect username in parenthesis allows to get more information - Pierre Vanliefland (pvanliefland) - Roy Klutman (royklutman) - Sofiane HADDAG (sofhad) - - Quentin Schuler (sukei) - Antoine M - frost-nzcr4 - Shahriar56 @@ -1371,6 +1390,7 @@ The Symfony Connect username in parenthesis allows to get more information - Oriol Viñals - arai - Achilles Kaloeridis (achilles) + - Sébastien Despont (bouillou) - Laurent Bassin (lbassin) - Mouad ZIANI (mouadziani) - Tomasz Ignatiuk @@ -1403,6 +1423,7 @@ The Symfony Connect username in parenthesis allows to get more information - Johnny Peck (johnnypeck) - Jordi Sala Morales (jsala) - Sander De la Marche (sanderdlm) + - skmedix (skmedix) - Loic Chardonnet - Ivan Menshykov - David Romaní @@ -1474,7 +1495,6 @@ The Symfony Connect username in parenthesis allows to get more information - Johnson Page (jwpage) - Kuba Werłos (kuba) - Ruben Gonzalez (rubenruateltek) - - Mokhtar Tlili (sf-djuba) - Michael Roterman (wtfzdotnet) - Philipp Keck - Pavol Tuka @@ -1490,6 +1510,7 @@ The Symfony Connect username in parenthesis allows to get more information - Dominik Ulrich - den - Gábor Tóth + - Bastien THOMAS - ouardisoft - Daniel Cestari - Matt Janssen @@ -1517,7 +1538,9 @@ The Symfony Connect username in parenthesis allows to get more information - Rootie - Sébastien Santoro (dereckson) - Daniel Alejandro Castro Arellano (lexcast) + - Jiří Bok - Vincent Chalamon + - Farhad Hedayatifard - Alan ZARLI - Thomas Jarrand - Baptiste Leduc (bleduc) @@ -1538,8 +1561,10 @@ The Symfony Connect username in parenthesis allows to get more information - Guillaume Gammelin - Valérian Galliat - Sorin Pop (sorinpop) + - Elías Fernández - d-ph - Stewart Malik + - Frank Schulze (xit) - Renan Taranto (renan-taranto) - Ninos Ego - Samael tomas @@ -1581,10 +1606,12 @@ The Symfony Connect username in parenthesis allows to get more information - Jérôme Nadaud (jnadaud) - Frank Naegler - Sam Malone + - Damien Fernandes - Ha Phan (haphan) - Chris Jones (leek) - neghmurken - stefan.r + - Florian Cellier - xaav - Jean-Christophe Cuvelier [Artack] - Mahmoud Mostafa (mahmoud) @@ -1624,6 +1651,7 @@ The Symfony Connect username in parenthesis allows to get more information - Michael H. Arieli - Miloš Milutinović - Jitendra Adhikari (adhocore) + - Kevin Jansen - Nicolas Martin (cocorambo) - Tom Panier (neemzy) - Fred Cox @@ -1639,6 +1667,7 @@ The Symfony Connect username in parenthesis allows to get more information - Adoni Pavlakis (adoni) - Nicolas Le Goff (nlegoff) - Maarten Nusteling (nusje2000) + - Peter van Dommelen - Anne-Sophie Bachelard - Gordienko Vladislav - Ahmed EBEN HASSINE (famas23) @@ -1647,12 +1676,13 @@ The Symfony Connect username in parenthesis allows to get more information - Chris de Kok - Eduard Bulava (nonanerz) - Andreas Kleemann (andesk) - - Ilya Levin (ilyachase) - Hubert Moreau (hmoreau) - Nicolas Appriou + - Silas Joisten (silasjoisten) - Igor Timoshenko (igor.timoshenko) - Pierre-Emmanuel CAPEL - Manuele Menozzi + - Yevhen Sidelnyk - “teerasak” - Anton Babenko (antonbabenko) - Irmantas Šiupšinskas (irmantas) @@ -1665,7 +1695,6 @@ The Symfony Connect username in parenthesis allows to get more information - Nicolas Valverde - Konstantin S. M. Möllers (ksmmoellers) - Ken Stanley - - Raffaele Carelle - ivan - Zachary Tong (polyfractal) - linh @@ -1682,6 +1711,7 @@ The Symfony Connect username in parenthesis allows to get more information - hamza - dantleech - Kajetan Kołtuniak (kajtii) + - Dan (dantleech) - Sander Goossens (sandergo90) - Rudy Onfroy - Tero Alén (tero) @@ -1698,6 +1728,7 @@ The Symfony Connect username in parenthesis allows to get more information - Abdiel Carrazana (abdielcs) - joris - Vadim Tyukov (vatson) + - alanzarli - Arman - Gabi Udrescu - Adamo Crespi (aerendir) @@ -1721,6 +1752,7 @@ The Symfony Connect username in parenthesis allows to get more information - Ondřej Frei - Bruno Rodrigues de Araujo (brunosinister) - Máximo Cuadros (mcuadros) + - Arkalo2 - Jacek Wilczyński (jacekwilczynski) - Christoph Kappestein - Camille Baronnet @@ -1751,11 +1783,13 @@ The Symfony Connect username in parenthesis allows to get more information - Asil Barkin Elik (asilelik) - Bhujagendra Ishaya - Guido Donnari + - Jérôme Dumas - Mert Simsek (mrtsmsk0) - Lin Clark - Christophe Meneses (c77men) - Jeremy David (jeremy.david) - Andrei O + - gr8b - Michał Marcin Brzuchalski (brzuchal) - Jordi Rejas - Troy McCabe @@ -1790,10 +1824,13 @@ The Symfony Connect username in parenthesis allows to get more information - Dominic Luidold - Piotr Antosik (antek88) - Nacho Martin (nacmartin) + - Thomas Bibaut - Thibaut Chieux - mwos + - Aydin Hassan - Volker Killesreiter (ol0lll) - Vedran Mihočinec (v-m-i) + - Rafał Treffler - Sergey Novikov (s12v) - creiner - Jan Pintr @@ -1843,7 +1880,6 @@ The Symfony Connect username in parenthesis allows to get more information - Mikkel Paulson - Michał Strzelecki - Bert Ramakers - - Hans Mackowiak - Hugo Fonseca (fonsecas72) - Marc Duboc (icemad) - uncaught @@ -1883,6 +1919,7 @@ The Symfony Connect username in parenthesis allows to get more information - Kamil Musial - Lucas Bustamante - Olaf Klischat + - Andrii - orlovv - Claude Dioudonnat - Jonathan Hedstrom @@ -1921,6 +1958,7 @@ The Symfony Connect username in parenthesis allows to get more information - Bruno MATEU - Jeremy Bush - Lucas Bäuerle + - Steven RENAUX (steven_renaux) - Laurens Laman - Thomason, James - Dario Savella @@ -1928,11 +1966,14 @@ The Symfony Connect username in parenthesis allows to get more information - Joas Schilling - Ener-Getick - Markus Thielen + - Peter Trebaticky - Moza Bogdan (bogdan_moza) - Viacheslav Sychov + - Zuruuh - Nicolas Sauveur (baishu) - Helmut Hummel (helhum) - Matt Brunt + - David Vancl - Carlos Ortega Huetos - Péter Buri (burci) - Evgeny Efimov (edefimov) @@ -1952,7 +1993,6 @@ The Symfony Connect username in parenthesis allows to get more information - Oleg Sedinkin (akeylimepie) - Jérémy Jourdin (jjk801) - BRAMILLE Sébastien (oktapodia) - - Loïc Ovigne (oviglo) - Artem Kolesnikov (tyomo4ka) - Markkus Millend - Clément @@ -1968,14 +2008,20 @@ The Symfony Connect username in parenthesis allows to get more information - rchoquet - v.shevelev - rvoisin + - Dan Brown - gitlost - Taras Girnyk + - Simon Mönch + - Barthold Bos - cthulhu - Andoni Larzabal (andonilarz) + - Wolfgang Klinger (wolfgangklingerplan2net) + - Staormin - Dmitry Derepko - Rémi Leclerc - Jan Vernarsky - Ionut Cioflan + - John Edmerson Pizarra - Sergio - Jonas Hünig - Mehrdad @@ -1983,6 +2029,7 @@ The Symfony Connect username in parenthesis allows to get more information - Eduardo García Sanz (coma) - Arend Hummeling - Makdessi Alex + - Dmitrii Baranov - fduch (fduch) - Juan Miguel Besada Vidal (soutlink) - Takashi Kanemoto (ttskch) @@ -2098,7 +2145,9 @@ The Symfony Connect username in parenthesis allows to get more information - martkop26 - Raphaël Davaillaud - Sander Hagen + - Alexander Menk - cilefen (cilefen) + - Prasetyo Wicaksono (jowy) - Mo Di (modi) - Victor Truhanovich (victor_truhanovich) - Pablo Schläpfer @@ -2150,6 +2199,7 @@ The Symfony Connect username in parenthesis allows to get more information - Jeffrey Cafferata (jcidnl) - Junaid Farooq (junaidfarooq) - Lars Ambrosius Wallenborn (larsborn) + - Pavel Starosek (octisher) - Oriol Mangas Abellan (oriolman) - Sebastian Göttschkes (sgoettschkes) - Marcin Nowak @@ -2159,6 +2209,7 @@ The Symfony Connect username in parenthesis allows to get more information - omniError - Zander Baldwin - László GÖRÖG + - djordy - Kévin Gomez (kevin) - Mihai Nica (redecs) - Andrei Igna @@ -2262,8 +2313,8 @@ The Symfony Connect username in parenthesis allows to get more information - Ilya Chekalsky - Ostrzyciel - George Giannoulopoulos + - Thibault G - Alexander Pasichnik (alex_brizzz) - - Florian Merle (florian-merle) - Felix Eymonot (hyanda) - Luis Ramirez (luisdeimos) - Ilia Sergunin (maranqz) @@ -2275,6 +2326,7 @@ The Symfony Connect username in parenthesis allows to get more information - Willem Verspyck - Kim Laï Trinh - Johan de Ruijter + - InbarAbraham - Jason Desrosiers - m.chwedziak - marbul @@ -2293,6 +2345,7 @@ The Symfony Connect username in parenthesis allows to get more information - Ilya Biryukov (ibiryukov) - Mathieu Ledru (matyo91) - Roma (memphys) + - Jozef Môstka (mostkaj) - Florian Caron (shalalalala) - Serhiy Lunak (slunak) - Wojciech Błoszyk (wbloszyk) @@ -2321,6 +2374,7 @@ The Symfony Connect username in parenthesis allows to get more information - Tom Corrigan (tomcorrigan) - Luis Galeas - Bogdan Scordaliu + - Sven Scholz - Martin Pärtel - Daniel Rotter (danrot) - Frédéric Bouchery (fbouchery) @@ -2329,6 +2383,7 @@ The Symfony Connect username in parenthesis allows to get more information - Phillip Look (plook) - Foxprodev - Artfaith + - Tom Kaminski - developer-av - Max Summe - Ema Panz @@ -2390,6 +2445,7 @@ The Symfony Connect username in parenthesis allows to get more information - Ivan Tse - René Kerner - Nathaniel Catchpole + - Jontsa - Igor Plantaš - upchuk - Adrien Samson (adriensamson) @@ -2408,13 +2464,13 @@ The Symfony Connect username in parenthesis allows to get more information - Wickex - tuqqu - Wojciech Gorczyca + - Ahmad Al-Naib - Neagu Cristian-Doru (cristian-neagu) - Mathieu Morlon (glutamatt) - NIRAV MUKUNDBHAI PATEL (niravpatel919) - Owen Gray (otis) - Rafał Muszyński (rafmus90) - Sébastien Decrême (sebdec) - - Wu (wu-agriconomie) - Timothy Anido (xanido) - Robert-Jan de Dreu - Mara Blaga @@ -2438,6 +2494,7 @@ The Symfony Connect username in parenthesis allows to get more information - Serhii Smirnov - Robert Queck - Peter Bouwdewijn + - Kurt Thiemann - Martins Eglitis - Daniil Gentili - Eduard Morcinek @@ -2446,6 +2503,7 @@ The Symfony Connect username in parenthesis allows to get more information - Matěj Humpál - Kasper Hansen - Nico Hiort af Ornäs + - Eddy - Amine Matmati - Kristen Gilden - caalholm @@ -2495,6 +2553,8 @@ The Symfony Connect username in parenthesis allows to get more information - Thomas Ploch - Victor Prudhomme - Simon Neidhold + - Wouter Ras + - Gil Hadad - Valentin VALCIU - Jeremiah VALERIE - Alexandre Beaujour @@ -2506,11 +2566,13 @@ The Symfony Connect username in parenthesis allows to get more information - Yannick Snobbert - Kevin Dew - James Cowgill + - Žan V. Dragan - sensio - Julien Menth (cfjulien) - Lyubomir Grozdanov (lubo13) - Nicolas Schwartz (nicoschwartz) - Tim Jabs (rubinum) + - Schvoy Norbert (schvoy) - Stéphane Seng (stephaneseng) - Peter Schultz - Robert Korulczyk @@ -2518,6 +2580,7 @@ The Symfony Connect username in parenthesis allows to get more information - Benhssaein Youssef - Benoit Leveque - bill moll + - chillbram - Benjamin Bender - PaoRuby - Holger Lösken @@ -2563,6 +2626,7 @@ The Symfony Connect username in parenthesis allows to get more information - Thomas Beaujean - alireza - Michael Bessolov + - sauliusnord - Zdeněk Drahoš - Dan Harper - moldcraft @@ -2608,7 +2672,9 @@ The Symfony Connect username in parenthesis allows to get more information - Tiago Garcia (tiagojsag) - Artiom - Jakub Simon + - TheMhv - Eviljeks + - Juliano Petronetto - robin.de.croock - Brandon Antonio Lorenzo - Bouke Haarsma @@ -2617,12 +2683,15 @@ The Symfony Connect username in parenthesis allows to get more information - Radosław Kowalewski - Enrico Schultz - tpetry + - Nikita Sklyarov - JustDylan23 - Juraj Surman - Martin Eckhardt - natechicago + - DaikiOnodera - Victor - Andreas Allacher + - Abdelilah Jabri - Alexis - Leonid Terentyev - Sergei Gorjunov @@ -2637,6 +2706,7 @@ The Symfony Connect username in parenthesis allows to get more information - Anton Sukhachev (mrsuh) - Pavlo Pelekh (pelekh) - Stefan Kleff (stefanxl) + - RichardGuilland - Marcel Siegert - ryunosuke - Bruno BOUTAREL @@ -2701,6 +2771,7 @@ The Symfony Connect username in parenthesis allows to get more information - Paul Seiffert (seiffert) - Vasily Khayrulin (sirian) - Stas Soroka (stasyan) + - Thomas Dubuffet (thomasdubuffet) - Stefan Hüsges (tronsha) - Jake Bishop (yakobeyak) - Dan Blows @@ -2715,6 +2786,7 @@ The Symfony Connect username in parenthesis allows to get more information - Andrew Coulton - Ulugbek Miniyarov - Jeremy Benoist + - Antoine Beyet - Michal Gebauer - René Landgrebe - Phil Davis @@ -2795,16 +2867,17 @@ The Symfony Connect username in parenthesis allows to get more information - Botond Dani (picur) - Rémi Faivre (rfv) - Radek Wionczek (rwionczek) + - tinect (tinect) - Nick Stemerdink - Bernhard Rusch - David Stone - Vincent Bouzeran + - fabi - Grayson Koonce - Ruben Jansen - Wissame MEKHILEF - Mihai Stancu - shreypuranik - - NanoSector - Thibaut Salanon - Romain Dorgueil - Christopher Parotat @@ -2890,6 +2963,7 @@ The Symfony Connect username in parenthesis allows to get more information - Yasmany Cubela Medina (bitgandtter) - Michał Dąbrowski (defrag) - Aryel Tupinamba (dfkimera) + - Elías (eliasfernandez) - Hans Höchtl (hhoechtl) - Simone Fumagalli (hpatoio) - Brian Graham (incognito) @@ -2912,6 +2986,7 @@ The Symfony Connect username in parenthesis allows to get more information - Artem Lopata (bumz) - Soha Jin - alex + - Alex Niedre - evgkord - Roman Orlov - Simon Ackermann @@ -2937,7 +3012,6 @@ The Symfony Connect username in parenthesis allows to get more information - Julien Moulin (lizjulien) - Raito Akehanareru (raito) - Mauro Foti (skler) - - skmedix (skmedix) - Thibaut Arnoud (thibautarnoud) - Valmont Pehaut-Pietri (valmonzo) - Yannick Warnier (ywarnier) @@ -2977,6 +3051,7 @@ The Symfony Connect username in parenthesis allows to get more information - Viet Pham - Alan Bondarchuk - Pchol + - Benjamin Ellis - Shamimul Alam - Cyril HERRERA - dropfen @@ -3008,6 +3083,7 @@ The Symfony Connect username in parenthesis allows to get more information - Cyrille Bourgois (cyrilleb) - Damien Vauchel (damien_vauchel) - Dmitrii Fedorenko (dmifedorenko) + - William Pinaud (docfx) - Frédéric G. Marand (fgm) - Freek Van der Herten (freekmurze) - Luca Genuzio (genuzio) @@ -3093,6 +3169,7 @@ The Symfony Connect username in parenthesis allows to get more information - Ashura - Götz Gottwald - Alessandra Lai + - timesince - alangvazq - Christoph Krapp - Ernest Hymel @@ -3174,11 +3251,11 @@ The Symfony Connect username in parenthesis allows to get more information - dakur - florian-michael-mast - tourze + - Dario Guarracino - sam-bee - Vlad Dumitrache - wetternest - Erik van Wingerden - - matlec - Valouleloup - Pathpat - Jaymin G @@ -3220,6 +3297,7 @@ The Symfony Connect username in parenthesis allows to get more information - Rosio (ben-rosio) - Simon Paarlberg (blamh) - Masao Maeda (brtriver) + - Alexander Dmitryuk (coden1) - Valery Maslov (coderberg) - Damien Harper (damien.harper) - Darius Leskauskas (darles) @@ -3230,6 +3308,7 @@ The Symfony Connect username in parenthesis allows to get more information - Dominik Hajduk (dominikalp) - Tomáš Polívka (draczris) - Dennis Smink (dsmink) + - Duncan de Boer (farmer-duck) - Franz Liedke (franzliedke) - Gaylord Poillon (gaylord_p) - gondo (gondo) @@ -3237,6 +3316,7 @@ The Symfony Connect username in parenthesis allows to get more information - Grummfy (grummfy) - Hadrien Cren (hcren) - Gusakov Nikita (hell0w0rd) + - Halil Hakan Karabay (hhkrby) - Oz (import) - Jaap van Otterdijk (jaapio) - Javier Núñez Berrocoso (javiernuber) @@ -3271,7 +3351,6 @@ The Symfony Connect username in parenthesis allows to get more information - Jimmy Leger (redpanda) - Ronny López (ronnylt) - Julius (sakalys) - - Sébastien JEAN (sebastien76) - Dmitry (staratel) - Marcin Szepczynski (szepczynski) - Tito Miguel Costa (titomiguelcosta) @@ -3286,11 +3365,14 @@ The Symfony Connect username in parenthesis allows to get more information - Pavel Barton - Exploit.cz - GuillaumeVerdon + - Dmitry Danilson - Marien Fressinaud - ureimers - akimsko - Youpie - Jason Stephens + - Korvin Szanto + - wkania - srsbiz - Taylan Kasap - Michael Orlitzky @@ -3321,6 +3403,7 @@ The Symfony Connect username in parenthesis allows to get more information - Evgeniy Koval - Lars Moelleken - dasmfm + - Karel Syrový - Claas Augner - Mathias Geat - neodevcode @@ -3372,7 +3455,9 @@ The Symfony Connect username in parenthesis allows to get more information - Christian Schiffler - Piers Warmers - Sylvain Lorinet + - Pavol Tuka - klyk50 + - Colin Michoudet - jc - BenjaminBeck - Aurelijus Rožėnas @@ -3402,6 +3487,7 @@ The Symfony Connect username in parenthesis allows to get more information - Philipp - lol768 - jamogon + - Tom Hart - Vyacheslav Slinko - Benjamin Laugueux - guangwu @@ -3454,6 +3540,7 @@ The Symfony Connect username in parenthesis allows to get more information - brian978 - Michael Schneider - n-aleha + - Richard Čepas - Talha Zekeriya Durmuş - Anatol Belski - Javier @@ -3507,7 +3594,6 @@ The Symfony Connect username in parenthesis allows to get more information - andrey-tech - David Ronchaud - Chris McGehee - - Bastien THOMAS - Shaun Simmons - Pierre-Louis LAUNAY - Arseny Razin @@ -3542,6 +3628,7 @@ The Symfony Connect username in parenthesis allows to get more information - Thorsten Hallwas - Brian Freytag - Arend Hummeling + - Joseph FRANCLIN - Marco Pfeiffer - Alex Nostadt - Michael Squires @@ -3587,8 +3674,11 @@ The Symfony Connect username in parenthesis allows to get more information - Michal Čihař - parhs - Harry Wiseman + - Emilien Escalle + - jwaguet - Diego Campoy - Oncle Tom + - Roland Franssen :) - Sam Anthony - Christian Stocker - Oussama Elgoumri @@ -3615,18 +3705,19 @@ The Symfony Connect username in parenthesis allows to get more information - Sean Templeton - Willem Mouwen - db306 + - Bohdan Pliachenko - Dr. Gianluigi "Zane" Zanettini - Michaël VEROUX - Julia - Lin Lu - arduanov - - Valmonzo - sualko - Marc Bennewitz - Fabien - Martin Komischke - Yendric - ADmad + - Hugo Posnic - Nicolas Roudaire - Marc Jauvin - Matthias Meyer @@ -3649,7 +3740,6 @@ The Symfony Connect username in parenthesis allows to get more information - Bernd Matzner (bmatzner) - Vladimir Vasilev (bobahvas) - Anton (bonio) - - Sébastien Despont (bouillou) - Bram Tweedegolf (bram_tweedegolf) - Brandon Kelly (brandonkelly) - Choong Wei Tjeng (choonge) @@ -3686,6 +3776,7 @@ The Symfony Connect username in parenthesis allows to get more information - Peter Orosz (ill_logical) - Ilia Lazarev (ilzrv) - Imangazaliev Muhammad (imangazaliev) + - wesign (inscrutable01) - Arkadiusz Kondas (itcraftsmanpl) - j0k (j0k) - joris de wit (jdewit) @@ -3759,6 +3850,7 @@ The Symfony Connect username in parenthesis allows to get more information - Julien Sanchez (sumbobyboys) - Ron Gähler (t-ronx) - Guillermo Gisinger (t3chn0r) + - Tomáš Korec (tomkorec) - Tom Newby (tomnewbyau) - Andrew Clark (tqt_andrew_clark) - Aaron Piotrowski (trowski) @@ -3800,6 +3892,7 @@ The Symfony Connect username in parenthesis allows to get more information - Dionysis Arvanitis - Sergey Fedotov - Konstantin Scheumann + - Josef Hlavatý - Michael - fh-github@fholzhauer.de - rogamoore diff --git a/UPGRADE-7.3.md b/UPGRADE-7.3.md new file mode 100644 index 0000000000000..77a3f14c3445b --- /dev/null +++ b/UPGRADE-7.3.md @@ -0,0 +1,387 @@ +UPGRADE FROM 7.2 to 7.3 +======================= + +Symfony 7.3 is a minor release. According to the Symfony release process, there should be no significant +backward compatibility breaks. Minor backward compatibility breaks are prefixed in this document with +`[BC BREAK]`, make sure your code is compatible with these entries before upgrading. +Read more about this in the [Symfony documentation](https://symfony.com/doc/7.3/setup/upgrade_minor.html). + +If you're upgrading from a version below 7.2, follow the [7.2 upgrade guide](UPGRADE-7.2.md) first. + +AssetMapper +----------- + + * `ImportMapRequireCommand` now takes `projectDir` as a required third constructor argument + +Console +------- + + * Omitting parameter types or returning a non-integer value from a `\Closure` set via `Command::setCode()` method is deprecated + + Before: + + ```php + $command->setCode(function ($input, $output) { + // ... + }); + ``` + + After: + + ```php + use Symfony\Component\Console\Input\InputInterface; + use Symfony\Component\Console\Output\OutputInterface; + + $command->setCode(function (InputInterface $input, OutputInterface $output): int { + // ... + + return 0; + }); + ``` + + * Deprecate methods `Command::getDefaultName()` and `Command::getDefaultDescription()` in favor of the `#[AsCommand]` attribute + * `#[AsCommand]` attribute is now marked as `@final`; you should use separate attributes to add more logic to commands + +DependencyInjection +------------------- + + * Deprecate `ContainerBuilder::getAutoconfiguredAttributes()` in favor of the `getAttributeAutoconfigurators()` method. + +DoctrineBridge +-------------- + + * Deprecate the `DoctrineExtractor::getTypes()` method, use `DoctrineExtractor::getType()` instead + +FrameworkBundle +--------------- + + * Not setting the `framework.property_info.with_constructor_extractor` option explicitly is deprecated + because its default value will change in version 8.0 + * Deprecate the `--show-arguments` option of the `container:debug` command, as arguments are now always shown + * Deprecate the `framework.validation.cache` config option + * Deprecate the `RateLimiterFactory` autowiring aliases, use `RateLimiterFactoryInterface` instead + * Deprecate setting the `framework.profiler.collect_serializer_data` config option to `false` + + When set to `true`, normalizers must be injected using the `NormalizerInterface`, and not using any concrete implementation. + + Before: + + ```php + public function __construct(ObjectNormalizer $normalizer) {} + ``` + + After: + + ```php + public function __construct(#[Autowire('@serializer.normalizer.object')] NormalizerInterface $normalizer) {} + ``` + + * The XML routing configuration files (`errors.xml` and `webhook.xml`) are + deprecated, use their PHP equivalent ones: + + Before: + + ```yaml + when@dev: + _errors: + resource: '@FrameworkBundle/Resources/config/routing/errors.xml' + prefix: /_error + + webhook: + resource: '@FrameworkBundle/Resources/config/routing/webhook.xml' + prefix: /webhook + ``` + + After: + + ```yaml + when@dev: + _errors: + resource: '@FrameworkBundle/Resources/config/routing/errors.php' + prefix: /_error + + webhook: + resource: '@FrameworkBundle/Resources/config/routing/webhook.php' + prefix: /webhook + ``` + +HttpFoundation +-------------- + + * `Request::getPreferredLanguage()` now favors a more preferred language above exactly matching a locale + +Ldap +---- + + * Deprecate `LdapUser::eraseCredentials()` in favor of `__serialize()` + +OptionsResolver +--------------- + + * Deprecate defining nested options via `setDefault()`, use `setOptions()` instead + + *Before* + ```php + $resolver->setDefault('option', function (OptionsResolver $resolver) { + // ... + }); + ``` + + *After* + ```php + $resolver->setOptions('option', function (OptionsResolver $resolver) { + // ... + }); + ``` + +PropertyInfo +------------ + + * Deprecate the `Type` class, use `Symfony\Component\TypeInfo\Type` class from `symfony/type-info` instead + * Deprecate the `PropertyTypeExtractorInterface::getTypes()` method, use `PropertyTypeExtractorInterface::getType()` instead + * Deprecate the `ConstructorArgumentTypeExtractorInterface::getTypesFromConstructor()` method, use `ConstructorArgumentTypeExtractorInterface::getTypeFromConstructor()` instead + +Security +-------- + + * Deprecate `UserInterface::eraseCredentials()` and `TokenInterface::eraseCredentials()`; + erase credentials e.g. using `__serialize()` instead + + Before: + + ```php + public function eraseCredentials(): void + { + } + ``` + + After: + + ```php + #[\Deprecated] + public function eraseCredentials(): void + { + } + + // If your eraseCredentials() method was used to empty a "password" property: + public function __serialize(): array + { + $data = (array) $this; + unset($data["\0".self::class."\0password"]); + + return $data; + } + ``` + + * Add argument `$vote` to `VoterInterface::vote()` and to `Voter::voteOnAttribute()`; + it should be used to report the reason of a vote. E.g: + + ```php + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool + { + $vote ??= new Vote(); + + $vote->reasons[] = 'A brief explanation of why access is granted or denied, as appropriate.'; + } + ``` + + * Add argument `$accessDecision` to `AccessDecisionManagerInterface::decide()` and `AuthorizationCheckerInterface::isGranted()`; + it should be used to report the reason of a decision, including all the related votes. + + * Add discovery support to `OidcTokenHandler` and `OidcUserInfoTokenHandler` + +SecurityBundle +-------------- + + * Deprecate the `security.hide_user_not_found` config option in favor of `security.expose_security_errors` + + Notifier + -------- + + * Deprecate the `Sms77` transport, use `SevenIo` instead + +Serializer +---------- + + * Deprecate the `CompiledClassMetadataFactory` and `CompiledClassMetadataCacheWarmer` classes + +TypeInfo +-------- + + * Deprecate constructing a `CollectionType` instance as a list that is not an array + * Deprecate the third `$asList` argument of `TypeFactoryTrait::iterable()`, use `TypeFactoryTrait::list()` instead + +Validator +--------- + + * Deprecate defining custom constraints not supporting named arguments + + Before: + + ```php + use Symfony\Component\Validator\Constraint; + + class CustomConstraint extends Constraint + { + public function __construct(array $options) + { + // ... + } + } + ``` + + After: + + ```php + use Symfony\Component\Validator\Attribute\HasNamedArguments; + use Symfony\Component\Validator\Constraint; + + class CustomConstraint extends Constraint + { + #[HasNamedArguments] + public function __construct($option1, $option2, $groups, $payload) + { + // ... + } + } + ``` + + * Deprecate passing an array of options to the constructors of the constraint classes, pass each option as a dedicated argument instead + + Before: + + ```php + new NotNull([ + 'groups' => ['foo', 'bar'], + 'message' => 'a custom constraint violation message', + ]) + ``` + + After: + + ```php + new NotNull( + groups: ['foo', 'bar'], + message: 'a custom constraint violation message', + ) + ``` + +VarDumper +--------- + + * Deprecate `ResourceCaster::castCurl()`, `ResourceCaster::castGd()` and `ResourceCaster::castOpensslX509()` + * Mark all casters as `@internal` + +VarExporter +----------- + + * Deprecate using `ProxyHelper::generateLazyProxy()` when native lazy proxies can be used - the method should be used to generate abstraction-based lazy decorators only + * Deprecate `LazyGhostTrait` and `LazyProxyTrait`, use native lazy objects instead + * Deprecate `ProxyHelper::generateLazyGhost()`, use native lazy objects instead + +WebProfilerBundle +----------------- + + * The XML routing configuration files (`profiler.xml` and `wdt.xml`) are + deprecated, use their PHP equivalent ones: + + Before: + + ```yaml + when@dev: + web_profiler_wdt: + resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' + prefix: /_wdt + + web_profiler_profiler: + resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml' + prefix: /_profiler + ``` + + After: + + ```yaml + when@dev: + web_profiler_wdt: + resource: '@WebProfilerBundle/Resources/config/routing/wdt.php' + prefix: /_wdt + + web_profiler_profiler: + resource: '@WebProfilerBundle/Resources/config/routing/profiler.php + prefix: /_profiler + ``` + +Workflow +-------- + + * Deprecate `Event::getWorkflow()` method + + Before: + + ```php + use Symfony\Component\Workflow\Attribute\AsCompletedListener; + use Symfony\Component\Workflow\Event\CompletedEvent; + + class MyListener + { + #[AsCompletedListener('my_workflow', 'to_state2')] + public function terminateOrder(CompletedEvent $event): void + { + $subject = $event->getSubject(); + if ($event->getWorkflow()->can($subject, 'to_state3')) { + $event->getWorkflow()->apply($subject, 'to_state3'); + } + } + } + ``` + + After: + + ```php + use Symfony\Component\DependencyInjection\Attribute\Target; + use Symfony\Component\Workflow\Attribute\AsCompletedListener; + use Symfony\Component\Workflow\Event\CompletedEvent; + use Symfony\Component\Workflow\WorkflowInterface; + + class MyListener + { + public function __construct( + #[Target('your_workflow_name')] + private readonly WorkflowInterface $workflow, + ) { + } + + #[AsCompletedListener('your_workflow_name', 'to_state2')] + public function terminateOrder(CompletedEvent $event): void + { + $subject = $event->getSubject(); + if ($this->workflow->can($subject, 'to_state3')) { + $this->workflow->apply($subject, 'to_state3'); + } + } + } + ``` + + Or: + + ```php + use Symfony\Component\DependencyInjection\ServiceLocator; + use Symfony\Component\DependencyInjection\Attribute\AutowireLocator; + use Symfony\Component\Workflow\Attribute\AsTransitionListener; + use Symfony\Component\Workflow\Event\TransitionEvent; + + class GenericListener + { + public function __construct( + #[AutowireLocator('workflow', 'name')] + private ServiceLocator $workflows + ) { + } + + #[AsTransitionListener()] + public function doSomething(TransitionEvent $event): void + { + $workflow = $this->workflows->get($event->getWorkflowName()); + } + } + ``` diff --git a/composer.json b/composer.json index 0f9b274fd697c..20bcb49c4b782 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,7 @@ "psr/http-message": "^1.0|^2.0", "psr/link": "^1.1|^2.0", "psr/log": "^1|^2|^3", - "symfony/contracts": "^3.5", + "symfony/contracts": "^3.6", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-intl-grapheme": "~1.0", "symfony/polyfill-intl-icu": "~1.0", @@ -83,6 +83,8 @@ "symfony/http-foundation": "self.version", "symfony/http-kernel": "self.version", "symfony/intl": "self.version", + "symfony/json-path": "self.version", + "symfony/json-streamer": "self.version", "symfony/ldap": "self.version", "symfony/lock": "self.version", "symfony/mailer": "self.version", @@ -90,6 +92,7 @@ "symfony/mime": "self.version", "symfony/monolog-bridge": "self.version", "symfony/notifier": "self.version", + "symfony/object-mapper": "self.version", "symfony/options-resolver": "self.version", "symfony/password-hasher": "self.version", "symfony/process": "self.version", @@ -140,9 +143,9 @@ "league/uri": "^6.5|^7.0", "masterminds/html5": "^2.7.2", "monolog/monolog": "^3.0", - "nikic/php-parser": "^4.18|^5.0", + "nikic/php-parser": "^5.0", "nyholm/psr7": "^1.0", - "pda/pheanstalk": "^4.0", + "pda/pheanstalk": "^5.1|^7.0", "php-http/discovery": "^1.15", "php-http/httplug": "^1.0|^2.0", "phpdocumentor/reflection-docblock": "^5.2", @@ -156,9 +159,10 @@ "symfony/phpunit-bridge": "^6.4|^7.0", "symfony/runtime": "self.version", "symfony/security-acl": "~2.8|~3.0", - "twig/cssinliner-extra": "^2.12|^3", - "twig/inky-extra": "^2.12|^3", - "twig/markdown-extra": "^2.12|^3", + "symfony/webpack-encore-bundle": "^1.0|^2.0", + "twig/cssinliner-extra": "^3", + "twig/inky-extra": "^3", + "twig/markdown-extra": "^3", "web-token/jwt-library": "^3.3.2|^4.0" }, "conflict": { @@ -202,6 +206,9 @@ ] }, "autoload-dev": { + "psr-4": { + "Symfony\\Bridge\\PhpUnit\\": "src/Symfony/Bridge/PhpUnit/" + }, "files": [ "src/Symfony/Component/Clock/Resources/now.php", "src/Symfony/Component/VarDumper/Resources/functions/dump.php" @@ -213,7 +220,7 @@ "url": "src/Symfony/Contracts", "options": { "versions": { - "symfony/contracts": "3.5.x-dev" + "symfony/contracts": "3.6.x-dev" } } }, diff --git a/psalm.xml b/psalm.xml index f5f9c5b4c4e88..a3dd6b8d5e191 100644 --- a/psalm.xml +++ b/psalm.xml @@ -10,6 +10,7 @@ findUnusedBaselineEntry="false" findUnusedCode="false" findUnusedIssueHandlerSuppression="false" + ensureOverrideAttribute="false" > @@ -32,6 +33,8 @@ + + diff --git a/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php b/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php index 7ddf3e72186d6..1efa7d78d0524 100644 --- a/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php +++ b/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityValueResolver.php @@ -21,6 +21,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Exception\NearMissValueResolverException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** @@ -35,6 +36,8 @@ public function __construct( private ManagerRegistry $registry, private ?ExpressionLanguage $expressionLanguage = null, private MapEntity $defaults = new MapEntity(), + /** @var array */ + private readonly array $typeAliases = [], ) { } @@ -50,6 +53,9 @@ public function resolve(Request $request, ArgumentMetadata $argument): array if (!$options->class || $options->disabled) { return []; } + + $options->class = $this->typeAliases[$options->class] ?? $options->class; + if (!$manager = $this->getManager($options->objectManager, $options->class)) { return []; } @@ -63,7 +69,11 @@ public function resolve(Request $request, ArgumentMetadata $argument): array } elseif (false === $object = $this->find($manager, $request, $options, $argument)) { // find by criteria if (!$criteria = $this->getCriteria($request, $options, $manager, $argument)) { - return []; + if (!class_exists(NearMissValueResolverException::class)) { + return []; + } + + throw new NearMissValueResolverException(sprintf('Cannot find mapping for "%s": declare one using either the #[MapEntity] attribute or mapped route parameters.', $options->class)); } try { $object = $manager->getRepository($options->class)->findOneBy($criteria); @@ -180,7 +190,7 @@ private function getCriteria(Request $request, MapEntity $options, ObjectManager return $criteria; } elseif (null === $mapping) { - trigger_deprecation('symfony/doctrine-bridge', '7.1', 'Relying on auto-mapping for Doctrine entities is deprecated for argument $%s of "%s": declare the identifier using either the #[MapEntity] attribute or mapped route parameters.', $argument->getName(), method_exists($argument, 'getControllerName') ? $argument->getControllerName() : 'n/a'); + trigger_deprecation('symfony/doctrine-bridge', '7.1', 'Relying on auto-mapping for Doctrine entities is deprecated for argument $%s of "%s": declare the mapping using either the #[MapEntity] attribute or mapped route parameters.', $argument->getName(), method_exists($argument, 'getControllerName') ? $argument->getControllerName() : 'n/a'); $mapping = $request->attributes->keys(); } diff --git a/src/Symfony/Bridge/Doctrine/Attribute/MapEntity.php b/src/Symfony/Bridge/Doctrine/Attribute/MapEntity.php index 73d73d58b23bb..c9d07ed389244 100644 --- a/src/Symfony/Bridge/Doctrine/Attribute/MapEntity.php +++ b/src/Symfony/Bridge/Doctrine/Attribute/MapEntity.php @@ -53,7 +53,7 @@ public function __construct( public function withDefaults(self $defaults, ?string $class): static { $clone = clone $this; - $clone->class ??= class_exists($class ?? '') ? $class : null; + $clone->class ??= class_exists($class ?? '') || interface_exists($class ?? '', false) ? $class : null; $clone->objectManager ??= $defaults->objectManager; $clone->expr ??= $defaults->expr; $clone->mapping ??= $defaults->mapping; diff --git a/src/Symfony/Bridge/Doctrine/CHANGELOG.md b/src/Symfony/Bridge/Doctrine/CHANGELOG.md index f1133dfefe9a6..961a0965d3431 100644 --- a/src/Symfony/Bridge/Doctrine/CHANGELOG.md +++ b/src/Symfony/Bridge/Doctrine/CHANGELOG.md @@ -1,6 +1,15 @@ CHANGELOG ========= +7.3 +--- + + * Reset the manager registry using native lazy objects when applicable + * Deprecate the `DoctrineExtractor::getTypes()` method, use `DoctrineExtractor::getType()` instead + * Add support for `Symfony\Component\Clock\DatePoint` as `DatePointType` Doctrine type + * Improve exception message when `EntityValueResolver` gets no mapping information + * Add type aliases support to `EntityValueResolver` + 7.2 --- diff --git a/src/Symfony/Bridge/Doctrine/DependencyInjection/AbstractDoctrineExtension.php b/src/Symfony/Bridge/Doctrine/DependencyInjection/AbstractDoctrineExtension.php index 51118c6dfafa2..83d8a85aed96d 100644 --- a/src/Symfony/Bridge/Doctrine/DependencyInjection/AbstractDoctrineExtension.php +++ b/src/Symfony/Bridge/Doctrine/DependencyInjection/AbstractDoctrineExtension.php @@ -301,6 +301,7 @@ protected function loadCacheDriver(string $cacheName, string $objectManagerName, $cacheDef->addMethodCall('setMemcached', [new Reference($this->getObjectManagerElementName(\sprintf('%s_memcached_instance', $objectManagerName)))]); break; case 'redis': + case 'valkey': $redisClass = !empty($cacheDriver['class']) ? $cacheDriver['class'] : '%'.$this->getObjectManagerElementName('cache.redis.class').'%'; $redisInstanceClass = !empty($cacheDriver['instance_class']) ? $cacheDriver['instance_class'] : '%'.$this->getObjectManagerElementName('cache.redis_instance.class').'%'; $redisHost = !empty($cacheDriver['host']) ? $cacheDriver['host'] : '%'.$this->getObjectManagerElementName('cache.redis_host').'%'; diff --git a/src/Symfony/Bridge/Doctrine/DependencyInjection/CompilerPass/RegisterDatePointTypePass.php b/src/Symfony/Bridge/Doctrine/DependencyInjection/CompilerPass/RegisterDatePointTypePass.php new file mode 100644 index 0000000000000..68474d94f2048 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/DependencyInjection/CompilerPass/RegisterDatePointTypePass.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\DependencyInjection\CompilerPass; + +use Symfony\Bridge\Doctrine\Types\DatePointType; +use Symfony\Component\Clock\DatePoint; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +final class RegisterDatePointTypePass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + if (!class_exists(DatePoint::class)) { + return; + } + + if (!$container->hasParameter('doctrine.dbal.connection_factory.types')) { + return; + } + + $types = $container->getParameter('doctrine.dbal.connection_factory.types'); + + $types['date_point'] ??= ['class' => DatePointType::class]; + + $container->setParameter('doctrine.dbal.connection_factory.types', $types); + } +} diff --git a/src/Symfony/Bridge/Doctrine/ManagerRegistry.php b/src/Symfony/Bridge/Doctrine/ManagerRegistry.php index 6a2c7a59542ef..a533b3bb8d12c 100644 --- a/src/Symfony/Bridge/Doctrine/ManagerRegistry.php +++ b/src/Symfony/Bridge/Doctrine/ManagerRegistry.php @@ -45,31 +45,62 @@ protected function resetService($name): void return; } - if (!$manager instanceof LazyLoadingInterface) { - throw new \LogicException(\sprintf('Resetting a non-lazy manager service is not supported. Declare the "%s" service as lazy.', $name)); + if (\PHP_VERSION_ID < 80400) { + if (!$manager instanceof LazyLoadingInterface) { + throw new \LogicException(\sprintf('Resetting a non-lazy manager service is not supported. Declare the "%s" service as lazy.', $name)); + } + trigger_deprecation('symfony/doctrine-bridge', '7.3', 'Support for proxy-manager is deprecated.'); + + if ($manager instanceof GhostObjectInterface) { + throw new \LogicException('Resetting a lazy-ghost-object manager service is not supported.'); + } + $manager->setProxyInitializer(\Closure::bind( + function (&$wrappedInstance, LazyLoadingInterface $manager) use ($name) { + $name = $this->aliases[$name] ?? $name; + $wrappedInstance = match (true) { + isset($this->fileMap[$name]) => $this->load($this->fileMap[$name], false), + !$method = $this->methodMap[$name] ?? null => throw new \LogicException(\sprintf('The "%s" service is synthetic and cannot be reset.', $name)), + (new \ReflectionMethod($this, $method))->isStatic() => $this->{$method}($this, false), + default => $this->{$method}(false), + }; + $manager->setProxyInitializer(null); + + return true; + }, + $this->container, + Container::class + )); + + return; } - if ($manager instanceof GhostObjectInterface) { - throw new \LogicException('Resetting a lazy-ghost-object manager service is not supported.'); + + $r = new \ReflectionClass($manager); + + if ($r->isUninitializedLazyObject($manager)) { + return; + } + + try { + $r->resetAsLazyProxy($manager, \Closure::bind( + function () use ($name) { + $name = $this->aliases[$name] ?? $name; + + return match (true) { + isset($this->fileMap[$name]) => $this->load($this->fileMap[$name], false), + !$method = $this->methodMap[$name] ?? null => throw new \LogicException(\sprintf('The "%s" service is synthetic and cannot be reset.', $name)), + (new \ReflectionMethod($this, $method))->isStatic() => $this->{$method}($this, false), + default => $this->{$method}(false), + }; + }, + $this->container, + Container::class + )); + } catch (\Error $e) { + if (__FILE__ !== $e->getFile()) { + throw $e; + } + + throw new \LogicException(\sprintf('Resetting a non-lazy manager service is not supported. Declare the "%s" service as lazy.', $name), 0, $e); } - $manager->setProxyInitializer(\Closure::bind( - function (&$wrappedInstance, LazyLoadingInterface $manager) use ($name) { - if (isset($this->aliases[$name])) { - $name = $this->aliases[$name]; - } - if (isset($this->fileMap[$name])) { - $wrappedInstance = $this->load($this->fileMap[$name], false); - } elseif ((new \ReflectionMethod($this, $this->methodMap[$name]))->isStatic()) { - $wrappedInstance = $this->{$this->methodMap[$name]}($this, false); - } else { - $wrappedInstance = $this->{$this->methodMap[$name]}(false); - } - - $manager->setProxyInitializer(null); - - return true; - }, - $this->container, - Container::class - )); } } diff --git a/src/Symfony/Bridge/Doctrine/Middleware/Debug/Driver.php b/src/Symfony/Bridge/Doctrine/Middleware/Debug/Driver.php index ea1ecfbd60b05..b6de4be534f7f 100644 --- a/src/Symfony/Bridge/Doctrine/Middleware/Debug/Driver.php +++ b/src/Symfony/Bridge/Doctrine/Middleware/Debug/Driver.php @@ -36,7 +36,7 @@ public function connect(array $params): ConnectionInterface { $connection = parent::connect($params); - if ('void' !== (string) (new \ReflectionMethod(DriverInterface\Connection::class, 'commit'))->getReturnType()) { + if ('void' !== (string) (new \ReflectionMethod(ConnectionInterface::class, 'commit'))->getReturnType()) { return new DBAL3\Connection( $connection, $this->debugDataHolder, diff --git a/src/Symfony/Bridge/Doctrine/Middleware/IdleConnection/Driver.php b/src/Symfony/Bridge/Doctrine/Middleware/IdleConnection/Driver.php index 566002cf8487c..693f6e5ac6827 100644 --- a/src/Symfony/Bridge/Doctrine/Middleware/IdleConnection/Driver.php +++ b/src/Symfony/Bridge/Doctrine/Middleware/IdleConnection/Driver.php @@ -17,6 +17,9 @@ final class Driver extends AbstractDriverMiddleware { + /** + * @param \ArrayObject $connectionExpiries + */ public function __construct( DriverInterface $driver, private \ArrayObject $connectionExpiries, diff --git a/src/Symfony/Bridge/Doctrine/PropertyInfo/DoctrineExtractor.php b/src/Symfony/Bridge/Doctrine/PropertyInfo/DoctrineExtractor.php index 478fbfbe8e251..050b84acece96 100644 --- a/src/Symfony/Bridge/Doctrine/PropertyInfo/DoctrineExtractor.php +++ b/src/Symfony/Bridge/Doctrine/PropertyInfo/DoctrineExtractor.php @@ -161,8 +161,13 @@ public function getType(string $class, string $property, array $context = []): ? }; } + /** + * @deprecated since Symfony 7.3, use "getType" instead + */ public function getTypes(string $class, string $property, array $context = []): ?array { + trigger_deprecation('symfony/property-info', '7.3', 'The "%s()" method is deprecated, use "%s::getType()" instead.', __METHOD__, self::class); + if (null === $metadata = $this->getMetadata($class)) { return null; } diff --git a/src/Symfony/Bridge/Doctrine/SchemaListener/AbstractSchemaListener.php b/src/Symfony/Bridge/Doctrine/SchemaListener/AbstractSchemaListener.php index 6f3410313d00a..cfe07b37da493 100644 --- a/src/Symfony/Bridge/Doctrine/SchemaListener/AbstractSchemaListener.php +++ b/src/Symfony/Bridge/Doctrine/SchemaListener/AbstractSchemaListener.php @@ -13,6 +13,9 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception\TableNotFoundException; +use Doctrine\DBAL\Schema\Name\Identifier; +use Doctrine\DBAL\Schema\Name\UnqualifiedName; +use Doctrine\DBAL\Schema\PrimaryKeyConstraint; use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs; @@ -30,7 +33,12 @@ protected function getIsSameDatabaseChecker(Connection $connection): \Closure $table->addColumn('id', Types::INTEGER) ->setAutoincrement(true) ->setNotnull(true); - $table->setPrimaryKey(['id']); + + if (class_exists(PrimaryKeyConstraint::class)) { + $table->addPrimaryKeyConstraint(new PrimaryKeyConstraint(null, [new UnqualifiedName(Identifier::unquoted('id'))], true)); + } else { + $table->setPrimaryKey(['id']); + } $schemaManager->createTable($table); diff --git a/src/Symfony/Bridge/Doctrine/Security/RememberMe/DoctrineTokenProvider.php b/src/Symfony/Bridge/Doctrine/Security/RememberMe/DoctrineTokenProvider.php index 251b011b5d44e..dd1b4b2e765b3 100644 --- a/src/Symfony/Bridge/Doctrine/Security/RememberMe/DoctrineTokenProvider.php +++ b/src/Symfony/Bridge/Doctrine/Security/RememberMe/DoctrineTokenProvider.php @@ -13,6 +13,9 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\ParameterType; +use Doctrine\DBAL\Schema\Name\Identifier; +use Doctrine\DBAL\Schema\Name\UnqualifiedName; +use Doctrine\DBAL\Schema\PrimaryKeyConstraint; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Types; use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentToken; @@ -57,6 +60,7 @@ public function loadTokenBySeries(string $series): PersistentTokenInterface $row = $stmt->fetchNumeric() ?: throw new TokenNotFoundException('No token found.'); [$class, $username, $value, $last_used] = $row; + return new PersistentToken($class, $username, $series, $value, new \DateTimeImmutable($last_used)); } @@ -193,6 +197,11 @@ private function addTableToSchema(Schema $schema): void $table->addColumn('lastUsed', Types::DATETIME_IMMUTABLE); $table->addColumn('class', Types::STRING, ['length' => 100]); $table->addColumn('username', Types::STRING, ['length' => 200]); - $table->setPrimaryKey(['series']); + + if (class_exists(PrimaryKeyConstraint::class)) { + $table->addPrimaryKeyConstraint(new PrimaryKeyConstraint(null, [new UnqualifiedName(Identifier::unquoted('series'))], true)); + } else { + $table->setPrimaryKey(['series']); + } } } diff --git a/src/Symfony/Bridge/Doctrine/Security/User/EntityUserProvider.php b/src/Symfony/Bridge/Doctrine/Security/User/EntityUserProvider.php index c6ddb921f1e21..78b962dfdbcae 100644 --- a/src/Symfony/Bridge/Doctrine/Security/User/EntityUserProvider.php +++ b/src/Symfony/Bridge/Doctrine/Security/User/EntityUserProvider.php @@ -100,6 +100,8 @@ public function refreshUser(UserInterface $user): UserInterface if ($refreshedUser instanceof Proxy && !$refreshedUser->__isInitialized()) { $refreshedUser->__load(); + } elseif (\PHP_VERSION_ID >= 80400 && ($r = new \ReflectionClass($refreshedUser))->isUninitializedLazyObject($refreshedUser)) { + $r->initializeLazyObject($refreshedUser); } return $refreshedUser; diff --git a/src/Symfony/Bridge/Doctrine/Tests/ArgumentResolver/EntityValueResolverTest.php b/src/Symfony/Bridge/Doctrine/Tests/ArgumentResolver/EntityValueResolverTest.php index 57195d8bad557..8207317803857 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/ArgumentResolver/EntityValueResolverTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/ArgumentResolver/EntityValueResolverTest.php @@ -24,6 +24,7 @@ use Symfony\Component\ExpressionLanguage\SyntaxError; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Exception\NearMissValueResolverException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class EntityValueResolverTest extends TestCase @@ -75,6 +76,11 @@ public function testResolveWithNoIdAndDataOptional() $request = new Request(); $argument = $this->createArgument(null, new MapEntity(), 'arg', true); + if (class_exists(NearMissValueResolverException::class)) { + $this->expectException(NearMissValueResolverException::class); + $this->expectExceptionMessage('Cannot find mapping for "stdClass": declare one using either the #[MapEntity] attribute or mapped route parameters.'); + } + $this->assertSame([], $resolver->resolve($request, $argument)); } @@ -94,6 +100,11 @@ public function testResolveWithStripNulls() $manager->expects($this->never()) ->method('getRepository'); + if (class_exists(NearMissValueResolverException::class)) { + $this->expectException(NearMissValueResolverException::class); + $this->expectExceptionMessage('Cannot find mapping for "stdClass": declare one using either the #[MapEntity] attribute or mapped route parameters.'); + } + $this->assertSame([], $resolver->resolve($request, $argument)); } @@ -125,6 +136,42 @@ public function testResolveWithId(string|int $id) $this->assertSame([$object], $resolver->resolve($request, $argument)); } + /** + * @dataProvider idsProvider + */ + public function testResolveWithIdAndTypeAlias(string|int $id) + { + $manager = $this->getMockBuilder(ObjectManager::class)->getMock(); + $registry = $this->createRegistry($manager); + $resolver = new EntityValueResolver( + $registry, + null, + new MapEntity(), + // Using \Throwable because it is an interface + ['Throwable' => 'stdClass'], + ); + + $request = new Request(); + $request->attributes->set('id', $id); + + $argument = $this->createArgument('Throwable', $mapEntity = new MapEntity(id: 'id')); + + $repository = $this->getMockBuilder(ObjectRepository::class)->getMock(); + $repository->expects($this->once()) + ->method('find') + ->with($id) + ->willReturn($object = new \stdClass()); + + $manager->expects($this->once()) + ->method('getRepository') + ->with('stdClass') + ->willReturn($repository); + + $this->assertSame([$object], $resolver->resolve($request, $argument)); + // Ensure the original MapEntity object was not updated + $this->assertNull($mapEntity->class); + } + public function testResolveWithNullId() { $manager = $this->createMock(ObjectManager::class); @@ -226,6 +273,11 @@ public function testResolveGuessOptional() $manager->expects($this->never())->method('getRepository'); + if (class_exists(NearMissValueResolverException::class)) { + $this->expectException(NearMissValueResolverException::class); + $this->expectExceptionMessage('Cannot find mapping for "stdClass": declare one using either the #[MapEntity] attribute or mapped route parameters.'); + } + $this->assertSame([], $resolver->resolve($request, $argument)); } @@ -494,7 +546,6 @@ private function createRegistry(?ObjectManager $manager = null): ManagerRegistry $registry->method('getManager')->willReturn($manager); } - return $registry; } } diff --git a/src/Symfony/Bridge/Doctrine/Tests/DataCollector/DoctrineDataCollectorTest.php b/src/Symfony/Bridge/Doctrine/Tests/DataCollector/DoctrineDataCollectorTest.php index d41dd096a44c8..b64a1cc4475c6 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/DataCollector/DoctrineDataCollectorTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/DataCollector/DoctrineDataCollectorTest.php @@ -246,7 +246,7 @@ private function createCollector(array $queries): DoctrineDataCollector ->getMock(); $connection->expects($this->any()) ->method('getDatabasePlatform') - ->willReturn(new MySqlPlatform()); + ->willReturn(new MySQLPlatform()); $registry = $this->createMock(ManagerRegistry::class); $registry diff --git a/src/Symfony/Bridge/Doctrine/Tests/DependencyInjection/CompilerPass/RegisterDatePointTypePassTest.php b/src/Symfony/Bridge/Doctrine/Tests/DependencyInjection/CompilerPass/RegisterDatePointTypePassTest.php new file mode 100644 index 0000000000000..3ded48d86cdd3 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/DependencyInjection/CompilerPass/RegisterDatePointTypePassTest.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\DependencyInjection\CompilerPass; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Doctrine\DependencyInjection\CompilerPass\RegisterDatePointTypePass; +use Symfony\Bridge\Doctrine\Types\DatePointType; +use Symfony\Component\Clock\DatePoint; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +class RegisterDatePointTypePassTest extends TestCase +{ + protected function setUp(): void + { + if (!class_exists(DatePoint::class)) { + self::markTestSkipped('The DatePoint class is not available.'); + } + } + + public function testRegistered() + { + $container = new ContainerBuilder(); + $container->setParameter('doctrine.dbal.connection_factory.types', ['foo' => 'bar']); + (new RegisterDatePointTypePass())->process($container); + + $expected = [ + 'foo' => 'bar', + 'date_point' => ['class' => DatePointType::class], + ]; + $this->assertSame($expected, $container->getParameter('doctrine.dbal.connection_factory.types')); + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/DoctrineTestHelper.php b/src/Symfony/Bridge/Doctrine/Tests/DoctrineTestHelper.php index e8d36c892b942..40472ff73ef40 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/DoctrineTestHelper.php +++ b/src/Symfony/Bridge/Doctrine/Tests/DoctrineTestHelper.php @@ -47,6 +47,10 @@ public static function createTestEntityManager(?Configuration $config = null): E $config ??= self::createTestConfiguration(); $eventManager = new EventManager(); + if (\PHP_VERSION_ID >= 80400 && method_exists($config, 'enableNativeLazyObjects')) { + $config->enableNativeLazyObjects(true); + } + return new EntityManager(DriverManager::getConnection($params, $config, $eventManager), $config, $eventManager); } diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/SingleAssociationToIntIdEntity.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/SingleAssociationToIntIdEntity.php index 94becf73b5795..0373417b2c8bb 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/SingleAssociationToIntIdEntity.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/SingleAssociationToIntIdEntity.php @@ -14,6 +14,7 @@ use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\Id; +use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\OneToOne; #[Entity] @@ -21,6 +22,7 @@ class SingleAssociationToIntIdEntity { public function __construct( #[Id, OneToOne(cascade: ['ALL'])] + #[JoinColumn(nullable: false)] protected SingleIntIdNoToStringEntity $entity, #[Column(nullable: true)] diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/SingleIntIdEntity.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/SingleIntIdEntity.php index 0970dea0669a9..3cebe3fe6e0a9 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/SingleIntIdEntity.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/SingleIntIdEntity.php @@ -16,7 +16,7 @@ use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\Id; -#[Entity] +#[Entity(repositoryClass: SingleIntIdEntityRepository::class)] class SingleIntIdEntity { #[Column(type: Types::JSON, nullable: true)] diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/SingleIntIdEntityRepository.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/SingleIntIdEntityRepository.php new file mode 100644 index 0000000000000..597f264099328 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/SingleIntIdEntityRepository.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Fixtures; + +use Doctrine\ORM\EntityRepository; + +class SingleIntIdEntityRepository extends EntityRepository +{ + public $result = null; + + public function findByCustom() + { + return $this->result; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/User.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/User.php index a85a12eb1a0b3..c49d7e93d26db 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/User.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/User.php @@ -45,6 +45,7 @@ public function getUserIdentifier(): string return $this->name; } + #[\Deprecated] public function eraseCredentials(): void { } diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/UserUuidNameDto.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/UserUuidNameDto.php new file mode 100644 index 0000000000000..8c2c60d21ba85 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/UserUuidNameDto.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Fixtures; + +use Symfony\Component\Uid\Uuid; + +class UserUuidNameDto +{ + public function __construct( + public ?Uuid $id, + public ?string $fullName, + public ?string $address, + ) { + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/UserUuidNameEntity.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/UserUuidNameEntity.php new file mode 100644 index 0000000000000..3ac3ead8d201a --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/UserUuidNameEntity.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Fixtures; + +use Doctrine\ORM\Mapping\Column; +use Doctrine\ORM\Mapping\Entity; +use Doctrine\ORM\Mapping\Id; +use Symfony\Component\Uid\Uuid; + +#[Entity] +class UserUuidNameEntity +{ + public function __construct( + #[Id, Column] + public ?Uuid $id = null, + #[Column(unique: true)] + public ?string $fullName = null, + ) { + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Form/DataTransformer/CollectionToArrayTransformerTest.php b/src/Symfony/Bridge/Doctrine/Tests/Form/DataTransformer/CollectionToArrayTransformerTest.php index c726546536199..338363d0acf74 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Form/DataTransformer/CollectionToArrayTransformerTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Form/DataTransformer/CollectionToArrayTransformerTest.php @@ -74,8 +74,7 @@ public function testTransformReadableCollection() 3 => 'bar', ]; - $collection = new class($array) implements ReadableCollection - { + $collection = new class($array) implements ReadableCollection { public function __construct(private readonly array $array) { } @@ -172,7 +171,7 @@ public function getIterator(): \Traversable public function count(): int { - return count($this->array); + return \count($this->array); } }; diff --git a/src/Symfony/Bridge/Doctrine/Tests/PropertyInfo/DoctrineExtractorTest.php b/src/Symfony/Bridge/Doctrine/Tests/PropertyInfo/DoctrineExtractorTest.php index 7903da227e912..04817d9389049 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/PropertyInfo/DoctrineExtractorTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/PropertyInfo/DoctrineExtractorTest.php @@ -30,6 +30,7 @@ use Symfony\Bridge\Doctrine\Tests\PropertyInfo\Fixtures\DoctrineWithEmbedded; use Symfony\Bridge\Doctrine\Tests\PropertyInfo\Fixtures\EnumInt; use Symfony\Bridge\Doctrine\Tests\PropertyInfo\Fixtures\EnumString; +use Symfony\Bridge\PhpUnit\ExpectUserDeprecationMessageTrait; use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; @@ -38,6 +39,8 @@ */ class DoctrineExtractorTest extends TestCase { + use ExpectUserDeprecationMessageTrait; + private function createExtractor(): DoctrineExtractor { $config = ORMSetup::createConfiguration(true); @@ -108,15 +111,24 @@ public function testTestGetPropertiesWithEmbedded() } /** + * @group legacy + * * @dataProvider legacyTypesProvider */ public function testExtractLegacy(string $property, ?array $type = null) { + $this->expectUserDeprecationMessage('Since symfony/property-info 7.3: The "Symfony\Bridge\Doctrine\PropertyInfo\DoctrineExtractor::getTypes()" method is deprecated, use "Symfony\Bridge\Doctrine\PropertyInfo\DoctrineExtractor::getType()" instead.'); + $this->assertEquals($type, $this->createExtractor()->getTypes(DoctrineDummy::class, $property, [])); } + /** + * @group legacy + */ public function testExtractWithEmbeddedLegacy() { + $this->expectUserDeprecationMessage('Since symfony/property-info 7.3: The "Symfony\Bridge\Doctrine\PropertyInfo\DoctrineExtractor::getTypes()" method is deprecated, use "Symfony\Bridge\Doctrine\PropertyInfo\DoctrineExtractor::getType()" instead.'); + $expectedTypes = [new LegacyType( LegacyType::BUILTIN_TYPE_OBJECT, false, @@ -132,8 +144,13 @@ public function testExtractWithEmbeddedLegacy() $this->assertEquals($expectedTypes, $actualTypes); } + /** + * @group legacy + */ public function testExtractEnumLegacy() { + $this->expectUserDeprecationMessage('Since symfony/property-info 7.3: The "Symfony\Bridge\Doctrine\PropertyInfo\DoctrineExtractor::getTypes()" method is deprecated, use "Symfony\Bridge\Doctrine\PropertyInfo\DoctrineExtractor::getType()" instead.'); + $this->assertEquals([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, EnumString::class)], $this->createExtractor()->getTypes(DoctrineEnum::class, 'enumString', [])); $this->assertEquals([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, EnumInt::class)], $this->createExtractor()->getTypes(DoctrineEnum::class, 'enumInt', [])); $this->assertNull($this->createExtractor()->getTypes(DoctrineEnum::class, 'enumStringArray', [])); @@ -141,6 +158,9 @@ public function testExtractEnumLegacy() $this->assertNull($this->createExtractor()->getTypes(DoctrineEnum::class, 'enumCustom', [])); } + /** + * @group legacy + */ public static function legacyTypesProvider(): array { // DBAL 4 has a special fallback strategy for BINGINT (int -> string) @@ -240,8 +260,13 @@ public function testGetPropertiesCatchException() $this->assertNull($this->createExtractor()->getProperties('Not\Exist')); } + /** + * @group legacy + */ public function testGetTypesCatchExceptionLegacy() { + $this->expectUserDeprecationMessage('Since symfony/property-info 7.3: The "Symfony\Bridge\Doctrine\PropertyInfo\DoctrineExtractor::getTypes()" method is deprecated, use "Symfony\Bridge\Doctrine\PropertyInfo\DoctrineExtractor::getType()" instead.'); + $this->assertNull($this->createExtractor()->getTypes('Not\Exist', 'baz')); } diff --git a/src/Symfony/Bridge/Doctrine/Tests/PropertyInfo/Fixtures/DoctrineFooType.php b/src/Symfony/Bridge/Doctrine/Tests/PropertyInfo/Fixtures/DoctrineFooType.php index 93e9818f4383c..6619f911ae1e0 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/PropertyInfo/Fixtures/DoctrineFooType.php +++ b/src/Symfony/Bridge/Doctrine/Tests/PropertyInfo/Fixtures/DoctrineFooType.php @@ -41,7 +41,7 @@ public function convertToDatabaseValue($value, AbstractPlatform $platform): ?str throw new ConversionException(sprintf('Expected "%s", got "%s"', 'Symfony\Bridge\Doctrine\Tests\PropertyInfo\Fixtures\Foo', get_debug_type($value))); } - return $foo->bar; + return $value->bar; } public function convertToPHPValue($value, AbstractPlatform $platform): ?Foo diff --git a/src/Symfony/Bridge/Doctrine/Tests/Security/RememberMe/DoctrineTokenProviderPostgresTest.php b/src/Symfony/Bridge/Doctrine/Tests/Security/RememberMe/DoctrineTokenProviderPostgresTest.php index 53cbbb07a211c..230ec78dc23cf 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Security/RememberMe/DoctrineTokenProviderPostgresTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Security/RememberMe/DoctrineTokenProviderPostgresTest.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Bridge\Doctrine\Tests\Security\RememberMe; use Doctrine\DBAL\Configuration; diff --git a/src/Symfony/Bridge/Doctrine/Tests/Security/User/EntityUserProviderTest.php b/src/Symfony/Bridge/Doctrine/Tests/Security/User/EntityUserProviderTest.php index a89ac84a7a9c1..82bc79f072ecd 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Security/User/EntityUserProviderTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Security/User/EntityUserProviderTest.php @@ -11,6 +11,7 @@ namespace Symfony\Bridge\Doctrine\Tests\Security\User; +use Doctrine\ORM\Configuration; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Tools\SchemaTool; @@ -219,8 +220,13 @@ public function testRefreshedUserProxyIsLoaded() $provider = new EntityUserProvider($this->getManager($em), User::class); $refreshedUser = $provider->refreshUser($user); - $this->assertInstanceOf(Proxy::class, $refreshedUser); - $this->assertTrue($refreshedUser->__isInitialized()); + if (\PHP_VERSION_ID >= 80400 && method_exists(Configuration::class, 'enableNativeLazyObjects')) { + $this->assertFalse((new \ReflectionClass(User::class))->isUninitializedLazyObject($refreshedUser)); + $this->assertSame('user1', $refreshedUser->name); + } else { + $this->assertInstanceOf(Proxy::class, $refreshedUser); + $this->assertTrue($refreshedUser->__isInitialized()); + } } private function getManager($em, $name = null) diff --git a/src/Symfony/Bridge/Doctrine/Tests/Types/DatePointTypeTest.php b/src/Symfony/Bridge/Doctrine/Tests/Types/DatePointTypeTest.php new file mode 100644 index 0000000000000..84b265ed6502c --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Types/DatePointTypeTest.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Types; + +use Doctrine\DBAL\Exception; +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Types\Type; +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Doctrine\Types\DatePointType; +use Symfony\Component\Clock\DatePoint; + +final class DatePointTypeTest extends TestCase +{ + private DatePointType $type; + + public static function setUpBeforeClass(): void + { + $name = DatePointType::NAME; + if (Type::hasType($name)) { + Type::overrideType($name, DatePointType::class); + } else { + Type::addType($name, DatePointType::class); + } + } + + protected function setUp(): void + { + if (!class_exists(DatePoint::class)) { + self::markTestSkipped('The DatePoint class is not available.'); + } + $this->type = Type::getType(DatePointType::NAME); + } + + public function testDatePointConvertsToDatabaseValue() + { + $datePoint = new DatePoint('2025-03-03 12:13:14'); + + $expected = $datePoint->format('Y-m-d H:i:s'); + $actual = $this->type->convertToDatabaseValue($datePoint, new PostgreSQLPlatform()); + + $this->assertSame($expected, $actual); + } + + public function testDatePointConvertsToPHPValue() + { + $datePoint = new DatePoint(); + $actual = $this->type->convertToPHPValue($datePoint, self::getSqlitePlatform()); + + $this->assertSame($datePoint, $actual); + } + + public function testNullConvertsToPHPValue() + { + $actual = $this->type->convertToPHPValue(null, self::getSqlitePlatform()); + + $this->assertNull($actual); + } + + public function testDateTimeImmutableConvertsToPHPValue() + { + $format = 'Y-m-d H:i:s'; + $dateTime = new \DateTimeImmutable('2025-03-03 12:13:14'); + $actual = $this->type->convertToPHPValue($dateTime, self::getSqlitePlatform()); + $expected = DatePoint::createFromInterface($dateTime); + + $this->assertSame($expected->format($format), $actual->format($format)); + } + + public function testDatabaseValueConvertsToPHPValue() + { + $actual = $this->type->convertToPHPValue('2025-03-03 12:13:14', new PostgreSQLPlatform()); + + $this->assertInstanceOf(DatePoint::class, $actual); + $this->assertSame('2025-03-03 12:13:14', $actual->format('Y-m-d H:i:s')); + } + + public function testGetName() + { + $this->assertSame('date_point', $this->type->getName()); + } + + private static function getSqlitePlatform(): AbstractPlatform + { + if (interface_exists(Exception::class)) { + // DBAL 4+ + return new \Doctrine\DBAL\Platforms\SQLitePlatform(); + } + + return new \Doctrine\DBAL\Platforms\SqlitePlatform(); + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Types/UlidTypeTest.php b/src/Symfony/Bridge/Doctrine/Tests/Types/UlidTypeTest.php index 15852c8a92b64..b490d94f4263f 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Types/UlidTypeTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Types/UlidTypeTest.php @@ -11,11 +11,11 @@ namespace Symfony\Bridge\Doctrine\Tests\Types; +use Doctrine\DBAL\Exception; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Platforms\MariaDBPlatform; use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Platforms\PostgreSQLPlatform; -use Doctrine\DBAL\Platforms\SQLitePlatform; use Doctrine\DBAL\Types\ConversionException; use Doctrine\DBAL\Types\Type; use PHPUnit\Framework\TestCase; @@ -23,12 +23,6 @@ use Symfony\Component\Uid\AbstractUid; use Symfony\Component\Uid\Ulid; -// DBAL 3 compatibility -class_exists('Doctrine\DBAL\Platforms\SqlitePlatform'); - -// DBAL 3 compatibility -class_exists('Doctrine\DBAL\Platforms\SqlitePlatform'); - final class UlidTypeTest extends TestCase { private const DUMMY_ULID = '01EEDQEK6ZAZE93J8KG5B4MBJC'; @@ -87,25 +81,25 @@ public function testNotSupportedTypeConversionForDatabaseValue() { $this->expectException(ConversionException::class); - $this->type->convertToDatabaseValue(new \stdClass(), new SQLitePlatform()); + $this->type->convertToDatabaseValue(new \stdClass(), self::getSqlitePlatform()); } public function testNullConversionForDatabaseValue() { - $this->assertNull($this->type->convertToDatabaseValue(null, new SQLitePlatform())); + $this->assertNull($this->type->convertToDatabaseValue(null, self::getSqlitePlatform())); } public function testUlidInterfaceConvertsToPHPValue() { $ulid = $this->createMock(AbstractUid::class); - $actual = $this->type->convertToPHPValue($ulid, new SQLitePlatform()); + $actual = $this->type->convertToPHPValue($ulid, self::getSqlitePlatform()); $this->assertSame($ulid, $actual); } public function testUlidConvertsToPHPValue() { - $ulid = $this->type->convertToPHPValue(self::DUMMY_ULID, new SQLitePlatform()); + $ulid = $this->type->convertToPHPValue(self::DUMMY_ULID, self::getSqlitePlatform()); $this->assertInstanceOf(Ulid::class, $ulid); $this->assertEquals(self::DUMMY_ULID, $ulid->__toString()); @@ -115,19 +109,19 @@ public function testInvalidUlidConversionForPHPValue() { $this->expectException(ConversionException::class); - $this->type->convertToPHPValue('abcdefg', new SQLitePlatform()); + $this->type->convertToPHPValue('abcdefg', self::getSqlitePlatform()); } public function testNullConversionForPHPValue() { - $this->assertNull($this->type->convertToPHPValue(null, new SQLitePlatform())); + $this->assertNull($this->type->convertToPHPValue(null, self::getSqlitePlatform())); } public function testReturnValueIfUlidForPHPValue() { $ulid = new Ulid(); - $this->assertSame($ulid, $this->type->convertToPHPValue($ulid, new SQLitePlatform())); + $this->assertSame($ulid, $this->type->convertToPHPValue($ulid, self::getSqlitePlatform())); } public function testGetName() @@ -146,13 +140,23 @@ public function testGetGuidTypeDeclarationSQL(AbstractPlatform $platform, string public static function provideSqlDeclarations(): \Generator { yield [new PostgreSQLPlatform(), 'UUID']; - yield [new SQLitePlatform(), 'BLOB']; + yield [self::getSqlitePlatform(), 'BLOB']; yield [new MySQLPlatform(), 'BINARY(16)']; yield [new MariaDBPlatform(), 'BINARY(16)']; } public function testRequiresSQLCommentHint() { - $this->assertTrue($this->type->requiresSQLCommentHint(new SQLitePlatform())); + $this->assertTrue($this->type->requiresSQLCommentHint(self::getSqlitePlatform())); + } + + private static function getSqlitePlatform(): AbstractPlatform + { + if (interface_exists(Exception::class)) { + // DBAL 4+ + return new \Doctrine\DBAL\Platforms\SQLitePlatform(); + } + + return new \Doctrine\DBAL\Platforms\SqlitePlatform(); } } diff --git a/src/Symfony/Bridge/Doctrine/Tests/Types/UuidTypeTest.php b/src/Symfony/Bridge/Doctrine/Tests/Types/UuidTypeTest.php index 8e4ab2937d05b..f26e43ffe66b3 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Types/UuidTypeTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Types/UuidTypeTest.php @@ -11,11 +11,11 @@ namespace Symfony\Bridge\Doctrine\Tests\Types; +use Doctrine\DBAL\Exception; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Platforms\MariaDBPlatform; use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Platforms\PostgreSQLPlatform; -use Doctrine\DBAL\Platforms\SqlitePlatform; use Doctrine\DBAL\Types\ConversionException; use Doctrine\DBAL\Types\Type; use PHPUnit\Framework\TestCase; @@ -92,25 +92,25 @@ public function testNotSupportedTypeConversionForDatabaseValue() { $this->expectException(ConversionException::class); - $this->type->convertToDatabaseValue(new \stdClass(), new SqlitePlatform()); + $this->type->convertToDatabaseValue(new \stdClass(), self::getSqlitePlatform()); } public function testNullConversionForDatabaseValue() { - $this->assertNull($this->type->convertToDatabaseValue(null, new SqlitePlatform())); + $this->assertNull($this->type->convertToDatabaseValue(null, self::getSqlitePlatform())); } public function testUuidInterfaceConvertsToPHPValue() { $uuid = $this->createMock(AbstractUid::class); - $actual = $this->type->convertToPHPValue($uuid, new SqlitePlatform()); + $actual = $this->type->convertToPHPValue($uuid, self::getSqlitePlatform()); $this->assertSame($uuid, $actual); } public function testUuidConvertsToPHPValue() { - $uuid = $this->type->convertToPHPValue(self::DUMMY_UUID, new SqlitePlatform()); + $uuid = $this->type->convertToPHPValue(self::DUMMY_UUID, self::getSqlitePlatform()); $this->assertInstanceOf(Uuid::class, $uuid); $this->assertEquals(self::DUMMY_UUID, $uuid->__toString()); @@ -120,19 +120,19 @@ public function testInvalidUuidConversionForPHPValue() { $this->expectException(ConversionException::class); - $this->type->convertToPHPValue('abcdefg', new SqlitePlatform()); + $this->type->convertToPHPValue('abcdefg', self::getSqlitePlatform()); } public function testNullConversionForPHPValue() { - $this->assertNull($this->type->convertToPHPValue(null, new SqlitePlatform())); + $this->assertNull($this->type->convertToPHPValue(null, self::getSqlitePlatform())); } public function testReturnValueIfUuidForPHPValue() { $uuid = Uuid::v4(); - $this->assertSame($uuid, $this->type->convertToPHPValue($uuid, new SqlitePlatform())); + $this->assertSame($uuid, $this->type->convertToPHPValue($uuid, self::getSqlitePlatform())); } public function testGetName() @@ -151,13 +151,23 @@ public function testGetGuidTypeDeclarationSQL(AbstractPlatform $platform, string public static function provideSqlDeclarations(): \Generator { yield [new PostgreSQLPlatform(), 'UUID']; - yield [new SqlitePlatform(), 'BLOB']; + yield [self::getSqlitePlatform(), 'BLOB']; yield [new MySQLPlatform(), 'BINARY(16)']; yield [new MariaDBPlatform(), 'BINARY(16)']; } public function testRequiresSQLCommentHint() { - $this->assertTrue($this->type->requiresSQLCommentHint(new SqlitePlatform())); + $this->assertTrue($this->type->requiresSQLCommentHint(self::getSqlitePlatform())); + } + + private static function getSqlitePlatform(): AbstractPlatform + { + if (interface_exists(Exception::class)) { + // DBAL 4+ + return new \Doctrine\DBAL\Platforms\SQLitePlatform(); + } + + return new \Doctrine\DBAL\Platforms\SqlitePlatform(); } } diff --git a/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityTest.php b/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityTest.php index fbfc2cb39b4ed..a3015722cea8d 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityTest.php @@ -61,6 +61,9 @@ public function testAttributeWithGroupsAndPaylod() self::assertSame(['some_group'], $constraint->groups); } + /** + * @group legacy + */ public function testValueOptionConfiguresFields() { $constraint = new UniqueEntity(['value' => 'email']); diff --git a/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityValidatorTest.php b/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityValidatorTest.php index d6d00aa1fa0d3..4f93768cddf7c 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityValidatorTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityValidatorTest.php @@ -14,8 +14,6 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\DBAL\Types\Type; use Doctrine\ORM\EntityRepository; -use Doctrine\ORM\Mapping\ClassMetadata; -use Doctrine\ORM\Mapping\ClassMetadataInfo; use Doctrine\ORM\Tools\SchemaTool; use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ObjectManager; @@ -32,7 +30,6 @@ use Symfony\Bridge\Doctrine\Tests\Fixtures\Dto; use Symfony\Bridge\Doctrine\Tests\Fixtures\Employee; use Symfony\Bridge\Doctrine\Tests\Fixtures\HireAnEmployee; -use Symfony\Bridge\Doctrine\Tests\Fixtures\MockableRepository; use Symfony\Bridge\Doctrine\Tests\Fixtures\Person; use Symfony\Bridge\Doctrine\Tests\Fixtures\SingleIntIdEntity; use Symfony\Bridge\Doctrine\Tests\Fixtures\SingleIntIdNoToStringEntity; @@ -44,9 +41,12 @@ use Symfony\Bridge\Doctrine\Tests\Fixtures\UpdateCompositeIntIdEntity; use Symfony\Bridge\Doctrine\Tests\Fixtures\UpdateCompositeObjectNoToStringIdEntity; use Symfony\Bridge\Doctrine\Tests\Fixtures\UpdateEmployeeProfile; +use Symfony\Bridge\Doctrine\Tests\Fixtures\UserUuidNameDto; +use Symfony\Bridge\Doctrine\Tests\Fixtures\UserUuidNameEntity; use Symfony\Bridge\Doctrine\Tests\TestRepositoryFactory; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntityValidator; +use Symfony\Component\Uid\Uuid; use Symfony\Component\Validator\Exception\ConstraintDefinitionException; use Symfony\Component\Validator\Exception\UnexpectedValueException; use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; @@ -98,53 +98,6 @@ protected function createRegistryMock($em = null) return $registry; } - protected function createRepositoryMock(string $className) - { - $repositoryMock = $this->getMockBuilder(MockableRepository::class) - ->disableOriginalConstructor() - ->onlyMethods(['find', 'findAll', 'findOneBy', 'findBy', 'getClassName', 'findByCustom']) - ->getMock(); - - $repositoryMock->method('getClassName') - ->willReturn($className); - - return $repositoryMock; - } - - protected function createEntityManagerMock($repositoryMock) - { - $em = $this->createMock(ObjectManager::class); - $em->expects($this->any()) - ->method('getRepository') - ->willReturn($repositoryMock) - ; - - $classMetadata = $this->createMock( - class_exists(ClassMetadataInfo::class) ? ClassMetadataInfo::class : ClassMetadata::class - ); - $classMetadata - ->method('getName') - ->willReturn($repositoryMock->getClassName()) - ; - $classMetadata - ->expects($this->any()) - ->method('hasField') - ->willReturn(true) - ; - $refl = $this->createMock(\ReflectionProperty::class); - $refl - ->method('getValue') - ->willReturn(true) - ; - $classMetadata->reflFields = ['name' => $refl]; - $em->expects($this->any()) - ->method('getClassMetadata') - ->willReturn($classMetadata) - ; - - return $em; - } - protected function createValidator(): UniqueEntityValidator { return new UniqueEntityValidator($this->registry); @@ -166,16 +119,17 @@ private function createSchema($em) $em->getClassMetadata(Employee::class), $em->getClassMetadata(CompositeObjectNoToStringIdEntity::class), $em->getClassMetadata(SingleIntIdStringWrapperNameEntity::class), + $em->getClassMetadata(UserUuidNameEntity::class), ]); } /** * This is a functional test as there is a large integration necessary to get the validator working. - * - * @dataProvider provideUniquenessConstraints */ - public function testValidateUniqueness(UniqueEntity $constraint) + public function testValidateUniqueness() { + $constraint = new UniqueEntity(message: 'myMessage', fields: ['name'], em: 'foo'); + $entity1 = new SingleIntIdEntity(1, 'Foo'); $entity2 = new SingleIntIdEntity(2, 'Foo'); @@ -223,6 +177,28 @@ public function testValidateEntityWithPrivatePropertyAndProxyObject() // this will load a proxy object $entity = $this->em->getReference(SingleIntIdWithPrivateNameEntity::class, 1); + $this->validator->validate($entity, new UniqueEntity( + fields: ['name'], + em: self::EM_NAME, + )); + + $this->assertNoViolation(); + } + + /** + * @group legacy + */ + public function testValidateEntityWithPrivatePropertyAndProxyObjectDoctrineStyle() + { + $entity = new SingleIntIdWithPrivateNameEntity(1, 'Foo'); + $this->em->persist($entity); + $this->em->flush(); + + $this->em->clear(); + + // this will load a proxy object + $entity = $this->em->getReference(SingleIntIdWithPrivateNameEntity::class, 1); + $this->validator->validate($entity, new UniqueEntity([ 'fields' => ['name'], 'em' => self::EM_NAME, @@ -231,10 +207,7 @@ public function testValidateEntityWithPrivatePropertyAndProxyObject() $this->assertNoViolation(); } - /** - * @dataProvider provideConstraintsWithCustomErrorPath - */ - public function testValidateCustomErrorPath(UniqueEntity $constraint) + public function testValidateCustomErrorPath() { $entity1 = new SingleIntIdEntity(1, 'Foo'); $entity2 = new SingleIntIdEntity(2, 'Foo'); @@ -242,7 +215,7 @@ public function testValidateCustomErrorPath(UniqueEntity $constraint) $this->em->persist($entity1); $this->em->flush(); - $this->validator->validate($entity2, $constraint); + $this->validator->validate($entity2, new UniqueEntity(message: 'myMessage', fields: ['name'], em: 'foo', errorPath: 'bar')); $this->buildViolation('myMessage') ->atPath('property.path.bar') @@ -253,22 +226,34 @@ public function testValidateCustomErrorPath(UniqueEntity $constraint) ->assertRaised(); } - public static function provideConstraintsWithCustomErrorPath(): iterable + /** + * @group legacy + */ + public function testValidateCustomErrorPathDoctrineStyle() { - yield 'Doctrine style' => [new UniqueEntity([ + $entity1 = new SingleIntIdEntity(1, 'Foo'); + $entity2 = new SingleIntIdEntity(2, 'Foo'); + + $this->em->persist($entity1); + $this->em->flush(); + + $this->validator->validate($entity2, new UniqueEntity([ 'message' => 'myMessage', 'fields' => ['name'], - 'em' => self::EM_NAME, + 'em' => 'foo', 'errorPath' => 'bar', - ])]; + ])); - yield 'Named arguments' => [new UniqueEntity(message: 'myMessage', fields: ['name'], em: 'foo', errorPath: 'bar')]; + $this->buildViolation('myMessage') + ->atPath('property.path.bar') + ->setParameter('{{ value }}', '"Foo"') + ->setInvalidValue($entity2) + ->setCause([$entity1]) + ->setCode(UniqueEntity::NOT_UNIQUE_ERROR) + ->assertRaised(); } - /** - * @dataProvider provideUniquenessConstraints - */ - public function testValidateUniquenessWithNull(UniqueEntity $constraint) + public function testValidateUniquenessWithNull() { $entity1 = new SingleIntIdEntity(1, null); $entity2 = new SingleIntIdEntity(2, null); @@ -277,7 +262,7 @@ public function testValidateUniquenessWithNull(UniqueEntity $constraint) $this->em->persist($entity2); $this->em->flush(); - $this->validator->validate($entity1, $constraint); + $this->validator->validate($entity1, new UniqueEntity(message: 'myMessage', fields: ['name'], em: 'foo')); $this->assertNoViolation(); } @@ -315,13 +300,6 @@ public function testValidateUniquenessWithIgnoreNullDisableOnSecondField(UniqueE public static function provideConstraintsWithIgnoreNullDisabled(): iterable { - yield 'Doctrine style' => [new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['name', 'name2'], - 'em' => self::EM_NAME, - 'ignoreNull' => false, - ])]; - yield 'Named arguments' => [new UniqueEntity(message: 'myMessage', fields: ['name', 'name2'], em: 'foo', ignoreNull: false)]; } @@ -363,36 +341,22 @@ public function testNoValidationIfFirstFieldIsNullAndNullValuesAreIgnored(Unique public static function provideConstraintsWithIgnoreNullEnabled(): iterable { - yield 'Doctrine style' => [new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['name', 'name2'], - 'em' => self::EM_NAME, - 'ignoreNull' => true, - ])]; - yield 'Named arguments' => [new UniqueEntity(message: 'myMessage', fields: ['name', 'name2'], em: 'foo', ignoreNull: true)]; } public static function provideConstraintsWithIgnoreNullEnabledOnFirstField(): iterable { - yield 'Doctrine style (name field)' => [new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['name', 'name2'], - 'em' => self::EM_NAME, - 'ignoreNull' => 'name', - ])]; - yield 'Named arguments (name field)' => [new UniqueEntity(message: 'myMessage', fields: ['name', 'name2'], em: 'foo', ignoreNull: 'name')]; } public function testValidateUniquenessWithValidCustomErrorPath() { - $constraint = new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['name', 'name2'], - 'em' => self::EM_NAME, - 'errorPath' => 'name2', - ]); + $constraint = new UniqueEntity( + message: 'myMessage', + fields: ['name', 'name2'], + em: self::EM_NAME, + errorPath: 'name2', + ); $entity1 = new DoubleNameEntity(1, 'Foo', 'Bar'); $entity2 = new DoubleNameEntity(2, 'Foo', 'Bar'); @@ -419,90 +383,50 @@ public function testValidateUniquenessWithValidCustomErrorPath() ->assertRaised(); } - /** - * @dataProvider provideConstraintsWithCustomRepositoryMethod - */ - public function testValidateUniquenessUsingCustomRepositoryMethod(UniqueEntity $constraint) + public function testValidateUniquenessUsingCustomRepositoryMethod() { - $repository = $this->createRepositoryMock(SingleIntIdEntity::class); - $repository->expects($this->once()) - ->method('findByCustom') - ->willReturn([]) - ; - $this->em = $this->createEntityManagerMock($repository); - $this->registry = $this->createRegistryMock($this->em); + $this->em->getRepository(SingleIntIdEntity::class)->result = []; $this->validator = $this->createValidator(); $this->validator->initialize($this->context); $entity1 = new SingleIntIdEntity(1, 'foo'); - $this->validator->validate($entity1, $constraint); + $this->validator->validate($entity1, new UniqueEntity(message: 'myMessage', fields: ['name'], em: 'foo', repositoryMethod: 'findByCustom')); $this->assertNoViolation(); } - /** - * @dataProvider provideConstraintsWithCustomRepositoryMethod - */ - public function testValidateUniquenessWithUnrewoundArray(UniqueEntity $constraint) + public function testValidateUniquenessWithUnrewoundArray() { $entity = new SingleIntIdEntity(1, 'foo'); - $repository = $this->createRepositoryMock(SingleIntIdEntity::class); - $repository->expects($this->once()) - ->method('findByCustom') - ->willReturnCallback( - function () use ($entity) { - $returnValue = [ - $entity, - ]; - next($returnValue); - - return $returnValue; - } - ) - ; - $this->em = $this->createEntityManagerMock($repository); - $this->registry = $this->createRegistryMock($this->em); + $returnValue = [ + $entity, + ]; + next($returnValue); + + $this->em->getRepository(SingleIntIdEntity::class)->result = $returnValue; $this->validator = $this->createValidator(); $this->validator->initialize($this->context); - $this->validator->validate($entity, $constraint); + $this->validator->validate($entity, new UniqueEntity(message: 'myMessage', fields: ['name'], em: 'foo', repositoryMethod: 'findByCustom')); $this->assertNoViolation(); } - public static function provideConstraintsWithCustomRepositoryMethod(): iterable - { - yield 'Doctrine style' => [new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['name'], - 'em' => self::EM_NAME, - 'repositoryMethod' => 'findByCustom', - ])]; - - yield 'Named arguments' => [new UniqueEntity(message: 'myMessage', fields: ['name'], em: 'foo', repositoryMethod: 'findByCustom')]; - } - /** * @dataProvider resultTypesProvider */ public function testValidateResultTypes($entity1, $result) { - $constraint = new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['name'], - 'em' => self::EM_NAME, - 'repositoryMethod' => 'findByCustom', - ]); + $constraint = new UniqueEntity( + message: 'myMessage', + fields: ['name'], + em: self::EM_NAME, + repositoryMethod: 'findByCustom', + ); - $repository = $this->createRepositoryMock($entity1::class); - $repository->expects($this->once()) - ->method('findByCustom') - ->willReturn($result) - ; - $this->em = $this->createEntityManagerMock($repository); - $this->registry = $this->createRegistryMock($this->em); + $this->em->getRepository(SingleIntIdEntity::class)->result = $result; $this->validator = $this->createValidator(); $this->validator->initialize($this->context); @@ -524,11 +448,11 @@ public static function resultTypesProvider(): array public function testAssociatedEntity() { - $constraint = new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['single'], - 'em' => self::EM_NAME, - ]); + $constraint = new UniqueEntity( + message: 'myMessage', + fields: ['single'], + em: self::EM_NAME, + ); $entity1 = new SingleIntIdEntity(1, 'foo'); $associated = new AssociationEntity(); @@ -560,11 +484,11 @@ public function testAssociatedEntity() public function testValidateUniquenessNotToStringEntityWithAssociatedEntity() { - $constraint = new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['single'], - 'em' => self::EM_NAME, - ]); + $constraint = new UniqueEntity( + message: 'myMessage', + fields: ['single'], + em: self::EM_NAME, + ); $entity1 = new SingleIntIdNoToStringEntity(1, 'foo'); $associated = new AssociationEntity2(); @@ -598,12 +522,12 @@ public function testValidateUniquenessNotToStringEntityWithAssociatedEntity() public function testAssociatedEntityWithNull() { - $constraint = new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['single'], - 'em' => self::EM_NAME, - 'ignoreNull' => false, - ]); + $constraint = new UniqueEntity( + message: 'myMessage', + fields: ['single'], + em: self::EM_NAME, + ignoreNull: false, + ); $associated = new AssociationEntity(); $associated->single = null; @@ -652,23 +576,17 @@ public function testAssociatedEntityReferencedByPrimaryKey() public function testValidateUniquenessWithArrayValue() { - $repository = $this->createRepositoryMock(SingleIntIdEntity::class); - $this->repositoryFactory->setRepository($this->em, SingleIntIdEntity::class, $repository); - - $constraint = new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['phoneNumbers'], - 'em' => self::EM_NAME, - 'repositoryMethod' => 'findByCustom', - ]); + $constraint = new UniqueEntity( + message: 'myMessage', + fields: ['phoneNumbers'], + em: self::EM_NAME, + repositoryMethod: 'findByCustom', + ); $entity1 = new SingleIntIdEntity(1, 'foo'); $entity1->phoneNumbers[] = 123; - $repository->expects($this->once()) - ->method('findByCustom') - ->willReturn([$entity1]) - ; + $this->em->getRepository(SingleIntIdEntity::class)->result = $entity1; $this->em->persist($entity1); $this->em->flush(); @@ -691,11 +609,11 @@ public function testValidateUniquenessWithArrayValue() public function testDedicatedEntityManagerNullObject() { - $constraint = new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['name'], - 'em' => self::EM_NAME, - ]); + $constraint = new UniqueEntity( + message: 'myMessage', + fields: ['name'], + em: self::EM_NAME, + ); $this->em = null; $this->registry = $this->createRegistryMock($this->em); @@ -712,14 +630,12 @@ public function testDedicatedEntityManagerNullObject() public function testEntityManagerNullObject() { - $constraint = new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['name'], + $constraint = new UniqueEntity( + message: 'myMessage', + fields: ['name'], // no "em" option set - ]); + ); - $this->em = null; - $this->registry = $this->createRegistryMock($this->em); $this->validator = $this->createValidator(); $this->validator->initialize($this->context); @@ -733,22 +649,14 @@ public function testEntityManagerNullObject() public function testValidateUniquenessOnNullResult() { - $repository = $this->createRepositoryMock(SingleIntIdEntity::class); - $repository - ->method('find') - ->willReturn(null) - ; - - $this->em = $this->createEntityManagerMock($repository); - $this->registry = $this->createRegistryMock($this->em); $this->validator = $this->createValidator(); $this->validator->initialize($this->context); - $constraint = new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['name'], - 'em' => self::EM_NAME, - ]); + $constraint = new UniqueEntity( + message: 'myMessage', + fields: ['name'], + em: self::EM_NAME, + ); $entity = new SingleIntIdEntity(1, null); @@ -761,12 +669,12 @@ public function testValidateUniquenessOnNullResult() public function testValidateInheritanceUniqueness() { - $constraint = new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['name'], - 'em' => self::EM_NAME, - 'entityClass' => Person::class, - ]); + $constraint = new UniqueEntity( + message: 'myMessage', + fields: ['name'], + em: self::EM_NAME, + entityClass: Person::class, + ); $entity1 = new Person(1, 'Foo'); $entity2 = new Employee(2, 'Foo'); @@ -795,12 +703,12 @@ public function testValidateInheritanceUniqueness() public function testInvalidateRepositoryForInheritance() { - $constraint = new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['name'], - 'em' => self::EM_NAME, - 'entityClass' => SingleStringIdEntity::class, - ]); + $constraint = new UniqueEntity( + message: 'myMessage', + fields: ['name'], + em: self::EM_NAME, + entityClass: SingleStringIdEntity::class, + ); $entity = new Person(1, 'Foo'); @@ -812,11 +720,11 @@ public function testInvalidateRepositoryForInheritance() public function testValidateUniquenessWithCompositeObjectNoToStringIdEntity() { - $constraint = new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['objectOne', 'objectTwo'], - 'em' => self::EM_NAME, - ]); + $constraint = new UniqueEntity( + message: 'myMessage', + fields: ['objectOne', 'objectTwo'], + em: self::EM_NAME, + ); $objectOne = new SingleIntIdNoToStringEntity(1, 'foo'); $objectTwo = new SingleIntIdNoToStringEntity(2, 'bar'); @@ -847,11 +755,11 @@ public function testValidateUniquenessWithCompositeObjectNoToStringIdEntity() public function testValidateUniquenessWithCustomDoctrineTypeValue() { - $constraint = new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['name'], - 'em' => self::EM_NAME, - ]); + $constraint = new UniqueEntity( + message: 'myMessage', + fields: ['name'], + em: self::EM_NAME, + ); $existingEntity = new SingleIntIdStringWrapperNameEntity(1, new StringWrapper('foo')); @@ -878,11 +786,11 @@ public function testValidateUniquenessWithCustomDoctrineTypeValue() */ public function testValidateUniquenessCause() { - $constraint = new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['name'], - 'em' => self::EM_NAME, - ]); + $constraint = new UniqueEntity( + message: 'myMessage', + fields: ['name'], + em: self::EM_NAME, + ); $entity1 = new SingleIntIdEntity(1, 'Foo'); $entity2 = new SingleIntIdEntity(2, 'Foo'); @@ -914,20 +822,14 @@ public function testValidateUniquenessCause() */ public function testValidateUniquenessWithEmptyIterator($entity, $result) { - $constraint = new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['name'], - 'em' => self::EM_NAME, - 'repositoryMethod' => 'findByCustom', - ]); + $constraint = new UniqueEntity( + message: 'myMessage', + fields: ['name'], + em: self::EM_NAME, + repositoryMethod: 'findByCustom', + ); - $repository = $this->createRepositoryMock($entity::class); - $repository->expects($this->once()) - ->method('findByCustom') - ->willReturn($result) - ; - $this->em = $this->createEntityManagerMock($repository); - $this->registry = $this->createRegistryMock($this->em); + $this->em->getRepository(SingleIntIdEntity::class)->result = $result; $this->validator = $this->createValidator(); $this->validator->initialize($this->context); @@ -938,11 +840,11 @@ public function testValidateUniquenessWithEmptyIterator($entity, $result) public function testValueMustBeObject() { - $constraint = new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['name'], - 'em' => self::EM_NAME, - ]); + $constraint = new UniqueEntity( + message: 'myMessage', + fields: ['name'], + em: self::EM_NAME, + ); $this->expectException(UnexpectedValueException::class); @@ -951,11 +853,11 @@ public function testValueMustBeObject() public function testValueCanBeNull() { - $constraint = new UniqueEntity([ - 'message' => 'myMessage', - 'fields' => ['name'], - 'em' => self::EM_NAME, - ]); + $constraint = new UniqueEntity( + message: 'myMessage', + fields: ['name'], + em: self::EM_NAME, + ); $this->validator->validate(null, $constraint); @@ -1052,6 +954,9 @@ public function testValidateDTOUniqueness() ->assertRaised(); } + /** + * @group legacy + */ public function testValidateDTOUniquenessDoctrineStyle() { $constraint = new UniqueEntity([ @@ -1112,6 +1017,9 @@ public function testValidateMappingOfFieldNames() ->assertRaised(); } + /** + * @group legacy + */ public function testValidateMappingOfFieldNamesDoctrineStyle() { $constraint = new UniqueEntity([ @@ -1153,6 +1061,9 @@ public function testInvalidateDTOFieldName() $this->validator->validate($dto, $constraint); } + /** + * @group legacy + */ public function testInvalidateDTOFieldNameDoctrineStyle() { $this->expectException(ConstraintDefinitionException::class); @@ -1183,6 +1094,9 @@ public function testInvalidateEntityFieldName() $this->validator->validate($dto, $constraint); } + /** + * @group legacy + */ public function testInvalidateEntityFieldNameDoctrineStyle() { $this->expectException(ConstraintDefinitionException::class); @@ -1228,6 +1142,9 @@ public function testValidateDTOUniquenessWhenUpdatingEntity() ->assertRaised(); } + /** + * @group legacy + */ public function testValidateDTOUniquenessWhenUpdatingEntityDoctrineStyle() { $constraint = new UniqueEntity([ @@ -1280,6 +1197,9 @@ public function testValidateDTOUniquenessWhenUpdatingEntityWithTheSameValue() $this->assertNoViolation(); } + /** + * @group legacy + */ public function testValidateDTOUniquenessWhenUpdatingEntityWithTheSameValueDoctrineStyle() { $constraint = new UniqueEntity([ @@ -1331,6 +1251,9 @@ public function testValidateIdentifierMappingOfFieldNames() $this->assertNoViolation(); } + /** + * @group legacy + */ public function testValidateIdentifierMappingOfFieldNamesDoctrineStyle() { $constraint = new UniqueEntity([ @@ -1388,6 +1311,9 @@ public function testInvalidateMissingIdentifierFieldName() $this->validator->validate($dto, $constraint); } + /** + * @group legacy + */ public function testInvalidateMissingIdentifierFieldNameDoctrineStyle() { $this->expectException(ConstraintDefinitionException::class); @@ -1435,6 +1361,9 @@ public function testUninitializedValueThrowException() $this->validator->validate($dto, $constraint); } + /** + * @group legacy + */ public function testUninitializedValueThrowExceptionDoctrineStyle() { $this->expectExceptionMessage('Typed property Symfony\Bridge\Doctrine\Tests\Fixtures\Dto::$foo must not be accessed before initialization'); @@ -1475,6 +1404,9 @@ public function testEntityManagerNullObjectWhenDTO() $this->validator->validate($dto, $constraint); } + /** + * @group legacy + */ public function testEntityManagerNullObjectWhenDTODoctrineStyle() { $this->expectException(ConstraintDefinitionException::class); @@ -1495,4 +1427,25 @@ public function testEntityManagerNullObjectWhenDTODoctrineStyle() $this->validator->validate($dto, $constraint); } + + public function testUuidIdentifierWithSameValueDifferentInstanceDoesNotCauseViolation() + { + $uuidString = 'ec562e21-1fc8-4e55-8de7-a42389ac75c5'; + $existingPerson = new UserUuidNameEntity(Uuid::fromString($uuidString), 'Foo Bar'); + $this->em->persist($existingPerson); + $this->em->flush(); + + $dto = new UserUuidNameDto(Uuid::fromString($uuidString), 'Foo Bar', ''); + + $constraint = new UniqueEntity( + fields: ['fullName'], + entityClass: UserUuidNameEntity::class, + identifierFieldNames: ['id'], + em: self::EM_NAME, + ); + + $this->validator->validate($dto, $constraint); + + $this->assertNoViolation(); + } } diff --git a/src/Symfony/Bridge/Doctrine/Tests/Validator/DoctrineLoaderTest.php b/src/Symfony/Bridge/Doctrine/Tests/Validator/DoctrineLoaderTest.php index ef304114be0c4..8b3494961d80b 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Validator/DoctrineLoaderTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Validator/DoctrineLoaderTest.php @@ -210,7 +210,7 @@ public function testClassNoAutoMapping() $this->assertSame(AutoMappingStrategy::DISABLED, $classMetadata->getAutoMappingStrategy()); $maxLengthMetadata = $classMetadata->getPropertyMetadata('maxLength'); - $this->assertEmpty($maxLengthMetadata); + $this->assertSame([], $maxLengthMetadata); /** @var PropertyMetadata[] $autoMappingExplicitlyEnabledMetadata */ $autoMappingExplicitlyEnabledMetadata = $classMetadata->getPropertyMetadata('autoMappingExplicitlyEnabled'); diff --git a/src/Symfony/Bridge/Doctrine/Types/DatePointType.php b/src/Symfony/Bridge/Doctrine/Types/DatePointType.php new file mode 100644 index 0000000000000..72a04e80cf7ee --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Types/DatePointType.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Types; + +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Types\DateTimeImmutableType; +use Symfony\Component\Clock\DatePoint; + +final class DatePointType extends DateTimeImmutableType +{ + public const NAME = 'date_point'; + + /** + * @param T $value + * + * @return (T is null ? null : DatePoint) + * + * @template T + */ + public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?DatePoint + { + if (null === $value || $value instanceof DatePoint) { + return $value; + } + + $value = parent::convertToPHPValue($value, $platform); + + return DatePoint::createFromInterface($value); + } + + public function getName(): string + { + return self::NAME; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntity.php b/src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntity.php index 34a16df0efce8..59ab0aa2627d0 100644 --- a/src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntity.php +++ b/src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntity.php @@ -11,6 +11,7 @@ namespace Symfony\Bridge\Doctrine\Validator\Constraints; +use Symfony\Component\Validator\Attribute\HasNamedArguments; use Symfony\Component\Validator\Constraint; /** @@ -46,6 +47,7 @@ class UniqueEntity extends Constraint * a fieldName => value associative array according to the fields option configuration * @param string|null $errorPath Bind the constraint violation to this field instead of the first one in the fields option configuration */ + #[HasNamedArguments] public function __construct( array|string $fields, ?string $message = null, @@ -58,11 +60,19 @@ public function __construct( ?array $identifierFieldNames = null, ?array $groups = null, $payload = null, - array $options = [], + ?array $options = null, ) { if (\is_array($fields) && \is_string(key($fields)) && [] === array_diff(array_keys($fields), array_merge(array_keys(get_class_vars(static::class)), ['value']))) { - $options = array_merge($fields, $options); + trigger_deprecation('symfony/validator', '7.3', 'Passing an array of options to configure the "%s" constraint is deprecated, use named arguments instead.', static::class); + + $options = array_merge($fields, $options ?? []); } else { + if (\is_array($options)) { + trigger_deprecation('symfony/validator', '7.3', 'Passing an array of options to configure the "%s" constraint is deprecated, use named arguments instead.', static::class); + } else { + $options = []; + } + $options['fields'] = $fields; } diff --git a/src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntityValidator.php b/src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntityValidator.php index 57dc44236e630..4aed1cd3a44c2 100644 --- a/src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntityValidator.php +++ b/src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntityValidator.php @@ -11,6 +11,7 @@ namespace Symfony\Bridge\Doctrine\Validator\Constraints; +use Doctrine\ORM\Mapping\ClassMetadata as OrmClassMetadata; use Doctrine\ORM\Mapping\MappingException as ORMMappingException; use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\Mapping\ClassMetadata; @@ -196,6 +197,12 @@ public function validate(mixed $value, Constraint $constraint): void foreach ($constraint->identifierFieldNames as $identifierFieldName) { $propertyValue = $this->getPropertyValue($entityClass, $identifierFieldName, current($result)); + if ($fieldValues[$identifierFieldName] instanceof \Stringable) { + $fieldValues[$identifierFieldName] = (string) $fieldValues[$identifierFieldName]; + } + if ($propertyValue instanceof \Stringable) { + $propertyValue = (string) $propertyValue; + } if ($fieldValues[$identifierFieldName] !== $propertyValue) { $entityMatched = false; break; @@ -287,9 +294,13 @@ private function getFieldValues(mixed $object, ClassMetadata $class, array $fiel throw new ConstraintDefinitionException(\sprintf('The field "%s" is not a property of class "%s".', $fieldName, $objectClass)); } - $fieldValues[$entityFieldName] = $isValueEntity && $object instanceof ($class->getName()) - ? $class->reflFields[$fieldName]->getValue($object) - : $this->getPropertyValue($objectClass, $fieldName, $object); + if ($isValueEntity && $object instanceof ($class->getName()) && property_exists(OrmClassMetadata::class, 'propertyAccessors')) { + $fieldValues[$entityFieldName] = $class->propertyAccessors[$fieldName]->getValue($object); + } elseif ($isValueEntity && $object instanceof ($class->getName())) { + $fieldValues[$entityFieldName] = $class->reflFields[$fieldName]->getValue($object); + } else { + $fieldValues[$entityFieldName] = $this->getPropertyValue($objectClass, $fieldName, $object); + } } return $fieldValues; diff --git a/src/Symfony/Bridge/Doctrine/Validator/DoctrineLoader.php b/src/Symfony/Bridge/Doctrine/Validator/DoctrineLoader.php index 93413c4f8e6d8..7cffa1461b48e 100644 --- a/src/Symfony/Bridge/Doctrine/Validator/DoctrineLoader.php +++ b/src/Symfony/Bridge/Doctrine/Validator/DoctrineLoader.php @@ -90,7 +90,7 @@ public function loadClassMetadata(ClassMetadata $metadata): bool } if (true === (self::getFieldMappingValue($mapping, 'unique') ?? false) && !isset($existingUniqueFields[self::getFieldMappingValue($mapping, 'fieldName')])) { - $metadata->addConstraint(new UniqueEntity(['fields' => self::getFieldMappingValue($mapping, 'fieldName')])); + $metadata->addConstraint(new UniqueEntity(fields: self::getFieldMappingValue($mapping, 'fieldName'))); $loaded = true; } @@ -103,7 +103,7 @@ public function loadClassMetadata(ClassMetadata $metadata): bool $metadata->addPropertyConstraint(self::getFieldMappingValue($mapping, 'declaredField'), new Valid()); $loaded = true; } elseif (property_exists($className, self::getFieldMappingValue($mapping, 'fieldName')) && (!$doctrineMetadata->isMappedSuperclass || $metadata->getReflectionClass()->getProperty(self::getFieldMappingValue($mapping, 'fieldName'))->isPrivate())) { - $metadata->addPropertyConstraint(self::getFieldMappingValue($mapping, 'fieldName'), new Length(['max' => self::getFieldMappingValue($mapping, 'length')])); + $metadata->addPropertyConstraint(self::getFieldMappingValue($mapping, 'fieldName'), new Length(max: self::getFieldMappingValue($mapping, 'length'))); $loaded = true; } } elseif (null === $lengthConstraint->max) { diff --git a/src/Symfony/Bridge/Monolog/Processor/ConsoleCommandProcessor.php b/src/Symfony/Bridge/Monolog/Processor/ConsoleCommandProcessor.php index 39f7d891cbd73..fddc605029bac 100644 --- a/src/Symfony/Bridge/Monolog/Processor/ConsoleCommandProcessor.php +++ b/src/Symfony/Bridge/Monolog/Processor/ConsoleCommandProcessor.php @@ -12,6 +12,7 @@ namespace Symfony\Bridge\Monolog\Processor; use Monolog\LogRecord; +use Monolog\ResettableInterface; use Symfony\Component\Console\ConsoleEvents; use Symfony\Component\Console\Event\ConsoleEvent; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -22,7 +23,7 @@ * * @author Piotr Stankowski */ -final class ConsoleCommandProcessor implements EventSubscriberInterface, ResetInterface +final class ConsoleCommandProcessor implements EventSubscriberInterface, ResetInterface, ResettableInterface { private array $commandData; diff --git a/src/Symfony/Bridge/Monolog/Processor/DebugProcessor.php b/src/Symfony/Bridge/Monolog/Processor/DebugProcessor.php index 0fccd6f3b78d7..1df5aeffc235a 100644 --- a/src/Symfony/Bridge/Monolog/Processor/DebugProcessor.php +++ b/src/Symfony/Bridge/Monolog/Processor/DebugProcessor.php @@ -13,12 +13,13 @@ use Monolog\Level; use Monolog\LogRecord; +use Monolog\ResettableInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpKernel\Log\DebugLoggerInterface; use Symfony\Contracts\Service\ResetInterface; -class DebugProcessor implements DebugLoggerInterface, ResetInterface +class DebugProcessor implements DebugLoggerInterface, ResetInterface, ResettableInterface { private array $records = []; private array $errorCount = []; diff --git a/src/Symfony/Bridge/Monolog/Processor/RouteProcessor.php b/src/Symfony/Bridge/Monolog/Processor/RouteProcessor.php index e7a58045edb5d..85b50a39f88d9 100644 --- a/src/Symfony/Bridge/Monolog/Processor/RouteProcessor.php +++ b/src/Symfony/Bridge/Monolog/Processor/RouteProcessor.php @@ -12,6 +12,7 @@ namespace Symfony\Bridge\Monolog\Processor; use Monolog\LogRecord; +use Monolog\ResettableInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\FinishRequestEvent; use Symfony\Component\HttpKernel\Event\RequestEvent; @@ -25,7 +26,7 @@ * * @final */ -class RouteProcessor implements EventSubscriberInterface, ResetInterface +class RouteProcessor implements EventSubscriberInterface, ResetInterface, ResettableInterface { private array $routeData = []; diff --git a/src/Symfony/Bridge/PhpUnit/Attribute/DnsSensitive.php b/src/Symfony/Bridge/PhpUnit/Attribute/DnsSensitive.php new file mode 100644 index 0000000000000..4c80ec5e2b8a7 --- /dev/null +++ b/src/Symfony/Bridge/PhpUnit/Attribute/DnsSensitive.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\PhpUnit\Attribute; + +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class DnsSensitive +{ + public function __construct( + public readonly ?string $class = null, + ) { + } +} diff --git a/src/Symfony/Bridge/PhpUnit/Attribute/TimeSensitive.php b/src/Symfony/Bridge/PhpUnit/Attribute/TimeSensitive.php new file mode 100644 index 0000000000000..da9e816a75075 --- /dev/null +++ b/src/Symfony/Bridge/PhpUnit/Attribute/TimeSensitive.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\PhpUnit\Attribute; + +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class TimeSensitive +{ + public function __construct( + public readonly ?string $class = null, + ) { + } +} diff --git a/src/Symfony/Bridge/PhpUnit/CHANGELOG.md b/src/Symfony/Bridge/PhpUnit/CHANGELOG.md index 3c747025792f5..0b139af321f5d 100644 --- a/src/Symfony/Bridge/PhpUnit/CHANGELOG.md +++ b/src/Symfony/Bridge/PhpUnit/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +7.3 +--- + + * Enable configuring clock and DNS mock namespaces with attributes + * Add support for CAA record type in DnsMock for improved DNS mocking capabilities + 7.2 --- diff --git a/src/Symfony/Bridge/PhpUnit/DnsMock.php b/src/Symfony/Bridge/PhpUnit/DnsMock.php index c558cd0bed39c..84251c10d2d36 100644 --- a/src/Symfony/Bridge/PhpUnit/DnsMock.php +++ b/src/Symfony/Bridge/PhpUnit/DnsMock.php @@ -30,6 +30,7 @@ class DnsMock 'NAPTR' => \DNS_NAPTR, 'TXT' => \DNS_TXT, 'HINFO' => \DNS_HINFO, + 'CAA' => '\\' !== \DIRECTORY_SEPARATOR ? \DNS_CAA : 0, ]; /** diff --git a/src/Symfony/Bridge/PhpUnit/Extension/DisableClockMockSubscriber.php b/src/Symfony/Bridge/PhpUnit/Extension/DisableClockMockSubscriber.php deleted file mode 100644 index 885e6ea585e54..0000000000000 --- a/src/Symfony/Bridge/PhpUnit/Extension/DisableClockMockSubscriber.php +++ /dev/null @@ -1,39 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\PhpUnit\Extension; - -use PHPUnit\Event\Code\TestMethod; -use PHPUnit\Event\Test\Finished; -use PHPUnit\Event\Test\FinishedSubscriber; -use PHPUnit\Metadata\Group; -use Symfony\Bridge\PhpUnit\ClockMock; - -/** - * @internal - */ -class DisableClockMockSubscriber implements FinishedSubscriber -{ - public function notify(Finished $event): void - { - $test = $event->test(); - - if (!$test instanceof TestMethod) { - return; - } - - foreach ($test->metadata() as $metadata) { - if ($metadata instanceof Group && 'time-sensitive' === $metadata->groupName()) { - ClockMock::withClockMock(false); - } - } - } -} diff --git a/src/Symfony/Bridge/PhpUnit/Extension/DisableDnsMockSubscriber.php b/src/Symfony/Bridge/PhpUnit/Extension/DisableDnsMockSubscriber.php deleted file mode 100644 index fc3e754d140d5..0000000000000 --- a/src/Symfony/Bridge/PhpUnit/Extension/DisableDnsMockSubscriber.php +++ /dev/null @@ -1,39 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Bridge\PhpUnit\Extension; - -use PHPUnit\Event\Code\TestMethod; -use PHPUnit\Event\Test\Finished; -use PHPUnit\Event\Test\FinishedSubscriber; -use PHPUnit\Metadata\Group; -use Symfony\Bridge\PhpUnit\DnsMock; - -/** - * @internal - */ -class DisableDnsMockSubscriber implements FinishedSubscriber -{ - public function notify(Finished $event): void - { - $test = $event->test(); - - if (!$test instanceof TestMethod) { - return; - } - - foreach ($test->metadata() as $metadata) { - if ($metadata instanceof Group && 'dns-sensitive' === $metadata->groupName()) { - DnsMock::withMockedHosts([]); - } - } - } -} diff --git a/src/Symfony/Bridge/PhpUnit/Extension/EnableClockMockSubscriber.php b/src/Symfony/Bridge/PhpUnit/Extension/EnableClockMockSubscriber.php index c10c5dcd18cd5..b3d563340bcb5 100644 --- a/src/Symfony/Bridge/PhpUnit/Extension/EnableClockMockSubscriber.php +++ b/src/Symfony/Bridge/PhpUnit/Extension/EnableClockMockSubscriber.php @@ -15,13 +15,20 @@ use PHPUnit\Event\Test\PreparationStarted; use PHPUnit\Event\Test\PreparationStartedSubscriber; use PHPUnit\Metadata\Group; +use Symfony\Bridge\PhpUnit\Attribute\TimeSensitive; use Symfony\Bridge\PhpUnit\ClockMock; +use Symfony\Bridge\PhpUnit\Metadata\AttributeReader; /** * @internal */ class EnableClockMockSubscriber implements PreparationStartedSubscriber { + public function __construct( + private AttributeReader $reader, + ) { + } + public function notify(PreparationStarted $event): void { $test = $event->test(); @@ -33,7 +40,12 @@ public function notify(PreparationStarted $event): void foreach ($test->metadata() as $metadata) { if ($metadata instanceof Group && 'time-sensitive' === $metadata->groupName()) { ClockMock::withClockMock(true); + break; } } + + if ($this->reader->forClassAndMethod($test->className(), $test->methodName(), TimeSensitive::class)) { + ClockMock::withClockMock(true); + } } } diff --git a/src/Symfony/Bridge/PhpUnit/Extension/RegisterClockMockSubscriber.php b/src/Symfony/Bridge/PhpUnit/Extension/RegisterClockMockSubscriber.php index e2955fe6003e8..b89f16404ff15 100644 --- a/src/Symfony/Bridge/PhpUnit/Extension/RegisterClockMockSubscriber.php +++ b/src/Symfony/Bridge/PhpUnit/Extension/RegisterClockMockSubscriber.php @@ -15,13 +15,20 @@ use PHPUnit\Event\TestSuite\Loaded; use PHPUnit\Event\TestSuite\LoadedSubscriber; use PHPUnit\Metadata\Group; +use Symfony\Bridge\PhpUnit\Attribute\TimeSensitive; use Symfony\Bridge\PhpUnit\ClockMock; +use Symfony\Bridge\PhpUnit\Metadata\AttributeReader; /** * @internal */ class RegisterClockMockSubscriber implements LoadedSubscriber { + public function __construct( + private AttributeReader $reader, + ) { + } + public function notify(Loaded $event): void { foreach ($event->testSuite()->tests() as $test) { @@ -34,6 +41,10 @@ public function notify(Loaded $event): void ClockMock::register($test->className()); } } + + foreach ($this->reader->forClassAndMethod($test->className(), $test->methodName(), TimeSensitive::class) as $attribute) { + ClockMock::register($attribute->class ?? $test->className()); + } } } } diff --git a/src/Symfony/Bridge/PhpUnit/Extension/RegisterDnsMockSubscriber.php b/src/Symfony/Bridge/PhpUnit/Extension/RegisterDnsMockSubscriber.php index 81382d5e13b43..80e9a3371f5c0 100644 --- a/src/Symfony/Bridge/PhpUnit/Extension/RegisterDnsMockSubscriber.php +++ b/src/Symfony/Bridge/PhpUnit/Extension/RegisterDnsMockSubscriber.php @@ -15,13 +15,20 @@ use PHPUnit\Event\TestSuite\Loaded; use PHPUnit\Event\TestSuite\LoadedSubscriber; use PHPUnit\Metadata\Group; +use Symfony\Bridge\PhpUnit\Attribute\DnsSensitive; use Symfony\Bridge\PhpUnit\DnsMock; +use Symfony\Bridge\PhpUnit\Metadata\AttributeReader; /** * @internal */ class RegisterDnsMockSubscriber implements LoadedSubscriber { + public function __construct( + private AttributeReader $reader, + ) { + } + public function notify(Loaded $event): void { foreach ($event->testSuite()->tests() as $test) { @@ -34,6 +41,10 @@ public function notify(Loaded $event): void DnsMock::register($test->className()); } } + + foreach ($this->reader->forClassAndMethod($test->className(), $test->methodName(), DnsSensitive::class) as $attribute) { + DnsMock::register($attribute->class ?? $test->className()); + } } } } diff --git a/src/Symfony/Bridge/PhpUnit/Legacy/SymfonyTestsListenerTrait.php b/src/Symfony/Bridge/PhpUnit/Legacy/SymfonyTestsListenerTrait.php index 2b45051e83d74..486d3bf155440 100644 --- a/src/Symfony/Bridge/PhpUnit/Legacy/SymfonyTestsListenerTrait.php +++ b/src/Symfony/Bridge/PhpUnit/Legacy/SymfonyTestsListenerTrait.php @@ -336,7 +336,7 @@ public static function handleError($type, $msg, $file, $line, $context = []) return $h ? $h($type, $msg, $file, $line, $context) : false; } - // If the message is serialized we need to extract the message. This occurs when the error is triggered by + // If the message is serialized we need to extract the message. This occurs when the error is triggered // by the isolated test path in \Symfony\Bridge\PhpUnit\Legacy\SymfonyTestsListenerTrait::endTest(). $parsedMsg = @unserialize($msg); if (\is_array($parsedMsg)) { diff --git a/src/Symfony/Bridge/PhpUnit/Metadata/AttributeReader.php b/src/Symfony/Bridge/PhpUnit/Metadata/AttributeReader.php new file mode 100644 index 0000000000000..ca4e4c4769219 --- /dev/null +++ b/src/Symfony/Bridge/PhpUnit/Metadata/AttributeReader.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\PhpUnit\Metadata; + +/** + * @internal + * + * @template T of object + */ +final class AttributeReader +{ + /** + * @var array, list>> + */ + private array $cache = []; + + /** + * @param class-string $className + * @param class-string $name + * + * @return list + */ + public function forClass(string $className, string $name): array + { + $attributes = $this->cache[$className] ??= $this->readAttributes(new \ReflectionClass($className)); + + return $attributes[$name] ?? []; + } + + /** + * @param class-string $className + * @param class-string $name + * + * @return list + */ + public function forMethod(string $className, string $methodName, string $name): array + { + $attributes = $this->cache[$className.'::'.$methodName] ??= $this->readAttributes(new \ReflectionMethod($className, $methodName)); + + return $attributes[$name] ?? []; + } + + /** + * @param class-string $className + * @param class-string $name + * + * @return list + */ + public function forClassAndMethod(string $className, string $methodName, string $name): array + { + return [ + ...$this->forClass($className, $name), + ...$this->forMethod($className, $methodName, $name), + ]; + } + + private function readAttributes(\ReflectionClass|\ReflectionMethod $reflection): array + { + $attributeInstances = []; + + foreach ($reflection->getAttributes() as $attribute) { + if (!str_starts_with($name = $attribute->getName(), 'Symfony\\Bridge\\PhpUnit\\Attribute\\')) { + continue; + } + + $attributeInstances[$name][] = $attribute->newInstance(); + } + + return $attributeInstances; + } +} diff --git a/src/Symfony/Bridge/PhpUnit/SymfonyExtension.php b/src/Symfony/Bridge/PhpUnit/SymfonyExtension.php index 1df4f20658905..ea87c3257ec16 100644 --- a/src/Symfony/Bridge/PhpUnit/SymfonyExtension.php +++ b/src/Symfony/Bridge/PhpUnit/SymfonyExtension.php @@ -11,15 +11,22 @@ namespace Symfony\Bridge\PhpUnit; +use PHPUnit\Event\Test\BeforeTestMethodErrored; +use PHPUnit\Event\Test\BeforeTestMethodErroredSubscriber; +use PHPUnit\Event\Test\Errored; +use PHPUnit\Event\Test\ErroredSubscriber; +use PHPUnit\Event\Test\Finished; +use PHPUnit\Event\Test\FinishedSubscriber; +use PHPUnit\Event\Test\Skipped; +use PHPUnit\Event\Test\SkippedSubscriber; use PHPUnit\Runner\Extension\Extension; use PHPUnit\Runner\Extension\Facade; use PHPUnit\Runner\Extension\ParameterCollection; use PHPUnit\TextUI\Configuration\Configuration; -use Symfony\Bridge\PhpUnit\Extension\DisableClockMockSubscriber; -use Symfony\Bridge\PhpUnit\Extension\DisableDnsMockSubscriber; use Symfony\Bridge\PhpUnit\Extension\EnableClockMockSubscriber; use Symfony\Bridge\PhpUnit\Extension\RegisterClockMockSubscriber; use Symfony\Bridge\PhpUnit\Extension\RegisterDnsMockSubscriber; +use Symfony\Bridge\PhpUnit\Metadata\AttributeReader; use Symfony\Component\ErrorHandler\DebugClassLoader; class SymfonyExtension implements Extension @@ -30,15 +37,47 @@ public function bootstrap(Configuration $configuration, Facade $facade, Paramete DebugClassLoader::enable(); } + $reader = new AttributeReader(); + if ($parameters->has('clock-mock-namespaces')) { foreach (explode(',', $parameters->get('clock-mock-namespaces')) as $namespace) { ClockMock::register($namespace.'\DummyClass'); } } - $facade->registerSubscriber(new RegisterClockMockSubscriber()); - $facade->registerSubscriber(new EnableClockMockSubscriber()); - $facade->registerSubscriber(new DisableClockMockSubscriber()); + $facade->registerSubscriber(new RegisterClockMockSubscriber($reader)); + $facade->registerSubscriber(new EnableClockMockSubscriber($reader)); + $facade->registerSubscriber(new class implements ErroredSubscriber { + public function notify(Errored $event): void + { + SymfonyExtension::disableClockMock(); + SymfonyExtension::disableDnsMock(); + } + }); + $facade->registerSubscriber(new class implements FinishedSubscriber { + public function notify(Finished $event): void + { + SymfonyExtension::disableClockMock(); + SymfonyExtension::disableDnsMock(); + } + }); + $facade->registerSubscriber(new class implements SkippedSubscriber { + public function notify(Skipped $event): void + { + SymfonyExtension::disableClockMock(); + SymfonyExtension::disableDnsMock(); + } + }); + + if (interface_exists(BeforeTestMethodErroredSubscriber::class)) { + $facade->registerSubscriber(new class implements BeforeTestMethodErroredSubscriber { + public function notify(BeforeTestMethodErrored $event): void + { + SymfonyExtension::disableClockMock(); + SymfonyExtension::disableDnsMock(); + } + }); + } if ($parameters->has('dns-mock-namespaces')) { foreach (explode(',', $parameters->get('dns-mock-namespaces')) as $namespace) { @@ -46,7 +85,22 @@ public function bootstrap(Configuration $configuration, Facade $facade, Paramete } } - $facade->registerSubscriber(new RegisterDnsMockSubscriber()); - $facade->registerSubscriber(new DisableDnsMockSubscriber()); + $facade->registerSubscriber(new RegisterDnsMockSubscriber($reader)); + } + + /** + * @internal + */ + public static function disableClockMock(): void + { + ClockMock::withClockMock(false); + } + + /** + * @internal + */ + public static function disableDnsMock(): void + { + DnsMock::withMockedHosts([]); } } diff --git a/src/Symfony/Bridge/PhpUnit/Tests/Fixtures/symfonyextension/tests/bootstrap.php b/src/Symfony/Bridge/PhpUnit/Tests/Fixtures/symfonyextension/tests/bootstrap.php index 95dcc78ef026c..385e7ea7e51e4 100644 --- a/src/Symfony/Bridge/PhpUnit/Tests/Fixtures/symfonyextension/tests/bootstrap.php +++ b/src/Symfony/Bridge/PhpUnit/Tests/Fixtures/symfonyextension/tests/bootstrap.php @@ -21,11 +21,12 @@ }); require __DIR__.'/../../../../SymfonyExtension.php'; -require __DIR__.'/../../../../Extension/DisableClockMockSubscriber.php'; -require __DIR__.'/../../../../Extension/DisableDnsMockSubscriber.php'; +require __DIR__.'/../../../../Attribute/DnsSensitive.php'; +require __DIR__.'/../../../../Attribute/TimeSensitive.php'; require __DIR__.'/../../../../Extension/EnableClockMockSubscriber.php'; require __DIR__.'/../../../../Extension/RegisterClockMockSubscriber.php'; require __DIR__.'/../../../../Extension/RegisterDnsMockSubscriber.php'; +require __DIR__.'/../../../../Metadata/AttributeReader.php'; if (file_exists(__DIR__.'/../../../../vendor/autoload.php')) { require __DIR__.'/../../../../vendor/autoload.php'; diff --git a/src/Symfony/Bridge/PhpUnit/Tests/Metadata/AttributeReaderTest.php b/src/Symfony/Bridge/PhpUnit/Tests/Metadata/AttributeReaderTest.php new file mode 100644 index 0000000000000..b82a7acc16e4e --- /dev/null +++ b/src/Symfony/Bridge/PhpUnit/Tests/Metadata/AttributeReaderTest.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\PhpUnit\Tests\Metadata; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\Attribute\DnsSensitive; +use Symfony\Bridge\PhpUnit\Attribute\TimeSensitive; +use Symfony\Bridge\PhpUnit\Metadata\AttributeReader; +use Symfony\Bridge\PhpUnit\Tests\Metadata\Fixtures\FooBar; + +/** + * @requires PHP 8.0 + */ +final class AttributeReaderTest extends TestCase +{ + /** + * @dataProvider provideReadCases + */ + public function testAttributesAreRead(string $method, string $attributeClass, array $expected) + { + $reader = new AttributeReader(); + + $attributes = $reader->forClassAndMethod(FooBar::class, $method, $attributeClass); + + self::assertContainsOnlyInstancesOf($attributeClass, $attributes); + self::assertSame($expected, array_column($attributes, 'class')); + } + + public static function provideReadCases(): iterable + { + yield ['testOne', DnsSensitive::class, [ + 'App\Foo\Bar\A', + 'App\Foo\Bar\B', + 'App\Foo\Baz\C', + ]]; + yield ['testTwo', DnsSensitive::class, [ + 'App\Foo\Bar\A', + 'App\Foo\Bar\B', + ]]; + yield ['testThree', DnsSensitive::class, [ + 'App\Foo\Bar\A', + 'App\Foo\Bar\B', + 'App\Foo\Corge\F', + ]]; + + yield ['testOne', TimeSensitive::class, [ + 'App\Foo\Bar\A', + ]]; + yield ['testTwo', TimeSensitive::class, [ + 'App\Foo\Bar\A', + 'App\Foo\Qux\D', + 'App\Foo\Qux\E', + ]]; + yield ['testThree', TimeSensitive::class, [ + 'App\Foo\Bar\A', + 'App\Foo\Corge\G', + ]]; + } + + public function testAttributesAreCached() + { + $reader = new AttributeReader(); + $cacheRef = new \ReflectionProperty(AttributeReader::class, 'cache'); + + self::assertSame([], $cacheRef->getValue($reader)); + + $reader->forClass(FooBar::class, TimeSensitive::class); + + self::assertCount(1, $cache = $cacheRef->getValue($reader)); + self::assertArrayHasKey(FooBar::class, $cache); + self::assertAttributesCount($cache[FooBar::class], 2, 1); + + $reader->forMethod(FooBar::class, 'testThree', DnsSensitive::class); + + self::assertCount(2, $cache = $cacheRef->getValue($reader)); + self::assertArrayHasKey($key = FooBar::class.'::testThree', $cache); + self::assertAttributesCount($cache[$key], 1, 1); + } + + private static function assertAttributesCount(array $attributes, int $expectedDnsCount, int $expectedTimeCount): void + { + self::assertArrayHasKey(DnsSensitive::class, $attributes); + self::assertCount($expectedDnsCount, $attributes[DnsSensitive::class]); + self::assertArrayHasKey(TimeSensitive::class, $attributes); + self::assertCount($expectedTimeCount, $attributes[TimeSensitive::class]); + } +} diff --git a/src/Symfony/Bridge/PhpUnit/Tests/Metadata/Fixtures/FooBar.php b/src/Symfony/Bridge/PhpUnit/Tests/Metadata/Fixtures/FooBar.php new file mode 100644 index 0000000000000..63b9d28d29e72 --- /dev/null +++ b/src/Symfony/Bridge/PhpUnit/Tests/Metadata/Fixtures/FooBar.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\PhpUnit\Tests\Metadata\Fixtures; + +use Symfony\Bridge\PhpUnit\Attribute\DnsSensitive; +use Symfony\Bridge\PhpUnit\Attribute\TimeSensitive; + +#[DnsSensitive('App\Foo\Bar\A')] +#[DnsSensitive('App\Foo\Bar\B')] +#[TimeSensitive('App\Foo\Bar\A')] +final class FooBar +{ + #[DnsSensitive('App\Foo\Baz\C')] + public function testOne() + { + } + + #[TimeSensitive('App\Foo\Qux\D')] + #[TimeSensitive('App\Foo\Qux\E')] + public function testTwo() + { + } + + #[DnsSensitive('App\Foo\Corge\F')] + #[TimeSensitive('App\Foo\Corge\G')] + public function testThree() + { + } +} diff --git a/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php b/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php index ac2d90757bbaf..1219c27be0970 100644 --- a/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php +++ b/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php @@ -14,9 +14,13 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\Attribute\DnsSensitive; +use Symfony\Bridge\PhpUnit\Attribute\TimeSensitive; use Symfony\Bridge\PhpUnit\Tests\Fixtures\symfonyextension\src\ClassExtendingFinalClass; use Symfony\Bridge\PhpUnit\Tests\Fixtures\symfonyextension\src\FinalClass; +#[DnsSensitive('App\Foo\A')] +#[TimeSensitive('App\Foo\A')] class SymfonyExtension extends TestCase { public function testExtensionOfFinalClass() @@ -28,6 +32,7 @@ public function testExtensionOfFinalClass() #[DataProvider('mockedNamespaces')] #[Group('time-sensitive')] + #[TimeSensitive('App\Bar\B')] public function testTimeMockIsRegistered(string $namespace) { $this->assertTrue(\function_exists(\sprintf('%s\time', $namespace))); @@ -35,6 +40,7 @@ public function testTimeMockIsRegistered(string $namespace) #[DataProvider('mockedNamespaces')] #[Group('time-sensitive')] + #[TimeSensitive('App\Bar\B')] public function testMicrotimeMockIsRegistered(string $namespace) { $this->assertTrue(\function_exists(\sprintf('%s\microtime', $namespace))); @@ -42,6 +48,7 @@ public function testMicrotimeMockIsRegistered(string $namespace) #[DataProvider('mockedNamespaces')] #[Group('time-sensitive')] + #[TimeSensitive('App\Bar\B')] public function testSleepMockIsRegistered(string $namespace) { $this->assertTrue(\function_exists(\sprintf('%s\sleep', $namespace))); @@ -49,6 +56,7 @@ public function testSleepMockIsRegistered(string $namespace) #[DataProvider('mockedNamespaces')] #[Group('time-sensitive')] + #[TimeSensitive('App\Bar\B')] public function testUsleepMockIsRegistered(string $namespace) { $this->assertTrue(\function_exists(\sprintf('%s\usleep', $namespace))); @@ -56,6 +64,7 @@ public function testUsleepMockIsRegistered(string $namespace) #[DataProvider('mockedNamespaces')] #[Group('time-sensitive')] + #[TimeSensitive('App\Bar\B')] public function testDateMockIsRegistered(string $namespace) { $this->assertTrue(\function_exists(\sprintf('%s\date', $namespace))); @@ -63,6 +72,7 @@ public function testDateMockIsRegistered(string $namespace) #[DataProvider('mockedNamespaces')] #[Group('time-sensitive')] + #[TimeSensitive('App\Bar\B')] public function testGmdateMockIsRegistered(string $namespace) { $this->assertTrue(\function_exists(\sprintf('%s\gmdate', $namespace))); @@ -70,6 +80,7 @@ public function testGmdateMockIsRegistered(string $namespace) #[DataProvider('mockedNamespaces')] #[Group('time-sensitive')] + #[TimeSensitive('App\Bar\B')] public function testHrtimeMockIsRegistered(string $namespace) { $this->assertTrue(\function_exists(\sprintf('%s\hrtime', $namespace))); @@ -77,6 +88,7 @@ public function testHrtimeMockIsRegistered(string $namespace) #[DataProvider('mockedNamespaces')] #[Group('dns-sensitive')] + #[DnsSensitive('App\Bar\B')] public function testCheckdnsrrMockIsRegistered(string $namespace) { $this->assertTrue(\function_exists(\sprintf('%s\checkdnsrr', $namespace))); @@ -84,6 +96,7 @@ public function testCheckdnsrrMockIsRegistered(string $namespace) #[DataProvider('mockedNamespaces')] #[Group('dns-sensitive')] + #[DnsSensitive('App\Bar\B')] public function testDnsCheckRecordMockIsRegistered(string $namespace) { $this->assertTrue(\function_exists(\sprintf('%s\dns_check_record', $namespace))); @@ -91,6 +104,7 @@ public function testDnsCheckRecordMockIsRegistered(string $namespace) #[DataProvider('mockedNamespaces')] #[Group('dns-sensitive')] + #[DnsSensitive('App\Bar\B')] public function testGetmxrrMockIsRegistered(string $namespace) { $this->assertTrue(\function_exists(\sprintf('%s\getmxrr', $namespace))); @@ -98,6 +112,7 @@ public function testGetmxrrMockIsRegistered(string $namespace) #[DataProvider('mockedNamespaces')] #[Group('dns-sensitive')] + #[DnsSensitive('App\Bar\B')] public function testDnsGetMxMockIsRegistered(string $namespace) { $this->assertTrue(\function_exists(\sprintf('%s\dns_get_mx', $namespace))); @@ -105,6 +120,7 @@ public function testDnsGetMxMockIsRegistered(string $namespace) #[DataProvider('mockedNamespaces')] #[Group('dns-sensitive')] + #[DnsSensitive('App\Bar\B')] public function testGethostbyaddrMockIsRegistered(string $namespace) { $this->assertTrue(\function_exists(\sprintf('%s\gethostbyaddr', $namespace))); @@ -112,6 +128,7 @@ public function testGethostbyaddrMockIsRegistered(string $namespace) #[DataProvider('mockedNamespaces')] #[Group('dns-sensitive')] + #[DnsSensitive('App\Bar\B')] public function testGethostbynameMockIsRegistered(string $namespace) { $this->assertTrue(\function_exists(\sprintf('%s\gethostbyname', $namespace))); @@ -119,6 +136,7 @@ public function testGethostbynameMockIsRegistered(string $namespace) #[DataProvider('mockedNamespaces')] #[Group('dns-sensitive')] + #[DnsSensitive('App\Bar\B')] public function testGethostbynamelMockIsRegistered(string $namespace) { $this->assertTrue(\function_exists(\sprintf('%s\gethostbynamel', $namespace))); @@ -126,6 +144,7 @@ public function testGethostbynamelMockIsRegistered(string $namespace) #[DataProvider('mockedNamespaces')] #[Group('dns-sensitive')] + #[DnsSensitive('App\Bar\B')] public function testDnsGetRecordMockIsRegistered(string $namespace) { $this->assertTrue(\function_exists(\sprintf('%s\dns_get_record', $namespace))); @@ -136,5 +155,7 @@ public static function mockedNamespaces(): iterable yield 'test class namespace' => [__NAMESPACE__]; yield 'namespace derived from test namespace' => ['Symfony\Bridge\PhpUnit']; yield 'explicitly configured namespace' => ['App']; + yield 'explicitly configured namespace through attribute on class' => ['App\Foo']; + yield 'explicitly configured namespace through attribute on method' => ['App\Bar']; } } diff --git a/src/Symfony/Bridge/PhpUnit/Tests/symfonyextension.phpt b/src/Symfony/Bridge/PhpUnit/Tests/symfonyextension.phpt index 2c808c2f5930e..933352f07eadc 100644 --- a/src/Symfony/Bridge/PhpUnit/Tests/symfonyextension.phpt +++ b/src/Symfony/Bridge/PhpUnit/Tests/symfonyextension.phpt @@ -11,9 +11,10 @@ PHPUnit %s Runtime: PHP %s Configuration: %s/src/Symfony/Bridge/PhpUnit/Tests/Fixtures/symfonyextension/phpunit-with-extension.xml.dist -D............................................. 46 / 46 (100%) +D................................................................ 65 / 76 ( 85%) +........... 76 / 76 (100%) Time: %s, Memory: %s OK, but there were issues! -Tests: 46, Assertions: 46, Deprecations: 1. +Tests: 76, Assertions: 76, Deprecations: 1. diff --git a/src/Symfony/Bridge/PhpUnit/Tests/symfonyextensionnotregistered.phpt b/src/Symfony/Bridge/PhpUnit/Tests/symfonyextensionnotregistered.phpt index aa3d4d3044de7..e66b677f772e9 100644 --- a/src/Symfony/Bridge/PhpUnit/Tests/symfonyextensionnotregistered.phpt +++ b/src/Symfony/Bridge/PhpUnit/Tests/symfonyextensionnotregistered.phpt @@ -11,11 +11,12 @@ PHPUnit %s Runtime: PHP %s Configuration: %s/src/Symfony/Bridge/PhpUnit/Tests/Fixtures/symfonyextension/phpunit-without-extension.xml.dist -FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF 46 / 46 (100%) +FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF 65 / 76 ( 85%) +FFFFFFFFFFF 76 / 76 (100%) Time: %s, Memory: %s -There were 46 failures: +There were 76 failures: %d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testExtensionOfFinalClass Expected deprecation with message "The "Symfony\Bridge\PhpUnit\Tests\Fixtures\symfonyextension\src\FinalClass" class is considered final. It may change without further notice as of its next major version. You should not extend it from "Symfony\Bridge\PhpUnit\Tests\Fixtures\symfonyextension\src\ClassExtendingFinalClass"." was not triggered @@ -40,6 +41,18 @@ Failed asserting that false is true. %s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d %s/.phpunit/phpunit-%s/phpunit:%d +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testTimeMockIsRegistered with data set "explicitly configured namespace through attribute on class" ('App\Foo') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testTimeMockIsRegistered with data set "explicitly configured namespace through attribute on method" ('App\Bar') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + %d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testMicrotimeMockIsRegistered with data set "test class namespace" ('Symfony\Bridge\PhpUnit\Tests') Failed asserting that false is true. @@ -58,6 +71,18 @@ Failed asserting that false is true. %s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d %s/.phpunit/phpunit-%s/phpunit:%d +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testMicrotimeMockIsRegistered with data set "explicitly configured namespace through attribute on class" ('App\Foo') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testMicrotimeMockIsRegistered with data set "explicitly configured namespace through attribute on method" ('App\Bar') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + %d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testSleepMockIsRegistered with data set "test class namespace" ('Symfony\Bridge\PhpUnit\Tests') Failed asserting that false is true. @@ -76,6 +101,18 @@ Failed asserting that false is true. %s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d %s/.phpunit/phpunit-%s/phpunit:%d +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testSleepMockIsRegistered with data set "explicitly configured namespace through attribute on class" ('App\Foo') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testSleepMockIsRegistered with data set "explicitly configured namespace through attribute on method" ('App\Bar') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + %d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testUsleepMockIsRegistered with data set "test class namespace" ('Symfony\Bridge\PhpUnit\Tests') Failed asserting that false is true. @@ -94,6 +131,18 @@ Failed asserting that false is true. %s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d %s/.phpunit/phpunit-%s/phpunit:%d +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testUsleepMockIsRegistered with data set "explicitly configured namespace through attribute on class" ('App\Foo') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testUsleepMockIsRegistered with data set "explicitly configured namespace through attribute on method" ('App\Bar') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + %d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testDateMockIsRegistered with data set "test class namespace" ('Symfony\Bridge\PhpUnit\Tests') Failed asserting that false is true. @@ -112,6 +161,18 @@ Failed asserting that false is true. %s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d %s/.phpunit/phpunit-%s/phpunit:%d +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testDateMockIsRegistered with data set "explicitly configured namespace through attribute on class" ('App\Foo') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testDateMockIsRegistered with data set "explicitly configured namespace through attribute on method" ('App\Bar') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + %d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testGmdateMockIsRegistered with data set "test class namespace" ('Symfony\Bridge\PhpUnit\Tests') Failed asserting that false is true. @@ -130,6 +191,18 @@ Failed asserting that false is true. %s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d %s/.phpunit/phpunit-%s/phpunit:%d +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testGmdateMockIsRegistered with data set "explicitly configured namespace through attribute on class" ('App\Foo') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testGmdateMockIsRegistered with data set "explicitly configured namespace through attribute on method" ('App\Bar') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + %d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testHrtimeMockIsRegistered with data set "test class namespace" ('Symfony\Bridge\PhpUnit\Tests') Failed asserting that false is true. @@ -148,6 +221,18 @@ Failed asserting that false is true. %s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d %s/.phpunit/phpunit-%s/phpunit:%d +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testHrtimeMockIsRegistered with data set "explicitly configured namespace through attribute on class" ('App\Foo') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testHrtimeMockIsRegistered with data set "explicitly configured namespace through attribute on method" ('App\Bar') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + %d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testCheckdnsrrMockIsRegistered with data set "test class namespace" ('Symfony\Bridge\PhpUnit\Tests') Failed asserting that false is true. @@ -166,6 +251,18 @@ Failed asserting that false is true. %s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d %s/.phpunit/phpunit-%s/phpunit:%d +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testCheckdnsrrMockIsRegistered with data set "explicitly configured namespace through attribute on class" ('App\Foo') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testCheckdnsrrMockIsRegistered with data set "explicitly configured namespace through attribute on method" ('App\Bar') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + %d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testDnsCheckRecordMockIsRegistered with data set "test class namespace" ('Symfony\Bridge\PhpUnit\Tests') Failed asserting that false is true. @@ -184,6 +281,18 @@ Failed asserting that false is true. %s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d %s/.phpunit/phpunit-%s/phpunit:%d +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testDnsCheckRecordMockIsRegistered with data set "explicitly configured namespace through attribute on class" ('App\Foo') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testDnsCheckRecordMockIsRegistered with data set "explicitly configured namespace through attribute on method" ('App\Bar') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + %d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testGetmxrrMockIsRegistered with data set "test class namespace" ('Symfony\Bridge\PhpUnit\Tests') Failed asserting that false is true. @@ -202,6 +311,18 @@ Failed asserting that false is true. %s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d %s/.phpunit/phpunit-%s/phpunit:%d +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testGetmxrrMockIsRegistered with data set "explicitly configured namespace through attribute on class" ('App\Foo') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testGetmxrrMockIsRegistered with data set "explicitly configured namespace through attribute on method" ('App\Bar') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + %d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testDnsGetMxMockIsRegistered with data set "test class namespace" ('Symfony\Bridge\PhpUnit\Tests') Failed asserting that false is true. @@ -220,6 +341,18 @@ Failed asserting that false is true. %s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d %s/.phpunit/phpunit-%s/phpunit:%d +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testDnsGetMxMockIsRegistered with data set "explicitly configured namespace through attribute on class" ('App\Foo') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testDnsGetMxMockIsRegistered with data set "explicitly configured namespace through attribute on method" ('App\Bar') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + %d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testGethostbyaddrMockIsRegistered with data set "test class namespace" ('Symfony\Bridge\PhpUnit\Tests') Failed asserting that false is true. @@ -238,6 +371,18 @@ Failed asserting that false is true. %s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d %s/.phpunit/phpunit-%s/phpunit:%d +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testGethostbyaddrMockIsRegistered with data set "explicitly configured namespace through attribute on class" ('App\Foo') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testGethostbyaddrMockIsRegistered with data set "explicitly configured namespace through attribute on method" ('App\Bar') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + %d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testGethostbynameMockIsRegistered with data set "test class namespace" ('Symfony\Bridge\PhpUnit\Tests') Failed asserting that false is true. @@ -256,6 +401,18 @@ Failed asserting that false is true. %s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d %s/.phpunit/phpunit-%s/phpunit:%d +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testGethostbynameMockIsRegistered with data set "explicitly configured namespace through attribute on class" ('App\Foo') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testGethostbynameMockIsRegistered with data set "explicitly configured namespace through attribute on method" ('App\Bar') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + %d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testGethostbynamelMockIsRegistered with data set "test class namespace" ('Symfony\Bridge\PhpUnit\Tests') Failed asserting that false is true. @@ -274,6 +431,18 @@ Failed asserting that false is true. %s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d %s/.phpunit/phpunit-%s/phpunit:%d +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testGethostbynamelMockIsRegistered with data set "explicitly configured namespace through attribute on class" ('App\Foo') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testGethostbynamelMockIsRegistered with data set "explicitly configured namespace through attribute on method" ('App\Bar') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + %d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testDnsGetRecordMockIsRegistered with data set "test class namespace" ('Symfony\Bridge\PhpUnit\Tests') Failed asserting that false is true. @@ -292,5 +461,17 @@ Failed asserting that false is true. %s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d %s/.phpunit/phpunit-%s/phpunit:%d +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testDnsGetRecordMockIsRegistered with data set "explicitly configured namespace through attribute on class" ('App\Foo') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + +%d) Symfony\Bridge\PhpUnit\Tests\SymfonyExtension::testDnsGetRecordMockIsRegistered with data set "explicitly configured namespace through attribute on method" ('App\Bar') +Failed asserting that false is true. + +%s/src/Symfony/Bridge/PhpUnit/Tests/SymfonyExtension.php:%d +%s/.phpunit/phpunit-%s/phpunit:%d + FAILURES! -Tests: 46, Assertions: 46, Failures: 46. +Tests: 76, Assertions: 76, Failures: 76. diff --git a/src/Symfony/Bridge/PhpUnit/bin/simple-phpunit.php b/src/Symfony/Bridge/PhpUnit/bin/simple-phpunit.php index 0472e8c1d81b3..5e5011192be90 100644 --- a/src/Symfony/Bridge/PhpUnit/bin/simple-phpunit.php +++ b/src/Symfony/Bridge/PhpUnit/bin/simple-phpunit.php @@ -110,7 +110,7 @@ } if (version_compare($PHPUNIT_VERSION, '10.0', '>=') && version_compare($PHPUNIT_VERSION, '11.0', '<')) { - fwrite(STDERR, 'This script does not work with PHPUnit 10.'.\PHP_EOL); + fwrite(\STDERR, 'This script does not work with PHPUnit 10.'.\PHP_EOL); exit(1); } @@ -462,7 +462,7 @@ class_exists(\SymfonyExcludeListSimplePhpunit::class, false) && PHPUnit\Util\Bla } } } elseif (!isset($argv[1]) || 'install' !== $argv[1] || file_exists('install')) { - if (!class_exists(\SymfonyExcludeListSimplePhpunit::class, false)) { + if (!class_exists(SymfonyExcludeListSimplePhpunit::class, false)) { class SymfonyExcludeListSimplePhpunit { } diff --git a/src/Symfony/Bridge/PhpUnit/bootstrap.php b/src/Symfony/Bridge/PhpUnit/bootstrap.php index f11b7ab7f4945..5fddda14eb847 100644 --- a/src/Symfony/Bridge/PhpUnit/bootstrap.php +++ b/src/Symfony/Bridge/PhpUnit/bootstrap.php @@ -21,7 +21,7 @@ } // Detect if we're loaded by an actual run of phpunit -if (!defined('PHPUNIT_COMPOSER_INSTALL') && !class_exists(\PHPUnit\TextUI\Command::class, false)) { +if (!defined('PHPUNIT_COMPOSER_INSTALL') && !class_exists(PHPUnit\TextUI\Command::class, false)) { return; } diff --git a/src/Symfony/Bridge/Twig/CHANGELOG.md b/src/Symfony/Bridge/Twig/CHANGELOG.md index b18e2745915ef..d6d929cb50ed6 100644 --- a/src/Symfony/Bridge/Twig/CHANGELOG.md +++ b/src/Symfony/Bridge/Twig/CHANGELOG.md @@ -1,6 +1,15 @@ CHANGELOG ========= +7.3 +--- + + * Add `is_granted_for_user()` Twig function + * Add `field_id()` Twig form helper function + * Add a `Twig` constraint that validates Twig templates + * Make `lint:twig` collect all deprecations instead of stopping at the first one + * Add `name` argument to `email.image` to override the attachment file name being set as the file path + 7.2 --- diff --git a/src/Symfony/Bridge/Twig/Command/LintCommand.php b/src/Symfony/Bridge/Twig/Command/LintCommand.php index 5472095238a12..cacc7e4440a81 100644 --- a/src/Symfony/Bridge/Twig/Command/LintCommand.php +++ b/src/Symfony/Bridge/Twig/Command/LintCommand.php @@ -89,7 +89,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->format = $input->getOption('format') ?? (GithubActionReporter::isGithubActionEnvironment() ? 'github' : 'txt'); if (['-'] === $filenames) { - return $this->display($input, $output, $io, [$this->validate(file_get_contents('php://stdin'), 'Standard Input')]); + return $this->display($input, $output, $io, [$this->validate(file_get_contents('php://stdin'), 'Standard Input', $showDeprecations)]); } if (!$filenames) { @@ -107,38 +107,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - if ($showDeprecations) { - $prevErrorHandler = set_error_handler(static function ($level, $message, $file, $line) use (&$prevErrorHandler) { - if (\E_USER_DEPRECATED === $level) { - $templateLine = 0; - if (preg_match('/ at line (\d+)[ .]/', $message, $matches)) { - $templateLine = $matches[1]; - } - - throw new Error($message, $templateLine); - } - - return $prevErrorHandler ? $prevErrorHandler($level, $message, $file, $line) : false; - }); - } - - try { - $filesInfo = $this->getFilesInfo($filenames); - } finally { - if ($showDeprecations) { - restore_error_handler(); - } - } - - return $this->display($input, $output, $io, $filesInfo); + return $this->display($input, $output, $io, $this->getFilesInfo($filenames, $showDeprecations)); } - private function getFilesInfo(array $filenames): array + private function getFilesInfo(array $filenames, bool $showDeprecations): array { $filesInfo = []; foreach ($filenames as $filename) { foreach ($this->findFiles($filename) as $file) { - $filesInfo[] = $this->validate(file_get_contents($file), $file); + $filesInfo[] = $this->validate(file_get_contents($file), $file, $showDeprecations); } } @@ -156,8 +133,26 @@ protected function findFiles(string $filename): iterable throw new RuntimeException(\sprintf('File or directory "%s" is not readable.', $filename)); } - private function validate(string $template, string $file): array + private function validate(string $template, string $file, bool $collectDeprecation): array { + $deprecations = []; + if ($collectDeprecation) { + $prevErrorHandler = set_error_handler(static function ($level, $message, $fileName, $line) use (&$prevErrorHandler, &$deprecations, $file) { + if (\E_USER_DEPRECATED === $level) { + $templateLine = 0; + if (preg_match('/ at line (\d+)[ .]/', $message, $matches)) { + $templateLine = $matches[1]; + } + + $deprecations[] = ['message' => $message, 'file' => $file, 'line' => $templateLine]; + + return true; + } + + return $prevErrorHandler ? $prevErrorHandler($level, $message, $fileName, $line) : false; + }); + } + $realLoader = $this->twig->getLoader(); try { $temporaryLoader = new ArrayLoader([$file => $template]); @@ -169,9 +164,13 @@ private function validate(string $template, string $file): array $this->twig->setLoader($realLoader); return ['template' => $template, 'file' => $file, 'line' => $e->getTemplateLine(), 'valid' => false, 'exception' => $e]; + } finally { + if ($collectDeprecation) { + restore_error_handler(); + } } - return ['template' => $template, 'file' => $file, 'valid' => true]; + return ['template' => $template, 'file' => $file, 'deprecations' => $deprecations, 'valid' => true]; } private function display(InputInterface $input, OutputInterface $output, SymfonyStyle $io, array $files): int @@ -188,6 +187,11 @@ private function displayTxt(OutputInterface $output, SymfonyStyle $io, array $fi { $errors = 0; $githubReporter = $errorAsGithubAnnotations ? new GithubActionReporter($output) : null; + $deprecations = array_merge(...array_column($filesInfo, 'deprecations')); + + foreach ($deprecations as $deprecation) { + $this->renderDeprecation($io, $deprecation['line'], $deprecation['message'], $deprecation['file'], $githubReporter); + } foreach ($filesInfo as $info) { if ($info['valid'] && $output->isVerbose()) { @@ -204,7 +208,7 @@ private function displayTxt(OutputInterface $output, SymfonyStyle $io, array $fi $io->warning(\sprintf('%d Twig files have valid syntax and %d contain errors.', \count($filesInfo) - $errors, $errors)); } - return min($errors, 1); + return !$deprecations && !$errors ? 0 : 1; } private function displayJson(OutputInterface $output, array $filesInfo): int @@ -226,6 +230,19 @@ private function displayJson(OutputInterface $output, array $filesInfo): int return min($errors, 1); } + private function renderDeprecation(SymfonyStyle $output, int $line, string $message, string $file, ?GithubActionReporter $githubReporter): void + { + $githubReporter?->error($message, $file, $line <= 0 ? null : $line); + + if ($file) { + $output->text(\sprintf(' DEPRECATION in %s (line %s)', $file, $line)); + } else { + $output->text(\sprintf(' DEPRECATION (line %s)', $line)); + } + + $output->text(\sprintf(' >> %s ', $message)); + } + private function renderException(SymfonyStyle $output, string $template, Error $exception, ?string $file = null, ?GithubActionReporter $githubReporter = null): void { $line = $exception->getTemplateLine(); diff --git a/src/Symfony/Bridge/Twig/Extension/FormExtension.php b/src/Symfony/Bridge/Twig/Extension/FormExtension.php index ec552d7c622ef..f1ae7068f11d1 100644 --- a/src/Symfony/Bridge/Twig/Extension/FormExtension.php +++ b/src/Symfony/Bridge/Twig/Extension/FormExtension.php @@ -62,6 +62,7 @@ public function getFunctions(): array new TwigFunction('csrf_token', [FormRenderer::class, 'renderCsrfToken']), new TwigFunction('form_parent', 'Symfony\Bridge\Twig\Extension\twig_get_form_parent'), new TwigFunction('field_name', $this->getFieldName(...)), + new TwigFunction('field_id', $this->getFieldId(...)), new TwigFunction('field_value', $this->getFieldValue(...)), new TwigFunction('field_label', $this->getFieldLabel(...)), new TwigFunction('field_help', $this->getFieldHelp(...)), @@ -93,6 +94,11 @@ public function getFieldName(FormView $view): string return $view->vars['full_name']; } + public function getFieldId(FormView $view): string + { + return $view->vars['id']; + } + public function getFieldValue(FormView $view): string|array { return $view->vars['value']; diff --git a/src/Symfony/Bridge/Twig/Extension/SecurityExtension.php b/src/Symfony/Bridge/Twig/Extension/SecurityExtension.php index 863df15606735..e0bb242586371 100644 --- a/src/Symfony/Bridge/Twig/Extension/SecurityExtension.php +++ b/src/Symfony/Bridge/Twig/Extension/SecurityExtension.php @@ -12,8 +12,11 @@ namespace Symfony\Bridge\Twig\Extension; use Symfony\Component\Security\Acl\Voter\FieldVote; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface; use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException; +use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Http\Impersonate\ImpersonateUrlGenerator; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; @@ -31,18 +34,47 @@ public function __construct( ) { } - public function isGranted(mixed $role, mixed $object = null, ?string $field = null): bool + public function isGranted(mixed $role, mixed $object = null, ?string $field = null, ?AccessDecision $accessDecision = null): bool { if (null === $this->securityChecker) { return false; } if (null !== $field) { + if (!class_exists(FieldVote::class)) { + throw new \LogicException('Passing a $field to the "is_granted()" function requires symfony/acl. Try running "composer require symfony/acl-bundle" if you need field-level access control.'); + } + $object = new FieldVote($object, $field); } try { - return $this->securityChecker->isGranted($role, $object); + return $this->securityChecker->isGranted($role, $object, $accessDecision); + } catch (AuthenticationCredentialsNotFoundException) { + return false; + } + } + + public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?string $field = null, ?AccessDecision $accessDecision = null): bool + { + if (null === $this->securityChecker) { + return false; + } + + if (!$this->securityChecker instanceof UserAuthorizationCheckerInterface) { + throw new \LogicException(\sprintf('You cannot use "%s()" if the authorization checker doesn\'t implement "%s".%s', __METHOD__, UserAuthorizationCheckerInterface::class, interface_exists(UserAuthorizationCheckerInterface::class) ? ' Try upgrading the "symfony/security-core" package to v7.3 minimum.' : '')); + } + + if (null !== $field) { + if (!class_exists(FieldVote::class)) { + throw new \LogicException('Passing a $field to the "is_granted_for_user()" function requires symfony/acl. Try running "composer require symfony/acl-bundle" if you need field-level access control.'); + } + + $subject = new FieldVote($subject, $field); + } + + try { + return $this->securityChecker->isGrantedForUser($user, $attribute, $subject, $accessDecision); } catch (AuthenticationCredentialsNotFoundException) { return false; } @@ -86,12 +118,18 @@ public function getImpersonatePath(string $identifier): string public function getFunctions(): array { - return [ + $functions = [ new TwigFunction('is_granted', $this->isGranted(...)), new TwigFunction('impersonation_exit_url', $this->getImpersonateExitUrl(...)), new TwigFunction('impersonation_exit_path', $this->getImpersonateExitPath(...)), new TwigFunction('impersonation_url', $this->getImpersonateUrl(...)), new TwigFunction('impersonation_path', $this->getImpersonatePath(...)), ]; + + if ($this->securityChecker instanceof UserAuthorizationCheckerInterface) { + $functions[] = new TwigFunction('is_granted_for_user', $this->isGrantedForUser(...)); + } + + return $functions; } } diff --git a/src/Symfony/Bridge/Twig/Extension/WebLinkExtension.php b/src/Symfony/Bridge/Twig/Extension/WebLinkExtension.php index 9eeb305aee36c..b06f0a8cedbe4 100644 --- a/src/Symfony/Bridge/Twig/Extension/WebLinkExtension.php +++ b/src/Symfony/Bridge/Twig/Extension/WebLinkExtension.php @@ -44,7 +44,7 @@ public function getFunctions(): array /** * Adds a "Link" HTTP header. * - * @param string $rel The relation type (e.g. "preload", "prefetch", "prerender" or "dns-prefetch") + * @param string $rel The relation type (e.g. "preload", "prefetch", or "dns-prefetch") * @param array $attributes The attributes of this link (e.g. "['as' => true]", "['pr' => 0.5]") * * @return string The relation URI @@ -115,7 +115,11 @@ public function prefetch(string $uri, array $attributes = []): string } /** - * Indicates to the client that it should prerender this resource . + * Indicates to the client that it should prerender this resource. + * + * This feature is deprecated and superseded by the Speculation Rules API. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/rel/prerender * * @param array $attributes The attributes of this link (e.g. "['as' => true]", "['pr' => 0.5]") * diff --git a/src/Symfony/Bridge/Twig/Mime/BodyRenderer.php b/src/Symfony/Bridge/Twig/Mime/BodyRenderer.php index 162f8c5ff4c09..00b7ba00f1996 100644 --- a/src/Symfony/Bridge/Twig/Mime/BodyRenderer.php +++ b/src/Symfony/Bridge/Twig/Mime/BodyRenderer.php @@ -43,7 +43,7 @@ public function render(Message $message): void return; } - if (null === $message->getTextTemplate() && null === $message->getHtmlTemplate()) { + if ($message->isRendered()) { // email has already been rendered return; } diff --git a/src/Symfony/Bridge/Twig/Mime/TemplatedEmail.php b/src/Symfony/Bridge/Twig/Mime/TemplatedEmail.php index 2d308947f8498..68b3913eba367 100644 --- a/src/Symfony/Bridge/Twig/Mime/TemplatedEmail.php +++ b/src/Symfony/Bridge/Twig/Mime/TemplatedEmail.php @@ -100,7 +100,7 @@ public function markAsRendered(): void */ public function __serialize(): array { - return [$this->htmlTemplate, $this->textTemplate, $this->context, parent::__serialize(), $this->locale]; + return [$this->htmlTemplate, $this->textTemplate, $this->context, parent::__serialize(), $this->locale]; } /** diff --git a/src/Symfony/Bridge/Twig/Mime/WrappedTemplatedEmail.php b/src/Symfony/Bridge/Twig/Mime/WrappedTemplatedEmail.php index a327e94b3321e..1feedc20370bb 100644 --- a/src/Symfony/Bridge/Twig/Mime/WrappedTemplatedEmail.php +++ b/src/Symfony/Bridge/Twig/Mime/WrappedTemplatedEmail.php @@ -39,14 +39,16 @@ public function toName(): string * some Twig namespace for email images (e.g. '@email/images/logo.png'). * @param string|null $contentType The media type (i.e. MIME type) of the image file (e.g. 'image/png'). * Some email clients require this to display embedded images. + * @param string|null $name A custom file name that overrides the original name (filepath) of the image */ - public function image(string $image, ?string $contentType = null): string + public function image(string $image, ?string $contentType = null, ?string $name = null): string { $file = $this->twig->getLoader()->getSourceContext($image); $body = $file->getPath() ? new File($file->getPath()) : $file->getCode(); - $this->message->addPart((new DataPart($body, $image, $contentType))->asInline()); + $name = $name ?: $image; + $this->message->addPart((new DataPart($body, $name, $contentType))->asInline()); - return 'cid:'.$image; + return 'cid:'.$name; } /** diff --git a/src/Symfony/Bridge/Twig/Node/TransNode.php b/src/Symfony/Bridge/Twig/Node/TransNode.php index 4064491f1e45a..c675db5610705 100644 --- a/src/Symfony/Bridge/Twig/Node/TransNode.php +++ b/src/Symfony/Bridge/Twig/Node/TransNode.php @@ -16,7 +16,6 @@ use Twig\Node\Expression\AbstractExpression; use Twig\Node\Expression\ArrayExpression; use Twig\Node\Expression\ConstantExpression; -use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\Node; use Twig\Node\TextNode; @@ -120,7 +119,7 @@ private function compileString(Node $body, ArrayExpression $vars, bool $ignoreSt if ('count' === $var && $this->hasNode('count')) { $vars->addElement($this->getNode('count'), $key); } else { - $varExpr = class_exists(ContextVariable::class) ? new ContextVariable($var, $body->getTemplateLine()) : new NameExpression($var, $body->getTemplateLine()); + $varExpr = new ContextVariable($var, $body->getTemplateLine()); $varExpr->setAttribute('ignore_strict_check', $ignoreStrictCheck); $vars->addElement($varExpr, $key); } diff --git a/src/Symfony/Bridge/Twig/NodeVisitor/TranslationDefaultDomainNodeVisitor.php b/src/Symfony/Bridge/Twig/NodeVisitor/TranslationDefaultDomainNodeVisitor.php index 3b8196fae410e..938d6439fe16b 100644 --- a/src/Symfony/Bridge/Twig/NodeVisitor/TranslationDefaultDomainNodeVisitor.php +++ b/src/Symfony/Bridge/Twig/NodeVisitor/TranslationDefaultDomainNodeVisitor.php @@ -17,10 +17,8 @@ use Twig\Node\BlockNode; use Twig\Node\EmptyNode; use Twig\Node\Expression\ArrayExpression; -use Twig\Node\Expression\AssignNameExpression; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\FilterExpression; -use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\Variable\AssignContextVariable; use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\ModuleNode; @@ -60,17 +58,10 @@ public function enterNode(Node $node, Environment $env): Node $var = '__internal_trans_default_domain'.hash('xxh128', $templateName); - if (class_exists(Nodes::class)) { - $name = new AssignContextVariable($var, $node->getTemplateLine()); - $this->scope->set('domain', new ContextVariable($var, $node->getTemplateLine())); + $name = new AssignContextVariable($var, $node->getTemplateLine()); + $this->scope->set('domain', new ContextVariable($var, $node->getTemplateLine())); - return new SetNode(false, new Nodes([$name]), new Nodes([$node->getNode('expr')]), $node->getTemplateLine()); - } - - $name = new AssignNameExpression($var, $node->getTemplateLine()); - $this->scope->set('domain', new NameExpression($var, $node->getTemplateLine())); - - return new SetNode(false, new Node([$name]), new Node([$node->getNode('expr')]), $node->getTemplateLine()); + return new SetNode(false, new Nodes([$name]), new Nodes([$node->getNode('expr')]), $node->getTemplateLine()); } if (!$this->scope->has('domain')) { diff --git a/src/Symfony/Bridge/Twig/Resources/views/Form/form_div_layout.html.twig b/src/Symfony/Bridge/Twig/Resources/views/Form/form_div_layout.html.twig index 1e421d5f9f5a9..537849faebaa4 100644 --- a/src/Symfony/Bridge/Twig/Resources/views/Form/form_div_layout.html.twig +++ b/src/Symfony/Bridge/Twig/Resources/views/Form/form_div_layout.html.twig @@ -89,7 +89,7 @@ {{- block('choice_widget_options') -}} {%- else -%} - + {%- endif -%} {% endfor %} {%- endblock choice_widget_options -%} diff --git a/src/Symfony/Bridge/Twig/Tests/Command/LintCommandTest.php b/src/Symfony/Bridge/Twig/Tests/Command/LintCommandTest.php index 3b0b453d2e2fe..9e4e23a87e813 100644 --- a/src/Symfony/Bridge/Twig/Tests/Command/LintCommandTest.php +++ b/src/Symfony/Bridge/Twig/Tests/Command/LintCommandTest.php @@ -94,7 +94,20 @@ public function testLintFileWithReportedDeprecation() $ret = $tester->execute(['filename' => [$filename], '--show-deprecations' => true], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE, 'decorated' => false]); $this->assertEquals(1, $ret, 'Returns 1 in case of error'); - $this->assertMatchesRegularExpression('/ERROR in \S+ \(line 1\)/', trim($tester->getDisplay())); + $this->assertMatchesRegularExpression('/DEPRECATION in \S+ \(line 1\)/', trim($tester->getDisplay())); + $this->assertStringContainsString('Filter "deprecated_filter" is deprecated', trim($tester->getDisplay())); + } + + public function testLintFileWithMultipleReportedDeprecation() + { + $tester = $this->createCommandTester(); + $filename = $this->createFile("{{ foo|deprecated_filter }}\n{{ bar|deprecated_filter }}"); + + $ret = $tester->execute(['filename' => [$filename], '--show-deprecations' => true], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE, 'decorated' => false]); + + $this->assertEquals(1, $ret, 'Returns 1 in case of error'); + $this->assertMatchesRegularExpression('/DEPRECATION in \S+ \(line 1\)/', trim($tester->getDisplay())); + $this->assertMatchesRegularExpression('/DEPRECATION in \S+ \(line 2\)/', trim($tester->getDisplay())); $this->assertStringContainsString('Filter "deprecated_filter" is deprecated', trim($tester->getDisplay())); } @@ -160,11 +173,7 @@ private function createCommandTester(): CommandTester private function createCommand(): Command { $environment = new Environment(new FilesystemLoader(\dirname(__DIR__).'/Fixtures/templates/')); - if (class_exists(DeprecatedCallableInfo::class)) { - $options = ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.1')]; - } else { - $options = ['deprecated' => true]; - } + $options = ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.1')]; $environment->addFilter(new TwigFilter('deprecated_filter', fn ($v) => $v, $options)); $command = new LintCommand($environment); diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/AbstractDivLayoutTestCase.php b/src/Symfony/Bridge/Twig/Tests/Extension/AbstractDivLayoutTestCase.php index cfa2c5c6475cf..28e8997a12e9f 100644 --- a/src/Symfony/Bridge/Twig/Tests/Extension/AbstractDivLayoutTestCase.php +++ b/src/Symfony/Bridge/Twig/Tests/Extension/AbstractDivLayoutTestCase.php @@ -870,6 +870,56 @@ public function testMultipleChoiceExpandedWithLabelsSetFalseByCallable() ); } + public function testSingleChoiceWithoutDuplicatePreferredIsSelected() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&d', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b', 'Choice&C' => '&c', 'Choice&D' => '&d'], + 'preferred_choices' => ['&b', '&d'], + 'duplicate_preferred_choices' => false, + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['separator' => '-- sep --'], + '/select + [@name="name"] + [ + ./option[@value="&d"][@selected="selected"] + /following-sibling::option[@disabled="disabled"][.="-- sep --"] + /following-sibling::option[@value="&a"][not(@selected)] + /following-sibling::option[@value="&c"][not(@selected)] + ] + [count(./option)=5] +' + ); + } + + public function testSingleChoiceWithoutDuplicateNotPreferredIsSelected() + { + $form = $this->factory->createNamed('name', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', '&d', [ + 'choices' => ['Choice&A' => '&a', 'Choice&B' => '&b', 'Choice&C' => '&c', 'Choice&D' => '&d'], + 'preferred_choices' => ['&b', '&d'], + 'duplicate_preferred_choices' => true, + 'multiple' => false, + 'expanded' => false, + ]); + + $this->assertWidgetMatchesXpath($form->createView(), ['separator' => '-- sep --'], + '/select + [@name="name"] + [ + ./option[@value="&d"][not(@selected)] + /following-sibling::option[@disabled="disabled"][.="-- sep --"] + /following-sibling::option[@value="&a"][not(@selected)] + /following-sibling::option[@value="&b"][not(@selected)] + /following-sibling::option[@value="&c"][not(@selected)] + /following-sibling::option[@value="&d"][@selected="selected"] + ] + [count(./option)=7] +' + ); + } + public function testFormEndWithRest() { $view = $this->factory->createNamedBuilder('name', 'Symfony\Component\Form\Extension\Core\Type\FormType') diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/AbstractLayoutTestCase.php b/src/Symfony/Bridge/Twig/Tests/Extension/AbstractLayoutTestCase.php index 5a541d7bd4124..2f7410d1f7591 100644 --- a/src/Symfony/Bridge/Twig/Tests/Extension/AbstractLayoutTestCase.php +++ b/src/Symfony/Bridge/Twig/Tests/Extension/AbstractLayoutTestCase.php @@ -1592,7 +1592,7 @@ public function testDateErrorBubbling() $form->get('date')->addError(new FormError('[trans]Error![/trans]')); $view = $form->createView(); - $this->assertEmpty($this->renderErrors($view)); + $this->assertSame('', $this->renderErrors($view)); $this->assertNotEmpty($this->renderErrors($view['date'])); } @@ -2213,7 +2213,7 @@ public function testTimeErrorBubbling() $form->get('time')->addError(new FormError('[trans]Error![/trans]')); $view = $form->createView(); - $this->assertEmpty($this->renderErrors($view)); + $this->assertSame('', $this->renderErrors($view)); $this->assertNotEmpty($this->renderErrors($view['time'])); } diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/DumpExtensionTest.php b/src/Symfony/Bridge/Twig/Tests/Extension/DumpExtensionTest.php index 8fe455e5d5706..01817ce597c5d 100644 --- a/src/Symfony/Bridge/Twig/Tests/Extension/DumpExtensionTest.php +++ b/src/Symfony/Bridge/Twig/Tests/Extension/DumpExtensionTest.php @@ -142,6 +142,6 @@ public function testCustomDumper() 'Custom dumper should be used to dump data.' ); - $this->assertEmpty($output, 'Dumper output should be ignored.'); + $this->assertSame('', $output, 'Dumper output should be ignored.'); } } diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/FormExtensionFieldHelpersTest.php b/src/Symfony/Bridge/Twig/Tests/Extension/FormExtensionFieldHelpersTest.php index efedc871c3480..320b855140c7b 100644 --- a/src/Symfony/Bridge/Twig/Tests/Extension/FormExtensionFieldHelpersTest.php +++ b/src/Symfony/Bridge/Twig/Tests/Extension/FormExtensionFieldHelpersTest.php @@ -119,6 +119,12 @@ public function testFieldName() $this->assertTrue($this->view->children['username']->isRendered()); } + public function testFieldId() + { + $this->assertSame('register_username', $this->rawExtension->getFieldId($this->view->children['username'])); + $this->assertSame('register_choice_multiple', $this->rawExtension->getFieldId($this->view->children['choice_multiple'])); + } + public function testFieldValue() { $this->assertSame('tgalopin', $this->rawExtension->getFieldValue($this->view->children['username'])); diff --git a/src/Symfony/Bridge/Twig/Tests/Extension/SecurityExtensionTest.php b/src/Symfony/Bridge/Twig/Tests/Extension/SecurityExtensionTest.php new file mode 100644 index 0000000000000..e0ca4dcbb6901 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Extension/SecurityExtensionTest.php @@ -0,0 +1,143 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\Extension; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ClassExistsMock; +use Symfony\Bridge\Twig\Extension\SecurityExtension; +use Symfony\Component\Security\Acl\Voter\FieldVote; +use Symfony\Component\Security\Core\Authorization\AccessDecision; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface; +use Symfony\Component\Security\Core\User\UserInterface; + +class SecurityExtensionTest extends TestCase +{ + public static function setUpBeforeClass(): void + { + ClassExistsMock::register(SecurityExtension::class); + } + + protected function tearDown(): void + { + ClassExistsMock::withMockedClasses([FieldVote::class => true]); + } + + /** + * @dataProvider provideObjectFieldAclCases + */ + public function testIsGrantedCreatesFieldVoteObjectWhenFieldNotNull($object, $field, $expectedSubject) + { + $securityChecker = $this->createMock(AuthorizationCheckerInterface::class); + $securityChecker + ->expects($this->once()) + ->method('isGranted') + ->with('ROLE', $expectedSubject) + ->willReturn(true); + + $securityExtension = new SecurityExtension($securityChecker); + $this->assertTrue($securityExtension->isGranted('ROLE', $object, $field)); + } + + public function testIsGrantedThrowsWhenFieldNotNullAndFieldVoteClassDoesNotExist() + { + if (!interface_exists(UserAuthorizationCheckerInterface::class)) { + $this->markTestSkipped('This test requires symfony/security-core 7.3 or superior.'); + } + + $securityChecker = $this->createMock(AuthorizationCheckerInterface::class); + + ClassExistsMock::withMockedClasses([FieldVote::class => false]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Passing a $field to the "is_granted()" function requires symfony/acl.'); + + $securityExtension = new SecurityExtension($securityChecker); + $securityExtension->isGranted('ROLE', 'object', 'bar'); + } + + /** + * @dataProvider provideObjectFieldAclCases + */ + public function testIsGrantedForUserCreatesFieldVoteObjectWhenFieldNotNull($object, $field, $expectedSubject) + { + if (!interface_exists(UserAuthorizationCheckerInterface::class)) { + $this->markTestSkipped('This test requires symfony/security-core 7.3 or superior.'); + } + + $user = $this->createMock(UserInterface::class); + $securityChecker = $this->createMockAuthorizationChecker(); + + $securityExtension = new SecurityExtension($securityChecker); + $this->assertTrue($securityExtension->isGrantedForUser($user, 'ROLE', $object, $field)); + $this->assertSame($user, $securityChecker->user); + $this->assertSame('ROLE', $securityChecker->attribute); + + if (null === $field) { + $this->assertSame($object, $securityChecker->subject); + } else { + $this->assertEquals($expectedSubject, $securityChecker->subject); + } + } + + public static function provideObjectFieldAclCases() + { + return [ + [null, null, null], + ['object', null, 'object'], + ['object', false, new FieldVote('object', false)], + ['object', 0, new FieldVote('object', 0)], + ['object', '', new FieldVote('object', '')], + ['object', 'field', new FieldVote('object', 'field')], + ]; + } + + public function testIsGrantedForUserThrowsWhenFieldNotNullAndFieldVoteClassDoesNotExist() + { + if (!interface_exists(UserAuthorizationCheckerInterface::class)) { + $this->markTestSkipped('This test requires symfony/security-core 7.3 or superior.'); + } + + $securityChecker = $this->createMockAuthorizationChecker(); + + ClassExistsMock::withMockedClasses([FieldVote::class => false]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Passing a $field to the "is_granted_for_user()" function requires symfony/acl.'); + + $securityExtension = new SecurityExtension($securityChecker); + $securityExtension->isGrantedForUser($this->createMock(UserInterface::class), 'ROLE', 'object', 'bar'); + } + + private function createMockAuthorizationChecker(): AuthorizationCheckerInterface&UserAuthorizationCheckerInterface + { + return new class implements AuthorizationCheckerInterface, UserAuthorizationCheckerInterface { + public UserInterface $user; + public mixed $attribute; + public mixed $subject; + + public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool + { + throw new \BadMethodCallException('This method should not be called.'); + } + + public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool + { + $this->user = $user; + $this->attribute = $attribute; + $this->subject = $subject; + + return true; + } + }; + } +} diff --git a/src/Symfony/Bridge/Twig/Tests/Fixtures/assets/images/logo1.png b/src/Symfony/Bridge/Twig/Tests/Fixtures/assets/images/logo1.png new file mode 100644 index 0000000000000..519ab7c691ba9 Binary files /dev/null and b/src/Symfony/Bridge/Twig/Tests/Fixtures/assets/images/logo1.png differ diff --git a/src/Symfony/Bridge/Twig/Tests/Fixtures/assets/images/logo2.png b/src/Symfony/Bridge/Twig/Tests/Fixtures/assets/images/logo2.png new file mode 120000 index 0000000000000..e9f523cbd5b31 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Fixtures/assets/images/logo2.png @@ -0,0 +1 @@ +logo1.png \ No newline at end of file diff --git a/src/Symfony/Bridge/Twig/Tests/Fixtures/templates/email/attach.html.twig b/src/Symfony/Bridge/Twig/Tests/Fixtures/templates/email/attach.html.twig new file mode 100644 index 0000000000000..e70e32fbcb757 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Fixtures/templates/email/attach.html.twig @@ -0,0 +1,3 @@ +

Attachments

+{{ email.attach('@assets/images/logo1.png') }} +{{ email.attach('@assets/images/logo2.png', name='image.png') }} diff --git a/src/Symfony/Bridge/Twig/Tests/Fixtures/templates/email/image.html.twig b/src/Symfony/Bridge/Twig/Tests/Fixtures/templates/email/image.html.twig new file mode 100644 index 0000000000000..074edf4c91b2f --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Fixtures/templates/email/image.html.twig @@ -0,0 +1,2 @@ + + diff --git a/src/Symfony/Bridge/Twig/Tests/Mime/BodyRendererTest.php b/src/Symfony/Bridge/Twig/Tests/Mime/BodyRendererTest.php index f5d37e7d45c4e..cce8ee9a68839 100644 --- a/src/Symfony/Bridge/Twig/Tests/Mime/BodyRendererTest.php +++ b/src/Symfony/Bridge/Twig/Tests/Mime/BodyRendererTest.php @@ -105,10 +105,14 @@ public function testRenderedOnce() ; $email->textTemplate('text'); + $this->assertFalse($email->isRendered()); $renderer->render($email); + $this->assertTrue($email->isRendered()); + $this->assertEquals('Text', $email->getTextBody()); $email->text('reset'); + $this->assertTrue($email->isRendered()); $renderer->render($email); $this->assertEquals('reset', $email->getTextBody()); diff --git a/src/Symfony/Bridge/Twig/Tests/Mime/WrappedTemplatedEmailTest.php b/src/Symfony/Bridge/Twig/Tests/Mime/WrappedTemplatedEmailTest.php new file mode 100644 index 0000000000000..428ebc93dc4ab --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Mime/WrappedTemplatedEmailTest.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\Mime; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Twig\Mime\BodyRenderer; +use Symfony\Bridge\Twig\Mime\TemplatedEmail; +use Twig\Environment; +use Twig\Loader\FilesystemLoader; + +/** + * @author Alexander Hofbauer buildEmail('email/image.html.twig'); + $body = $email->toString(); + $contentId1 = $email->getAttachments()[0]->getContentId(); + $contentId2 = $email->getAttachments()[1]->getContentId(); + + $part1 = str_replace("\n", "\r\n", + << + Content-Type: image/png; name="$contentId1" + Content-Transfer-Encoding: base64 + Content-Disposition: inline; + name="$contentId1"; + filename="@assets/images/logo1.png" + + PART + ); + + $part2 = str_replace("\n", "\r\n", + << + Content-Type: image/png; name="$contentId2" + Content-Transfer-Encoding: base64 + Content-Disposition: inline; + name="$contentId2"; filename=image.png + + PART + ); + + self::assertStringContainsString('![](cid:@assets/images/logo1.png)![](cid:image.png)', $body); + self::assertStringContainsString($part1, $body); + self::assertStringContainsString($part2, $body); + } + + public function testEmailAttach() + { + $email = $this->buildEmail('email/attach.html.twig'); + $body = $email->toString(); + + $part1 = str_replace("\n", "\r\n", + <<from('a.hofbauer@fify.at') + ->htmlTemplate($template); + + $loader = new FilesystemLoader(\dirname(__DIR__).'/Fixtures/templates/'); + $loader->addPath(\dirname(__DIR__).'/Fixtures/assets', 'assets'); + + $environment = new Environment($loader); + $renderer = new BodyRenderer($environment); + $renderer->render($email); + + return $email; + } +} diff --git a/src/Symfony/Bridge/Twig/Tests/Node/DumpNodeTest.php b/src/Symfony/Bridge/Twig/Tests/Node/DumpNodeTest.php index 6d584c89b44b3..33297ae4a35ba 100644 --- a/src/Symfony/Bridge/Twig/Tests/Node/DumpNodeTest.php +++ b/src/Symfony/Bridge/Twig/Tests/Node/DumpNodeTest.php @@ -16,9 +16,7 @@ use Twig\Compiler; use Twig\Environment; use Twig\Loader\LoaderInterface; -use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\Variable\ContextVariable; -use Twig\Node\Node; use Twig\Node\Nodes; class DumpNodeTest extends TestCase @@ -73,15 +71,9 @@ public function testIndented() public function testOneVar() { - if (class_exists(Nodes::class)) { - $vars = new Nodes([ - new ContextVariable('foo', 7), - ]); - } else { - $vars = new Node([ - new NameExpression('foo', 7), - ]); - } + $vars = new Nodes([ + new ContextVariable('foo', 7), + ]); $node = new DumpNode('bar', $vars, 7); @@ -103,18 +95,10 @@ public function testOneVar() public function testMultiVars() { - if (class_exists(Nodes::class)) { - $vars = new Nodes([ - new ContextVariable('foo', 7), - new ContextVariable('bar', 7), - ]); - } else { - $vars = new Node([ - new NameExpression('foo', 7), - new NameExpression('bar', 7), - ]); - } - + $vars = new Nodes([ + new ContextVariable('foo', 7), + new ContextVariable('bar', 7), + ]); $node = new DumpNode('bar', $vars, 7); $env = new Environment($this->createMock(LoaderInterface::class)); diff --git a/src/Symfony/Bridge/Twig/Tests/Node/FormThemeTest.php b/src/Symfony/Bridge/Twig/Tests/Node/FormThemeTest.php index f98b93da17e8a..e581ff284938e 100644 --- a/src/Symfony/Bridge/Twig/Tests/Node/FormThemeTest.php +++ b/src/Symfony/Bridge/Twig/Tests/Node/FormThemeTest.php @@ -21,9 +21,7 @@ use Twig\Loader\LoaderInterface; use Twig\Node\Expression\ArrayExpression; use Twig\Node\Expression\ConstantExpression; -use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\Variable\ContextVariable; -use Twig\Node\Node; use Twig\Node\Nodes; class FormThemeTest extends TestCase @@ -32,18 +30,11 @@ class FormThemeTest extends TestCase public function testConstructor() { - $form = class_exists(ContextVariable::class) ? new ContextVariable('form', 0) : new NameExpression('form', 0); - if (class_exists(Nodes::class)) { - $resources = new Nodes([ - new ConstantExpression('tpl1', 0), - new ConstantExpression('tpl2', 0), - ]); - } else { - $resources = new Node([ - new ConstantExpression('tpl1', 0), - new ConstantExpression('tpl2', 0), - ]); - } + $form = new ContextVariable('form', 0); + $resources = new Nodes([ + new ConstantExpression('tpl1', 0), + new ConstantExpression('tpl2', 0), + ]); $node = new FormThemeNode($form, $resources, 0); @@ -54,7 +45,7 @@ public function testConstructor() public function testCompile() { - $form = class_exists(ContextVariable::class) ? new ContextVariable('form', 0) : new NameExpression('form', 0); + $form = new ContextVariable('form', 0); $resources = new ArrayExpression([ new ConstantExpression(1, 0), new ConstantExpression('tpl1', 0), diff --git a/src/Symfony/Bridge/Twig/Tests/Node/SearchAndRenderBlockNodeTest.php b/src/Symfony/Bridge/Twig/Tests/Node/SearchAndRenderBlockNodeTest.php index ab9113acf5c57..0c0afbfa2a272 100644 --- a/src/Symfony/Bridge/Twig/Tests/Node/SearchAndRenderBlockNodeTest.php +++ b/src/Symfony/Bridge/Twig/Tests/Node/SearchAndRenderBlockNodeTest.php @@ -18,12 +18,9 @@ use Twig\Extension\CoreExtension; use Twig\Loader\LoaderInterface; use Twig\Node\Expression\ArrayExpression; -use Twig\Node\Expression\ConditionalExpression; use Twig\Node\Expression\ConstantExpression; -use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\Ternary\ConditionalTernary; use Twig\Node\Expression\Variable\ContextVariable; -use Twig\Node\Node; use Twig\Node\Nodes; use Twig\TwigFunction; @@ -31,15 +28,9 @@ class SearchAndRenderBlockNodeTest extends TestCase { public function testCompileWidget() { - if (class_exists(Nodes::class)) { - $arguments = new Nodes([ - new ContextVariable('form', 0), - ]); - } else { - $arguments = new Node([ - new NameExpression('form', 0), - ]); - } + $arguments = new Nodes([ + new ContextVariable('form', 0), + ]); $node = new SearchAndRenderBlockNode(new TwigFunction('form_widget'), $arguments, 0); @@ -56,23 +47,13 @@ public function testCompileWidget() public function testCompileWidgetWithVariables() { - if (class_exists(Nodes::class)) { - $arguments = new Nodes([ - new ContextVariable('form', 0), - new ArrayExpression([ - new ConstantExpression('foo', 0), - new ConstantExpression('bar', 0), - ], 0), - ]); - } else { - $arguments = new Node([ - new NameExpression('form', 0), - new ArrayExpression([ - new ConstantExpression('foo', 0), - new ConstantExpression('bar', 0), - ], 0), - ]); - } + $arguments = new Nodes([ + new ContextVariable('form', 0), + new ArrayExpression([ + new ConstantExpression('foo', 0), + new ConstantExpression('bar', 0), + ], 0), + ]); $node = new SearchAndRenderBlockNode(new TwigFunction('form_widget'), $arguments, 0); @@ -89,17 +70,10 @@ public function testCompileWidgetWithVariables() public function testCompileLabelWithLabel() { - if (class_exists(Nodes::class)) { - $arguments = new Nodes([ - new ContextVariable('form', 0), - new ConstantExpression('my label', 0), - ]); - } else { - $arguments = new Node([ - new NameExpression('form', 0), - new ConstantExpression('my label', 0), - ]); - } + $arguments = new Nodes([ + new ContextVariable('form', 0), + new ConstantExpression('my label', 0), + ]); $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); @@ -116,17 +90,10 @@ public function testCompileLabelWithLabel() public function testCompileLabelWithNullLabel() { - if (class_exists(Nodes::class)) { - $arguments = new Nodes([ - new ContextVariable('form', 0), - new ConstantExpression(null, 0), - ]); - } else { - $arguments = new Node([ - new NameExpression('form', 0), - new ConstantExpression(null, 0), - ]); - } + $arguments = new Nodes([ + new ContextVariable('form', 0), + new ConstantExpression(null, 0), + ]); $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); @@ -145,17 +112,10 @@ public function testCompileLabelWithNullLabel() public function testCompileLabelWithEmptyStringLabel() { - if (class_exists(Nodes::class)) { - $arguments = new Nodes([ - new ContextVariable('form', 0), - new ConstantExpression('', 0), - ]); - } else { - $arguments = new Node([ - new NameExpression('form', 0), - new ConstantExpression('', 0), - ]); - } + $arguments = new Nodes([ + new ContextVariable('form', 0), + new ConstantExpression('', 0), + ]); $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); @@ -174,15 +134,9 @@ public function testCompileLabelWithEmptyStringLabel() public function testCompileLabelWithDefaultLabel() { - if (class_exists(Nodes::class)) { - $arguments = new Nodes([ - new ContextVariable('form', 0), - ]); - } else { - $arguments = new Node([ - new NameExpression('form', 0), - ]); - } + $arguments = new Nodes([ + new ContextVariable('form', 0), + ]); $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); @@ -199,25 +153,14 @@ public function testCompileLabelWithDefaultLabel() public function testCompileLabelWithAttributes() { - if (class_exists(Nodes::class)) { - $arguments = new Nodes([ - new ContextVariable('form', 0), - new ConstantExpression(null, 0), - new ArrayExpression([ - new ConstantExpression('foo', 0), - new ConstantExpression('bar', 0), - ], 0), - ]); - } else { - $arguments = new Node([ - new NameExpression('form', 0), - new ConstantExpression(null, 0), - new ArrayExpression([ - new ConstantExpression('foo', 0), - new ConstantExpression('bar', 0), - ], 0), - ]); - } + $arguments = new Nodes([ + new ContextVariable('form', 0), + new ConstantExpression(null, 0), + new ArrayExpression([ + new ConstantExpression('foo', 0), + new ConstantExpression('bar', 0), + ], 0), + ]); $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); @@ -237,29 +180,16 @@ public function testCompileLabelWithAttributes() public function testCompileLabelWithLabelAndAttributes() { - if (class_exists(Nodes::class)) { - $arguments = new Nodes([ - new ContextVariable('form', 0), - new ConstantExpression('value in argument', 0), - new ArrayExpression([ - new ConstantExpression('foo', 0), - new ConstantExpression('bar', 0), - new ConstantExpression('label', 0), - new ConstantExpression('value in attributes', 0), - ], 0), - ]); - } else { - $arguments = new Node([ - new NameExpression('form', 0), - new ConstantExpression('value in argument', 0), - new ArrayExpression([ - new ConstantExpression('foo', 0), - new ConstantExpression('bar', 0), - new ConstantExpression('label', 0), - new ConstantExpression('value in attributes', 0), - ], 0), - ]); - } + $arguments = new Nodes([ + new ContextVariable('form', 0), + new ConstantExpression('value in argument', 0), + new ArrayExpression([ + new ConstantExpression('foo', 0), + new ConstantExpression('bar', 0), + new ConstantExpression('label', 0), + new ConstantExpression('value in attributes', 0), + ], 0), + ]); $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); @@ -276,33 +206,17 @@ public function testCompileLabelWithLabelAndAttributes() public function testCompileLabelWithLabelThatEvaluatesToNull() { - if (class_exists(ConditionalTernary::class)) { - $conditional = new ConditionalTernary( - // if - new ConstantExpression(true, 0), - // then - new ConstantExpression(null, 0), - // else - new ConstantExpression(null, 0), - 0 - ); - } else { - $conditional = new ConditionalExpression( - // if - new ConstantExpression(true, 0), - // then - new ConstantExpression(null, 0), - // else - new ConstantExpression(null, 0), - 0 - ); - } - - if (class_exists(Nodes::class)) { - $arguments = new Nodes([new ContextVariable('form', 0), $conditional]); - } else { - $arguments = new Node([new NameExpression('form', 0), $conditional]); - } + $conditional = new ConditionalTernary( + // if + new ConstantExpression(true, 0), + // then + new ConstantExpression(null, 0), + // else + new ConstantExpression(null, 0), + 0 + ); + + $arguments = new Nodes([new ContextVariable('form', 0), $conditional]); $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); @@ -323,51 +237,26 @@ public function testCompileLabelWithLabelThatEvaluatesToNull() public function testCompileLabelWithLabelThatEvaluatesToNullAndAttributes() { - if (class_exists(ConditionalTernary::class)) { - $conditional = new ConditionalTernary( - // if - new ConstantExpression(true, 0), - // then - new ConstantExpression(null, 0), - // else - new ConstantExpression(null, 0), - 0 - ); - } else { - $conditional = new ConditionalExpression( - // if - new ConstantExpression(true, 0), - // then - new ConstantExpression(null, 0), - // else - new ConstantExpression(null, 0), - 0 - ); - } - - if (class_exists(Nodes::class)) { - $arguments = new Nodes([ - new ContextVariable('form', 0), - $conditional, - new ArrayExpression([ - new ConstantExpression('foo', 0), - new ConstantExpression('bar', 0), - new ConstantExpression('label', 0), - new ConstantExpression('value in attributes', 0), - ], 0), - ]); - } else { - $arguments = new Node([ - new NameExpression('form', 0), - $conditional, - new ArrayExpression([ - new ConstantExpression('foo', 0), - new ConstantExpression('bar', 0), - new ConstantExpression('label', 0), - new ConstantExpression('value in attributes', 0), - ], 0), - ]); - } + $conditional = new ConditionalTernary( + // if + new ConstantExpression(true, 0), + // then + new ConstantExpression(null, 0), + // else + new ConstantExpression(null, 0), + 0 + ); + + $arguments = new Nodes([ + new ContextVariable('form', 0), + $conditional, + new ArrayExpression([ + new ConstantExpression('foo', 0), + new ConstantExpression('bar', 0), + new ConstantExpression('label', 0), + new ConstantExpression('value in attributes', 0), + ], 0), + ]); $node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0); diff --git a/src/Symfony/Bridge/Twig/Tests/Node/TransNodeTest.php b/src/Symfony/Bridge/Twig/Tests/Node/TransNodeTest.php index 24fa4d255a037..5a55a0c846bb8 100644 --- a/src/Symfony/Bridge/Twig/Tests/Node/TransNodeTest.php +++ b/src/Symfony/Bridge/Twig/Tests/Node/TransNodeTest.php @@ -16,7 +16,6 @@ use Twig\Compiler; use Twig\Environment; use Twig\Loader\LoaderInterface; -use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\TextNode; @@ -28,7 +27,7 @@ class TransNodeTest extends TestCase public function testCompileStrict() { $body = new TextNode('trans %var%', 0); - $vars = class_exists(ContextVariable::class) ? new ContextVariable('foo', 0) : new NameExpression('foo', 0); + $vars = new ContextVariable('foo', 0); $node = new TransNode($body, null, null, $vars); $env = new Environment($this->createMock(LoaderInterface::class), ['strict_variables' => true]); diff --git a/src/Symfony/Bridge/Twig/Tests/NodeVisitor/TranslationNodeVisitorTest.php b/src/Symfony/Bridge/Twig/Tests/NodeVisitor/TranslationNodeVisitorTest.php index 2d52c4ea5d427..fc48beb6caba1 100644 --- a/src/Symfony/Bridge/Twig/Tests/NodeVisitor/TranslationNodeVisitorTest.php +++ b/src/Symfony/Bridge/Twig/Tests/NodeVisitor/TranslationNodeVisitorTest.php @@ -18,7 +18,6 @@ use Twig\Node\Expression\ArrayExpression; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\FilterExpression; -use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\Node; use Twig\Node\Nodes; @@ -41,17 +40,10 @@ public function testMessageExtractionWithInvalidDomainNode() { $message = 'new key'; - if (class_exists(Nodes::class)) { - $n = new Nodes([ - new ArrayExpression([], 0), - new ContextVariable('variable', 0), - ]); - } else { - $n = new Node([ - new ArrayExpression([], 0), - new NameExpression('variable', 0), - ]); - } + $n = new Nodes([ + new ArrayExpression([], 0), + new ContextVariable('variable', 0), + ]); $node = new FilterExpression( new ConstantExpression($message, 0), diff --git a/src/Symfony/Bridge/Twig/Tests/NodeVisitor/TwigNodeProvider.php b/src/Symfony/Bridge/Twig/Tests/NodeVisitor/TwigNodeProvider.php index a6910855e38b1..4a0f11b365944 100644 --- a/src/Symfony/Bridge/Twig/Tests/NodeVisitor/TwigNodeProvider.php +++ b/src/Symfony/Bridge/Twig/Tests/NodeVisitor/TwigNodeProvider.php @@ -14,11 +14,11 @@ use Symfony\Bridge\Twig\Node\TransDefaultDomainNode; use Symfony\Bridge\Twig\Node\TransNode; use Twig\Node\BodyNode; +use Twig\Node\EmptyNode; use Twig\Node\Expression\ArrayExpression; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\FilterExpression; use Twig\Node\ModuleNode; -use Twig\Node\Node; use Twig\Node\Nodes; use Twig\Source; use Twig\TwigFilter; @@ -30,10 +30,10 @@ public static function getModule($content) return new ModuleNode( new BodyNode([new ConstantExpression($content, 0)]), null, - new ArrayExpression([], 0), - new ArrayExpression([], 0), - new ArrayExpression([], 0), - null, + new EmptyNode(), + new EmptyNode(), + new EmptyNode(), + new EmptyNode(), new Source('', '') ); } @@ -47,16 +47,10 @@ public static function getTransFilter($message, $domain = null, $arguments = nul ] : []; } - if (class_exists(Nodes::class)) { - $args = new Nodes($arguments); - } else { - $args = new Node($arguments); - } - return new FilterExpression( new ConstantExpression($message, 0), new TwigFilter('trans'), - $args, + new Nodes($arguments), 0 ); } diff --git a/src/Symfony/Bridge/Twig/Tests/TokenParser/FormThemeTokenParserTest.php b/src/Symfony/Bridge/Twig/Tests/TokenParser/FormThemeTokenParserTest.php index 4e8209ef33f6a..0c4bcdf62f89b 100644 --- a/src/Symfony/Bridge/Twig/Tests/TokenParser/FormThemeTokenParserTest.php +++ b/src/Symfony/Bridge/Twig/Tests/TokenParser/FormThemeTokenParserTest.php @@ -18,7 +18,6 @@ use Twig\Loader\LoaderInterface; use Twig\Node\Expression\ArrayExpression; use Twig\Node\Expression\ConstantExpression; -use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\Variable\ContextVariable; use Twig\Parser; use Twig\Source; @@ -48,7 +47,7 @@ public static function getTestsForFormTheme() [ '{% form_theme form "tpl1" %}', new FormThemeNode( - class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new NameExpression('form', 1), + new ContextVariable('form', 1), new ArrayExpression([ new ConstantExpression(0, 1), new ConstantExpression('tpl1', 1), @@ -59,7 +58,7 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name [ '{% form_theme form "tpl1" "tpl2" %}', new FormThemeNode( - class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new NameExpression('form', 1), + new ContextVariable('form', 1), new ArrayExpression([ new ConstantExpression(0, 1), new ConstantExpression('tpl1', 1), @@ -72,7 +71,7 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name [ '{% form_theme form with "tpl1" %}', new FormThemeNode( - class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new NameExpression('form', 1), + new ContextVariable('form', 1), new ConstantExpression('tpl1', 1), 1 ), @@ -80,7 +79,7 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name [ '{% form_theme form with ["tpl1"] %}', new FormThemeNode( - class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new NameExpression('form', 1), + new ContextVariable('form', 1), new ArrayExpression([ new ConstantExpression(0, 1), new ConstantExpression('tpl1', 1), @@ -91,7 +90,7 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name [ '{% form_theme form with ["tpl1", "tpl2"] %}', new FormThemeNode( - class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new NameExpression('form', 1), + new ContextVariable('form', 1), new ArrayExpression([ new ConstantExpression(0, 1), new ConstantExpression('tpl1', 1), @@ -104,7 +103,7 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name [ '{% form_theme form with ["tpl1", "tpl2"] only %}', new FormThemeNode( - class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new NameExpression('form', 1), + new ContextVariable('form', 1), new ArrayExpression([ new ConstantExpression(0, 1), new ConstantExpression('tpl1', 1), diff --git a/src/Symfony/Bridge/Twig/Tests/Validator/Constraints/TwigTest.php b/src/Symfony/Bridge/Twig/Tests/Validator/Constraints/TwigTest.php new file mode 100644 index 0000000000000..cac1b316cbeda --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Validator/Constraints/TwigTest.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\Validator\Constraints; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\Twig\Validator\Constraints\Twig; +use Symfony\Component\Validator\Mapping\ClassMetadata; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; + +/** + * @author Mokhtar Tlili + */ +class TwigTest extends TestCase +{ + public function testAttributes() + { + $metadata = new ClassMetadata(TwigDummy::class); + $loader = new AttributeLoader(); + self::assertTrue($loader->loadClassMetadata($metadata)); + + [$bConstraint] = $metadata->properties['b']->getConstraints(); + self::assertSame('myMessage', $bConstraint->message); + self::assertSame(['Default', 'TwigDummy'], $bConstraint->groups); + + [$cConstraint] = $metadata->properties['c']->getConstraints(); + self::assertSame(['my_group'], $cConstraint->groups); + self::assertSame('some attached data', $cConstraint->payload); + + [$dConstraint] = $metadata->properties['d']->getConstraints(); + self::assertFalse($dConstraint->skipDeprecations); + } +} + +class TwigDummy +{ + #[Twig] + private $a; + + #[Twig(message: 'myMessage')] + private $b; + + #[Twig(groups: ['my_group'], payload: 'some attached data')] + private $c; + + #[Twig(skipDeprecations: false)] + private $d; +} diff --git a/src/Symfony/Bridge/Twig/Tests/Validator/Constraints/TwigValidatorTest.php b/src/Symfony/Bridge/Twig/Tests/Validator/Constraints/TwigValidatorTest.php new file mode 100644 index 0000000000000..da5597ad1f45f --- /dev/null +++ b/src/Symfony/Bridge/Twig/Tests/Validator/Constraints/TwigValidatorTest.php @@ -0,0 +1,126 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Tests\Validator\Constraints; + +use Symfony\Bridge\Twig\Validator\Constraints\Twig; +use Symfony\Bridge\Twig\Validator\Constraints\TwigValidator; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; +use Twig\DeprecatedCallableInfo; +use Twig\Environment; +use Twig\Loader\ArrayLoader; +use Twig\TwigFilter; + +/** + * @author Mokhtar Tlili + */ +class TwigValidatorTest extends ConstraintValidatorTestCase +{ + protected function createValidator(): TwigValidator + { + $environment = new Environment(new ArrayLoader()); + $environment->addFilter(new TwigFilter('humanize_filter', fn ($v) => $v)); + if (class_exists(DeprecatedCallableInfo::class)) { + $options = ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.1')]; + } else { + $options = ['deprecated' => true]; + } + + $environment->addFilter(new TwigFilter('deprecated_filter', fn ($v) => $v, $options)); + + return new TwigValidator($environment); + } + + /** + * @dataProvider getValidValues + */ + public function testTwigIsValid($value) + { + $this->validator->validate($value, new Twig()); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getInvalidValues + */ + public function testInvalidValues($value, $message, $line) + { + $constraint = new Twig('myMessageTest'); + + $this->validator->validate($value, $constraint); + + $this->buildViolation('myMessageTest') + ->setParameter('{{ error }}', $message) + ->setParameter('{{ line }}', $line) + ->setCode(Twig::INVALID_TWIG_ERROR) + ->assertRaised(); + } + + /** + * When deprecations are skipped by the validator, the testsuite reporter will catch them so we need to mark the test as legacy. + * + * @group legacy + */ + public function testTwigWithSkipDeprecation() + { + $constraint = new Twig(skipDeprecations: true); + + $this->validator->validate('{{ name|deprecated_filter }}', $constraint); + + $this->assertNoViolation(); + } + + public function testTwigWithoutSkipDeprecation() + { + $constraint = new Twig(skipDeprecations: false); + + $this->validator->validate('{{ name|deprecated_filter }}', $constraint); + + $line = 1; + $error = 'Twig Filter "deprecated_filter" is deprecated in at line 1 at line 1.'; + if (class_exists(DeprecatedCallableInfo::class)) { + $line = 0; + $error = 'Since foo/bar 1.1: Twig Filter "deprecated_filter" is deprecated.'; + } + $this->buildViolation($constraint->message) + ->setParameter('{{ error }}', $error) + ->setParameter('{{ line }}', $line) + ->setCode(Twig::INVALID_TWIG_ERROR) + ->assertRaised(); + } + + public static function getValidValues() + { + return [ + ['Hello {{ name }}'], + ['{% if condition %}Yes{% else %}No{% endif %}'], + ['{# Comment #}'], + ['Hello {{ "world"|upper }}'], + ['{% for i in 1..3 %}Item {{ i }}{% endfor %}'], + ['{{ name|humanize_filter }}'], + ]; + } + + public static function getInvalidValues() + { + return [ + // Invalid syntax example (missing end tag) + ['{% if condition %}Oops', 'Unexpected end of template at line 1.', 1], + // Another syntax error example (unclosed variable) + ['Hello {{ name', 'Unexpected token "end of template" ("end of print statement" expected) at line 1.', 1], + // Unknown filter error + ['Hello {{ name|unknown_filter }}', 'Unknown "unknown_filter" filter at line 1.', 1], + // Invalid variable syntax + ['Hello {{ .name }}', 'Unexpected token "operator" of value "." at line 1.', 1], + ]; + } +} diff --git a/src/Symfony/Bridge/Twig/TokenParser/DumpTokenParser.php b/src/Symfony/Bridge/Twig/TokenParser/DumpTokenParser.php index e671f9ba0b7dd..457edece240ba 100644 --- a/src/Symfony/Bridge/Twig/TokenParser/DumpTokenParser.php +++ b/src/Symfony/Bridge/Twig/TokenParser/DumpTokenParser.php @@ -14,6 +14,7 @@ use Symfony\Bridge\Twig\Node\DumpNode; use Twig\Node\Expression\Variable\LocalVariable; use Twig\Node\Node; +use Twig\Node\Nodes; use Twig\Token; use Twig\TokenParser\AbstractTokenParser; @@ -34,11 +35,24 @@ public function parse(Token $token): Node { $values = null; if (!$this->parser->getStream()->test(Token::BLOCK_END_TYPE)) { - $values = $this->parser->getExpressionParser()->parseMultitargetExpression(); + $values = $this->parseMultitargetExpression(); } $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); - return new DumpNode(class_exists(LocalVariable::class) ? new LocalVariable(null, $token->getLine()) : $this->parser->getVarName(), $values, $token->getLine(), $this->getTag()); + return new DumpNode(new LocalVariable(null, $token->getLine()), $values, $token->getLine()); + } + + private function parseMultitargetExpression(): Node + { + $targets = []; + while (true) { + $targets[] = $this->parser->parseExpression(); + if (!$this->parser->getStream()->nextIf(Token::PUNCTUATION_TYPE, ',')) { + break; + } + } + + return new Nodes($targets); } public function getTag(): string diff --git a/src/Symfony/Bridge/Twig/TokenParser/FormThemeTokenParser.php b/src/Symfony/Bridge/Twig/TokenParser/FormThemeTokenParser.php index 413a8f51ed02f..347634bddb6de 100644 --- a/src/Symfony/Bridge/Twig/TokenParser/FormThemeTokenParser.php +++ b/src/Symfony/Bridge/Twig/TokenParser/FormThemeTokenParser.php @@ -29,12 +29,12 @@ public function parse(Token $token): Node $lineno = $token->getLine(); $stream = $this->parser->getStream(); - $form = $this->parser->getExpressionParser()->parseExpression(); + $form = $this->parser->parseExpression(); $only = false; if ($this->parser->getStream()->test(Token::NAME_TYPE, 'with')) { $this->parser->getStream()->next(); - $resources = $this->parser->getExpressionParser()->parseExpression(); + $resources = $this->parser->parseExpression(); if ($this->parser->getStream()->nextIf(Token::NAME_TYPE, 'only')) { $only = true; @@ -42,7 +42,7 @@ public function parse(Token $token): Node } else { $resources = new ArrayExpression([], $stream->getCurrent()->getLine()); do { - $resources->addElement($this->parser->getExpressionParser()->parseExpression()); + $resources->addElement($this->parser->parseExpression()); } while (!$stream->test(Token::BLOCK_END_TYPE)); } diff --git a/src/Symfony/Bridge/Twig/TokenParser/StopwatchTokenParser.php b/src/Symfony/Bridge/Twig/TokenParser/StopwatchTokenParser.php index ac6baa6d88fa8..ea0382bc6c874 100644 --- a/src/Symfony/Bridge/Twig/TokenParser/StopwatchTokenParser.php +++ b/src/Symfony/Bridge/Twig/TokenParser/StopwatchTokenParser.php @@ -12,7 +12,6 @@ namespace Symfony\Bridge\Twig\TokenParser; use Symfony\Bridge\Twig\Node\StopwatchNode; -use Twig\Node\Expression\AssignNameExpression; use Twig\Node\Expression\Variable\LocalVariable; use Twig\Node\Node; use Twig\Token; @@ -36,7 +35,7 @@ public function parse(Token $token): Node $stream = $this->parser->getStream(); // {% stopwatch 'bar' %} - $name = $this->parser->getExpressionParser()->parseExpression(); + $name = $this->parser->parseExpression(); $stream->expect(Token::BLOCK_END_TYPE); @@ -45,7 +44,7 @@ public function parse(Token $token): Node $stream->expect(Token::BLOCK_END_TYPE); if ($this->stopwatchIsAvailable) { - return new StopwatchNode($name, $body, class_exists(LocalVariable::class) ? new LocalVariable(null, $token->getLine()) : new AssignNameExpression($this->parser->getVarName(), $token->getLine()), $lineno, $this->getTag()); + return new StopwatchNode($name, $body, new LocalVariable(null, $token->getLine()), $lineno); } return $body; diff --git a/src/Symfony/Bridge/Twig/TokenParser/TransDefaultDomainTokenParser.php b/src/Symfony/Bridge/Twig/TokenParser/TransDefaultDomainTokenParser.php index c6d850d07cbf7..b9eb5f5112577 100644 --- a/src/Symfony/Bridge/Twig/TokenParser/TransDefaultDomainTokenParser.php +++ b/src/Symfony/Bridge/Twig/TokenParser/TransDefaultDomainTokenParser.php @@ -25,11 +25,11 @@ final class TransDefaultDomainTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { - $expr = $this->parser->getExpressionParser()->parseExpression(); + $expr = $this->parser->parseExpression(); $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); - return new TransDefaultDomainNode($expr, $token->getLine(), $this->getTag()); + return new TransDefaultDomainNode($expr, $token->getLine()); } public function getTag(): string diff --git a/src/Symfony/Bridge/Twig/TokenParser/TransTokenParser.php b/src/Symfony/Bridge/Twig/TokenParser/TransTokenParser.php index 67b92dd8d55fd..d4353742707d7 100644 --- a/src/Symfony/Bridge/Twig/TokenParser/TransTokenParser.php +++ b/src/Symfony/Bridge/Twig/TokenParser/TransTokenParser.php @@ -36,29 +36,30 @@ public function parse(Token $token): Node $vars = new ArrayExpression([], $lineno); $domain = null; $locale = null; + if (!$stream->test(Token::BLOCK_END_TYPE)) { if ($stream->test('count')) { // {% trans count 5 %} $stream->next(); - $count = $this->parser->getExpressionParser()->parseExpression(); + $count = $this->parser->parseExpression(); } if ($stream->test('with')) { // {% trans with vars %} $stream->next(); - $vars = $this->parser->getExpressionParser()->parseExpression(); + $vars = $this->parser->parseExpression(); } if ($stream->test('from')) { // {% trans from "messages" %} $stream->next(); - $domain = $this->parser->getExpressionParser()->parseExpression(); + $domain = $this->parser->parseExpression(); } if ($stream->test('into')) { // {% trans into "fr" %} $stream->next(); - $locale = $this->parser->getExpressionParser()->parseExpression(); + $locale = $this->parser->parseExpression(); } elseif (!$stream->test(Token::BLOCK_END_TYPE)) { throw new SyntaxError('Unexpected token. Twig was looking for the "with", "from", or "into" keyword.', $stream->getCurrent()->getLine(), $stream->getSourceContext()); } diff --git a/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php b/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php index 5da9a1484ac94..16421eaf504d4 100644 --- a/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php +++ b/src/Symfony/Bridge/Twig/UndefinedCallableHandler.php @@ -61,6 +61,7 @@ class UndefinedCallableHandler 'logout_url' => 'security-http', 'logout_path' => 'security-http', 'is_granted' => 'security-core', + 'is_granted_for_user' => 'security-core', 'impersonation_path' => 'security-http', 'impersonation_url' => 'security-http', 'impersonation_exit_path' => 'security-http', diff --git a/src/Symfony/Bridge/Twig/Validator/Constraints/Twig.php b/src/Symfony/Bridge/Twig/Validator/Constraints/Twig.php new file mode 100644 index 0000000000000..7cf050e87a32e --- /dev/null +++ b/src/Symfony/Bridge/Twig/Validator/Constraints/Twig.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Validator\Constraints; + +use Symfony\Component\Validator\Attribute\HasNamedArguments; +use Symfony\Component\Validator\Constraint; + +/** + * @author Mokhtar Tlili + */ +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +class Twig extends Constraint +{ + public const INVALID_TWIG_ERROR = 'e7fc55d5-e586-4cc1-924e-d27ee7fcd1b5'; + + protected const ERROR_NAMES = [ + self::INVALID_TWIG_ERROR => 'INVALID_TWIG_ERROR', + ]; + + #[HasNamedArguments] + public function __construct( + public string $message = 'This value is not a valid Twig template.', + public bool $skipDeprecations = true, + ?array $groups = null, + mixed $payload = null, + ) { + parent::__construct(null, $groups, $payload); + } +} diff --git a/src/Symfony/Bridge/Twig/Validator/Constraints/TwigValidator.php b/src/Symfony/Bridge/Twig/Validator/Constraints/TwigValidator.php new file mode 100644 index 0000000000000..3064341f3b10d --- /dev/null +++ b/src/Symfony/Bridge/Twig/Validator/Constraints/TwigValidator.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Validator\Constraints; + +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; +use Symfony\Component\Validator\Exception\UnexpectedValueException; +use Twig\Environment; +use Twig\Error\Error; +use Twig\Loader\ArrayLoader; +use Twig\Source; + +/** + * @author Mokhtar Tlili + */ +class TwigValidator extends ConstraintValidator +{ + public function __construct(private Environment $twig) + { + } + + public function validate(mixed $value, Constraint $constraint): void + { + if (!$constraint instanceof Twig) { + throw new UnexpectedTypeException($constraint, Twig::class); + } + + if (null === $value || '' === $value) { + return; + } + + if (!\is_scalar($value) && !$value instanceof \Stringable) { + throw new UnexpectedValueException($value, 'string'); + } + + $value = (string) $value; + + $realLoader = $this->twig->getLoader(); + try { + $temporaryLoader = new ArrayLoader([$value]); + $this->twig->setLoader($temporaryLoader); + + if (!$constraint->skipDeprecations) { + $prevErrorHandler = set_error_handler(static function ($level, $message, $file, $line) use (&$prevErrorHandler) { + if (\E_USER_DEPRECATED !== $level) { + return $prevErrorHandler ? $prevErrorHandler($level, $message, $file, $line) : false; + } + + $templateLine = 0; + if (preg_match('/ at line (\d+)[ .]/', $message, $matches)) { + $templateLine = $matches[1]; + } + + throw new Error($message, $templateLine); + }); + } + + try { + $this->twig->parse($this->twig->tokenize(new Source($value, ''))); + } finally { + if (!$constraint->skipDeprecations) { + restore_error_handler(); + } + } + } catch (Error $e) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ error }}', $e->getMessage()) + ->setParameter('{{ line }}', $e->getTemplateLine()) + ->setCode(Twig::INVALID_TWIG_ERROR) + ->addViolation(); + } finally { + $this->twig->setLoader($realLoader); + } + } +} diff --git a/src/Symfony/Bridge/Twig/composer.json b/src/Symfony/Bridge/Twig/composer.json index 3af8ccbb7ecce..dd2e55d752dc1 100644 --- a/src/Symfony/Bridge/Twig/composer.json +++ b/src/Symfony/Bridge/Twig/composer.json @@ -19,7 +19,7 @@ "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/translation-contracts": "^2.5|^3", - "twig/twig": "^3.12" + "twig/twig": "^3.21" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3|^4", @@ -30,9 +30,9 @@ "symfony/dependency-injection": "^6.4|^7.0", "symfony/emoji": "^7.1", "symfony/finder": "^6.4|^7.0", - "symfony/form": "^6.4|^7.0", + "symfony/form": "^6.4.20|^7.2.5", "symfony/html-sanitizer": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-foundation": "^7.3", "symfony/http-kernel": "^6.4|^7.0", "symfony/intl": "^6.4|^7.0", "symfony/mime": "^6.4|^7.0", @@ -40,6 +40,7 @@ "symfony/property-info": "^6.4|^7.0", "symfony/routing": "^6.4|^7.0", "symfony/translation": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0", "symfony/yaml": "^6.4|^7.0", "symfony/security-acl": "^2.8|^3.0", "symfony/security-core": "^6.4|^7.0", @@ -51,9 +52,9 @@ "symfony/expression-language": "^6.4|^7.0", "symfony/web-link": "^6.4|^7.0", "symfony/workflow": "^6.4|^7.0", - "twig/cssinliner-extra": "^2.12|^3", - "twig/inky-extra": "^2.12|^3", - "twig/markdown-extra": "^2.12|^3" + "twig/cssinliner-extra": "^3", + "twig/inky-extra": "^3", + "twig/markdown-extra": "^3" }, "conflict": { "phpdocumentor/reflection-docblock": "<3.2.2", diff --git a/src/Symfony/Bundle/DebugBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DebugBundle/DependencyInjection/Configuration.php index 4dbdc4c7abb81..a72034d98293a 100644 --- a/src/Symfony/Bundle/DebugBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DebugBundle/DependencyInjection/Configuration.php @@ -26,7 +26,9 @@ public function getConfigTreeBuilder(): TreeBuilder $treeBuilder = new TreeBuilder('debug'); $rootNode = $treeBuilder->getRootNode(); - $rootNode->children() + $rootNode + ->docUrl('https://symfony.com/doc/{version:major}.{version:minor}/reference/configuration/debug.html', 'symfony/debug-bundle') + ->children() ->integerNode('max_items') ->info('Max number of displayed items past the first level, -1 means no limit.') ->min(-1) diff --git a/src/Symfony/Bundle/DebugBundle/composer.json b/src/Symfony/Bundle/DebugBundle/composer.json index d00a4db6424c0..31b480091abdc 100644 --- a/src/Symfony/Bundle/DebugBundle/composer.json +++ b/src/Symfony/Bundle/DebugBundle/composer.json @@ -18,19 +18,16 @@ "require": { "php": ">=8.2", "ext-xml": "*", + "composer-runtime-api": ">=2.1", + "symfony/config": "^7.3", "symfony/dependency-injection": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", "symfony/twig-bridge": "^6.4|^7.0", "symfony/var-dumper": "^6.4|^7.0" }, "require-dev": { - "symfony/config": "^6.4|^7.0", "symfony/web-profiler-bundle": "^6.4|^7.0" }, - "conflict": { - "symfony/config": "<6.4", - "symfony/dependency-injection": "<6.4" - }, "autoload": { "psr-4": { "Symfony\\Bundle\\DebugBundle\\": "" }, "exclude-from-classmap": [ diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index 3227eddc20e21..ce62c9cdf836b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -1,6 +1,62 @@ CHANGELOG ========= +7.3 +--- + + * Add `errors.php` and `webhook.php` routing configuration files (use them instead of their XML equivalent) + + Before: + + ```yaml + when@dev: + _errors: + resource: '@FrameworkBundle/Resources/config/routing/errors.xml' + prefix: /_error + + webhook: + resource: '@FrameworkBundle/Resources/config/routing/webhook.xml' + prefix: /webhook + ``` + + After: + + ```yaml + when@dev: + _errors: + resource: '@FrameworkBundle/Resources/config/routing/errors.php' + prefix: /_error + + webhook: + resource: '@FrameworkBundle/Resources/config/routing/webhook.php' + prefix: /webhook + ``` + + * Add support for the ObjectMapper component + * Add support for assets pre-compression + * Rename `TranslationUpdateCommand` to `TranslationExtractCommand` + * Add JsonStreamer services and configuration + * Add new `framework.property_info.with_constructor_extractor` option to allow enabling or disabling the constructor extractor integration + * Deprecate the `--show-arguments` option of the `container:debug` command, as arguments are now always shown + * Add autowiring alias for `RateLimiterFactoryInterface` + * Add `framework.validation.disable_translation` option + * Add support for signal plain name in the `messenger.stop_worker_on_signals` configuration + * Deprecate the `framework.validation.cache` option + * Add `--method` option to the `debug:router` command + * Auto-exclude DI extensions, test cases, entities and messenger messages + * Add DI alias from `ServicesResetterInterface` to `services_resetter` + * Add `methods` argument in `#[IsCsrfTokenValid]` attribute + * Allow configuring the logging channel per type of exceptions + * Enable service argument resolution on classes that use the `#[Route]` attribute, + the `#[AsController]` attribute is no longer required + * Deprecate setting the `framework.profiler.collect_serializer_data` config option to `false` + * Set `framework.rate_limiter.limiters.*.lock_factory` to `auto` by default + * Deprecate `RateLimiterFactory` autowiring aliases, use `RateLimiterFactoryInterface` instead + * Allow configuring compound rate limiters + * Make `ValidatorCacheWarmer` use `kernel.build_dir` instead of `cache_dir` + * Make `SerializeCacheWarmer` use `kernel.build_dir` instead of `cache_dir` + * Support executing custom workflow validators during container compilation + 7.2 --- diff --git a/src/Symfony/Bundle/FrameworkBundle/CacheWarmer/SerializerCacheWarmer.php b/src/Symfony/Bundle/FrameworkBundle/CacheWarmer/SerializerCacheWarmer.php index 46da4daaab4d1..fbf7083b70b28 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CacheWarmer/SerializerCacheWarmer.php +++ b/src/Symfony/Bundle/FrameworkBundle/CacheWarmer/SerializerCacheWarmer.php @@ -41,6 +41,9 @@ public function __construct( protected function doWarmUp(string $cacheDir, ArrayAdapter $arrayAdapter, ?string $buildDir = null): bool { + if (!$buildDir) { + return false; + } if (!$this->loaders) { return true; } diff --git a/src/Symfony/Bundle/FrameworkBundle/CacheWarmer/ValidatorCacheWarmer.php b/src/Symfony/Bundle/FrameworkBundle/CacheWarmer/ValidatorCacheWarmer.php index 6ecaa4bd14d01..9c313f80a8662 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CacheWarmer/ValidatorCacheWarmer.php +++ b/src/Symfony/Bundle/FrameworkBundle/CacheWarmer/ValidatorCacheWarmer.php @@ -41,6 +41,10 @@ public function __construct( protected function doWarmUp(string $cacheDir, ArrayAdapter $arrayAdapter, ?string $buildDir = null): bool { + if (!$buildDir) { + return false; + } + $loaders = $this->validatorBuilder->getLoaders(); $metadataFactory = new LazyLoadingMetadataFactory(new LoaderChain($loaders), $arrayAdapter); diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/AboutCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/AboutCommand.php index 4dc86130a8cc5..0c6899328a2fc 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/AboutCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/AboutCommand.php @@ -84,9 +84,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int ['Architecture', (\PHP_INT_SIZE * 8).' bits'], ['Intl locale', class_exists(\Locale::class, false) && \Locale::getDefault() ? \Locale::getDefault() : 'n/a'], ['Timezone', date_default_timezone_get().' ('.(new \DateTimeImmutable())->format(\DateTimeInterface::W3C).')'], - ['OPcache', \extension_loaded('Zend OPcache') ? (filter_var(\ini_get('opcache.enable'), FILTER_VALIDATE_BOOLEAN) ? 'Enabled' : 'Not enabled') : 'Not installed'], - ['APCu', \extension_loaded('apcu') ? (filter_var(\ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? 'Enabled' : 'Not enabled') : 'Not installed'], - ['Xdebug', \extension_loaded('xdebug') ? ($xdebugMode && 'off' !== $xdebugMode ? 'Enabled (' . $xdebugMode . ')' : 'Not enabled') : 'Not installed'], + ['OPcache', \extension_loaded('Zend OPcache') ? (filter_var(\ini_get('opcache.enable'), \FILTER_VALIDATE_BOOLEAN) ? 'Enabled' : 'Not enabled') : 'Not installed'], + ['APCu', \extension_loaded('apcu') ? (filter_var(\ini_get('apc.enabled'), \FILTER_VALIDATE_BOOLEAN) ? 'Enabled' : 'Not enabled') : 'Not installed'], + ['Xdebug', \extension_loaded('xdebug') ? ($xdebugMode && 'off' !== $xdebugMode ? 'Enabled ('.$xdebugMode.')' : 'Not enabled') : 'Not installed'], ]; $io->table([], $rows); diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/ConfigDebugCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/ConfigDebugCommand.php index 55c101e9c29e3..8d5f85ceea4ca 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/ConfigDebugCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/ConfigDebugCommand.php @@ -104,6 +104,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->title( \sprintf('Current configuration for %s', $name === $extensionAlias ? \sprintf('extension with alias "%s"', $extensionAlias) : \sprintf('"%s"', $name)) ); + + if ($docUrl = $this->getDocUrl($extension, $container)) { + $io->comment(\sprintf('Documentation at %s', $docUrl)); + } } $io->writeln($this->convertToFormat([$extensionAlias => $config], $format)); @@ -269,4 +273,15 @@ private function getAvailableFormatOptions(): array { return ['txt', 'yaml', 'json']; } + + private function getDocUrl(ExtensionInterface $extension, ContainerBuilder $container): ?string + { + $configuration = $extension instanceof ConfigurationInterface ? $extension : $extension->getConfiguration($container->getExtensionConfig($extension->getAlias()), $container); + + return $configuration + ->getConfigTreeBuilder() + ->getRootNode() + ->getNode(true) + ->getAttribute('docUrl'); + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/ConfigDumpReferenceCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/ConfigDumpReferenceCommand.php index 7e5cd765fd2d3..3cb744d746cae 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/ConfigDumpReferenceCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/ConfigDumpReferenceCommand.php @@ -23,6 +23,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\DependencyInjection\Extension\ConfigurationExtensionInterface; use Symfony\Component\Yaml\Yaml; /** @@ -123,6 +124,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $message .= \sprintf(' at path "%s"', $path); } + if ($docUrl = $this->getExtensionDocUrl($extension)) { + $message .= \sprintf(' (see %s)', $docUrl); + } + switch ($format) { case 'yaml': $io->writeln(\sprintf('# %s', $message)); @@ -182,4 +187,18 @@ private function getAvailableFormatOptions(): array { return ['yaml', 'xml']; } + + private function getExtensionDocUrl(ConfigurationInterface|ConfigurationExtensionInterface $extension): ?string + { + $kernel = $this->getApplication()->getKernel(); + $container = $this->getContainerBuilder($kernel); + + $configuration = $extension instanceof ConfigurationInterface ? $extension : $extension->getConfiguration($container->getExtensionConfig($extension->getAlias()), $container); + + return $configuration + ->getConfigTreeBuilder() + ->getRootNode() + ->getNode(true) + ->getAttribute('docUrl'); + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/ContainerDebugCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/ContainerDebugCommand.php index 2c5b8291bf189..17c71bdca688a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/ContainerDebugCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/ContainerDebugCommand.php @@ -151,6 +151,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $tag = $this->findProperTagName($input, $errorIo, $object, $tag); $options = ['tag' => $tag]; } elseif ($name = $input->getArgument('name')) { + if ($input->getOption('show-arguments')) { + $errorIo->warning('The "--show-arguments" option is deprecated.'); + } + $name = $this->findProperServiceName($input, $errorIo, $object, $name, $input->getOption('show-hidden')); $options = ['id' => $name]; } elseif ($input->getOption('deprecations')) { @@ -161,7 +165,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $helper = new DescriptorHelper(); $options['format'] = $input->getOption('format'); - $options['show_arguments'] = $input->getOption('show-arguments'); $options['show_hidden'] = $input->getOption('show-hidden'); $options['raw_text'] = $input->getOption('raw'); $options['output'] = $io; diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/RouterDebugCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/RouterDebugCommand.php index 13a6f75d01230..e543771150fc5 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/RouterDebugCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/RouterDebugCommand.php @@ -55,6 +55,7 @@ protected function configure(): void new InputOption('show-aliases', null, InputOption::VALUE_NONE, 'Show aliases in overview'), new InputOption('format', null, InputOption::VALUE_REQUIRED, \sprintf('The output format ("%s")', implode('", "', $this->getAvailableFormatOptions())), 'txt'), new InputOption('raw', null, InputOption::VALUE_NONE, 'To output raw route(s)'), + new InputOption('method', null, InputOption::VALUE_REQUIRED, 'Filter by HTTP method', '', ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']), ]) ->setHelp(<<<'EOF' The %command.name% displays the configured routes: @@ -76,6 +77,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $name = $input->getArgument('name'); + $method = strtoupper($input->getOption('method')); $helper = new DescriptorHelper($this->fileLinkFormatter); $routes = $this->router->getRouteCollection(); $container = null; @@ -85,7 +87,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($name) { $route = $routes->get($name); - $matchingRoutes = $this->findRouteNameContaining($name, $routes); + $matchingRoutes = $this->findRouteNameContaining($name, $routes, $method); if (!$input->isInteractive() && !$route && \count($matchingRoutes) > 1) { $helper->describe($io, $this->findRouteContaining($name, $routes), [ @@ -94,6 +96,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'show_controllers' => $input->getOption('show-controllers'), 'show_aliases' => $input->getOption('show-aliases'), 'output' => $io, + 'method' => $method, ]); return 0; @@ -124,17 +127,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'show_aliases' => $input->getOption('show-aliases'), 'output' => $io, 'container' => $container, + 'method' => $method, ]); } return 0; } - private function findRouteNameContaining(string $name, RouteCollection $routes): array + private function findRouteNameContaining(string $name, RouteCollection $routes, string $method): array { $foundRoutesNames = []; foreach ($routes as $routeName => $route) { - if (false !== stripos($routeName, $name)) { + if (false !== stripos($routeName, $name) && (!$method || !$route->getMethods() || \in_array($method, $route->getMethods(), true))) { $foundRoutesNames[] = $routeName; } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationExtractCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationExtractCommand.php new file mode 100644 index 0000000000000..d7967bbe8cc85 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationExtractCommand.php @@ -0,0 +1,503 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\Translation\Catalogue\MergeOperation; +use Symfony\Component\Translation\Catalogue\TargetOperation; +use Symfony\Component\Translation\Extractor\ExtractorInterface; +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\Component\Translation\MessageCatalogueInterface; +use Symfony\Component\Translation\Reader\TranslationReaderInterface; +use Symfony\Component\Translation\Writer\TranslationWriterInterface; + +/** + * A command that parses templates to extract translation messages and adds them + * into the translation files. + * + * @author Michel Salib + */ +#[AsCommand(name: 'translation:extract', description: 'Extract missing translations keys from code to translation files')] +class TranslationExtractCommand extends Command +{ + private const ASC = 'asc'; + private const DESC = 'desc'; + private const SORT_ORDERS = [self::ASC, self::DESC]; + private const FORMATS = [ + 'xlf12' => ['xlf', '1.2'], + 'xlf20' => ['xlf', '2.0'], + ]; + private const NO_FILL_PREFIX = "\0NoFill\0"; + + public function __construct( + private TranslationWriterInterface $writer, + private TranslationReaderInterface $reader, + private ExtractorInterface $extractor, + private string $defaultLocale, + private ?string $defaultTransPath = null, + private ?string $defaultViewsPath = null, + private array $transPaths = [], + private array $codePaths = [], + private array $enabledLocales = [], + ) { + parent::__construct(); + + if (!method_exists($writer, 'getFormats')) { + throw new \InvalidArgumentException(\sprintf('The writer class "%s" does not implement the "getFormats()" method.', $writer::class)); + } + } + + protected function configure(): void + { + $this + ->setDefinition([ + new InputArgument('locale', InputArgument::REQUIRED, 'The locale'), + new InputArgument('bundle', InputArgument::OPTIONAL, 'The bundle name or directory where to load the messages'), + new InputOption('prefix', null, InputOption::VALUE_OPTIONAL, 'Override the default prefix', '__'), + new InputOption('no-fill', null, InputOption::VALUE_NONE, 'Extract translation keys without filling in values'), + new InputOption('format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format', 'xlf12'), + new InputOption('dump-messages', null, InputOption::VALUE_NONE, 'Should the messages be dumped in the console'), + new InputOption('force', null, InputOption::VALUE_NONE, 'Should the extract be done'), + new InputOption('clean', null, InputOption::VALUE_NONE, 'Should clean not found messages'), + new InputOption('domain', null, InputOption::VALUE_OPTIONAL, 'Specify the domain to extract'), + new InputOption('sort', null, InputOption::VALUE_OPTIONAL, 'Return list of messages sorted alphabetically'), + new InputOption('as-tree', null, InputOption::VALUE_OPTIONAL, 'Dump the messages as a tree-like structure: The given value defines the level where to switch to inline YAML'), + ]) + ->setHelp(<<<'EOF' +The %command.name% command extracts translation strings from templates +of a given bundle or the default translations directory. It can display them or merge +the new ones into the translation files. + +When new translation strings are found it can automatically add a prefix to the translation +message. However, if the --no-fill option is used, the --prefix +option has no effect, since the translation values are left empty. + +Example running against a Bundle (AcmeBundle) + + php %command.full_name% --dump-messages en AcmeBundle + php %command.full_name% --force --prefix="new_" fr AcmeBundle + +Example running against default messages directory + + php %command.full_name% --dump-messages en + php %command.full_name% --force --prefix="new_" fr + +You can sort the output with the --sort flag: + + php %command.full_name% --dump-messages --sort=asc en AcmeBundle + php %command.full_name% --force --sort=desc fr + +You can dump a tree-like structure using the yaml format with --as-tree flag: + + php %command.full_name% --force --format=yaml --as-tree=3 en AcmeBundle + +EOF + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $errorIo = $io->getErrorStyle(); + + // check presence of force or dump-message + if (true !== $input->getOption('force') && true !== $input->getOption('dump-messages')) { + $errorIo->error('You must choose one of --force or --dump-messages'); + + return 1; + } + + $format = $input->getOption('format'); + $xliffVersion = '1.2'; + + if (\array_key_exists($format, self::FORMATS)) { + [$format, $xliffVersion] = self::FORMATS[$format]; + } + + // check format + $supportedFormats = $this->writer->getFormats(); + if (!\in_array($format, $supportedFormats, true)) { + $errorIo->error(['Wrong output format', 'Supported formats are: '.implode(', ', $supportedFormats).', xlf12 and xlf20.']); + + return 1; + } + + /** @var KernelInterface $kernel */ + $kernel = $this->getApplication()->getKernel(); + + // Define Root Paths + $transPaths = $this->getRootTransPaths(); + $codePaths = $this->getRootCodePaths($kernel); + + $currentName = 'default directory'; + + // Override with provided Bundle info + if (null !== $input->getArgument('bundle')) { + try { + $foundBundle = $kernel->getBundle($input->getArgument('bundle')); + $bundleDir = $foundBundle->getPath(); + $transPaths = [is_dir($bundleDir.'/Resources/translations') ? $bundleDir.'/Resources/translations' : $bundleDir.'/translations']; + $codePaths = [is_dir($bundleDir.'/Resources/views') ? $bundleDir.'/Resources/views' : $bundleDir.'/templates']; + if ($this->defaultTransPath) { + $transPaths[] = $this->defaultTransPath; + } + if ($this->defaultViewsPath) { + $codePaths[] = $this->defaultViewsPath; + } + $currentName = $foundBundle->getName(); + } catch (\InvalidArgumentException) { + // such a bundle does not exist, so treat the argument as path + $path = $input->getArgument('bundle'); + + $transPaths = [$path.'/translations']; + $codePaths = [$path.'/templates']; + + if (!is_dir($transPaths[0])) { + throw new InvalidArgumentException(\sprintf('"%s" is neither an enabled bundle nor a directory.', $transPaths[0])); + } + } + } + + $io->title('Translation Messages Extractor and Dumper'); + $io->comment(\sprintf('Generating "%s" translation files for "%s"', $input->getArgument('locale'), $currentName)); + + $io->comment('Parsing templates...'); + $prefix = $input->getOption('no-fill') ? self::NO_FILL_PREFIX : $input->getOption('prefix'); + $extractedCatalogue = $this->extractMessages($input->getArgument('locale'), $codePaths, $prefix); + + $io->comment('Loading translation files...'); + $currentCatalogue = $this->loadCurrentMessages($input->getArgument('locale'), $transPaths); + + if (null !== $domain = $input->getOption('domain')) { + $currentCatalogue = $this->filterCatalogue($currentCatalogue, $domain); + $extractedCatalogue = $this->filterCatalogue($extractedCatalogue, $domain); + } + + // process catalogues + $operation = $input->getOption('clean') + ? new TargetOperation($currentCatalogue, $extractedCatalogue) + : new MergeOperation($currentCatalogue, $extractedCatalogue); + + // Exit if no messages found. + if (!\count($operation->getDomains())) { + $errorIo->warning('No translation messages were found.'); + + return 0; + } + + $resultMessage = 'Translation files were successfully updated'; + + $operation->moveMessagesToIntlDomainsIfPossible('new'); + + if ($sort = $input->getOption('sort')) { + $sort = strtolower($sort); + if (!\in_array($sort, self::SORT_ORDERS, true)) { + $errorIo->error(['Wrong sort order', 'Supported formats are: '.implode(', ', self::SORT_ORDERS).'.']); + + return 1; + } + } + + // show compiled list of messages + if (true === $input->getOption('dump-messages')) { + $extractedMessagesCount = 0; + $io->newLine(); + foreach ($operation->getDomains() as $domain) { + $newKeys = array_keys($operation->getNewMessages($domain)); + $allKeys = array_keys($operation->getMessages($domain)); + + $list = array_merge( + array_diff($allKeys, $newKeys), + array_map(fn ($id) => \sprintf('%s', $id), $newKeys), + array_map(fn ($id) => \sprintf('%s', $id), array_keys($operation->getObsoleteMessages($domain))) + ); + + $domainMessagesCount = \count($list); + + if (self::DESC === $sort) { + rsort($list); + } else { + sort($list); + } + + $io->section(\sprintf('Messages extracted for domain "%s" (%d message%s)', $domain, $domainMessagesCount, $domainMessagesCount > 1 ? 's' : '')); + $io->listing($list); + + $extractedMessagesCount += $domainMessagesCount; + } + + if ('xlf' === $format) { + $io->comment(\sprintf('Xliff output version is %s', $xliffVersion)); + } + + $resultMessage = \sprintf('%d message%s successfully extracted', $extractedMessagesCount, $extractedMessagesCount > 1 ? 's were' : ' was'); + } + + // save the files + if (true === $input->getOption('force')) { + $io->comment('Writing files...'); + + $bundleTransPath = false; + foreach ($transPaths as $path) { + if (is_dir($path)) { + $bundleTransPath = $path; + } + } + + if (!$bundleTransPath) { + $bundleTransPath = end($transPaths); + } + + $operationResult = $operation->getResult(); + if ($sort) { + $operationResult = $this->sortCatalogue($operationResult, $sort); + } + + if (true === $input->getOption('no-fill')) { + $this->removeNoFillTranslations($operationResult); + } + + $this->writer->write($operationResult, $format, ['path' => $bundleTransPath, 'default_locale' => $this->defaultLocale, 'xliff_version' => $xliffVersion, 'as_tree' => $input->getOption('as-tree'), 'inline' => $input->getOption('as-tree') ?? 0]); + + if (true === $input->getOption('dump-messages')) { + $resultMessage .= ' and translation files were updated'; + } + } + + $io->success($resultMessage.'.'); + + return 0; + } + + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestArgumentValuesFor('locale')) { + $suggestions->suggestValues($this->enabledLocales); + + return; + } + + /** @var KernelInterface $kernel */ + $kernel = $this->getApplication()->getKernel(); + if ($input->mustSuggestArgumentValuesFor('bundle')) { + $bundles = []; + + foreach ($kernel->getBundles() as $bundle) { + $bundles[] = $bundle->getName(); + if ($bundle->getContainerExtension()) { + $bundles[] = $bundle->getContainerExtension()->getAlias(); + } + } + + $suggestions->suggestValues($bundles); + + return; + } + + if ($input->mustSuggestOptionValuesFor('format')) { + $suggestions->suggestValues(array_merge( + $this->writer->getFormats(), + array_keys(self::FORMATS) + )); + + return; + } + + if ($input->mustSuggestOptionValuesFor('domain') && $locale = $input->getArgument('locale')) { + $extractedCatalogue = $this->extractMessages($locale, $this->getRootCodePaths($kernel), $input->getOption('prefix')); + + $currentCatalogue = $this->loadCurrentMessages($locale, $this->getRootTransPaths()); + + // process catalogues + $operation = $input->getOption('clean') + ? new TargetOperation($currentCatalogue, $extractedCatalogue) + : new MergeOperation($currentCatalogue, $extractedCatalogue); + + $suggestions->suggestValues($operation->getDomains()); + + return; + } + + if ($input->mustSuggestOptionValuesFor('sort')) { + $suggestions->suggestValues(self::SORT_ORDERS); + } + } + + private function filterCatalogue(MessageCatalogue $catalogue, string $domain): MessageCatalogue + { + $filteredCatalogue = new MessageCatalogue($catalogue->getLocale()); + + // extract intl-icu messages only + $intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX; + if ($intlMessages = $catalogue->all($intlDomain)) { + $filteredCatalogue->add($intlMessages, $intlDomain); + } + + // extract all messages and subtract intl-icu messages + if ($messages = array_diff($catalogue->all($domain), $intlMessages)) { + $filteredCatalogue->add($messages, $domain); + } + foreach ($catalogue->getResources() as $resource) { + $filteredCatalogue->addResource($resource); + } + + if ($metadata = $catalogue->getMetadata('', $intlDomain)) { + foreach ($metadata as $k => $v) { + $filteredCatalogue->setMetadata($k, $v, $intlDomain); + } + } + + if ($metadata = $catalogue->getMetadata('', $domain)) { + foreach ($metadata as $k => $v) { + $filteredCatalogue->setMetadata($k, $v, $domain); + } + } + + return $filteredCatalogue; + } + + private function sortCatalogue(MessageCatalogue $catalogue, string $sort): MessageCatalogue + { + $sortedCatalogue = new MessageCatalogue($catalogue->getLocale()); + + foreach ($catalogue->getDomains() as $domain) { + // extract intl-icu messages only + $intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX; + if ($intlMessages = $catalogue->all($intlDomain)) { + if (self::DESC === $sort) { + krsort($intlMessages); + } elseif (self::ASC === $sort) { + ksort($intlMessages); + } + + $sortedCatalogue->add($intlMessages, $intlDomain); + } + + // extract all messages and subtract intl-icu messages + if ($messages = array_diff($catalogue->all($domain), $intlMessages)) { + if (self::DESC === $sort) { + krsort($messages); + } elseif (self::ASC === $sort) { + ksort($messages); + } + + $sortedCatalogue->add($messages, $domain); + } + + if ($metadata = $catalogue->getMetadata('', $intlDomain)) { + foreach ($metadata as $k => $v) { + $sortedCatalogue->setMetadata($k, $v, $intlDomain); + } + } + + if ($metadata = $catalogue->getMetadata('', $domain)) { + foreach ($metadata as $k => $v) { + $sortedCatalogue->setMetadata($k, $v, $domain); + } + } + } + + foreach ($catalogue->getResources() as $resource) { + $sortedCatalogue->addResource($resource); + } + + return $sortedCatalogue; + } + + private function extractMessages(string $locale, array $transPaths, string $prefix): MessageCatalogue + { + $extractedCatalogue = new MessageCatalogue($locale); + $this->extractor->setPrefix($prefix); + $transPaths = $this->filterDuplicateTransPaths($transPaths); + foreach ($transPaths as $path) { + if (is_dir($path) || is_file($path)) { + $this->extractor->extract($path, $extractedCatalogue); + } + } + + return $extractedCatalogue; + } + + private function filterDuplicateTransPaths(array $transPaths): array + { + $transPaths = array_filter(array_map('realpath', $transPaths)); + + sort($transPaths); + + $filteredPaths = []; + + foreach ($transPaths as $path) { + foreach ($filteredPaths as $filteredPath) { + if (str_starts_with($path, $filteredPath.\DIRECTORY_SEPARATOR)) { + continue 2; + } + } + + $filteredPaths[] = $path; + } + + return $filteredPaths; + } + + private function loadCurrentMessages(string $locale, array $transPaths): MessageCatalogue + { + $currentCatalogue = new MessageCatalogue($locale); + foreach ($transPaths as $path) { + if (is_dir($path)) { + $this->reader->read($path, $currentCatalogue); + } + } + + return $currentCatalogue; + } + + private function getRootTransPaths(): array + { + $transPaths = $this->transPaths; + if ($this->defaultTransPath) { + $transPaths[] = $this->defaultTransPath; + } + + return $transPaths; + } + + private function getRootCodePaths(KernelInterface $kernel): array + { + $codePaths = $this->codePaths; + $codePaths[] = $kernel->getProjectDir().'/src'; + if ($this->defaultViewsPath) { + $codePaths[] = $this->defaultViewsPath; + } + + return $codePaths; + } + + private function removeNoFillTranslations(MessageCatalogueInterface $operation): void + { + foreach ($operation->all('messages') as $key => $message) { + if (str_starts_with($message, self::NO_FILL_PREFIX)) { + $operation->set($key, '', 'messages'); + } + } + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php index 194d1c50d25d5..de5aa93896057 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php +++ b/src/Symfony/Bundle/FrameworkBundle/Command/TranslationUpdateCommand.php @@ -11,45 +11,12 @@ namespace Symfony\Bundle\FrameworkBundle\Command; -use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Completion\CompletionInput; -use Symfony\Component\Console\Completion\CompletionSuggestions; -use Symfony\Component\Console\Exception\InvalidArgumentException; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\Component\HttpKernel\KernelInterface; -use Symfony\Component\Translation\Catalogue\MergeOperation; -use Symfony\Component\Translation\Catalogue\TargetOperation; use Symfony\Component\Translation\Extractor\ExtractorInterface; -use Symfony\Component\Translation\MessageCatalogue; -use Symfony\Component\Translation\MessageCatalogueInterface; use Symfony\Component\Translation\Reader\TranslationReaderInterface; use Symfony\Component\Translation\Writer\TranslationWriterInterface; -/** - * A command that parses templates to extract translation messages and adds them - * into the translation files. - * - * @author Michel Salib - * - * @final - */ -#[AsCommand(name: 'translation:extract', description: 'Extract missing translations keys from code to translation files')] -class TranslationUpdateCommand extends Command +class TranslationUpdateCommand extends TranslationExtractCommand { - private const ASC = 'asc'; - private const DESC = 'desc'; - private const SORT_ORDERS = [self::ASC, self::DESC]; - private const FORMATS = [ - 'xlf12' => ['xlf', '1.2'], - 'xlf20' => ['xlf', '2.0'], - ]; - private const NO_FILL_PREFIX = "\0NoFill\0"; - public function __construct( private TranslationWriterInterface $writer, private TranslationReaderInterface $reader, @@ -61,445 +28,7 @@ public function __construct( private array $codePaths = [], private array $enabledLocales = [], ) { - parent::__construct(); - - if (!method_exists($writer, 'getFormats')) { - throw new \InvalidArgumentException(sprintf('The writer class "%s" does not implement the "getFormats()" method.', $writer::class)); - } - } - - protected function configure(): void - { - $this - ->setDefinition([ - new InputArgument('locale', InputArgument::REQUIRED, 'The locale'), - new InputArgument('bundle', InputArgument::OPTIONAL, 'The bundle name or directory where to load the messages'), - new InputOption('prefix', null, InputOption::VALUE_OPTIONAL, 'Override the default prefix', '__'), - new InputOption('no-fill', null, InputOption::VALUE_NONE, 'Extract translation keys without filling in values'), - new InputOption('format', null, InputOption::VALUE_OPTIONAL, 'Override the default output format', 'xlf12'), - new InputOption('dump-messages', null, InputOption::VALUE_NONE, 'Should the messages be dumped in the console'), - new InputOption('force', null, InputOption::VALUE_NONE, 'Should the extract be done'), - new InputOption('clean', null, InputOption::VALUE_NONE, 'Should clean not found messages'), - new InputOption('domain', null, InputOption::VALUE_OPTIONAL, 'Specify the domain to extract'), - new InputOption('sort', null, InputOption::VALUE_OPTIONAL, 'Return list of messages sorted alphabetically'), - new InputOption('as-tree', null, InputOption::VALUE_OPTIONAL, 'Dump the messages as a tree-like structure: The given value defines the level where to switch to inline YAML'), - ]) - ->setHelp(<<<'EOF' -The %command.name% command extracts translation strings from templates -of a given bundle or the default translations directory. It can display them or merge -the new ones into the translation files. - -When new translation strings are found it can automatically add a prefix to the translation -message. However, if the --no-fill option is used, the --prefix -option has no effect, since the translation values are left empty. - -Example running against a Bundle (AcmeBundle) - - php %command.full_name% --dump-messages en AcmeBundle - php %command.full_name% --force --prefix="new_" fr AcmeBundle - -Example running against default messages directory - - php %command.full_name% --dump-messages en - php %command.full_name% --force --prefix="new_" fr - -You can sort the output with the --sort flag: - - php %command.full_name% --dump-messages --sort=asc en AcmeBundle - php %command.full_name% --force --sort=desc fr - -You can dump a tree-like structure using the yaml format with --as-tree flag: - - php %command.full_name% --force --format=yaml --as-tree=3 en AcmeBundle - -EOF - ) - ; - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - $errorIo = $io->getErrorStyle(); - - // check presence of force or dump-message - if (true !== $input->getOption('force') && true !== $input->getOption('dump-messages')) { - $errorIo->error('You must choose one of --force or --dump-messages'); - - return 1; - } - - $format = $input->getOption('format'); - $xliffVersion = '1.2'; - - if (\array_key_exists($format, self::FORMATS)) { - [$format, $xliffVersion] = self::FORMATS[$format]; - } - - // check format - $supportedFormats = $this->writer->getFormats(); - if (!\in_array($format, $supportedFormats, true)) { - $errorIo->error(['Wrong output format', 'Supported formats are: '.implode(', ', $supportedFormats).', xlf12 and xlf20.']); - - return 1; - } - - /** @var KernelInterface $kernel */ - $kernel = $this->getApplication()->getKernel(); - - // Define Root Paths - $transPaths = $this->getRootTransPaths(); - $codePaths = $this->getRootCodePaths($kernel); - - $currentName = 'default directory'; - - // Override with provided Bundle info - if (null !== $input->getArgument('bundle')) { - try { - $foundBundle = $kernel->getBundle($input->getArgument('bundle')); - $bundleDir = $foundBundle->getPath(); - $transPaths = [is_dir($bundleDir.'/Resources/translations') ? $bundleDir.'/Resources/translations' : $bundleDir.'/translations']; - $codePaths = [is_dir($bundleDir.'/Resources/views') ? $bundleDir.'/Resources/views' : $bundleDir.'/templates']; - if ($this->defaultTransPath) { - $transPaths[] = $this->defaultTransPath; - } - if ($this->defaultViewsPath) { - $codePaths[] = $this->defaultViewsPath; - } - $currentName = $foundBundle->getName(); - } catch (\InvalidArgumentException) { - // such a bundle does not exist, so treat the argument as path - $path = $input->getArgument('bundle'); - - $transPaths = [$path.'/translations']; - $codePaths = [$path.'/templates']; - - if (!is_dir($transPaths[0])) { - throw new InvalidArgumentException(\sprintf('"%s" is neither an enabled bundle nor a directory.', $transPaths[0])); - } - } - } - - $io->title('Translation Messages Extractor and Dumper'); - $io->comment(\sprintf('Generating "%s" translation files for "%s"', $input->getArgument('locale'), $currentName)); - - $io->comment('Parsing templates...'); - $prefix = $input->getOption('no-fill') ? self::NO_FILL_PREFIX : $input->getOption('prefix'); - $extractedCatalogue = $this->extractMessages($input->getArgument('locale'), $codePaths, $prefix); - - $io->comment('Loading translation files...'); - $currentCatalogue = $this->loadCurrentMessages($input->getArgument('locale'), $transPaths); - - if (null !== $domain = $input->getOption('domain')) { - $currentCatalogue = $this->filterCatalogue($currentCatalogue, $domain); - $extractedCatalogue = $this->filterCatalogue($extractedCatalogue, $domain); - } - - // process catalogues - $operation = $input->getOption('clean') - ? new TargetOperation($currentCatalogue, $extractedCatalogue) - : new MergeOperation($currentCatalogue, $extractedCatalogue); - - // Exit if no messages found. - if (!\count($operation->getDomains())) { - $errorIo->warning('No translation messages were found.'); - - return 0; - } - - $resultMessage = 'Translation files were successfully updated'; - - $operation->moveMessagesToIntlDomainsIfPossible('new'); - - if ($sort = $input->getOption('sort')) { - $sort = strtolower($sort); - if (!\in_array($sort, self::SORT_ORDERS, true)) { - $errorIo->error(['Wrong sort order', 'Supported formats are: '.implode(', ', self::SORT_ORDERS).'.']); - - return 1; - } - } - - // show compiled list of messages - if (true === $input->getOption('dump-messages')) { - $extractedMessagesCount = 0; - $io->newLine(); - foreach ($operation->getDomains() as $domain) { - $newKeys = array_keys($operation->getNewMessages($domain)); - $allKeys = array_keys($operation->getMessages($domain)); - - $list = array_merge( - array_diff($allKeys, $newKeys), - array_map(fn ($id) => \sprintf('%s', $id), $newKeys), - array_map(fn ($id) => \sprintf('%s', $id), array_keys($operation->getObsoleteMessages($domain))) - ); - - $domainMessagesCount = \count($list); - - if (self::DESC === $sort) { - rsort($list); - } else { - sort($list); - } - - $io->section(\sprintf('Messages extracted for domain "%s" (%d message%s)', $domain, $domainMessagesCount, $domainMessagesCount > 1 ? 's' : '')); - $io->listing($list); - - $extractedMessagesCount += $domainMessagesCount; - } - - if ('xlf' === $format) { - $io->comment(\sprintf('Xliff output version is %s', $xliffVersion)); - } - - $resultMessage = \sprintf('%d message%s successfully extracted', $extractedMessagesCount, $extractedMessagesCount > 1 ? 's were' : ' was'); - } - - // save the files - if (true === $input->getOption('force')) { - $io->comment('Writing files...'); - - $bundleTransPath = false; - foreach ($transPaths as $path) { - if (is_dir($path)) { - $bundleTransPath = $path; - } - } - - if (!$bundleTransPath) { - $bundleTransPath = end($transPaths); - } - - $operationResult = $operation->getResult(); - if ($sort) { - $operationResult = $this->sortCatalogue($operationResult, $sort); - } - - if (true === $input->getOption('no-fill')) { - $this->removeNoFillTranslations($operationResult); - } - - $this->writer->write($operationResult, $format, ['path' => $bundleTransPath, 'default_locale' => $this->defaultLocale, 'xliff_version' => $xliffVersion, 'as_tree' => $input->getOption('as-tree'), 'inline' => $input->getOption('as-tree') ?? 0]); - - if (true === $input->getOption('dump-messages')) { - $resultMessage .= ' and translation files were updated'; - } - } - - $io->success($resultMessage.'.'); - - return 0; - } - - public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void - { - if ($input->mustSuggestArgumentValuesFor('locale')) { - $suggestions->suggestValues($this->enabledLocales); - - return; - } - - /** @var KernelInterface $kernel */ - $kernel = $this->getApplication()->getKernel(); - if ($input->mustSuggestArgumentValuesFor('bundle')) { - $bundles = []; - - foreach ($kernel->getBundles() as $bundle) { - $bundles[] = $bundle->getName(); - if ($bundle->getContainerExtension()) { - $bundles[] = $bundle->getContainerExtension()->getAlias(); - } - } - - $suggestions->suggestValues($bundles); - - return; - } - - if ($input->mustSuggestOptionValuesFor('format')) { - $suggestions->suggestValues(array_merge( - $this->writer->getFormats(), - array_keys(self::FORMATS) - )); - - return; - } - - if ($input->mustSuggestOptionValuesFor('domain') && $locale = $input->getArgument('locale')) { - $extractedCatalogue = $this->extractMessages($locale, $this->getRootCodePaths($kernel), $input->getOption('prefix')); - - $currentCatalogue = $this->loadCurrentMessages($locale, $this->getRootTransPaths()); - - // process catalogues - $operation = $input->getOption('clean') - ? new TargetOperation($currentCatalogue, $extractedCatalogue) - : new MergeOperation($currentCatalogue, $extractedCatalogue); - - $suggestions->suggestValues($operation->getDomains()); - - return; - } - - if ($input->mustSuggestOptionValuesFor('sort')) { - $suggestions->suggestValues(self::SORT_ORDERS); - } - } - - private function filterCatalogue(MessageCatalogue $catalogue, string $domain): MessageCatalogue - { - $filteredCatalogue = new MessageCatalogue($catalogue->getLocale()); - - // extract intl-icu messages only - $intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX; - if ($intlMessages = $catalogue->all($intlDomain)) { - $filteredCatalogue->add($intlMessages, $intlDomain); - } - - // extract all messages and subtract intl-icu messages - if ($messages = array_diff($catalogue->all($domain), $intlMessages)) { - $filteredCatalogue->add($messages, $domain); - } - foreach ($catalogue->getResources() as $resource) { - $filteredCatalogue->addResource($resource); - } - - if ($metadata = $catalogue->getMetadata('', $intlDomain)) { - foreach ($metadata as $k => $v) { - $filteredCatalogue->setMetadata($k, $v, $intlDomain); - } - } - - if ($metadata = $catalogue->getMetadata('', $domain)) { - foreach ($metadata as $k => $v) { - $filteredCatalogue->setMetadata($k, $v, $domain); - } - } - - return $filteredCatalogue; - } - - private function sortCatalogue(MessageCatalogue $catalogue, string $sort): MessageCatalogue - { - $sortedCatalogue = new MessageCatalogue($catalogue->getLocale()); - - foreach ($catalogue->getDomains() as $domain) { - // extract intl-icu messages only - $intlDomain = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX; - if ($intlMessages = $catalogue->all($intlDomain)) { - if (self::DESC === $sort) { - krsort($intlMessages); - } elseif (self::ASC === $sort) { - ksort($intlMessages); - } - - $sortedCatalogue->add($intlMessages, $intlDomain); - } - - // extract all messages and subtract intl-icu messages - if ($messages = array_diff($catalogue->all($domain), $intlMessages)) { - if (self::DESC === $sort) { - krsort($messages); - } elseif (self::ASC === $sort) { - ksort($messages); - } - - $sortedCatalogue->add($messages, $domain); - } - - if ($metadata = $catalogue->getMetadata('', $intlDomain)) { - foreach ($metadata as $k => $v) { - $sortedCatalogue->setMetadata($k, $v, $intlDomain); - } - } - - if ($metadata = $catalogue->getMetadata('', $domain)) { - foreach ($metadata as $k => $v) { - $sortedCatalogue->setMetadata($k, $v, $domain); - } - } - } - - foreach ($catalogue->getResources() as $resource) { - $sortedCatalogue->addResource($resource); - } - - return $sortedCatalogue; - } - - private function extractMessages(string $locale, array $transPaths, string $prefix): MessageCatalogue - { - $extractedCatalogue = new MessageCatalogue($locale); - $this->extractor->setPrefix($prefix); - $transPaths = $this->filterDuplicateTransPaths($transPaths); - foreach ($transPaths as $path) { - if (is_dir($path) || is_file($path)) { - $this->extractor->extract($path, $extractedCatalogue); - } - } - - return $extractedCatalogue; - } - - private function filterDuplicateTransPaths(array $transPaths): array - { - $transPaths = array_filter(array_map('realpath', $transPaths)); - - sort($transPaths); - - $filteredPaths = []; - - foreach ($transPaths as $path) { - foreach ($filteredPaths as $filteredPath) { - if (str_starts_with($path, $filteredPath.\DIRECTORY_SEPARATOR)) { - continue 2; - } - } - - $filteredPaths[] = $path; - } - - return $filteredPaths; - } - - private function loadCurrentMessages(string $locale, array $transPaths): MessageCatalogue - { - $currentCatalogue = new MessageCatalogue($locale); - foreach ($transPaths as $path) { - if (is_dir($path)) { - $this->reader->read($path, $currentCatalogue); - } - } - - return $currentCatalogue; - } - - private function getRootTransPaths(): array - { - $transPaths = $this->transPaths; - if ($this->defaultTransPath) { - $transPaths[] = $this->defaultTransPath; - } - - return $transPaths; - } - - private function getRootCodePaths(KernelInterface $kernel): array - { - $codePaths = $this->codePaths; - $codePaths[] = $kernel->getProjectDir().'/src'; - if ($this->defaultViewsPath) { - $codePaths[] = $this->defaultViewsPath; - } - - return $codePaths; - } - - private function removeNoFillTranslations(MessageCatalogueInterface $operation): void - { - foreach ($operation->all('messages') as $key => $message) { - if (str_starts_with($message, self::NO_FILL_PREFIX)) { - $operation->set($key, '', 'messages'); - } - } + trigger_deprecation('symfony/framework-bundle', '7.3', 'The "%s" class is deprecated, use "%s" instead.', __CLASS__, TranslationExtractCommand::class); + parent::__construct($writer, $reader, $extractor, $defaultLocale, $defaultTransPath, $defaultViewsPath, $transPaths, $codePaths, $enabledLocales); } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/Descriptor.php b/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/Descriptor.php index af5c3b10ac415..e76b742474a37 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/Descriptor.php +++ b/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/Descriptor.php @@ -49,7 +49,7 @@ public function describe(OutputInterface $output, mixed $object, array $options } match (true) { - $object instanceof RouteCollection => $this->describeRouteCollection($object, $options), + $object instanceof RouteCollection => $this->describeRouteCollection($this->filterRoutesByHttpMethod($object, $options['method'] ?? ''), $options), $object instanceof Route => $this->describeRoute($object, $options), $object instanceof ParameterBag => $this->describeContainerParameters($object, $options), $object instanceof ContainerBuilder && !empty($options['env-vars']) => $this->describeContainerEnvVars($this->getContainerEnvVars($object), $options), @@ -360,4 +360,20 @@ protected function getServiceEdges(ContainerBuilder $container, string $serviceI return []; } } + + private function filterRoutesByHttpMethod(RouteCollection $routes, string $method): RouteCollection + { + if (!$method) { + return $routes; + } + $filteredRoutes = clone $routes; + + foreach ($filteredRoutes as $routeName => $route) { + if ($route->getMethods() && !\in_array($method, $route->getMethods(), true)) { + $filteredRoutes->remove($routeName); + } + } + + return $filteredRoutes; + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/JsonDescriptor.php b/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/JsonDescriptor.php index 5b83f0746c4f4..c7705a1a05975 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/JsonDescriptor.php +++ b/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/JsonDescriptor.php @@ -63,7 +63,7 @@ protected function describeContainerTags(ContainerBuilder $container, array $opt foreach ($this->findDefinitionsByTag($container, $showHidden) as $tag => $definitions) { $data[$tag] = []; foreach ($definitions as $definition) { - $data[$tag][] = $this->getContainerDefinitionData($definition, true, false, $container, $options['id'] ?? null); + $data[$tag][] = $this->getContainerDefinitionData($definition, true, $container, $options['id'] ?? null); } } @@ -79,7 +79,7 @@ protected function describeContainerService(object $service, array $options = [] if ($service instanceof Alias) { $this->describeContainerAlias($service, $options, $container); } elseif ($service instanceof Definition) { - $this->writeData($this->getContainerDefinitionData($service, isset($options['omit_tags']) && $options['omit_tags'], isset($options['show_arguments']) && $options['show_arguments'], $container, $options['id']), $options); + $this->writeData($this->getContainerDefinitionData($service, isset($options['omit_tags']) && $options['omit_tags'], $container, $options['id']), $options); } else { $this->writeData($service::class, $options); } @@ -92,7 +92,6 @@ protected function describeContainerServices(ContainerBuilder $container, array : $this->sortServiceIds($container->getServiceIds()); $showHidden = isset($options['show_hidden']) && $options['show_hidden']; $omitTags = isset($options['omit_tags']) && $options['omit_tags']; - $showArguments = isset($options['show_arguments']) && $options['show_arguments']; $data = ['definitions' => [], 'aliases' => [], 'services' => []]; if (isset($options['filter'])) { @@ -112,7 +111,7 @@ protected function describeContainerServices(ContainerBuilder $container, array if ($service->hasTag('container.excluded')) { continue; } - $data['definitions'][$serviceId] = $this->getContainerDefinitionData($service, $omitTags, $showArguments, $container, $serviceId); + $data['definitions'][$serviceId] = $this->getContainerDefinitionData($service, $omitTags, $container, $serviceId); } else { $data['services'][$serviceId] = $service::class; } @@ -123,7 +122,7 @@ protected function describeContainerServices(ContainerBuilder $container, array protected function describeContainerDefinition(Definition $definition, array $options = [], ?ContainerBuilder $container = null): void { - $this->writeData($this->getContainerDefinitionData($definition, isset($options['omit_tags']) && $options['omit_tags'], isset($options['show_arguments']) && $options['show_arguments'], $container, $options['id'] ?? null), $options); + $this->writeData($this->getContainerDefinitionData($definition, isset($options['omit_tags']) && $options['omit_tags'], $container, $options['id'] ?? null), $options); } protected function describeContainerAlias(Alias $alias, array $options = [], ?ContainerBuilder $container = null): void @@ -135,7 +134,7 @@ protected function describeContainerAlias(Alias $alias, array $options = [], ?Co } $this->writeData( - [$this->getContainerAliasData($alias), $this->getContainerDefinitionData($container->getDefinition((string) $alias), isset($options['omit_tags']) && $options['omit_tags'], isset($options['show_arguments']) && $options['show_arguments'], $container, (string) $alias)], + [$this->getContainerAliasData($alias), $this->getContainerDefinitionData($container->getDefinition((string) $alias), isset($options['omit_tags']) && $options['omit_tags'], $container, (string) $alias)], array_merge($options, ['id' => (string) $alias]) ); } @@ -245,7 +244,7 @@ protected function sortParameters(ParameterBag $parameters): array return $sortedParameters; } - private function getContainerDefinitionData(Definition $definition, bool $omitTags = false, bool $showArguments = false, ?ContainerBuilder $container = null, ?string $id = null): array + private function getContainerDefinitionData(Definition $definition, bool $omitTags = false, ?ContainerBuilder $container = null, ?string $id = null): array { $data = [ 'class' => (string) $definition->getClass(), @@ -269,9 +268,7 @@ private function getContainerDefinitionData(Definition $definition, bool $omitTa $data['description'] = $classDescription; } - if ($showArguments) { - $data['arguments'] = $this->describeValue($definition->getArguments(), $omitTags, $showArguments, $container, $id); - } + $data['arguments'] = $this->describeValue($definition->getArguments(), $omitTags, $container, $id); $data['file'] = $definition->getFile(); @@ -418,12 +415,12 @@ private function getCallableData(mixed $callable): array throw new \InvalidArgumentException('Callable is not describable.'); } - private function describeValue($value, bool $omitTags, bool $showArguments, ?ContainerBuilder $container = null, ?string $id = null): mixed + private function describeValue($value, bool $omitTags, ?ContainerBuilder $container = null, ?string $id = null): mixed { if (\is_array($value)) { $data = []; foreach ($value as $k => $v) { - $data[$k] = $this->describeValue($v, $omitTags, $showArguments, $container, $id); + $data[$k] = $this->describeValue($v, $omitTags, $container, $id); } return $data; @@ -445,11 +442,11 @@ private function describeValue($value, bool $omitTags, bool $showArguments, ?Con } if ($value instanceof ArgumentInterface) { - return $this->describeValue($value->getValues(), $omitTags, $showArguments, $container, $id); + return $this->describeValue($value->getValues(), $omitTags, $container, $id); } if ($value instanceof Definition) { - return $this->getContainerDefinitionData($value, $omitTags, $showArguments, $container, $id); + return $this->getContainerDefinitionData($value, $omitTags, $container, $id); } return $value; diff --git a/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/MarkdownDescriptor.php b/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/MarkdownDescriptor.php index 5203d14c329e9..d057c598deff6 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/MarkdownDescriptor.php +++ b/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/MarkdownDescriptor.php @@ -155,7 +155,6 @@ protected function describeContainerServices(ContainerBuilder $container, array $serviceIds = isset($options['tag']) && $options['tag'] ? $this->sortTaggedServicesByPriority($container->findTaggedServiceIds($options['tag'])) : $this->sortServiceIds($container->getServiceIds()); - $showArguments = isset($options['show_arguments']) && $options['show_arguments']; $services = ['definitions' => [], 'aliases' => [], 'services' => []]; if (isset($options['filter'])) { @@ -185,7 +184,7 @@ protected function describeContainerServices(ContainerBuilder $container, array $this->write("\n\nDefinitions\n-----------\n"); foreach ($services['definitions'] as $id => $service) { $this->write("\n"); - $this->describeContainerDefinition($service, ['id' => $id, 'show_arguments' => $showArguments], $container); + $this->describeContainerDefinition($service, ['id' => $id], $container); } } @@ -231,9 +230,7 @@ protected function describeContainerDefinition(Definition $definition, array $op $output .= "\n".'- Deprecated: no'; } - if (isset($options['show_arguments']) && $options['show_arguments']) { - $output .= "\n".'- Arguments: '.($definition->getArguments() ? 'yes' : 'no'); - } + $output .= "\n".'- Arguments: '.($definition->getArguments() ? 'yes' : 'no'); if ($definition->getFile()) { $output .= "\n".'- File: `'.$definition->getFile().'`'; diff --git a/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/TextDescriptor.php b/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/TextDescriptor.php index 5efaab496bb94..12b3454115e2c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/TextDescriptor.php +++ b/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/TextDescriptor.php @@ -351,9 +351,8 @@ protected function describeContainerDefinition(Definition $definition, array $op } } - $showArguments = isset($options['show_arguments']) && $options['show_arguments']; $argumentsInformation = []; - if ($showArguments && ($arguments = $definition->getArguments())) { + if ($arguments = $definition->getArguments()) { foreach ($arguments as $argument) { if ($argument instanceof ServiceClosureArgument) { $argument = $argument->getValues()[0]; diff --git a/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/XmlDescriptor.php b/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/XmlDescriptor.php index c41ac296f14d8..8daa61d2a2855 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/XmlDescriptor.php +++ b/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/XmlDescriptor.php @@ -59,17 +59,17 @@ protected function describeContainerService(object $service, array $options = [] throw new \InvalidArgumentException('An "id" option must be provided.'); } - $this->writeDocument($this->getContainerServiceDocument($service, $options['id'], $container, isset($options['show_arguments']) && $options['show_arguments'])); + $this->writeDocument($this->getContainerServiceDocument($service, $options['id'], $container)); } protected function describeContainerServices(ContainerBuilder $container, array $options = []): void { - $this->writeDocument($this->getContainerServicesDocument($container, $options['tag'] ?? null, isset($options['show_hidden']) && $options['show_hidden'], isset($options['show_arguments']) && $options['show_arguments'], $options['filter'] ?? null)); + $this->writeDocument($this->getContainerServicesDocument($container, $options['tag'] ?? null, isset($options['show_hidden']) && $options['show_hidden'], $options['filter'] ?? null)); } protected function describeContainerDefinition(Definition $definition, array $options = [], ?ContainerBuilder $container = null): void { - $this->writeDocument($this->getContainerDefinitionDocument($definition, $options['id'] ?? null, isset($options['omit_tags']) && $options['omit_tags'], isset($options['show_arguments']) && $options['show_arguments'], $container)); + $this->writeDocument($this->getContainerDefinitionDocument($definition, $options['id'] ?? null, isset($options['omit_tags']) && $options['omit_tags'], $container)); } protected function describeContainerAlias(Alias $alias, array $options = [], ?ContainerBuilder $container = null): void @@ -83,7 +83,7 @@ protected function describeContainerAlias(Alias $alias, array $options = [], ?Co return; } - $dom->appendChild($dom->importNode($this->getContainerDefinitionDocument($container->getDefinition((string) $alias), (string) $alias, false, false, $container)->childNodes->item(0), true)); + $dom->appendChild($dom->importNode($this->getContainerDefinitionDocument($container->getDefinition((string) $alias), (string) $alias, false, $container)->childNodes->item(0), true)); $this->writeDocument($dom); } @@ -260,7 +260,7 @@ private function getContainerTagsDocument(ContainerBuilder $container, bool $sho $tagXML->setAttribute('name', $tag); foreach ($definitions as $serviceId => $definition) { - $definitionXML = $this->getContainerDefinitionDocument($definition, $serviceId, true, false, $container); + $definitionXML = $this->getContainerDefinitionDocument($definition, $serviceId, true, $container); $tagXML->appendChild($dom->importNode($definitionXML->childNodes->item(0), true)); } } @@ -268,17 +268,17 @@ private function getContainerTagsDocument(ContainerBuilder $container, bool $sho return $dom; } - private function getContainerServiceDocument(object $service, string $id, ?ContainerBuilder $container = null, bool $showArguments = false): \DOMDocument + private function getContainerServiceDocument(object $service, string $id, ?ContainerBuilder $container = null): \DOMDocument { $dom = new \DOMDocument('1.0', 'UTF-8'); if ($service instanceof Alias) { $dom->appendChild($dom->importNode($this->getContainerAliasDocument($service, $id)->childNodes->item(0), true)); if ($container) { - $dom->appendChild($dom->importNode($this->getContainerDefinitionDocument($container->getDefinition((string) $service), (string) $service, false, $showArguments, $container)->childNodes->item(0), true)); + $dom->appendChild($dom->importNode($this->getContainerDefinitionDocument($container->getDefinition((string) $service), (string) $service, false, $container)->childNodes->item(0), true)); } } elseif ($service instanceof Definition) { - $dom->appendChild($dom->importNode($this->getContainerDefinitionDocument($service, $id, false, $showArguments, $container)->childNodes->item(0), true)); + $dom->appendChild($dom->importNode($this->getContainerDefinitionDocument($service, $id, false, $container)->childNodes->item(0), true)); } else { $dom->appendChild($serviceXML = $dom->createElement('service')); $serviceXML->setAttribute('id', $id); @@ -288,7 +288,7 @@ private function getContainerServiceDocument(object $service, string $id, ?Conta return $dom; } - private function getContainerServicesDocument(ContainerBuilder $container, ?string $tag = null, bool $showHidden = false, bool $showArguments = false, ?callable $filter = null): \DOMDocument + private function getContainerServicesDocument(ContainerBuilder $container, ?string $tag = null, bool $showHidden = false, ?callable $filter = null): \DOMDocument { $dom = new \DOMDocument('1.0', 'UTF-8'); $dom->appendChild($containerXML = $dom->createElement('container')); @@ -311,14 +311,14 @@ private function getContainerServicesDocument(ContainerBuilder $container, ?stri continue; } - $serviceXML = $this->getContainerServiceDocument($service, $serviceId, null, $showArguments); + $serviceXML = $this->getContainerServiceDocument($service, $serviceId, null); $containerXML->appendChild($containerXML->ownerDocument->importNode($serviceXML->childNodes->item(0), true)); } return $dom; } - private function getContainerDefinitionDocument(Definition $definition, ?string $id = null, bool $omitTags = false, bool $showArguments = false, ?ContainerBuilder $container = null): \DOMDocument + private function getContainerDefinitionDocument(Definition $definition, ?string $id = null, bool $omitTags = false, ?ContainerBuilder $container = null): \DOMDocument { $dom = new \DOMDocument('1.0', 'UTF-8'); $dom->appendChild($serviceXML = $dom->createElement('definition')); @@ -378,10 +378,8 @@ private function getContainerDefinitionDocument(Definition $definition, ?string } } - if ($showArguments) { - foreach ($this->getArgumentNodes($definition->getArguments(), $dom, $container) as $node) { - $serviceXML->appendChild($node); - } + foreach ($this->getArgumentNodes($definition->getArguments(), $dom, $container) as $node) { + $serviceXML->appendChild($node); } if (!$omitTags) { @@ -443,7 +441,7 @@ private function getArgumentNodes(array $arguments, \DOMDocument $dom, ?Containe $argumentXML->appendChild($childArgumentXML); } } elseif ($argument instanceof Definition) { - $argumentXML->appendChild($dom->importNode($this->getContainerDefinitionDocument($argument, null, false, true, $container)->childNodes->item(0), true)); + $argumentXML->appendChild($dom->importNode($this->getContainerDefinitionDocument($argument, null, false, $container)->childNodes->item(0), true)); } elseif ($argument instanceof AbstractArgument) { $argumentXML->setAttribute('type', 'abstract'); $argumentXML->appendChild(new \DOMText($argument->getText())); diff --git a/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php b/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php index af453619b5ab8..de7395d5a83f7 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php +++ b/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php @@ -35,6 +35,7 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\User\UserInterface; @@ -202,6 +203,21 @@ protected function isGranted(mixed $attribute, mixed $subject = null): bool return $this->container->get('security.authorization_checker')->isGranted($attribute, $subject); } + /** + * Checks if the attribute is granted against the current authentication token and optionally supplied subject. + */ + protected function getAccessDecision(mixed $attribute, mixed $subject = null): AccessDecision + { + if (!$this->container->has('security.authorization_checker')) { + throw new \LogicException('The SecurityBundle is not registered in your application. Try running "composer require symfony/security-bundle".'); + } + + $accessDecision = new AccessDecision(); + $accessDecision->isGranted = $this->container->get('security.authorization_checker')->isGranted($attribute, $subject, $accessDecision); + + return $accessDecision; + } + /** * Throws an exception unless the attribute is granted against the current authentication token and optionally * supplied subject. @@ -210,12 +226,24 @@ protected function isGranted(mixed $attribute, mixed $subject = null): bool */ protected function denyAccessUnlessGranted(mixed $attribute, mixed $subject = null, string $message = 'Access Denied.'): void { - if (!$this->isGranted($attribute, $subject)) { - $exception = $this->createAccessDeniedException($message); - $exception->setAttributes([$attribute]); - $exception->setSubject($subject); + if (class_exists(AccessDecision::class)) { + $accessDecision = $this->getAccessDecision($attribute, $subject); + $isGranted = $accessDecision->isGranted; + } else { + $accessDecision = null; + $isGranted = $this->isGranted($attribute, $subject); + } + + if (!$isGranted) { + $e = $this->createAccessDeniedException(3 > \func_num_args() && $accessDecision ? $accessDecision->getMessage() : $message); + $e->setAttributes([$attribute]); + $e->setSubject($subject); + + if ($accessDecision) { + $e->setAccessDecision($accessDecision); + } - throw $exception; + throw $e; } } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index ae2523e515d0c..53361e3127e34 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -53,6 +53,7 @@ class UnusedTagsPass implements CompilerPassInterface 'form.type_guesser', 'html_sanitizer', 'http_client.client', + 'json_streamer.value_transformer', 'kernel.cache_clearer', 'kernel.cache_warmer', 'kernel.event_listener', @@ -70,6 +71,7 @@ class UnusedTagsPass implements CompilerPassInterface 'monolog.logger', 'notifier.channel', 'property_info.access_extractor', + 'property_info.constructor_extractor', 'property_info.initializable_extractor', 'property_info.list_extractor', 'property_info.type_extractor', @@ -82,6 +84,7 @@ class UnusedTagsPass implements CompilerPassInterface 'routing.route_loader', 'scheduler.schedule_provider', 'scheduler.task', + 'security.access_token_handler.oidc.encryption_algorithm', 'security.access_token_handler.oidc.signature_algorithm', 'security.authenticator.login_linker', 'security.expression_language_provider', @@ -103,6 +106,8 @@ class UnusedTagsPass implements CompilerPassInterface 'validator.group_provider', 'validator.initializer', 'workflow', + 'object_mapper.transform_callable', + 'object_mapper.condition_callable', ]; public function process(ContainerBuilder $container): void diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 678698f4d0747..0e9b20b0ca015 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -17,6 +17,7 @@ use Symfony\Bundle\FullStack; use Symfony\Component\Asset\Package; use Symfony\Component\AssetMapper\AssetMapper; +use Symfony\Component\AssetMapper\Compressor\CompressorInterface; use Symfony\Component\Cache\Adapter\DoctrineAdapter; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\NodeBuilder; @@ -30,6 +31,7 @@ use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\IpUtils; +use Symfony\Component\JsonStreamer\StreamWriterInterface; use Symfony\Component\Lock\Lock; use Symfony\Component\Lock\Store\SemaphoreStore; use Symfony\Component\Mailer\Mailer; @@ -46,9 +48,11 @@ use Symfony\Component\Translation\Translator; use Symfony\Component\TypeInfo\Type; use Symfony\Component\Uid\Factory\UuidFactory; +use Symfony\Component\Validator\Constraints\Email; use Symfony\Component\Validator\Validation; use Symfony\Component\Webhook\Controller\WebhookController; use Symfony\Component\WebLink\HttpHeaderSerializer; +use Symfony\Component\Workflow\Validator\DefinitionValidatorInterface; use Symfony\Component\Workflow\WorkflowEvents; /** @@ -73,6 +77,7 @@ public function getConfigTreeBuilder(): TreeBuilder $rootNode = $treeBuilder->getRootNode(); $rootNode + ->docUrl('https://symfony.com/doc/{version:major}.{version:minor}/reference/configuration/framework.html', 'symfony/framework-bundle') ->beforeNormalization() ->ifTrue(fn ($v) => !isset($v['assets']) && isset($v['templating']) && class_exists(Package::class)) ->then(function ($v) { @@ -180,6 +185,7 @@ public function getConfigTreeBuilder(): TreeBuilder $this->addHtmlSanitizerSection($rootNode, $enableIfStandalone); $this->addWebhookSection($rootNode, $enableIfStandalone); $this->addRemoteEventSection($rootNode, $enableIfStandalone); + $this->addJsonStreamerSection($rootNode, $enableIfStandalone); return $treeBuilder; } @@ -250,6 +256,8 @@ private function addFormSection(ArrayNodeDefinition $rootNode, callable $enableI ->scalarNode('field_name')->defaultValue('_token')->end() ->arrayNode('field_attr') ->performNoDeepMerging() + ->normalizeKeys(false) + ->useAttributeAsKey('name') ->scalarPrototype()->end() ->defaultValue(['data-controller' => 'csrf-protection']) ->end() @@ -397,6 +405,7 @@ private function addWorkflowSection(ArrayNodeDefinition $rootNode): void ->useAttributeAsKey('name') ->prototype('array') ->fixXmlConfig('support') + ->fixXmlConfig('definition_validator') ->fixXmlConfig('place') ->fixXmlConfig('transition') ->fixXmlConfig('event_to_dispatch', 'events_to_dispatch') @@ -426,11 +435,28 @@ private function addWorkflowSection(ArrayNodeDefinition $rootNode): void ->prototype('scalar') ->cannotBeEmpty() ->validate() - ->ifTrue(fn ($v) => !class_exists($v) && !interface_exists($v, false)) + ->ifTrue(static fn ($v) => !class_exists($v) && !interface_exists($v, false)) ->thenInvalid('The supported class or interface "%s" does not exist.') ->end() ->end() ->end() + ->arrayNode('definition_validators') + ->prototype('scalar') + ->cannotBeEmpty() + ->validate() + ->ifTrue(static fn ($v) => !class_exists($v)) + ->thenInvalid('The validation class %s does not exist.') + ->end() + ->validate() + ->ifTrue(static fn ($v) => !is_a($v, DefinitionValidatorInterface::class, true)) + ->thenInvalid(\sprintf('The validation class %%s is not an instance of "%s".', DefinitionValidatorInterface::class)) + ->end() + ->validate() + ->ifTrue(static fn ($v) => 1 <= (new \ReflectionClass($v))->getConstructor()?->getNumberOfRequiredParameters()) + ->thenInvalid('The %s validation class constructor must not have any arguments.') + ->end() + ->end() + ->end() ->scalarNode('support_strategy') ->cannotBeEmpty() ->end() @@ -442,7 +468,7 @@ private function addWorkflowSection(ArrayNodeDefinition $rootNode): void ->variableNode('events_to_dispatch') ->defaultValue(null) ->validate() - ->ifTrue(function ($v) { + ->ifTrue(static function ($v) { if (null === $v) { return false; } @@ -469,14 +495,14 @@ private function addWorkflowSection(ArrayNodeDefinition $rootNode): void ->arrayNode('places') ->beforeNormalization() ->always() - ->then(function ($places) { + ->then(static function ($places) { if (!\is_array($places)) { throw new InvalidConfigurationException('The "places" option must be an array in workflow configuration.'); } // It's an indexed array of shape ['place1', 'place2'] if (isset($places[0]) && \is_string($places[0])) { - return array_map(function (string $place) { + return array_map(static function (string $place) { return ['name' => $place]; }, $places); } @@ -516,7 +542,7 @@ private function addWorkflowSection(ArrayNodeDefinition $rootNode): void ->arrayNode('transitions') ->beforeNormalization() ->always() - ->then(function ($transitions) { + ->then(static function ($transitions) { if (!\is_array($transitions)) { throw new InvalidConfigurationException('The "transitions" option must be an array in workflow configuration.'); } @@ -583,20 +609,20 @@ private function addWorkflowSection(ArrayNodeDefinition $rootNode): void ->end() ->end() ->validate() - ->ifTrue(function ($v) { + ->ifTrue(static function ($v) { return $v['supports'] && isset($v['support_strategy']); }) ->thenInvalid('"supports" and "support_strategy" cannot be used together.') ->end() ->validate() - ->ifTrue(function ($v) { + ->ifTrue(static function ($v) { return !$v['supports'] && !isset($v['support_strategy']); }) ->thenInvalid('"supports" or "support_strategy" should be configured.') ->end() ->beforeNormalization() ->always() - ->then(function ($values) { + ->then(static function ($values) { // Special case to deal with XML when the user wants an empty array if (\array_key_exists('event_to_dispatch', $values) && null === $values['event_to_dispatch']) { $values['events_to_dispatch'] = []; @@ -924,6 +950,29 @@ private function addAssetMapperSection(ArrayNodeDefinition $rootNode, callable $ ->info('The directory to store JavaScript vendors.') ->defaultValue('%kernel.project_dir%/assets/vendor') ->end() + ->arrayNode('precompress') + ->info('Precompress assets with Brotli, Zstandard and gzip.') + ->canBeEnabled() + ->fixXmlConfig('format') + ->fixXmlConfig('extension') + ->children() + ->arrayNode('formats') + ->info('Array of formats to enable. "brotli", "zstandard" and "gzip" are supported. Defaults to all formats supported by the system. The entire list must be provided.') + ->prototype('scalar')->end() + ->performNoDeepMerging() + ->validate() + ->ifTrue(static fn (array $v) => array_diff($v, ['brotli', 'zstandard', 'gzip'])) + ->thenInvalid('Unsupported format: "brotli", "zstandard" and "gzip" are supported.') + ->end() + ->end() + ->arrayNode('extensions') + ->info('Array of extensions to compress. The entire list must be provided, no merging occurs.') + ->prototype('scalar')->end() + ->performNoDeepMerging() + ->defaultValue(interface_exists(CompressorInterface::class) ? CompressorInterface::DEFAULT_EXTENSIONS : []) + ->end() + ->end() + ->end() ->end() ->end() ->end() @@ -940,6 +989,7 @@ private function addTranslatorSection(ArrayNodeDefinition $rootNode, callable $e ->fixXmlConfig('fallback') ->fixXmlConfig('path') ->fixXmlConfig('provider') + ->fixXmlConfig('global') ->children() ->arrayNode('fallbacks') ->info('Defaults to the value of "default_locale".') @@ -994,6 +1044,33 @@ private function addTranslatorSection(ArrayNodeDefinition $rootNode, callable $e ->end() ->defaultValue([]) ->end() + ->arrayNode('globals') + ->info('Global parameters.') + ->example(['app_version' => 3.14]) + ->normalizeKeys(false) + ->useAttributeAsKey('name') + ->arrayPrototype() + ->fixXmlConfig('parameter') + ->children() + ->variableNode('value')->end() + ->stringNode('message')->end() + ->arrayNode('parameters') + ->normalizeKeys(false) + ->useAttributeAsKey('name') + ->scalarPrototype()->end() + ->end() + ->stringNode('domain')->end() + ->end() + ->beforeNormalization() + ->ifTrue(static fn ($v) => !\is_array($v)) + ->then(static fn ($v) => ['value' => $v]) + ->end() + ->validate() + ->ifTrue(static fn ($v) => !(isset($v['value']) xor isset($v['message']))) + ->thenInvalid('The "globals" parameter should be either a string or an array with a "value" or a "message" key') + ->end() + ->end() + ->end() ->end() ->end() ->end() @@ -1008,7 +1085,9 @@ private function addValidationSection(ArrayNodeDefinition $rootNode, callable $e ->info('Validation configuration') ->{$enableIfStandalone('symfony/validator', Validation::class)}() ->children() - ->scalarNode('cache')->end() + ->scalarNode('cache') + ->setDeprecated('symfony/framework-bundle', '7.3', 'Setting the "%path%.%node%" configuration option is deprecated. It will be removed in version 8.0.') + ->end() ->booleanNode('enable_attributes')->{class_exists(FullStack::class) ? 'defaultFalse' : 'defaultTrue'}()->end() ->arrayNode('static_method') ->defaultValue(['loadValidatorMetadata']) @@ -1017,7 +1096,7 @@ private function addValidationSection(ArrayNodeDefinition $rootNode, callable $e ->validate()->castToArray()->end() ->end() ->scalarNode('translation_domain')->defaultValue('validators')->end() - ->enumNode('email_validation_mode')->values(['html5', 'loose', 'strict'])->defaultValue('html5')->end() + ->enumNode('email_validation_mode')->values((class_exists(Email::class) ? Email::VALIDATION_MODES : ['html5-allow-no-tld', 'html5', 'strict']) + ['loose'])->defaultValue('html5')->end() ->arrayNode('mapping') ->addDefaultsIfNotSet() ->fixXmlConfig('path') @@ -1028,18 +1107,17 @@ private function addValidationSection(ArrayNodeDefinition $rootNode, callable $e ->end() ->end() ->arrayNode('not_compromised_password') - ->canBeDisabled() + ->canBeDisabled('When disabled, compromised passwords will be accepted as valid.') ->children() - ->booleanNode('enabled') - ->defaultTrue() - ->info('When disabled, compromised passwords will be accepted as valid.') - ->end() ->scalarNode('endpoint') ->defaultNull() ->info('API endpoint for the NotCompromisedPassword Validator.') ->end() ->end() ->end() + ->booleanNode('disable_translation') + ->defaultFalse() + ->end() ->arrayNode('auto_mapping') ->info('A collection of namespaces for which auto-mapping will be enabled by default, or null to opt-in with the EnableAutoMapping constraint.') ->example([ @@ -1200,8 +1278,23 @@ private function addPropertyInfoSection(ArrayNodeDefinition $rootNode, callable ->arrayNode('property_info') ->info('Property info configuration') ->{$enableIfStandalone('symfony/property-info', PropertyInfoExtractorInterface::class)}() + ->children() + ->booleanNode('with_constructor_extractor') + ->info('Registers the constructor extractor.') + ->end() + ->end() ->end() ->end() + ->validate() + ->ifTrue(fn ($v) => $v['property_info']['enabled'] && !isset($v['property_info']['with_constructor_extractor'])) + ->then(function ($v) { + $v['property_info']['with_constructor_extractor'] = false; + + trigger_deprecation('symfony/framework-bundle', '7.3', 'Not setting the "with_constructor_extractor" option explicitly is deprecated because its default value will change in version 8.0.'); + + return $v; + }) + ->end() ; } @@ -1242,6 +1335,7 @@ private function addCacheSection(ArrayNodeDefinition $rootNode, callable $willBe ->scalarNode('directory')->defaultValue('%kernel.cache_dir%/pools/app')->end() ->scalarNode('default_psr6_provider')->end() ->scalarNode('default_redis_provider')->defaultValue('redis://localhost')->end() + ->scalarNode('default_valkey_provider')->defaultValue('valkey://localhost')->end() ->scalarNode('default_memcached_provider')->defaultValue('memcached://localhost')->end() ->scalarNode('default_doctrine_dbal_provider')->defaultValue('database_connection')->end() ->scalarNode('default_pdo_provider')->defaultValue($willBeAvailable('doctrine/dbal', Connection::class) && class_exists(DoctrineAdapter::class) ? 'database_connection' : null)->end() @@ -1386,6 +1480,10 @@ private function addExceptionsSection(ArrayNodeDefinition $rootNode): void ->end() ->defaultNull() ->end() + ->scalarNode('log_channel') + ->info('The channel of log message. Null to let Symfony decide.') + ->defaultNull() + ->end() ->end() ->end() ->end() @@ -1532,6 +1630,7 @@ private function addMessengerSection(ArrayNodeDefinition $rootNode, callable $en ->{$enableIfStandalone('symfony/messenger', MessageBusInterface::class)}() ->fixXmlConfig('transport') ->fixXmlConfig('bus', 'buses') + ->fixXmlConfig('stop_worker_on_signal') ->validate() ->ifTrue(fn ($v) => isset($v['buses']) && \count($v['buses']) > 1 && null === $v['default_bus']) ->thenInvalid('You must specify the "default_bus" if you define more than one bus.') @@ -1664,7 +1763,26 @@ function ($a) { ->arrayNode('stop_worker_on_signals') ->defaultValue([]) ->info('A list of signals that should stop the worker; defaults to SIGTERM and SIGINT.') - ->integerPrototype()->end() + ->beforeNormalization() + ->always(function ($signals) { + if (!\is_array($signals)) { + throw new InvalidConfigurationException('The "stop_worker_on_signals" option must be an array in messenger configuration.'); + } + + return array_map(static function ($v) { + if (\is_string($v) && str_starts_with($v, 'SIG') && \array_key_exists($v, get_defined_constants(true)['pcntl'])) { + return \constant($v); + } + + if (!\is_int($v)) { + throw new InvalidConfigurationException('The "stop_worker_on_signals" option must be an array of pcntl signals in messenger configuration.'); + } + + return $v; + }, $signals); + }) + ->end() + ->scalarPrototype()->end() ->end() ->scalarNode('default_bus')->defaultNull()->end() ->arrayNode('buses') @@ -2199,6 +2317,88 @@ private function addMailerSection(ArrayNodeDefinition $rootNode, callable $enabl ->end() ->end() ->end() + ->arrayNode('dkim_signer') + ->addDefaultsIfNotSet() + ->fixXmlConfig('option') + ->canBeEnabled() + ->info('DKIM signer configuration') + ->children() + ->scalarNode('key') + ->info('Key content, or path to key (in PEM format with the `file://` prefix)') + ->defaultValue('') + ->cannotBeEmpty() + ->end() + ->scalarNode('domain')->defaultValue('')->end() + ->scalarNode('select')->defaultValue('')->end() + ->scalarNode('passphrase') + ->info('The private key passphrase') + ->defaultValue('') + ->end() + ->arrayNode('options') + ->performNoDeepMerging() + ->normalizeKeys(false) + ->useAttributeAsKey('name') + ->prototype('variable')->end() + ->end() + ->end() + ->end() + ->arrayNode('smime_signer') + ->addDefaultsIfNotSet() + ->canBeEnabled() + ->info('S/MIME signer configuration') + ->children() + ->scalarNode('key') + ->info('Path to key (in PEM format)') + ->defaultValue('') + ->cannotBeEmpty() + ->end() + ->scalarNode('certificate') + ->info('Path to certificate (in PEM format without the `file://` prefix)') + ->defaultValue('') + ->cannotBeEmpty() + ->end() + ->scalarNode('passphrase') + ->info('The private key passphrase') + ->defaultNull() + ->end() + ->scalarNode('extra_certificates')->defaultNull()->end() + ->integerNode('sign_options')->defaultNull()->end() + ->end() + ->end() + ->arrayNode('smime_encrypter') + ->addDefaultsIfNotSet() + ->canBeEnabled() + ->info('S/MIME encrypter configuration') + ->children() + ->scalarNode('repository') + ->info('S/MIME certificate repository service. This service shall implement the `Symfony\Component\Mailer\EventListener\SmimeCertificateRepositoryInterface`.') + ->defaultValue('') + ->cannotBeEmpty() + ->end() + ->integerNode('cipher') + ->info('A set of algorithms used to encrypt the message') + ->defaultNull() + ->beforeNormalization() + ->always(function ($v): ?int { + if (null === $v) { + return null; + } + if (\defined('OPENSSL_CIPHER_'.$v)) { + return \constant('OPENSSL_CIPHER_'.$v); + } + + throw new \InvalidArgumentException(\sprintf('"%s" is not a valid OPENSSL cipher.', $v)); + }) + ->end() + ->validate() + ->ifTrue(function ($v) { + return \extension_loaded('openssl') && null !== $v && !\defined('OPENSSL_CIPHER_'.$v); + }) + ->thenInvalid('You must provide a valid cipher.') + ->end() + ->end() + ->end() + ->end() ->end() ->end() ->end() @@ -2326,7 +2526,7 @@ private function addRateLimiterSection(ArrayNodeDefinition $rootNode, callable $ ->children() ->scalarNode('lock_factory') ->info('The service ID of the lock factory used by this limiter (or null to disable locking).') - ->defaultValue('lock.factory') + ->defaultValue('auto') ->end() ->scalarNode('cache_pool') ->info('The cache pool to use for storing the current limiter state.') @@ -2339,7 +2539,12 @@ private function addRateLimiterSection(ArrayNodeDefinition $rootNode, callable $ ->enumNode('policy') ->info('The algorithm to be used by this limiter.') ->isRequired() - ->values(['fixed_window', 'token_bucket', 'sliding_window', 'no_limit']) + ->values(['fixed_window', 'token_bucket', 'sliding_window', 'compound', 'no_limit']) + ->end() + ->arrayNode('limiters') + ->info('The limiter names to use when using the "compound" policy.') + ->beforeNormalization()->castToArray()->end() + ->scalarPrototype()->end() ->end() ->integerNode('limit') ->info('The maximum allowed hits in a fixed interval or burst.') @@ -2358,8 +2563,8 @@ private function addRateLimiterSection(ArrayNodeDefinition $rootNode, callable $ ->end() ->end() ->validate() - ->ifTrue(fn ($v) => 'no_limit' !== $v['policy'] && !isset($v['limit'])) - ->thenInvalid('A limit must be provided when using a policy different than "no_limit".') + ->ifTrue(static fn ($v) => !\in_array($v['policy'], ['no_limit', 'compound']) && !isset($v['limit'])) + ->thenInvalid('A limit must be provided when using a policy different than "compound" or "no_limit".') ->end() ->end() ->end() @@ -2546,4 +2751,16 @@ private function addHtmlSanitizerSection(ArrayNodeDefinition $rootNode, callable ->end() ; } + + private function addJsonStreamerSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone): void + { + $rootNode + ->children() + ->arrayNode('json_streamer') + ->info('JSON streamer configuration') + ->{$enableIfStandalone('symfony/json-streamer', StreamWriterInterface::class)}() + ->end() + ->end() + ; + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 556a0cff6aad7..385a4caf38ded 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -12,12 +12,16 @@ namespace Symfony\Bundle\FrameworkBundle\DependencyInjection; use Composer\InstalledVersions; +use Doctrine\ORM\Mapping\Embeddable; +use Doctrine\ORM\Mapping\Entity; +use Doctrine\ORM\Mapping\MappedSuperclass; use Http\Client\HttpAsyncClient; use Http\Client\HttpClient; use phpDocumentor\Reflection\DocBlockFactoryInterface; use phpDocumentor\Reflection\Types\ContextFactory; use PhpParser\Parser; use PHPStan\PhpDocParser\Parser\PhpDocParser; +use PHPUnit\Framework\TestCase; use Psr\Cache\CacheItemPoolInterface; use Psr\Clock\ClockInterface as PsrClockInterface; use Psr\Container\ContainerInterface as PsrContainerInterface; @@ -32,6 +36,7 @@ use Symfony\Component\Asset\PackageInterface; use Symfony\Component\AssetMapper\AssetMapper; use Symfony\Component\AssetMapper\Compiler\AssetCompilerInterface; +use Symfony\Component\AssetMapper\Compressor\CompressorInterface; use Symfony\Component\BrowserKit\AbstractBrowser; use Symfony\Component\Cache\Adapter\AdapterInterface; use Symfony\Component\Cache\Adapter\ArrayAdapter; @@ -48,13 +53,16 @@ use Symfony\Component\Config\Resource\FileResource; use Symfony\Component\Config\ResourceCheckerInterface; use Symfony\Component\Console\Application; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\DataCollector\CommandDataCollector; use Symfony\Component\Console\Debug\CliRequest; use Symfony\Component\Console\Messenger\RunCommandMessageHandler; use Symfony\Component\DependencyInjection\Alias; +use Symfony\Component\DependencyInjection\Argument\IteratorArgument; use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -98,20 +106,32 @@ use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\HttpKernel\Log\DebugLoggerConfigurator; +use Symfony\Component\HttpKernel\Profiler\ProfilerStateChecker; +use Symfony\Component\JsonStreamer\Attribute\JsonStreamable; +use Symfony\Component\JsonStreamer\JsonStreamWriter; +use Symfony\Component\JsonStreamer\StreamReaderInterface; +use Symfony\Component\JsonStreamer\StreamWriterInterface; +use Symfony\Component\JsonStreamer\ValueTransformer\ValueTransformerInterface; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\LockInterface; use Symfony\Component\Lock\PersistingStoreInterface; use Symfony\Component\Lock\Store\StoreFactory; use Symfony\Component\Mailer\Bridge as MailerBridge; use Symfony\Component\Mailer\Command\MailerTestCommand; +use Symfony\Component\Mailer\EventListener\DkimSignedMessageListener; use Symfony\Component\Mailer\EventListener\MessengerTransportListener; +use Symfony\Component\Mailer\EventListener\SmimeEncryptedMessageListener; +use Symfony\Component\Mailer\EventListener\SmimeSignedMessageListener; use Symfony\Component\Mailer\Mailer; use Symfony\Component\Mercure\HubRegistry; +use Symfony\Component\Messenger\Attribute\AsMessage; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\Bridge as MessengerBridge; +use Symfony\Component\Messenger\EventListener\ResetMemoryUsageListener; use Symfony\Component\Messenger\Handler\BatchHandlerInterface; use Symfony\Component\Messenger\MessageBus; use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Messenger\Middleware\DeduplicateMiddleware; use Symfony\Component\Messenger\Middleware\RouterContextMiddleware; use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; use Symfony\Component\Messenger\Transport\TransportFactoryInterface as MessengerTransportFactoryInterface; @@ -127,8 +147,12 @@ use Symfony\Component\Notifier\Recipient\Recipient; use Symfony\Component\Notifier\TexterInterface; use Symfony\Component\Notifier\Transport\TransportFactoryInterface as NotifierTransportFactoryInterface; +use Symfony\Component\ObjectMapper\ConditionCallableInterface; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; +use Symfony\Component\ObjectMapper\TransformCallableInterface; use Symfony\Component\Process\Messenger\RunProcessMessageHandler; use Symfony\Component\PropertyAccess\PropertyAccessor; +use Symfony\Component\PropertyInfo\Extractor\ConstructorArgumentTypeExtractorInterface; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface; @@ -136,18 +160,20 @@ use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\PropertyInfo\PropertyInitializableExtractorInterface; use Symfony\Component\PropertyInfo\PropertyListExtractorInterface; -use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface; use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; -use Symfony\Component\PropertyInfo\PropertyWriteInfoExtractorInterface; +use Symfony\Component\RateLimiter\CompoundRateLimiterFactory; use Symfony\Component\RateLimiter\LimiterInterface; use Symfony\Component\RateLimiter\RateLimiterFactory; +use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; use Symfony\Component\RateLimiter\Storage\CacheStorage; use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer; use Symfony\Component\RemoteEvent\RemoteEvent; +use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Scheduler\Attribute\AsCronTask; use Symfony\Component\Scheduler\Attribute\AsPeriodicTask; use Symfony\Component\Scheduler\Attribute\AsSchedule; use Symfony\Component\Scheduler\Messenger\SchedulerTransportFactory; +use Symfony\Component\Scheduler\Messenger\Serializer\Normalizer\SchedulerTriggerNormalizer; use Symfony\Component\Security\Core\AuthenticationEvents; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; @@ -163,6 +189,7 @@ use Symfony\Component\Serializer\NameConverter\SnakeCaseToCamelCaseNameConverter; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NumberNormalizer; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Stopwatch\Stopwatch; use Symfony\Component\String\LazyString; @@ -173,12 +200,15 @@ use Symfony\Component\Translation\Extractor\PhpAstExtractor; use Symfony\Component\Translation\LocaleSwitcher; use Symfony\Component\Translation\PseudoLocalizationTranslator; +use Symfony\Component\Translation\TranslatableMessage; use Symfony\Component\Translation\Translator; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeResolver\PhpDocAwareReflectionTypeResolver; use Symfony\Component\TypeInfo\TypeResolver\StringTypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface; use Symfony\Component\Uid\Factory\UuidFactory; use Symfony\Component\Uid\UuidV4; +use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\ExpressionLanguageProvider; use Symfony\Component\Validator\ConstraintValidatorInterface; use Symfony\Component\Validator\GroupProviderInterface; @@ -193,6 +223,7 @@ use Symfony\Component\Yaml\Yaml; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CallbackInterface; +use Symfony\Contracts\Cache\NamespacedPoolInterface; use Symfony\Contracts\Cache\TagAwareCacheInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -395,7 +426,20 @@ public function load(array $configs, ContainerBuilder $container): void $this->registerPropertyAccessConfiguration($config['property_access'], $container, $loader); $this->registerSecretsConfiguration($config['secrets'], $container, $loader, $config['secret'] ?? null); - $container->getDefinition('exception_listener')->replaceArgument(3, $config['exceptions']); + $exceptionListener = $container->getDefinition('exception_listener'); + + $loggers = []; + foreach ($config['exceptions'] as $exception) { + if (!isset($exception['log_channel'])) { + continue; + } + $loggers[$exception['log_channel']] = new Reference('monolog.logger.'.$exception['log_channel'], ContainerInterface::NULL_ON_INVALID_REFERENCE); + } + + $exceptionListener + ->replaceArgument(3, $config['exceptions']) + ->setArgument(4, $loggers) + ; if ($this->readConfigEnabled('serializer', $container, $config['serializer'])) { if (!class_exists(Serializer::class)) { @@ -415,12 +459,20 @@ public function load(array $configs, ContainerBuilder $container): void $container->removeDefinition('console.command.serializer_debug'); } - if ($this->readConfigEnabled('type_info', $container, $config['type_info'])) { + if ($typeInfoEnabled = $this->readConfigEnabled('type_info', $container, $config['type_info'])) { $this->registerTypeInfoConfiguration($container, $loader); } if ($propertyInfoEnabled) { - $this->registerPropertyInfoConfiguration($container, $loader); + $this->registerPropertyInfoConfiguration($config['property_info'], $container, $loader); + } + + if ($this->readConfigEnabled('json_streamer', $container, $config['json_streamer'])) { + if (!$typeInfoEnabled) { + throw new LogicException('JsonStreamer support cannot be enabled as the TypeInfo component is not '.(interface_exists(TypeResolverInterface::class) ? 'enabled.' : 'installed. Try running "composer require symfony/type-info".')); + } + + $this->registerJsonStreamerConfiguration($config['json_streamer'], $container, $loader); } if ($this->readConfigEnabled('lock', $container, $config['lock'])) { @@ -519,7 +571,7 @@ public function load(array $configs, ContainerBuilder $container): void // messenger depends on validation being registered if ($messengerEnabled) { - $this->registerMessengerConfiguration($config['messenger'], $container, $loader, $this->readConfigEnabled('validation', $container, $config['validation'])); + $this->registerMessengerConfiguration($config['messenger'], $container, $loader, $this->readConfigEnabled('validation', $container, $config['validation']), $this->readConfigEnabled('lock', $container, $config['lock'])); } else { $container->removeDefinition('console.command.messenger_consume_messages'); $container->removeDefinition('console.command.messenger_stats'); @@ -594,6 +646,13 @@ public function load(array $configs, ContainerBuilder $container): void ->addTag('assets.package'); $container->registerForAutoconfiguration(AssetCompilerInterface::class) ->addTag('asset_mapper.compiler'); + $container->registerAttributeForAutoconfiguration(AsCommand::class, static function (ChildDefinition $definition, AsCommand $attribute): void { + $definition->addTag('console.command', [ + 'command' => $attribute->name, + 'description' => $attribute->description, + 'help' => $attribute->help ?? null, + ]); + }); $container->registerForAutoconfiguration(Command::class) ->addTag('console.command'); $container->registerForAutoconfiguration(ResourceCheckerInterface::class) @@ -615,7 +674,7 @@ public function load(array $configs, ContainerBuilder $container): void $container->registerForAutoconfiguration(DataCollectorInterface::class) ->addTag('data_collector'); $container->registerForAutoconfiguration(FormTypeInterface::class) - ->addTag('form.type'); + ->addTag('form.type', ['csrf_token_id' => '%.form.type_extension.csrf.token_id%']); $container->registerForAutoconfiguration(FormTypeGuesserInterface::class) ->addTag('form.type_guesser'); $container->registerForAutoconfiguration(FormTypeExtensionInterface::class) @@ -642,6 +701,8 @@ public function load(array $configs, ContainerBuilder $container): void ->addTag('property_info.list_extractor'); $container->registerForAutoconfiguration(PropertyTypeExtractorInterface::class) ->addTag('property_info.type_extractor'); + $container->registerForAutoconfiguration(ConstructorArgumentTypeExtractorInterface::class) + ->addTag('property_info.constructor_extractor'); $container->registerForAutoconfiguration(PropertyDescriptionExtractorInterface::class) ->addTag('property_info.description_extractor'); $container->registerForAutoconfiguration(PropertyAccessExtractorInterface::class) @@ -684,6 +745,9 @@ public function load(array $configs, ContainerBuilder $container): void $container->registerAttributeForAutoconfiguration(AsController::class, static function (ChildDefinition $definition, AsController $attribute): void { $definition->addTag('controller.service_arguments'); }); + $container->registerAttributeForAutoconfiguration(Route::class, static function (ChildDefinition $definition, Route $attribute, \ReflectionClass|\ReflectionMethod $reflection): void { + $definition->addTag('controller.service_arguments'); + }); $container->registerAttributeForAutoconfiguration(AsRemoteEventConsumer::class, static function (ChildDefinition $definition, AsRemoteEventConsumer $attribute): void { $definition->addTag('remote_event.consumer', ['consumer' => $attribute->name]); }); @@ -726,12 +790,43 @@ static function (ChildDefinition $definition, AsPeriodicTask|AsCronTask $attribu ); } + $container->registerForAutoconfiguration(CompilerPassInterface::class) + ->addTag('container.excluded', ['source' => 'because it\'s a compiler pass']); + $container->registerForAutoconfiguration(Constraint::class) + ->addTag('container.excluded', ['source' => 'because it\'s a validation constraint']); + $container->registerForAutoconfiguration(TestCase::class) + ->addTag('container.excluded', ['source' => 'because it\'s a test case']); + $container->registerForAutoconfiguration(\UnitEnum::class) + ->addTag('container.excluded', ['source' => 'because it\'s an enum']); + $container->registerAttributeForAutoconfiguration(AsMessage::class, static function (ChildDefinition $definition) { + $definition->addTag('container.excluded', ['source' => 'because it\'s a messenger message']); + }); + $container->registerAttributeForAutoconfiguration(\Attribute::class, static function (ChildDefinition $definition) { + $definition->addTag('container.excluded', ['source' => 'because it\'s a PHP attribute']); + }); + $container->registerAttributeForAutoconfiguration(Entity::class, static function (ChildDefinition $definition) { + $definition->addTag('container.excluded', ['source' => 'because it\'s a Doctrine entity']); + }); + $container->registerAttributeForAutoconfiguration(Embeddable::class, static function (ChildDefinition $definition) { + $definition->addTag('container.excluded', ['source' => 'because it\'s a Doctrine embeddable']); + }); + $container->registerAttributeForAutoconfiguration(MappedSuperclass::class, static function (ChildDefinition $definition) { + $definition->addTag('container.excluded', ['source' => 'because it\'s a Doctrine mapped superclass']); + }); + + $container->registerAttributeForAutoconfiguration(JsonStreamable::class, static function (ChildDefinition $definition, JsonStreamable $attribute) { + $definition->addTag('json_streamer.streamable', [ + 'object' => $attribute->asObject, + 'list' => $attribute->asList, + ])->addTag('container.excluded', ['source' => 'because it\'s a streamable JSON']); + }); + if (!$container->getParameter('kernel.debug')) { // remove tagged iterator argument for resource checkers $container->getDefinition('config_cache_factory')->setArguments([]); } - if (!$config['disallow_search_engine_index'] ?? false) { + if (!$config['disallow_search_engine_index']) { $container->removeDefinition('disallow_search_engine_index_response_listener'); } @@ -777,9 +872,7 @@ private function registerFormConfiguration(array $config, ContainerBuilder $cont $container->setParameter('form.type_extension.csrf.enabled', true); $container->setParameter('form.type_extension.csrf.field_name', $config['form']['csrf_protection']['field_name']); $container->setParameter('form.type_extension.csrf.field_attr', $config['form']['csrf_protection']['field_attr']); - - $container->getDefinition('form.type_extension.csrf') - ->replaceArgument(7, $config['form']['csrf_protection']['token_id']); + $container->setParameter('.form.type_extension.csrf.token_id', $config['form']['csrf_protection']['token_id']); } else { $container->setParameter('form.type_extension.csrf.enabled', false); } @@ -787,6 +880,14 @@ private function registerFormConfiguration(array $config, ContainerBuilder $cont if (!ContainerBuilder::willBeAvailable('symfony/translation', Translator::class, ['symfony/framework-bundle', 'symfony/form'])) { $container->removeDefinition('form.type_extension.upload.validator'); } + + if (ContainerBuilder::willBeAvailable('symfony/object-mapper', ObjectMapperInterface::class, ['symfony/framework-bundle'])) { + $loader->load('object_mapper.php'); + $container->registerForAutoconfiguration(TransformCallableInterface::class) + ->addTag('object_mapper.transform_callable'); + $container->registerForAutoconfiguration(ConditionCallableInterface::class) + ->addTag('object_mapper.condition_callable'); + } } private function registerHttpCacheConfiguration(array $config, ContainerBuilder $container, bool $httpMethodOverride): void @@ -863,6 +964,11 @@ private function registerProfilerConfiguration(array $config, ContainerBuilder $ $loader->load('collectors.php'); $loader->load('cache_debug.php'); + if (!class_exists(ProfilerStateChecker::class)) { + $container->removeDefinition('profiler.state_checker'); + $container->removeDefinition('profiler.is_disabled_state_checker'); + } + if ($this->isInitializedConfigEnabled('form')) { $loader->load('form_debug.php'); } @@ -897,6 +1003,10 @@ private function registerProfilerConfiguration(array $config, ContainerBuilder $ $loader->load('notifier_debug.php'); } + if (false === $config['collect_serializer_data']) { + trigger_deprecation('symfony/framework-bundle', '7.3', 'Setting the "framework.profiler.collect_serializer_data" config option to "false" is deprecated.'); + } + if ($this->isInitializedConfigEnabled('serializer') && $config['collect_serializer_data']) { $loader->load('serializer_debug.php'); } @@ -1013,7 +1123,8 @@ private function registerWorkflowConfiguration(array $config, ContainerBuilder $ } } $metadataStoreDefinition->replaceArgument(2, $transitionsMetadataDefinition); - $container->setDefinition(\sprintf('%s.metadata_store', $workflowId), $metadataStoreDefinition); + $metadataStoreId = \sprintf('%s.metadata_store', $workflowId); + $container->setDefinition($metadataStoreId, $metadataStoreDefinition); // Create places $places = array_column($workflow['places'], 'name'); @@ -1024,7 +1135,8 @@ private function registerWorkflowConfiguration(array $config, ContainerBuilder $ $definitionDefinition->addArgument($places); $definitionDefinition->addArgument($transitions); $definitionDefinition->addArgument($initialMarking); - $definitionDefinition->addArgument(new Reference(\sprintf('%s.metadata_store', $workflowId))); + $definitionDefinition->addArgument(new Reference($metadataStoreId)); + $definitionDefinitionId = \sprintf('%s.definition', $workflowId); // Create MarkingStore $markingStoreDefinition = null; @@ -1038,14 +1150,26 @@ private function registerWorkflowConfiguration(array $config, ContainerBuilder $ $markingStoreDefinition = new Reference($workflow['marking_store']['service']); } + // Validation + $workflow['definition_validators'][] = match ($workflow['type']) { + 'state_machine' => Workflow\Validator\StateMachineValidator::class, + 'workflow' => Workflow\Validator\WorkflowValidator::class, + default => throw new \LogicException(\sprintf('Invalid workflow type "%s".', $workflow['type'])), + }; + // Create Workflow $workflowDefinition = new ChildDefinition(\sprintf('%s.abstract', $type)); - $workflowDefinition->replaceArgument(0, new Reference(\sprintf('%s.definition', $workflowId))); + $workflowDefinition->replaceArgument(0, new Reference($definitionDefinitionId)); $workflowDefinition->replaceArgument(1, $markingStoreDefinition); $workflowDefinition->replaceArgument(3, $name); $workflowDefinition->replaceArgument(4, $workflow['events_to_dispatch']); - $workflowDefinition->addTag('workflow', ['name' => $name, 'metadata' => $workflow['metadata']]); + $workflowDefinition->addTag('workflow', [ + 'name' => $name, + 'metadata' => $workflow['metadata'], + 'definition_validators' => $workflow['definition_validators'], + 'definition_id' => $definitionDefinitionId, + ]); if ('workflow' === $type) { $workflowDefinition->addTag('workflow.workflow', ['name' => $name]); } elseif ('state_machine' === $type) { @@ -1054,21 +1178,10 @@ private function registerWorkflowConfiguration(array $config, ContainerBuilder $ // Store to container $container->setDefinition($workflowId, $workflowDefinition); - $container->setDefinition(\sprintf('%s.definition', $workflowId), $definitionDefinition); + $container->setDefinition($definitionDefinitionId, $definitionDefinition); $container->registerAliasForArgument($workflowId, WorkflowInterface::class, $name.'.'.$type); $container->registerAliasForArgument($workflowId, WorkflowInterface::class, $name); - // Validate Workflow - if ('state_machine' === $workflow['type']) { - $validator = new Workflow\Validator\StateMachineValidator(); - } else { - $validator = new Workflow\Validator\WorkflowValidator(); - } - - $trs = array_map(fn (Reference $ref): Workflow\Transition => $container->get((string) $ref), $transitions); - $realDefinition = new Workflow\Definition($places, $trs, $initialMarking); - $validator->validate($realDefinition, $name); - // Add workflow to Registry if ($workflow['supports']) { foreach ($workflow['supports'] as $supportedClassName) { @@ -1381,6 +1494,26 @@ private function registerAssetMapperConfiguration(array $config, ContainerBuilde ->replaceArgument(3, $config['importmap_polyfill']) ->replaceArgument(4, $config['importmap_script_attributes']) ; + + if (interface_exists(CompressorInterface::class)) { + $compressors = []; + foreach ($config['precompress']['formats'] as $format) { + $compressors[$format] = new Reference("asset_mapper.compressor.$format"); + } + + $container->getDefinition('asset_mapper.compressor')->replaceArgument(0, $compressors ?: null); + + if ($config['precompress']['enabled']) { + $container + ->getDefinition('asset_mapper.local_public_assets_filesystem') + ->addArgument(new Reference('asset_mapper.compressor')) + ->addArgument($config['precompress']['extensions']) + ; + } + } else { + $container->removeDefinition('asset_mapper.compressor'); + $container->removeDefinition('asset_mapper.assets.command.compress'); + } } /** @@ -1559,6 +1692,10 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder $translator->replaceArgument(4, $options); } + foreach ($config['globals'] as $name => $global) { + $translator->addMethodCall('addGlobalParameter', [$name, $global['value'] ?? new Definition(TranslatableMessage::class, [$global['message'], $global['parameters'] ?? [], $global['domain'] ?? null])]); + } + if ($config['pseudo_localization']['enabled']) { $options = $config['pseudo_localization']; unset($options['enabled']); @@ -1670,6 +1807,10 @@ private function registerValidationConfiguration(array $config, ContainerBuilder $validatorBuilder->addMethodCall('setMappingCache', [new Reference('validator.mapping.cache.adapter')]); } + if ($config['disable_translation'] ?? false) { + $validatorBuilder->addMethodCall('disableTranslation'); + } + $container->setParameter('validator.auto_mapping', $config['auto_mapping']); if (!$propertyInfoEnabled || !class_exists(PropertyInfoLoader::class)) { $container->removeDefinition('validator.property_info_loader'); @@ -1772,8 +1913,6 @@ private function registerPropertyAccessConfiguration(array $config, ContainerBui ->getDefinition('property_accessor') ->replaceArgument(0, $magicMethods) ->replaceArgument(1, $throw) - ->replaceArgument(3, new Reference(PropertyReadInfoExtractorInterface::class, ContainerInterface::NULL_ON_INVALID_REFERENCE)) - ->replaceArgument(4, new Reference(PropertyWriteInfoExtractorInterface::class, ContainerInterface::NULL_ON_INVALID_REFERENCE)) ; } @@ -1883,6 +2022,11 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder $container->removeDefinition('serializer.normalizer.mime_message'); } + // BC layer Serializer < 7.3 + if (!class_exists(NumberNormalizer::class)) { + $container->removeDefinition('serializer.normalizer.number'); + } + // BC layer Serializer < 7.2 if (!class_exists(SnakeCaseToCamelCaseNameConverter::class)) { $container->removeDefinition('serializer.name_converter.snake_case_to_camel_case'); @@ -1948,30 +2092,43 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder $container->setParameter('serializer.default_context', $defaultContext); } - if (!$container->hasDefinition('serializer.normalizer.object')) { - return; - } - - $arguments = $container->getDefinition('serializer.normalizer.object')->getArguments(); - $context = $arguments[6] ?? $defaultContext; - - if (isset($config['circular_reference_handler']) && $config['circular_reference_handler']) { - $context += ['circular_reference_handler' => new Reference($config['circular_reference_handler'])]; - $container->getDefinition('serializer.normalizer.object')->setArgument(5, null); + if ($config['circular_reference_handler'] ?? false) { + $container->setParameter('.serializer.circular_reference_handler', $config['circular_reference_handler']); } if ($config['max_depth_handler'] ?? false) { - $context += ['max_depth_handler' => new Reference($config['max_depth_handler'])]; + $container->setParameter('.serializer.max_depth_handler', $config['max_depth_handler']); } - $container->getDefinition('serializer.normalizer.object')->setArgument(6, $context); - $container->getDefinition('serializer.normalizer.property')->setArgument(5, $defaultContext); $container->setParameter('.serializer.named_serializers', $config['named_serializers'] ?? []); } - private function registerPropertyInfoConfiguration(ContainerBuilder $container, PhpFileLoader $loader): void + private function registerJsonStreamerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void + { + if (!class_exists(JsonStreamWriter::class)) { + throw new LogicException('JsonStreamer support cannot be enabled as the JsonStreamer component is not installed. Try running "composer require symfony/json-streamer".'); + } + + $container->registerForAutoconfiguration(ValueTransformerInterface::class) + ->addTag('json_streamer.value_transformer'); + + $loader->load('json_streamer.php'); + + $container->registerAliasForArgument('json_streamer.stream_writer', StreamWriterInterface::class, 'json.stream_writer'); + $container->registerAliasForArgument('json_streamer.stream_reader', StreamReaderInterface::class, 'json.stream_reader'); + + $container->setParameter('.json_streamer.stream_writers_dir', '%kernel.cache_dir%/json_streamer/stream_writer'); + $container->setParameter('.json_streamer.stream_readers_dir', '%kernel.cache_dir%/json_streamer/stream_reader'); + $container->setParameter('.json_streamer.lazy_ghosts_dir', '%kernel.cache_dir%/json_streamer/lazy_ghost'); + + if (\PHP_VERSION_ID >= 80400) { + $container->removeDefinition('.json_streamer.cache_warmer.lazy_ghost'); + } + } + + private function registerPropertyInfoConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void { if (!interface_exists(PropertyInfoExtractorInterface::class)) { throw new LogicException('PropertyInfo support cannot be enabled as the PropertyInfo component is not installed. Try running "composer require symfony/property-info".'); @@ -1979,18 +2136,24 @@ private function registerPropertyInfoConfiguration(ContainerBuilder $container, $loader->load('property_info.php'); + if (!$config['with_constructor_extractor']) { + $container->removeDefinition('property_info.constructor_extractor'); + } + if ( ContainerBuilder::willBeAvailable('phpstan/phpdoc-parser', PhpDocParser::class, ['symfony/framework-bundle', 'symfony/property-info']) && ContainerBuilder::willBeAvailable('phpdocumentor/type-resolver', ContextFactory::class, ['symfony/framework-bundle', 'symfony/property-info']) ) { $definition = $container->register('property_info.phpstan_extractor', PhpStanExtractor::class); $definition->addTag('property_info.type_extractor', ['priority' => -1000]); + $definition->addTag('property_info.constructor_extractor', ['priority' => -1000]); } if (ContainerBuilder::willBeAvailable('phpdocumentor/reflection-docblock', DocBlockFactoryInterface::class, ['symfony/framework-bundle', 'symfony/property-info'], true)) { $definition = $container->register('property_info.php_doc_extractor', PhpDocExtractor::class); $definition->addTag('property_info.description_extractor', ['priority' => -1000]); $definition->addTag('property_info.type_extractor', ['priority' => -1001]); + $definition->addTag('property_info.constructor_extractor', ['priority' => -1001]); } if ($container->getParameter('kernel.debug')) { @@ -2128,9 +2291,14 @@ private function registerSchedulerConfiguration(ContainerBuilder $container, Php if (!$this->hasConsole()) { $container->removeDefinition('console.command.scheduler_debug'); } + + // BC layer Scheduler < 7.3 + if (!class_exists(SchedulerTriggerNormalizer::class)) { + $container->removeDefinition('serializer.normalizer.scheduler_trigger'); + } } - private function registerMessengerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader, bool $validationEnabled): void + private function registerMessengerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader, bool $validationEnabled, bool $lockEnabled): void { if (!interface_exists(MessageBusInterface::class)) { throw new LogicException('Messenger support cannot be enabled as the Messenger component is not installed. Try running "composer require symfony/messenger".'); @@ -2146,6 +2314,10 @@ private function registerMessengerConfiguration(array $config, ContainerBuilder $container->removeDefinition('serializer.normalizer.flatten_exception'); } + if (!class_exists(ResetMemoryUsageListener::class)) { + $container->removeDefinition('messenger.listener.reset_memory_usage'); + } + if (ContainerBuilder::willBeAvailable('symfony/amqp-messenger', MessengerBridge\Amqp\Transport\AmqpTransportFactory::class, ['symfony/framework-bundle', 'symfony/messenger'])) { $container->getDefinition('messenger.transport.amqp.factory')->addTag('messenger.transport_factory'); } @@ -2185,6 +2357,13 @@ private function registerMessengerConfiguration(array $config, ContainerBuilder ['id' => 'handle_message'], ], ]; + + if ($lockEnabled && class_exists(DeduplicateMiddleware::class) && class_exists(LockFactory::class)) { + $defaultMiddleware['before'][] = ['id' => 'deduplicate_middleware']; + } else { + $container->removeDefinition('messenger.middleware.deduplicate_middleware'); + } + foreach ($config['buses'] as $busId => $bus) { $middleware = $bus['middleware']; @@ -2258,13 +2437,17 @@ private function registerMessengerConfiguration(array $config, ContainerBuilder $transportRateLimiterReferences = []; foreach ($config['transports'] as $name => $transport) { $serializerId = $transport['serializer'] ?? 'messenger.default_serializer'; + $tags = [ + 'alias' => $name, + 'is_failure_transport' => \in_array($name, $failureTransports, true), + ]; + if (str_starts_with($transport['dsn'], 'sync://')) { + $tags['is_consumable'] = false; + } $transportDefinition = (new Definition(TransportInterface::class)) ->setFactory([new Reference('messenger.transport_factory'), 'createTransport']) ->setArguments([$transport['dsn'], $transport['options'] + ['transport_name' => $name], new Reference($serializerId)]) - ->addTag('messenger.receiver', [ - 'alias' => $name, - 'is_failure_transport' => \in_array($name, $failureTransports, true), - ]) + ->addTag('messenger.receiver', $tags) ; $container->setDefinition($transportId = 'messenger.transport.'.$name, $transportDefinition); $senderAliases[$name] = $transportId; @@ -2394,7 +2577,7 @@ private function registerCacheConfiguration(array $config, ContainerBuilder $con // Inline any env vars referenced in the parameter $container->setParameter('cache.prefix.seed', $container->resolveEnvPlaceholders($container->getParameter('cache.prefix.seed'), true)); } - foreach (['psr6', 'redis', 'memcached', 'doctrine_dbal', 'pdo'] as $name) { + foreach (['psr6', 'redis', 'valkey', 'memcached', 'doctrine_dbal', 'pdo'] as $name) { if (isset($config[$name = 'default_'.$name.'_provider'])) { $container->setAlias('cache.'.$name, new Alias(CachePoolPass::getServiceProvider($container, $config[$name]), false)); } @@ -2406,12 +2589,13 @@ private function registerCacheConfiguration(array $config, ContainerBuilder $con 'tags' => false, ]; } + $redisTagAwareAdapters = [['cache.adapter.redis_tag_aware'], ['cache.adapter.valkey_tag_aware']]; foreach ($config['pools'] as $name => $pool) { $pool['adapters'] = $pool['adapters'] ?: ['cache.app']; - $isRedisTagAware = ['cache.adapter.redis_tag_aware'] === $pool['adapters']; + $isRedisTagAware = \in_array($pool['adapters'], $redisTagAwareAdapters, true); foreach ($pool['adapters'] as $provider => $adapter) { - if (($config['pools'][$adapter]['adapters'] ?? null) === ['cache.adapter.redis_tag_aware']) { + if (\in_array($config['pools'][$adapter]['adapters'] ?? null, $redisTagAwareAdapters, true)) { $isRedisTagAware = true; } elseif ($config['pools'][$adapter]['tags'] ?? false) { $pool['adapters'][$provider] = $adapter = '.'.$adapter.'.inner'; @@ -2462,6 +2646,10 @@ private function registerCacheConfiguration(array $config, ContainerBuilder $con $container->registerAliasForArgument($tagAwareId, TagAwareCacheInterface::class, $pool['name'] ?? $name); $container->registerAliasForArgument($name, CacheInterface::class, $pool['name'] ?? $name); $container->registerAliasForArgument($name, CacheItemPoolInterface::class, $pool['name'] ?? $name); + + if (interface_exists(NamespacedPoolInterface::class)) { + $container->registerAliasForArgument($name, NamespacedPoolInterface::class, $pool['name'] ?? $name); + } } $definition->setPublic($pool['public']); @@ -2668,7 +2856,6 @@ private function registerMailerConfiguration(array $config, ContainerBuilder $co } $transports = $config['dsn'] ? ['main' => $config['dsn']] : $config['transports']; $container->getDefinition('mailer.transports')->setArgument(0, $transports); - $container->getDefinition('mailer.default_transport')->setArgument(0, current($transports)); $mailer = $container->getDefinition('mailer.mailer'); if (false === $messageBus = $config['message_bus']) { @@ -2678,6 +2865,7 @@ private function registerMailerConfiguration(array $config, ContainerBuilder $co } $classToServices = [ + MailerBridge\AhaSend\Transport\AhaSendTransportFactory::class => 'mailer.transport_factory.ahasend', MailerBridge\Azure\Transport\AzureTransportFactory::class => 'mailer.transport_factory.azure', MailerBridge\Brevo\Transport\BrevoTransportFactory::class => 'mailer.transport_factory.brevo', MailerBridge\Google\Transport\GmailTransportFactory::class => 'mailer.transport_factory.gmail', @@ -2708,6 +2896,7 @@ private function registerMailerConfiguration(array $config, ContainerBuilder $co if ($webhookEnabled) { $webhookRequestParsers = [ + MailerBridge\AhaSend\Webhook\AhaSendRequestParser::class => 'mailer.webhook.request_parser.ahasend', MailerBridge\Brevo\Webhook\BrevoRequestParser::class => 'mailer.webhook.request_parser.brevo', MailerBridge\MailerSend\Webhook\MailerSendRequestParser::class => 'mailer.webhook.request_parser.mailersend', MailerBridge\Mailchimp\Webhook\MailchimpRequestParser::class => 'mailer.webhook.request_parser.mailchimp', @@ -2754,6 +2943,46 @@ private function registerMailerConfiguration(array $config, ContainerBuilder $co $container->removeDefinition('mailer.messenger_transport_listener'); } + if ($config['dkim_signer']['enabled']) { + if (!class_exists(DkimSignedMessageListener::class)) { + throw new LogicException('DKIM signed messages support cannot be enabled as this version of the Mailer component does not support it.'); + } + $dkimSigner = $container->getDefinition('mailer.dkim_signer'); + $dkimSigner->setArgument(0, $config['dkim_signer']['key']); + $dkimSigner->setArgument(1, $config['dkim_signer']['domain']); + $dkimSigner->setArgument(2, $config['dkim_signer']['select']); + $dkimSigner->setArgument(3, $config['dkim_signer']['options']); + $dkimSigner->setArgument(4, $config['dkim_signer']['passphrase']); + } else { + $container->removeDefinition('mailer.dkim_signer'); + $container->removeDefinition('mailer.dkim_signer.listener'); + } + + if ($config['smime_signer']['enabled']) { + if (!class_exists(SmimeSignedMessageListener::class)) { + throw new LogicException('SMIME signed messages support cannot be enabled as this version of the Mailer component does not support it.'); + } + $smimeSigner = $container->getDefinition('mailer.smime_signer'); + $smimeSigner->setArgument(0, $config['smime_signer']['certificate']); + $smimeSigner->setArgument(1, $config['smime_signer']['key']); + $smimeSigner->setArgument(2, $config['smime_signer']['passphrase']); + $smimeSigner->setArgument(3, $config['smime_signer']['extra_certificates']); + $smimeSigner->setArgument(4, $config['smime_signer']['sign_options']); + } else { + $container->removeDefinition('mailer.smime_signer'); + $container->removeDefinition('mailer.smime_signer.listener'); + } + + if ($config['smime_encrypter']['enabled']) { + if (!class_exists(SmimeEncryptedMessageListener::class)) { + throw new LogicException('S/MIME encrypted messages support cannot be enabled as this version of the Mailer component does not support it.'); + } + $container->setAlias('mailer.smime_encrypter.repository', $config['smime_encrypter']['repository']); + $container->setParameter('mailer.smime_encrypter.cipher', $config['smime_encrypter']['cipher']); + } else { + $container->removeDefinition('mailer.smime_encrypter.listener'); + } + if ($webhookEnabled) { $loader->load('mailer_webhook.php'); } @@ -2855,6 +3084,7 @@ private function registerNotifierConfiguration(array $config, ContainerBuilder $ NotifierBridge\Lox24\Lox24TransportFactory::class => 'notifier.transport_factory.lox24', NotifierBridge\Mailjet\MailjetTransportFactory::class => 'notifier.transport_factory.mailjet', NotifierBridge\Mastodon\MastodonTransportFactory::class => 'notifier.transport_factory.mastodon', + NotifierBridge\Matrix\MatrixTransportFactory::class => 'notifier.transport_factory.matrix', NotifierBridge\Mattermost\MattermostTransportFactory::class => 'notifier.transport_factory.mattermost', NotifierBridge\Mercure\MercureTransportFactory::class => 'notifier.transport_factory.mercure', NotifierBridge\MessageBird\MessageBirdTransportFactory::class => 'notifier.transport_factory.message-bird', @@ -2965,6 +3195,7 @@ private function registerNotifierConfiguration(array $config, ContainerBuilder $ $loader->load('notifier_webhook.php'); $webhookRequestParsers = [ + NotifierBridge\Smsbox\Webhook\SmsboxRequestParser::class => 'notifier.webhook.request_parser.smsbox', NotifierBridge\Sweego\Webhook\SweegoRequestParser::class => 'notifier.webhook.request_parser.sweego', NotifierBridge\Twilio\Webhook\TwilioRequestParser::class => 'notifier.webhook.request_parser.twilio', NotifierBridge\Vonage\Webhook\VonageRequestParser::class => 'notifier.webhook.request_parser.vonage', @@ -3017,13 +3248,30 @@ private function registerRateLimiterConfiguration(array $config, ContainerBuilde { $loader->load('rate_limiter.php'); + $limiters = []; + $compoundLimiters = []; + foreach ($config['limiters'] as $name => $limiterConfig) { + if ('compound' === $limiterConfig['policy']) { + $compoundLimiters[$name] = $limiterConfig; + + continue; + } + + unset($limiterConfig['limiters']); + + $limiters[] = $name; + // default configuration (when used by other DI extensions) $limiterConfig += ['lock_factory' => 'lock.factory', 'cache_pool' => 'cache.rate_limiter']; $limiter = $container->setDefinition($limiterId = 'limiter.'.$name, new ChildDefinition('limiter')) ->addTag('rate_limiter', ['name' => $name]); + if ('auto' === $limiterConfig['lock_factory']) { + $limiterConfig['lock_factory'] = $this->isInitializedConfigEnabled('lock') ? 'lock.factory' : null; + } + if (null !== $limiterConfig['lock_factory']) { if (!interface_exists(LockInterface::class)) { throw new LogicException(\sprintf('Rate limiter "%s" requires the Lock component to be installed. Try running "composer require symfony/lock".', $name)); @@ -3047,7 +3295,36 @@ private function registerRateLimiterConfiguration(array $config, ContainerBuilde $limiterConfig['id'] = $name; $limiter->replaceArgument(0, $limiterConfig); - $container->registerAliasForArgument($limiterId, RateLimiterFactory::class, $name.'.limiter'); + $factoryAlias = $container->registerAliasForArgument($limiterId, RateLimiterFactory::class, $name.'.limiter'); + + if (interface_exists(RateLimiterFactoryInterface::class)) { + $container->registerAliasForArgument($limiterId, RateLimiterFactoryInterface::class, $name.'.limiter'); + $factoryAlias->setDeprecated('symfony/dependency-injection', '7.3', 'The "%alias_id%" autowiring alias is deprecated and will be removed in 8.0, use "RateLimiterFactoryInterface" instead.'); + } + } + + if ($compoundLimiters && !class_exists(CompoundRateLimiterFactory::class)) { + throw new LogicException('Configuring compound rate limiters is only available in symfony/rate-limiter 7.3+.'); + } + + foreach ($compoundLimiters as $name => $limiterConfig) { + if (!$limiterConfig['limiters']) { + throw new LogicException(\sprintf('Compound rate limiter "%s" requires at least one sub-limiter.', $name)); + } + + if (\array_diff($limiterConfig['limiters'], $limiters)) { + throw new LogicException(\sprintf('Compound rate limiter "%s" requires at least one sub-limiter to be configured.', $name)); + } + + $container->register($limiterId = 'limiter.'.$name, CompoundRateLimiterFactory::class) + ->addTag('rate_limiter', ['name' => $name]) + ->addArgument(new IteratorArgument(\array_map( + static fn (string $name) => new Reference('limiter.'.$name), + $limiterConfig['limiters'] + ))) + ; + + $container->registerAliasForArgument($limiterId, RateLimiterFactoryInterface::class, $name.'.limiter'); } } diff --git a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php index e83c4dfe611d1..7c5ba6e39e121 100644 --- a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php +++ b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php @@ -54,8 +54,10 @@ use Symfony\Component\HttpKernel\DependencyInjection\RemoveEmptyControllerArgumentLocatorsPass; use Symfony\Component\HttpKernel\DependencyInjection\ResettableServicePass; use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\JsonStreamer\DependencyInjection\StreamablePass; use Symfony\Component\Messenger\DependencyInjection\MessengerPass; use Symfony\Component\Mime\DependencyInjection\AddMimeTypeGuesserPass; +use Symfony\Component\PropertyInfo\DependencyInjection\PropertyInfoConstructorPass; use Symfony\Component\PropertyInfo\DependencyInjection\PropertyInfoPass; use Symfony\Component\Routing\DependencyInjection\AddExpressionLanguageProvidersPass; use Symfony\Component\Routing\DependencyInjection\RoutingResolverPass; @@ -75,6 +77,7 @@ use Symfony\Component\VarExporter\Internal\Registry; use Symfony\Component\Workflow\DependencyInjection\WorkflowDebugPass; use Symfony\Component\Workflow\DependencyInjection\WorkflowGuardListenerPass; +use Symfony\Component\Workflow\DependencyInjection\WorkflowValidatorPass; // Help opcache.preload discover always-needed symbols class_exists(ApcuAdapter::class); @@ -164,12 +167,14 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new FragmentRendererPass()); $this->addCompilerPassIfExists($container, SerializerPass::class); $this->addCompilerPassIfExists($container, PropertyInfoPass::class); + $this->addCompilerPassIfExists($container, PropertyInfoConstructorPass::class); $container->addCompilerPass(new ControllerArgumentValueResolverPass()); $container->addCompilerPass(new CachePoolPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 32); $container->addCompilerPass(new CachePoolClearerPass(), PassConfig::TYPE_AFTER_REMOVING); $container->addCompilerPass(new CachePoolPrunerPass(), PassConfig::TYPE_AFTER_REMOVING); $this->addCompilerPassIfExists($container, FormPass::class); $this->addCompilerPassIfExists($container, WorkflowGuardListenerPass::class); + $this->addCompilerPassIfExists($container, WorkflowValidatorPass::class); $container->addCompilerPass(new ResettableServicePass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -32); $container->addCompilerPass(new RegisterLocaleAwareServicesPass()); $container->addCompilerPass(new TestServiceContainerWeakRefPass(), PassConfig::TYPE_BEFORE_REMOVING, -32); @@ -186,6 +191,7 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new ErrorLoggerCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -32); $container->addCompilerPass(new VirtualRequestStackPass()); $container->addCompilerPass(new TranslationUpdateCommandPass(), PassConfig::TYPE_BEFORE_REMOVING); + $this->addCompilerPassIfExists($container, StreamablePass::class); if ($container->getParameter('kernel.debug')) { $container->addCompilerPass(new AddDebugLogProcessorPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 2); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php index 404e7af18d0a1..eeb1ceb4f8962 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php @@ -17,6 +17,7 @@ use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\AssetMapper\AssetMapperRepository; use Symfony\Component\AssetMapper\Command\AssetMapperCompileCommand; +use Symfony\Component\AssetMapper\Command\CompressAssetsCommand; use Symfony\Component\AssetMapper\Command\DebugAssetMapperCommand; use Symfony\Component\AssetMapper\Command\ImportMapAuditCommand; use Symfony\Component\AssetMapper\Command\ImportMapInstallCommand; @@ -28,6 +29,11 @@ use Symfony\Component\AssetMapper\Compiler\CssAssetUrlCompiler; use Symfony\Component\AssetMapper\Compiler\JavaScriptImportPathCompiler; use Symfony\Component\AssetMapper\Compiler\SourceMappingUrlsCompiler; +use Symfony\Component\AssetMapper\Compressor\BrotliCompressor; +use Symfony\Component\AssetMapper\Compressor\ChainCompressor; +use Symfony\Component\AssetMapper\Compressor\CompressorInterface; +use Symfony\Component\AssetMapper\Compressor\GzipCompressor; +use Symfony\Component\AssetMapper\Compressor\ZstandardCompressor; use Symfony\Component\AssetMapper\Factory\CachedMappedAssetFactory; use Symfony\Component\AssetMapper\Factory\MappedAssetFactory; use Symfony\Component\AssetMapper\ImportMap\ImportMapAuditor; @@ -226,6 +232,7 @@ ->args([ service('asset_mapper.importmap.manager'), service('asset_mapper.importmap.version_checker'), + param('kernel.project_dir'), ]) ->tag('console.command') @@ -254,5 +261,20 @@ ->set('asset_mapper.importmap.command.outdated', ImportMapOutdatedCommand::class) ->args([service('asset_mapper.importmap.update_checker')]) ->tag('console.command') + + ->set('asset_mapper.compressor.brotli', BrotliCompressor::class) + ->set('asset_mapper.compressor.zstandard', ZstandardCompressor::class) + ->set('asset_mapper.compressor.gzip', GzipCompressor::class) + + ->set('asset_mapper.compressor', ChainCompressor::class) + ->args([ + abstract_arg('compressor'), + service('logger'), + ]) + ->alias(CompressorInterface::class, 'asset_mapper.compressor') + + ->set('asset_mapper.assets.command.compress', CompressAssetsCommand::class) + ->args([service('asset_mapper.compressor')]) + ->tag('console.command') ; }; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/cache.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/cache.php index ad4dca42d3b78..3d96ba05994ca 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/cache.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/cache.php @@ -140,6 +140,7 @@ 'reset' => 'reset', ]) ->tag('monolog.logger', ['channel' => 'cache']) + ->alias('cache.adapter.valkey', 'cache.adapter.redis') ->set('cache.adapter.redis_tag_aware', RedisTagAwareAdapter::class) ->abstract() @@ -156,6 +157,7 @@ 'reset' => 'reset', ]) ->tag('monolog.logger', ['channel' => 'cache']) + ->alias('cache.adapter.valkey_tag_aware', 'cache.adapter.redis_tag_aware') ->set('cache.adapter.memcached', MemcachedAdapter::class) ->abstract() diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php index 9df82e20e2c28..7ef10bb522af0 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php @@ -36,7 +36,7 @@ use Symfony\Bundle\FrameworkBundle\Command\SecretsRevealCommand; use Symfony\Bundle\FrameworkBundle\Command\SecretsSetCommand; use Symfony\Bundle\FrameworkBundle\Command\TranslationDebugCommand; -use Symfony\Bundle\FrameworkBundle\Command\TranslationUpdateCommand; +use Symfony\Bundle\FrameworkBundle\Command\TranslationExtractCommand; use Symfony\Bundle\FrameworkBundle\Command\WorkflowDumpCommand; use Symfony\Bundle\FrameworkBundle\Command\YamlLintCommand; use Symfony\Bundle\FrameworkBundle\Console\Application; @@ -44,6 +44,7 @@ use Symfony\Component\Console\EventListener\ErrorListener; use Symfony\Component\Console\Messenger\RunCommandMessageHandler; use Symfony\Component\Dotenv\Command\DebugCommand as DotenvDebugCommand; +use Symfony\Component\ErrorHandler\Command\ErrorDumpCommand; use Symfony\Component\Messenger\Command\ConsumeMessagesCommand; use Symfony\Component\Messenger\Command\DebugCommand as MessengerDebugCommand; use Symfony\Component\Messenger\Command\FailedMessagesRemoveCommand; @@ -59,6 +60,7 @@ use Symfony\Component\Translation\Command\TranslationPushCommand; use Symfony\Component\Translation\Command\XliffLintCommand; use Symfony\Component\Validator\Command\DebugCommand as ValidatorDebugCommand; +use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface; return static function (ContainerConfigurator $container) { $container->services() @@ -266,7 +268,7 @@ ]) ->tag('console.command') - ->set('console.command.translation_extract', TranslationUpdateCommand::class) + ->set('console.command.translation_extract', TranslationExtractCommand::class) ->args([ service('translation.writer'), service('translation.reader'), @@ -385,6 +387,14 @@ ]) ->tag('console.command') + ->set('console.command.error_dumper', ErrorDumpCommand::class) + ->args([ + service('filesystem'), + service('error_renderer.html'), + service(EntrypointLookupInterface::class)->nullOnInvalid(), + ]) + ->tag('console.command') + ->set('console.messenger.application', Application::class) ->share(false) ->call('setAutoExit', [false]) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/debug.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/debug.php index 5c426653daeca..842f5b35b412a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/debug.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/debug.php @@ -25,6 +25,7 @@ service('debug.stopwatch'), service('logger')->nullOnInvalid(), service('.virtual_request_stack')->nullOnInvalid(), + service('profiler.is_disabled_state_checker')->nullOnInvalid(), ]) ->tag('monolog.logger', ['channel' => 'event']) ->tag('kernel.reset', ['method' => 'reset']) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/form_csrf.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/form_csrf.php index c63d087c864db..a86bb7c60fdcf 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/form_csrf.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/form_csrf.php @@ -24,7 +24,7 @@ param('validator.translation_domain'), service('form.server_params'), param('form.type_extension.csrf.field_attr'), - abstract_arg('framework.form.csrf_protection.token_id'), + param('.form.type_extension.csrf.token_id'), ]) ->tag('form.type_extension') ; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/json_streamer.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/json_streamer.php new file mode 100644 index 0000000000000..79fb25833e066 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/json_streamer.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Component\JsonStreamer\CacheWarmer\LazyGhostCacheWarmer; +use Symfony\Component\JsonStreamer\CacheWarmer\StreamerCacheWarmer; +use Symfony\Component\JsonStreamer\JsonStreamReader; +use Symfony\Component\JsonStreamer\JsonStreamWriter; +use Symfony\Component\JsonStreamer\Mapping\GenericTypePropertyMetadataLoader; +use Symfony\Component\JsonStreamer\Mapping\PropertyMetadataLoader; +use Symfony\Component\JsonStreamer\Mapping\Read\AttributePropertyMetadataLoader as ReadAttributePropertyMetadataLoader; +use Symfony\Component\JsonStreamer\Mapping\Read\DateTimeTypePropertyMetadataLoader as ReadDateTimeTypePropertyMetadataLoader; +use Symfony\Component\JsonStreamer\Mapping\Write\AttributePropertyMetadataLoader as WriteAttributePropertyMetadataLoader; +use Symfony\Component\JsonStreamer\Mapping\Write\DateTimeTypePropertyMetadataLoader as WriteDateTimeTypePropertyMetadataLoader; +use Symfony\Component\JsonStreamer\ValueTransformer\DateTimeToStringValueTransformer; +use Symfony\Component\JsonStreamer\ValueTransformer\StringToDateTimeValueTransformer; + +return static function (ContainerConfigurator $container) { + $container->services() + // stream reader/writer + ->set('json_streamer.stream_writer', JsonStreamWriter::class) + ->args([ + tagged_locator('json_streamer.value_transformer'), + service('json_streamer.write.property_metadata_loader'), + param('.json_streamer.stream_writers_dir'), + ]) + ->set('json_streamer.stream_reader', JsonStreamReader::class) + ->args([ + tagged_locator('json_streamer.value_transformer'), + service('json_streamer.read.property_metadata_loader'), + param('.json_streamer.stream_readers_dir'), + param('.json_streamer.lazy_ghosts_dir'), + ]) + ->alias(JsonStreamWriter::class, 'json_streamer.stream_writer') + ->alias(JsonStreamReader::class, 'json_streamer.stream_reader') + + // metadata + ->set('json_streamer.write.property_metadata_loader', PropertyMetadataLoader::class) + ->args([ + service('type_info.resolver'), + ]) + ->set('.json_streamer.write.property_metadata_loader.generic', GenericTypePropertyMetadataLoader::class) + ->decorate('json_streamer.write.property_metadata_loader') + ->args([ + service('.inner'), + service('type_info.type_context_factory'), + ]) + ->set('.json_streamer.write.property_metadata_loader.date_time', WriteDateTimeTypePropertyMetadataLoader::class) + ->decorate('json_streamer.write.property_metadata_loader') + ->args([ + service('.inner'), + ]) + ->set('.json_streamer.write.property_metadata_loader.attribute', WriteAttributePropertyMetadataLoader::class) + ->decorate('json_streamer.write.property_metadata_loader') + ->args([ + service('.inner'), + tagged_locator('json_streamer.value_transformer'), + service('type_info.resolver'), + ]) + + ->set('json_streamer.read.property_metadata_loader', PropertyMetadataLoader::class) + ->args([ + service('type_info.resolver'), + ]) + ->set('.json_streamer.read.property_metadata_loader.generic', GenericTypePropertyMetadataLoader::class) + ->decorate('json_streamer.read.property_metadata_loader') + ->args([ + service('.inner'), + service('type_info.type_context_factory'), + ]) + ->set('.json_streamer.read.property_metadata_loader.date_time', ReadDateTimeTypePropertyMetadataLoader::class) + ->decorate('json_streamer.read.property_metadata_loader') + ->args([ + service('.inner'), + ]) + ->set('.json_streamer.read.property_metadata_loader.attribute', ReadAttributePropertyMetadataLoader::class) + ->decorate('json_streamer.read.property_metadata_loader') + ->args([ + service('.inner'), + tagged_locator('json_streamer.value_transformer'), + service('type_info.resolver'), + ]) + + // value transformers + ->set('json_streamer.value_transformer.date_time_to_string', DateTimeToStringValueTransformer::class) + ->tag('json_streamer.value_transformer') + + ->set('json_streamer.value_transformer.string_to_date_time', StringToDateTimeValueTransformer::class) + ->tag('json_streamer.value_transformer') + + // cache + ->set('.json_streamer.cache_warmer.streamer', StreamerCacheWarmer::class) + ->args([ + abstract_arg('streamable'), + service('json_streamer.write.property_metadata_loader'), + service('json_streamer.read.property_metadata_loader'), + param('.json_streamer.stream_writers_dir'), + param('.json_streamer.stream_readers_dir'), + service('logger')->ignoreOnInvalid(), + ]) + ->tag('kernel.cache_warmer') + + ->set('.json_streamer.cache_warmer.lazy_ghost', LazyGhostCacheWarmer::class) + ->args([ + abstract_arg('streamable class names'), + param('.json_streamer.lazy_ghosts_dir'), + ]) + ->tag('kernel.cache_warmer') + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer.php index 9eb545ca268ea..43e7fb9a5e4cb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer.php @@ -12,16 +12,21 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Symfony\Component\Mailer\Command\MailerTestCommand; +use Symfony\Component\Mailer\EventListener\DkimSignedMessageListener; use Symfony\Component\Mailer\EventListener\EnvelopeListener; use Symfony\Component\Mailer\EventListener\MessageListener; use Symfony\Component\Mailer\EventListener\MessageLoggerListener; use Symfony\Component\Mailer\EventListener\MessengerTransportListener; +use Symfony\Component\Mailer\EventListener\SmimeEncryptedMessageListener; +use Symfony\Component\Mailer\EventListener\SmimeSignedMessageListener; use Symfony\Component\Mailer\Mailer; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\Messenger\MessageHandler; use Symfony\Component\Mailer\Transport; use Symfony\Component\Mailer\Transport\TransportInterface; use Symfony\Component\Mailer\Transport\Transports; +use Symfony\Component\Mime\Crypto\DkimSigner; +use Symfony\Component\Mime\Crypto\SMimeSigner; return static function (ContainerConfigurator $container) { $container->services() @@ -45,11 +50,7 @@ tagged_iterator('mailer.transport_factory'), ]) - ->set('mailer.default_transport', TransportInterface::class) - ->factory([service('mailer.transport_factory'), 'fromString']) - ->args([ - abstract_arg('env(MAILER_DSN)'), - ]) + ->alias('mailer.default_transport', 'mailer.transports') ->alias(TransportInterface::class, 'mailer.default_transport') ->set('mailer.messenger.message_handler', MessageHandler::class) @@ -78,6 +79,43 @@ ->set('mailer.messenger_transport_listener', MessengerTransportListener::class) ->tag('kernel.event_subscriber') + ->set('mailer.dkim_signer', DkimSigner::class) + ->args([ + abstract_arg('key'), + abstract_arg('domain'), + abstract_arg('select'), + abstract_arg('options'), + abstract_arg('passphrase'), + ]) + + ->set('mailer.smime_signer', SMimeSigner::class) + ->args([ + abstract_arg('certificate'), + abstract_arg('key'), + abstract_arg('passphrase'), + abstract_arg('extraCertificates'), + abstract_arg('signOptions'), + ]) + + ->set('mailer.dkim_signer.listener', DkimSignedMessageListener::class) + ->args([ + service('mailer.dkim_signer'), + ]) + ->tag('kernel.event_subscriber') + + ->set('mailer.smime_signer.listener', SmimeSignedMessageListener::class) + ->args([ + service('mailer.smime_signer'), + ]) + ->tag('kernel.event_subscriber') + + ->set('mailer.smime_encrypter.listener', SmimeEncryptedMessageListener::class) + ->args([ + service('mailer.smime_encrypter.repository'), + param('mailer.smime_encrypter.cipher'), + ]) + ->tag('kernel.event_subscriber') + ->set('console.command.mailer_test', MailerTestCommand::class) ->args([ service('mailer.transports'), diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php index c0e7cc06a4eb8..2c79b4d55556f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_transports.php @@ -11,6 +11,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\Component\Mailer\Bridge\AhaSend\Transport\AhaSendTransportFactory; use Symfony\Component\Mailer\Bridge\Amazon\Transport\SesTransportFactory; use Symfony\Component\Mailer\Bridge\Azure\Transport\AzureTransportFactory; use Symfony\Component\Mailer\Bridge\Brevo\Transport\BrevoTransportFactory; @@ -47,6 +48,7 @@ ->tag('monolog.logger', ['channel' => 'mailer']); $factories = [ + 'ahasend' => AhaSendTransportFactory::class, 'amazon' => SesTransportFactory::class, 'azure' => AzureTransportFactory::class, 'brevo' => BrevoTransportFactory::class, diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php index c574324db0b9f..b815336b2528f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/mailer_webhook.php @@ -11,6 +11,8 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\Component\Mailer\Bridge\AhaSend\RemoteEvent\AhaSendPayloadConverter; +use Symfony\Component\Mailer\Bridge\AhaSend\Webhook\AhaSendRequestParser; use Symfony\Component\Mailer\Bridge\Brevo\RemoteEvent\BrevoPayloadConverter; use Symfony\Component\Mailer\Bridge\Brevo\Webhook\BrevoRequestParser; use Symfony\Component\Mailer\Bridge\Mailchimp\RemoteEvent\MailchimpPayloadConverter; @@ -86,6 +88,11 @@ ->args([service('mailer.payload_converter.sweego')]) ->alias(SweegoRequestParser::class, 'mailer.webhook.request_parser.sweego') + ->set('mailer.payload_converter.ahasend', AhaSendPayloadConverter::class) + ->set('mailer.webhook.request_parser.ahasend', AhaSendRequestParser::class) + ->args([service('mailer.payload_converter.ahasend')]) + ->alias(AhaSendRequestParser::class, 'mailer.webhook.request_parser.ahasend') + ->set('mailer.payload_converter.mailchimp', MailchimpPayloadConverter::class) ->set('mailer.webhook.request_parser.mailchimp', MailchimpRequestParser::class) ->args([service('mailer.payload_converter.mailchimp')]) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/messenger.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/messenger.php index 40f5b84caa2e0..e02cd1ca34c0d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/messenger.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/messenger.php @@ -18,6 +18,7 @@ use Symfony\Component\Messenger\Bridge\Redis\Transport\RedisTransportFactory; use Symfony\Component\Messenger\EventListener\AddErrorDetailsStampListener; use Symfony\Component\Messenger\EventListener\DispatchPcntlSignalListener; +use Symfony\Component\Messenger\EventListener\ResetMemoryUsageListener; use Symfony\Component\Messenger\EventListener\ResetServicesListener; use Symfony\Component\Messenger\EventListener\SendFailedMessageForRetryListener; use Symfony\Component\Messenger\EventListener\SendFailedMessageToFailureTransportListener; @@ -25,6 +26,7 @@ use Symfony\Component\Messenger\EventListener\StopWorkerOnRestartSignalListener; use Symfony\Component\Messenger\Handler\RedispatchMessageHandler; use Symfony\Component\Messenger\Middleware\AddBusNameStampMiddleware; +use Symfony\Component\Messenger\Middleware\DeduplicateMiddleware; use Symfony\Component\Messenger\Middleware\DispatchAfterCurrentBusMiddleware; use Symfony\Component\Messenger\Middleware\FailedMessageProcessingMiddleware; use Symfony\Component\Messenger\Middleware\HandleMessageMiddleware; @@ -86,6 +88,11 @@ ->tag('monolog.logger', ['channel' => 'messenger']) ->call('setLogger', [service('logger')->ignoreOnInvalid()]) + ->set('messenger.middleware.deduplicate_middleware', DeduplicateMiddleware::class) + ->args([ + service('lock.factory'), + ]) + ->set('messenger.middleware.add_bus_name_stamp_middleware', AddBusNameStampMiddleware::class) ->abstract() @@ -212,6 +219,9 @@ service('services_resetter'), ]) + ->set('messenger.listener.reset_memory_usage', ResetMemoryUsageListener::class) + ->tag('kernel.event_subscriber') + ->set('messenger.routable_message_bus', RoutableMessageBus::class) ->args([ abstract_arg('message bus locator'), diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php index f28007decf81b..d1adcfc370395 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php @@ -36,6 +36,7 @@ 'line-notify' => Bridge\LineNotify\LineNotifyTransportFactory::class, 'linked-in' => Bridge\LinkedIn\LinkedInTransportFactory::class, 'mastodon' => Bridge\Mastodon\MastodonTransportFactory::class, + 'matrix' => Bridge\Matrix\MatrixTransportFactory::class, 'mattermost' => Bridge\Mattermost\MattermostTransportFactory::class, 'mercure' => Bridge\Mercure\MercureTransportFactory::class, 'microsoft-teams' => Bridge\MicrosoftTeams\MicrosoftTeamsTransportFactory::class, diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_webhook.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_webhook.php index 6447f41394679..0b30c33e25afa 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_webhook.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_webhook.php @@ -11,12 +11,16 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\Component\Notifier\Bridge\Smsbox\Webhook\SmsboxRequestParser; use Symfony\Component\Notifier\Bridge\Sweego\Webhook\SweegoRequestParser; use Symfony\Component\Notifier\Bridge\Twilio\Webhook\TwilioRequestParser; use Symfony\Component\Notifier\Bridge\Vonage\Webhook\VonageRequestParser; return static function (ContainerConfigurator $container) { $container->services() + ->set('notifier.webhook.request_parser.smsbox', SmsboxRequestParser::class) + ->alias(SmsboxRequestParser::class, 'notifier.webhook.request_parser.smsbox') + ->set('notifier.webhook.request_parser.sweego', SweegoRequestParser::class) ->alias(SweegoRequestParser::class, 'notifier.webhook.request_parser.sweego') diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/object_mapper.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/object_mapper.php new file mode 100644 index 0000000000000..8addad4da04fe --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/object_mapper.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; +use Symfony\Component\ObjectMapper\Metadata\ReflectionObjectMapperMetadataFactory; +use Symfony\Component\ObjectMapper\ObjectMapper; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('object_mapper.metadata_factory', ReflectionObjectMapperMetadataFactory::class) + ->alias(ObjectMapperMetadataFactoryInterface::class, 'object_mapper.metadata_factory') + + ->set('object_mapper', ObjectMapper::class) + ->args([ + service('object_mapper.metadata_factory'), + service('property_accessor')->ignoreOnInvalid(), + tagged_locator('object_mapper.transform_callable'), + tagged_locator('object_mapper.condition_callable'), + ]) + ->alias(ObjectMapperInterface::class, 'object_mapper') + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/profiling.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/profiling.php index 4ae34649b4aaf..68fb295bb8768 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/profiling.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/profiling.php @@ -16,6 +16,7 @@ use Symfony\Component\HttpKernel\EventListener\ProfilerListener; use Symfony\Component\HttpKernel\Profiler\FileProfilerStorage; use Symfony\Component\HttpKernel\Profiler\Profiler; +use Symfony\Component\HttpKernel\Profiler\ProfilerStateChecker; return static function (ContainerConfigurator $container) { $container->services() @@ -56,5 +57,15 @@ ->set('.virtual_request_stack', VirtualRequestStack::class) ->args([service('request_stack')]) ->public() + + ->set('profiler.state_checker', ProfilerStateChecker::class) + ->args([ + service_locator(['profiler' => service('profiler')->ignoreOnUninitialized()]), + param('kernel.runtime_mode.web'), + ]) + + ->set('profiler.is_disabled_state_checker', 'Closure') + ->factory(['Closure', 'fromCallable']) + ->args([[service('profiler.state_checker'), 'isProfilerDisabled']]) ; }; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/property_access.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/property_access.php index 85ab9f18e6e3b..4c9feb660597f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/property_access.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/property_access.php @@ -13,6 +13,8 @@ use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyWriteInfoExtractorInterface; return static function (ContainerConfigurator $container) { $container->services() @@ -21,8 +23,8 @@ abstract_arg('magic methods allowed, set by the extension'), abstract_arg('throw exceptions, set by the extension'), service('cache.property_access')->ignoreOnInvalid(), - abstract_arg('propertyReadInfoExtractor, set by the extension'), - abstract_arg('propertyWriteInfoExtractor, set by the extension'), + service(PropertyReadInfoExtractorInterface::class)->nullOnInvalid(), + service(PropertyWriteInfoExtractorInterface::class)->nullOnInvalid(), ]) ->alias(PropertyAccessorInterface::class, 'property_accessor') diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/property_info.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/property_info.php index 90587839d54c4..505dda6f4fd75 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/property_info.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/property_info.php @@ -11,6 +11,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\Component\PropertyInfo\Extractor\ConstructorExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface; use Symfony\Component\PropertyInfo\PropertyDescriptionExtractorInterface; @@ -43,10 +44,15 @@ ->set('property_info.reflection_extractor', ReflectionExtractor::class) ->tag('property_info.list_extractor', ['priority' => -1000]) ->tag('property_info.type_extractor', ['priority' => -1002]) + ->tag('property_info.constructor_extractor', ['priority' => -1002]) ->tag('property_info.access_extractor', ['priority' => -1000]) ->tag('property_info.initializable_extractor', ['priority' => -1000]) ->alias(PropertyReadInfoExtractorInterface::class, 'property_info.reflection_extractor') ->alias(PropertyWriteInfoExtractorInterface::class, 'property_info.reflection_extractor') + + ->set('property_info.constructor_extractor', ConstructorExtractor::class) + ->args([[]]) + ->tag('property_info.type_extractor', ['priority' => -999]) ; }; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing/errors.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing/errors.php new file mode 100644 index 0000000000000..36a46dee407ea --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing/errors.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; +use Symfony\Component\Routing\Loader\XmlFileLoader; + +return function (RoutingConfigurator $routes): void { + foreach (debug_backtrace() as $trace) { + if (isset($trace['object']) && $trace['object'] instanceof XmlFileLoader && 'doImport' === $trace['function']) { + if (__DIR__ === dirname(realpath($trace['args'][3]))) { + trigger_deprecation('symfony/routing', '7.3', 'The "errors.xml" routing configuration file is deprecated, import "errors.php" instead.'); + + break; + } + } + } + + $routes->add('_preview_error', '/{code}.{_format}') + ->controller('error_controller::preview') + ->defaults(['_format' => 'html']) + ->requirements(['code' => '\d+']) + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing/errors.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing/errors.xml index 13a9cc4076c79..f890aef1e3365 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing/errors.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing/errors.xml @@ -4,9 +4,5 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/routing https://symfony.com/schema/routing/routing-1.0.xsd"> - - error_controller::preview - html - \d+ - + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing/webhook.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing/webhook.php new file mode 100644 index 0000000000000..ea80311599fa0 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing/webhook.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; +use Symfony\Component\Routing\Loader\XmlFileLoader; + +return function (RoutingConfigurator $routes): void { + foreach (debug_backtrace() as $trace) { + if (isset($trace['object']) && $trace['object'] instanceof XmlFileLoader && 'doImport' === $trace['function']) { + if (__DIR__ === dirname(realpath($trace['args'][3]))) { + trigger_deprecation('symfony/routing', '7.3', 'The "webhook.xml" routing configuration file is deprecated, import "webhook.php" instead.'); + + break; + } + } + } + + $routes->add('_webhook_controller', '/{type}') + ->controller('webhook_controller::handle') + ->requirements(['type' => '.+']) + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing/webhook.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing/webhook.xml index dfa95cfac555e..8cb64ebb74fd7 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing/webhook.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/routing/webhook.xml @@ -4,8 +4,5 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/routing https://symfony.com/schema/routing/routing-1.0.xsd"> - - webhook.controller::handle - .+ - + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/scheduler.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/scheduler.php index 7b2856d8272ee..4cbfb73b56226 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/scheduler.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/scheduler.php @@ -13,6 +13,7 @@ use Symfony\Component\Scheduler\EventListener\DispatchSchedulerEventListener; use Symfony\Component\Scheduler\Messenger\SchedulerTransportFactory; +use Symfony\Component\Scheduler\Messenger\Serializer\Normalizer\SchedulerTriggerNormalizer; use Symfony\Component\Scheduler\Messenger\ServiceCallMessageHandler; return static function (ContainerConfigurator $container) { @@ -34,5 +35,7 @@ service('event_dispatcher'), ]) ->tag('kernel.event_subscriber') + ->set('serializer.normalizer.scheduler_trigger', SchedulerTriggerNormalizer::class) + ->tag('serializer.normalizer', ['built_in' => true, 'priority' => -880]) ; }; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index 491cd1e4ffb7c..7f4b48a18b296 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd @@ -46,6 +46,7 @@ + @@ -79,7 +80,7 @@ - + @@ -206,6 +207,7 @@ + @@ -230,6 +232,16 @@ + + + + + + + + + + @@ -244,6 +256,7 @@ + @@ -273,6 +286,24 @@ + + + + + + + + + + + + + + + + + + @@ -286,6 +317,7 @@ + @@ -364,6 +396,7 @@ + @@ -416,6 +449,7 @@ + @@ -595,6 +629,7 @@ + @@ -776,6 +811,9 @@ + + + @@ -798,6 +836,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -998,4 +1060,9 @@ + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php index 4686a88f662d6..e0a256bbe3640 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php @@ -44,6 +44,7 @@ use Symfony\Component\Serializer\Normalizer\JsonSerializableNormalizer; use Symfony\Component\Serializer\Normalizer\MimeMessageNormalizer; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NumberNormalizer; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Normalizer\ProblemNormalizer; use Symfony\Component\Serializer\Normalizer\PropertyNormalizer; @@ -55,7 +56,7 @@ return static function (ContainerConfigurator $container) { $container->parameters() - ->set('serializer.mapping.cache.file', '%kernel.cache_dir%/serialization.php') + ->set('serializer.mapping.cache.file', '%kernel.build_dir%/serialization.php') ; $container->services() @@ -129,7 +130,7 @@ service('property_info')->ignoreOnInvalid(), service('serializer.mapping.class_discriminator_resolver')->ignoreOnInvalid(), null, - null, + abstract_arg('default context, set in the SerializerPass'), service('property_info')->ignoreOnInvalid(), ]) ->tag('serializer.normalizer', ['built_in' => true, 'priority' => -1000]) @@ -221,5 +222,8 @@ ->set('serializer.normalizer.backed_enum', BackedEnumNormalizer::class) ->tag('serializer.normalizer', ['built_in' => true, 'priority' => -915]) + + ->set('serializer.normalizer.number', NumberNormalizer::class) + ->tag('serializer.normalizer', ['built_in' => true, 'priority' => -915]) ; }; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.php index e5a86d8f411f5..936867d542afb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/services.php @@ -42,6 +42,7 @@ use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerAggregate; use Symfony\Component\HttpKernel\Config\FileLocator; use Symfony\Component\HttpKernel\DependencyInjection\ServicesResetter; +use Symfony\Component\HttpKernel\DependencyInjection\ServicesResetterInterface; use Symfony\Component\HttpKernel\EventListener\LocaleAwareListener; use Symfony\Component\HttpKernel\HttpCache\Store; use Symfony\Component\HttpKernel\HttpCache\StoreInterface; @@ -157,6 +158,9 @@ class_exists(WorkflowEvents::class) ? WorkflowEvents::ALIASES : [] ->set('uri_signer', UriSigner::class) ->args([ new Parameter('kernel.secret'), + '_hash', + '_expiration', + service('clock')->nullOnInvalid(), ]) ->lazy() ->alias(UriSigner::class, 'uri_signer') @@ -177,6 +181,7 @@ class_exists(WorkflowEvents::class) ? WorkflowEvents::ALIASES : [] ->set('services_resetter', ServicesResetter::class) ->public() + ->alias(ServicesResetterInterface::class, 'services_resetter') ->set('reverse_container', ReverseContainer::class) ->args([ diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.php index adde2de238e05..535b42edc1bc3 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.php @@ -28,7 +28,7 @@ return static function (ContainerConfigurator $container) { $container->parameters() - ->set('validator.mapping.cache.file', param('kernel.cache_dir').'/validation.php'); + ->set('validator.mapping.cache.file', '%kernel.build_dir%/validation.php'); $validatorsDir = \dirname((new \ReflectionClass(EmailValidator::class))->getFileName()); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator_debug.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator_debug.php index e9fe441140742..b195aea2b57b0 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator_debug.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator_debug.php @@ -20,6 +20,7 @@ ->decorate('validator', null, 255) ->args([ service('debug.validator.inner'), + service('profiler.is_disabled_state_checker')->nullOnInvalid(), ]) ->tag('kernel.reset', [ 'method' => 'reset', diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php index 6f8358fb0c7b8..a4e975dac8749 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php @@ -138,6 +138,7 @@ service('logger')->nullOnInvalid(), param('kernel.debug'), abstract_arg('an exceptions to log & status code mapping'), + abstract_arg('list of loggers by log_channel'), ]) ->tag('kernel.event_subscriber') ->tag('monolog.logger', ['channel' => 'request']) diff --git a/src/Symfony/Bundle/FrameworkBundle/Test/HttpClientAssertionsTrait.php b/src/Symfony/Bundle/FrameworkBundle/Test/HttpClientAssertionsTrait.php index 9d22a822fb851..4a8afbab4ab98 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Test/HttpClientAssertionsTrait.php +++ b/src/Symfony/Bundle/FrameworkBundle/Test/HttpClientAssertionsTrait.php @@ -14,10 +14,9 @@ use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Component\HttpClient\DataCollector\HttpClientDataCollector; -/* +/** * @author Mathieu Santostefano */ - trait HttpClientAssertionsTrait { public static function assertHttpClientRequest(string $expectedUrl, string $expectedMethod = 'GET', string|array|null $expectedBody = null, array $expectedHeaders = [], string $httpClientId = 'http_client'): void diff --git a/src/Symfony/Bundle/FrameworkBundle/Test/NotificationAssertionsTrait.php b/src/Symfony/Bundle/FrameworkBundle/Test/NotificationAssertionsTrait.php index b68473561eb4d..2c4c5467d4ebd 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Test/NotificationAssertionsTrait.php +++ b/src/Symfony/Bundle/FrameworkBundle/Test/NotificationAssertionsTrait.php @@ -17,7 +17,7 @@ use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Test\Constraint as NotifierConstraint; -/* +/** * @author Smaïne Milianni */ trait NotificationAssertionsTrait diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/SerializerCacheWarmerTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/SerializerCacheWarmerTest.php index 5feb0c8ec1bd7..9b765c36a18e6 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/SerializerCacheWarmerTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/SerializerCacheWarmerTest.php @@ -30,9 +30,50 @@ public function testWarmUp(array $loaders) @unlink($file); $warmer = new SerializerCacheWarmer($loaders, $file); - $warmer->warmUp(\dirname($file)); + $warmer->warmUp(\dirname($file), \dirname($file)); + + $this->assertFileExists($file); + + $arrayPool = new PhpArrayAdapter($file, new NullAdapter()); + + $this->assertTrue($arrayPool->getItem('Symfony_Bundle_FrameworkBundle_Tests_Fixtures_Serialization_Person')->isHit()); + $this->assertTrue($arrayPool->getItem('Symfony_Bundle_FrameworkBundle_Tests_Fixtures_Serialization_Author')->isHit()); + } + + /** + * @dataProvider loaderProvider + */ + public function testWarmUpAbsoluteFilePath(array $loaders) + { + $file = sys_get_temp_dir().'/0/cache-serializer.php'; + @unlink($file); + + $cacheDir = sys_get_temp_dir().'/1'; + + $warmer = new SerializerCacheWarmer($loaders, $file); + $warmer->warmUp($cacheDir, $cacheDir); $this->assertFileExists($file); + $this->assertFileDoesNotExist($cacheDir.'/cache-serializer.php'); + + $arrayPool = new PhpArrayAdapter($file, new NullAdapter()); + + $this->assertTrue($arrayPool->getItem('Symfony_Bundle_FrameworkBundle_Tests_Fixtures_Serialization_Person')->isHit()); + $this->assertTrue($arrayPool->getItem('Symfony_Bundle_FrameworkBundle_Tests_Fixtures_Serialization_Author')->isHit()); + } + + /** + * @dataProvider loaderProvider + */ + public function testWarmUpWithoutBuildDir(array $loaders) + { + $file = sys_get_temp_dir().'/cache-serializer.php'; + @unlink($file); + + $warmer = new SerializerCacheWarmer($loaders, $file); + $warmer->warmUp(\dirname($file)); + + $this->assertFileDoesNotExist($file); $arrayPool = new PhpArrayAdapter($file, new NullAdapter()); @@ -66,7 +107,7 @@ public function testWarmUpWithoutLoader() @unlink($file); $warmer = new SerializerCacheWarmer([], $file); - $warmer->warmUp(\dirname($file)); + $warmer->warmUp(\dirname($file), \dirname($file)); $this->assertFileExists($file); } @@ -79,7 +120,10 @@ public function testClassAutoloadException() { $this->assertFalse(class_exists($mappedClass = 'AClassThatDoesNotExist_FWB_CacheWarmer_SerializerCacheWarmerTest', false)); - $warmer = new SerializerCacheWarmer([new YamlFileLoader(__DIR__.'/../Fixtures/Serialization/Resources/does_not_exist.yaml')], tempnam(sys_get_temp_dir(), __FUNCTION__)); + $file = tempnam(sys_get_temp_dir(), __FUNCTION__); + @unlink($file); + + $warmer = new SerializerCacheWarmer([new YamlFileLoader(__DIR__.'/../Fixtures/Serialization/Resources/does_not_exist.yaml')], $file); spl_autoload_register($classLoader = function ($class) use ($mappedClass) { if ($class === $mappedClass) { @@ -87,7 +131,8 @@ public function testClassAutoloadException() } }, true, true); - $warmer->warmUp('foo'); + $warmer->warmUp(\dirname($file), \dirname($file)); + $this->assertFileExists($file); spl_autoload_unregister($classLoader); } @@ -98,12 +143,12 @@ public function testClassAutoloadException() */ public function testClassAutoloadExceptionWithUnrelatedException() { - $this->expectException(\DomainException::class); - $this->expectExceptionMessage('This exception should not be caught by the warmer.'); - $this->assertFalse(class_exists($mappedClass = 'AClassThatDoesNotExist_FWB_CacheWarmer_SerializerCacheWarmerTest', false)); - $warmer = new SerializerCacheWarmer([new YamlFileLoader(__DIR__.'/../Fixtures/Serialization/Resources/does_not_exist.yaml')], tempnam(sys_get_temp_dir(), __FUNCTION__)); + $file = tempnam(sys_get_temp_dir(), __FUNCTION__); + @unlink($file); + + $warmer = new SerializerCacheWarmer([new YamlFileLoader(__DIR__.'/../Fixtures/Serialization/Resources/does_not_exist.yaml')], basename($file)); spl_autoload_register($classLoader = function ($class) use ($mappedClass) { if ($class === $mappedClass) { @@ -112,8 +157,17 @@ public function testClassAutoloadExceptionWithUnrelatedException() } }, true, true); - $warmer->warmUp('foo'); + $this->expectException(\DomainException::class); + $this->expectExceptionMessage('This exception should not be caught by the warmer.'); + + try { + $warmer->warmUp(\dirname($file), \dirname($file)); + } catch (\DomainException $e) { + $this->assertFileDoesNotExist($file); - spl_autoload_unregister($classLoader); + throw $e; + } finally { + spl_autoload_unregister($classLoader); + } } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/ValidatorCacheWarmerTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/ValidatorCacheWarmerTest.php index cc471e43fc685..af0bb1b50d3dd 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/ValidatorCacheWarmerTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/ValidatorCacheWarmerTest.php @@ -32,7 +32,7 @@ public function testWarmUp() @unlink($file); $warmer = new ValidatorCacheWarmer($validatorBuilder, $file); - $warmer->warmUp(\dirname($file)); + $warmer->warmUp(\dirname($file), \dirname($file)); $this->assertFileExists($file); @@ -42,6 +42,53 @@ public function testWarmUp() $this->assertTrue($arrayPool->getItem('Symfony.Bundle.FrameworkBundle.Tests.Fixtures.Validation.Author')->isHit()); } + public function testWarmUpAbsoluteFilePath() + { + $validatorBuilder = new ValidatorBuilder(); + $validatorBuilder->addXmlMapping(__DIR__.'/../Fixtures/Validation/Resources/person.xml'); + $validatorBuilder->addYamlMapping(__DIR__.'/../Fixtures/Validation/Resources/author.yml'); + $validatorBuilder->addMethodMapping('loadValidatorMetadata'); + $validatorBuilder->enableAttributeMapping(); + + $file = sys_get_temp_dir().'/0/cache-validator.php'; + @unlink($file); + + $cacheDir = sys_get_temp_dir().'/1'; + + $warmer = new ValidatorCacheWarmer($validatorBuilder, $file); + $warmer->warmUp($cacheDir, $cacheDir); + + $this->assertFileExists($file); + $this->assertFileDoesNotExist($cacheDir.'/cache-validator.php'); + + $arrayPool = new PhpArrayAdapter($file, new NullAdapter()); + + $this->assertTrue($arrayPool->getItem('Symfony.Bundle.FrameworkBundle.Tests.Fixtures.Validation.Person')->isHit()); + $this->assertTrue($arrayPool->getItem('Symfony.Bundle.FrameworkBundle.Tests.Fixtures.Validation.Author')->isHit()); + } + + public function testWarmUpWithoutBuilDir() + { + $validatorBuilder = new ValidatorBuilder(); + $validatorBuilder->addXmlMapping(__DIR__.'/../Fixtures/Validation/Resources/person.xml'); + $validatorBuilder->addYamlMapping(__DIR__.'/../Fixtures/Validation/Resources/author.yml'); + $validatorBuilder->addMethodMapping('loadValidatorMetadata'); + $validatorBuilder->enableAttributeMapping(); + + $file = sys_get_temp_dir().'/cache-validator.php'; + @unlink($file); + + $warmer = new ValidatorCacheWarmer($validatorBuilder, $file); + $warmer->warmUp(\dirname($file)); + + $this->assertFileDoesNotExist($file); + + $arrayPool = new PhpArrayAdapter($file, new NullAdapter()); + + $this->assertTrue($arrayPool->getItem('Symfony.Bundle.FrameworkBundle.Tests.Fixtures.Validation.Person')->isHit()); + $this->assertTrue($arrayPool->getItem('Symfony.Bundle.FrameworkBundle.Tests.Fixtures.Validation.Author')->isHit()); + } + public function testWarmUpWithAnnotations() { $validatorBuilder = new ValidatorBuilder(); @@ -52,7 +99,7 @@ public function testWarmUpWithAnnotations() @unlink($file); $warmer = new ValidatorCacheWarmer($validatorBuilder, $file); - $warmer->warmUp(\dirname($file)); + $warmer->warmUp(\dirname($file), \dirname($file)); $this->assertFileExists($file); @@ -72,7 +119,7 @@ public function testWarmUpWithoutLoader() @unlink($file); $warmer = new ValidatorCacheWarmer($validatorBuilder, $file); - $warmer->warmUp(\dirname($file)); + $warmer->warmUp(\dirname($file), \dirname($file)); $this->assertFileExists($file); } @@ -85,9 +132,12 @@ public function testClassAutoloadException() { $this->assertFalse(class_exists($mappedClass = 'AClassThatDoesNotExist_FWB_CacheWarmer_ValidatorCacheWarmerTest', false)); + $file = tempnam(sys_get_temp_dir(), __FUNCTION__); + @unlink($file); + $validatorBuilder = new ValidatorBuilder(); $validatorBuilder->addYamlMapping(__DIR__.'/../Fixtures/Validation/Resources/does_not_exist.yaml'); - $warmer = new ValidatorCacheWarmer($validatorBuilder, tempnam(sys_get_temp_dir(), __FUNCTION__)); + $warmer = new ValidatorCacheWarmer($validatorBuilder, $file); spl_autoload_register($classloader = function ($class) use ($mappedClass) { if ($class === $mappedClass) { @@ -95,7 +145,9 @@ public function testClassAutoloadException() } }, true, true); - $warmer->warmUp('foo'); + $warmer->warmUp(\dirname($file), \dirname($file)); + + $this->assertFileExists($file); spl_autoload_unregister($classloader); } @@ -106,14 +158,14 @@ public function testClassAutoloadException() */ public function testClassAutoloadExceptionWithUnrelatedException() { - $this->expectException(\DomainException::class); - $this->expectExceptionMessage('This exception should not be caught by the warmer.'); + $file = tempnam(sys_get_temp_dir(), __FUNCTION__); + @unlink($file); $this->assertFalse(class_exists($mappedClass = 'AClassThatDoesNotExist_FWB_CacheWarmer_ValidatorCacheWarmerTest', false)); $validatorBuilder = new ValidatorBuilder(); $validatorBuilder->addYamlMapping(__DIR__.'/../Fixtures/Validation/Resources/does_not_exist.yaml'); - $warmer = new ValidatorCacheWarmer($validatorBuilder, tempnam(sys_get_temp_dir(), __FUNCTION__)); + $warmer = new ValidatorCacheWarmer($validatorBuilder, basename($file)); spl_autoload_register($classLoader = function ($class) use ($mappedClass) { if ($class === $mappedClass) { @@ -122,8 +174,17 @@ public function testClassAutoloadExceptionWithUnrelatedException() } }, true, true); - $warmer->warmUp('foo'); + $this->expectException(\DomainException::class); + $this->expectExceptionMessage('This exception should not be caught by the warmer.'); + + try { + $warmer->warmUp(\dirname($file), \dirname($file)); + } catch (\DomainException $e) { + $this->assertFileDoesNotExist($file); - spl_autoload_unregister($classLoader); + throw $e; + } finally { + spl_autoload_unregister($classLoader); + } } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationUpdateCommandCompletionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationExtractCommandCompletionTest.php similarity index 93% rename from src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationUpdateCommandCompletionTest.php rename to src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationExtractCommandCompletionTest.php index 4627508cb1559..6d2f22d96a183 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationUpdateCommandCompletionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationExtractCommandCompletionTest.php @@ -12,7 +12,7 @@ namespace Symfony\Bundle\FrameworkBundle\Tests\Command; use PHPUnit\Framework\TestCase; -use Symfony\Bundle\FrameworkBundle\Command\TranslationUpdateCommand; +use Symfony\Bundle\FrameworkBundle\Command\TranslationExtractCommand; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Component\Console\Tester\CommandCompletionTester; use Symfony\Component\DependencyInjection\Container; @@ -25,7 +25,7 @@ use Symfony\Component\Translation\Translator; use Symfony\Component\Translation\Writer\TranslationWriter; -class TranslationUpdateCommandCompletionTest extends TestCase +class TranslationExtractCommandCompletionTest extends TestCase { private Filesystem $fs; private string $translationDir; @@ -129,7 +129,7 @@ function ($path, $catalogue) use ($loadedMessages) { ->method('getContainer') ->willReturn($container); - $command = new TranslationUpdateCommand($writer, $loader, $extractor, 'en', $this->translationDir.'/translations', $this->translationDir.'/templates', $transPaths, $codePaths, ['en', 'fr']); + $command = new TranslationExtractCommand($writer, $loader, $extractor, 'en', $this->translationDir.'/translations', $this->translationDir.'/templates', $transPaths, $codePaths, ['en', 'fr']); $application = new Application($kernel); $application->add($command); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationUpdateCommandTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationExtractCommandTest.php similarity index 96% rename from src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationUpdateCommandTest.php rename to src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationExtractCommandTest.php index f803c2908defa..c5e78de12a3f6 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationUpdateCommandTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Command/TranslationExtractCommandTest.php @@ -12,7 +12,7 @@ namespace Symfony\Bundle\FrameworkBundle\Tests\Command; use PHPUnit\Framework\TestCase; -use Symfony\Bundle\FrameworkBundle\Command\TranslationUpdateCommand; +use Symfony\Bundle\FrameworkBundle\Command\TranslationExtractCommand; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\DependencyInjection\Container; @@ -26,7 +26,7 @@ use Symfony\Component\Translation\Translator; use Symfony\Component\Translation\Writer\TranslationWriter; -class TranslationUpdateCommandTest extends TestCase +class TranslationExtractCommandTest extends TestCase { private Filesystem $fs; private string $translationDir; @@ -163,9 +163,9 @@ public function testFilterDuplicateTransPaths() } } - $command = $this->createMock(TranslationUpdateCommand::class); + $command = $this->createMock(TranslationExtractCommand::class); - $method = new \ReflectionMethod(TranslationUpdateCommand::class, 'filterDuplicateTransPaths'); + $method = new \ReflectionMethod(TranslationExtractCommand::class, 'filterDuplicateTransPaths'); $filteredTransPaths = $method->invoke($command, $transPaths); @@ -193,7 +193,7 @@ public function testRemoveNoFillTranslationsMethod($noFillCounter, $messages) ->method('set'); // Calling private method - $translationUpdate = $this->createMock(TranslationUpdateCommand::class); + $translationUpdate = $this->createMock(TranslationExtractCommand::class); $reflection = new \ReflectionObject($translationUpdate); $method = $reflection->getMethod('removeNoFillTranslations'); $method->invokeArgs($translationUpdate, [$operation]); @@ -301,7 +301,7 @@ function (MessageCatalogue $catalogue) use ($writerMessages) { ->method('getContainer') ->willReturn($container); - $command = new TranslationUpdateCommand($writer, $loader, $extractor, 'en', $this->translationDir.'/translations', $this->translationDir.'/templates', $transPaths, $codePaths); + $command = new TranslationExtractCommand($writer, $loader, $extractor, 'en', $this->translationDir.'/translations', $this->translationDir.'/templates', $transPaths, $codePaths); $application = new Application($kernel); $application->add($command); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Console/ApplicationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Console/ApplicationTest.php index 9afb5a2fd85f6..0b92a813c2d27 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Console/ApplicationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Console/ApplicationTest.php @@ -135,7 +135,11 @@ public function testRunOnlyWarnsOnUnregistrableCommand() $kernel ->method('getBundles') ->willReturn([$this->createBundleMock( - [(new Command('fine'))->setCode(function (InputInterface $input, OutputInterface $output) { $output->write('fine'); })] + [(new Command('fine'))->setCode(function (InputInterface $input, OutputInterface $output): int { + $output->write('fine'); + + return 0; + })] )]); $kernel ->method('getContainer') @@ -163,7 +167,11 @@ public function testRegistrationErrorsAreDisplayedOnCommandNotFound() $kernel ->method('getBundles') ->willReturn([$this->createBundleMock( - [(new Command(null))->setCode(function (InputInterface $input, OutputInterface $output) { $output->write('fine'); })] + [(new Command(null))->setCode(function (InputInterface $input, OutputInterface $output): int { + $output->write('fine'); + + return 0; + })] )]); $kernel ->method('getContainer') @@ -193,7 +201,11 @@ public function testRunOnlyWarnsOnUnregistrableCommandAtTheEnd() $kernel ->method('getBundles') ->willReturn([$this->createBundleMock( - [(new Command('fine'))->setCode(function (InputInterface $input, OutputInterface $output) { $output->write('fine'); })] + [(new Command('fine'))->setCode(function (InputInterface $input, OutputInterface $output): int { + $output->write('fine'); + + return 0; + })] )]); $kernel ->method('getContainer') diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Console/Descriptor/AbstractDescriptorTestCase.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Console/Descriptor/AbstractDescriptorTestCase.php index dde1f000b3787..eb18fbcc75b79 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Console/Descriptor/AbstractDescriptorTestCase.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Console/Descriptor/AbstractDescriptorTestCase.php @@ -50,6 +50,21 @@ public static function getDescribeRouteCollectionTestData(): array return static::getDescriptionTestData(ObjectsProvider::getRouteCollections()); } + /** @dataProvider getDescribeRouteCollectionWithHttpMethodFilterTestData */ + public function testDescribeRouteCollectionWithHttpMethodFilter(string $httpMethod, RouteCollection $routes, $expectedDescription) + { + $this->assertDescription($expectedDescription, $routes, ['method' => $httpMethod]); + } + + public static function getDescribeRouteCollectionWithHttpMethodFilterTestData(): iterable + { + foreach (ObjectsProvider::getRouteCollectionsByHttpMethod() as $httpMethod => $routeCollection) { + foreach (static::getDescriptionTestData($routeCollection) as $testData) { + yield [$httpMethod, ...$testData]; + } + } + } + /** @dataProvider getDescribeRouteTestData */ public function testDescribeRoute(Route $route, $expectedDescription) { @@ -110,7 +125,7 @@ public static function getDescribeContainerDefinitionTestData(): array /** @dataProvider getDescribeContainerDefinitionWithArgumentsShownTestData */ public function testDescribeContainerDefinitionWithArgumentsShown(Definition $definition, $expectedDescription) { - $this->assertDescription($expectedDescription, $definition, ['show_arguments' => true]); + $this->assertDescription($expectedDescription, $definition, []); } public static function getDescribeContainerDefinitionWithArgumentsShownTestData(): array @@ -273,6 +288,7 @@ private function assertDescription($expectedDescription, $describedObject, array $options['is_debug'] = false; $options['raw_output'] = true; $options['raw_text'] = true; + $options['method'] ??= null; $output = new BufferedOutput(BufferedOutput::VERBOSITY_NORMAL, true); if ('txt' === $this->getFormat()) { @@ -307,7 +323,7 @@ private static function getContainerBuilderDescriptionTestData(array $objects): 'public' => ['show_hidden' => false], 'tag1' => ['show_hidden' => true, 'tag' => 'tag1'], 'tags' => ['group_by' => 'tags', 'show_hidden' => true], - 'arguments' => ['show_hidden' => false, 'show_arguments' => true], + 'arguments' => ['show_hidden' => false], ]; $data = []; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Console/Descriptor/ObjectsProvider.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Console/Descriptor/ObjectsProvider.php index 84adc4ac9bc45..8eb1c438601c2 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Console/Descriptor/ObjectsProvider.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Console/Descriptor/ObjectsProvider.php @@ -37,6 +37,38 @@ public static function getRouteCollections() return ['route_collection_1' => $collection1]; } + public static function getRouteCollectionsByHttpMethod(): array + { + $collection = new RouteCollection(); + foreach (self::getRoutes() as $name => $route) { + $collection->add($name, $route); + } + + // Clone the original collection and add a route without any specific method restrictions + $collectionWithRouteWithoutMethodRestriction = clone $collection; + $collectionWithRouteWithoutMethodRestriction->add( + 'route_3', + new RouteStub( + '/other/route', + [], + [], + ['opt1' => 'val1', 'opt2' => 'val2'], + 'localhost', + ['http', 'https'], + [], + ) + ); + + return [ + 'GET' => [ + 'route_collection_2' => $collectionWithRouteWithoutMethodRestriction, + ], + 'PUT' => [ + 'route_collection_3' => $collection, + ], + ]; + } + public static function getRoutes() { return [ diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Controller/AbstractControllerTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Controller/AbstractControllerTest.php index 55a3639848c32..5f5fc5ca51ecb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Controller/AbstractControllerTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Controller/AbstractControllerTest.php @@ -40,7 +40,10 @@ use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; @@ -352,7 +355,19 @@ public function testIsGranted() public function testdenyAccessUnlessGranted() { $authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class); - $authorizationChecker->expects($this->once())->method('isGranted')->willReturn(false); + $authorizationChecker + ->expects($this->once()) + ->method('isGranted') + ->willReturnCallback(function ($attribute, $subject, ?AccessDecision $accessDecision = null) { + if (class_exists(AccessDecision::class)) { + $this->assertInstanceOf(AccessDecision::class, $accessDecision); + $accessDecision->votes[] = $vote = new Vote(); + $vote->result = VoterInterface::ACCESS_DENIED; + $vote->reasons[] = 'Why should I.'; + } + + return false; + }); $container = new Container(); $container->set('security.authorization_checker', $authorizationChecker); @@ -361,8 +376,17 @@ public function testdenyAccessUnlessGranted() $controller->setContainer($container); $this->expectException(AccessDeniedException::class); + $this->expectExceptionMessage('Access Denied.'.(class_exists(AccessDecision::class) ? ' Why should I.' : '')); - $controller->denyAccessUnlessGranted('foo'); + try { + $controller->denyAccessUnlessGranted('foo'); + } catch (AccessDeniedException $e) { + if (class_exists(AccessDecision::class)) { + $this->assertFalse($e->getAccessDecision()->isGranted); + } + + throw $e; + } } /** diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index 53706d2e05e32..c8142e98ab1a7 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -15,12 +15,14 @@ use PHPUnit\Framework\TestCase; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Configuration; use Symfony\Bundle\FullStack; +use Symfony\Component\AssetMapper\Compressor\CompressorInterface; use Symfony\Component\Cache\Adapter\DoctrineAdapter; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\Config\Definition\Processor; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HtmlSanitizer\HtmlSanitizer; use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\JsonStreamer\JsonStreamWriter; use Symfony\Component\Lock\Store\SemaphoreStore; use Symfony\Component\Mailer\Mailer; use Symfony\Component\Messenger\MessageBusInterface; @@ -141,6 +143,11 @@ public function testAssetMapperCanBeEnabled() 'vendor_dir' => '%kernel.project_dir%/assets/vendor', 'importmap_script_attributes' => [], 'exclude_dotfiles' => true, + 'precompress' => [ + 'enabled' => false, + 'formats' => [], + 'extensions' => interface_exists(CompressorInterface::class) ? CompressorInterface::DEFAULT_EXTENSIONS : [], + ], ]; $this->assertEquals($defaultConfig, $config['asset_mapper']); @@ -699,6 +706,22 @@ public function testSerializerJsonDetailedErrorMessagesNotSetByDefaultWithDebugD $this->assertSame([], $config['serializer']['default_context'] ?? []); } + public function testFormCsrfProtectionFieldAttrDoNotNormalizeKeys() + { + $processor = new Processor(); + $config = $processor->processConfiguration(new Configuration(false), [ + [ + 'form' => [ + 'csrf_protection' => [ + 'field_attr' => ['data-example-attr' => 'value'], + ], + ], + ], + ]); + + $this->assertSame(['data-example-attr' => 'value'], $config['form']['csrf_protection']['field_attr'] ?? []); + } + protected static function getBundleDefaultConfig() { return [ @@ -762,12 +785,14 @@ protected static function getBundleDefaultConfig() 'localizable_html_attributes' => [], ], 'providers' => [], + 'globals' => [], ], 'validation' => [ 'enabled' => !class_exists(FullStack::class), 'enable_attributes' => !class_exists(FullStack::class), 'static_method' => ['loadValidatorMetadata'], 'translation_domain' => 'validators', + 'disable_translation' => false, 'mapping' => [ 'paths' => [], ], @@ -801,7 +826,7 @@ protected static function getBundleDefaultConfig() ], 'property_info' => [ 'enabled' => !class_exists(FullStack::class), - ], + ] + (!class_exists(FullStack::class) ? ['with_constructor_extractor' => false] : []), 'router' => [ 'enabled' => false, 'default_uri' => null, @@ -847,6 +872,11 @@ protected static function getBundleDefaultConfig() 'vendor_dir' => '%kernel.project_dir%/assets/vendor', 'importmap_script_attributes' => [], 'exclude_dotfiles' => true, + 'precompress' => [ + 'enabled' => false, + 'formats' => [], + 'extensions' => interface_exists(CompressorInterface::class) ? CompressorInterface::DEFAULT_EXTENSIONS : [], + ], ], 'cache' => [ 'pools' => [], @@ -854,6 +884,7 @@ protected static function getBundleDefaultConfig() 'system' => 'cache.adapter.system', 'directory' => '%kernel.cache_dir%/pools/app', 'default_redis_provider' => 'redis://localhost', + 'default_valkey_provider' => 'valkey://localhost', 'default_memcached_provider' => 'memcached://localhost', 'default_doctrine_dbal_provider' => 'database_connection', 'default_pdo_provider' => ContainerBuilder::willBeAvailable('doctrine/dbal', Connection::class, ['symfony/framework-bundle']) && class_exists(DoctrineAdapter::class) ? 'database_connection' : null, @@ -910,6 +941,27 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor 'enabled' => !class_exists(FullStack::class) && class_exists(Mailer::class), 'message_bus' => null, 'headers' => [], + 'dkim_signer' => [ + 'enabled' => false, + 'options' => [], + 'key' => '', + 'domain' => '', + 'select' => '', + 'passphrase' => '', + ], + 'smime_signer' => [ + 'enabled' => false, + 'key' => '', + 'certificate' => '', + 'passphrase' => null, + 'extra_certificates' => null, + 'sign_options' => null, + ], + 'smime_encrypter' => [ + 'enabled' => false, + 'repository' => '', + 'cipher' => null, + ], ], 'notifier' => [ 'enabled' => !class_exists(FullStack::class) && class_exists(Notifier::class), @@ -959,6 +1011,9 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor 'remote-event' => [ 'enabled' => !class_exists(FullStack::class) && class_exists(RemoteEvent::class), ], + 'json_streamer' => [ + 'enabled' => !class_exists(FullStack::class) && class_exists(JsonStreamWriter::class), + ], ]; } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/Workflow/Validator/DefinitionValidator.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/Workflow/Validator/DefinitionValidator.php new file mode 100644 index 0000000000000..7244e927ca763 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/Workflow/Validator/DefinitionValidator.php @@ -0,0 +1,16 @@ +loadFromExtension('framework', [ + 'annotations' => false, + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + 'csrf_protection' => [ + 'enabled' => true, + ], + 'form' => [ + 'csrf_protection' => [ + 'field-attr' => [ + 'data-foo' => 'bar', + 'data-bar' => 'baz', + ], + ], + ], + 'session' => [ + 'storage_factory_id' => 'session.storage.factory.native', + ], +]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/full.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/full.php index 0a32ce8b36434..cb776282936c8 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/full.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/full.php @@ -74,7 +74,10 @@ ], ], ], - 'property_info' => true, + 'property_info' => [ + 'enabled' => true, + 'with_constructor_extractor' => true, + ], 'type_info' => true, 'ide' => 'file%%link%%format', 'request' => [ diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/profiler_collect_serializer_data.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/json_streamer.php similarity index 75% rename from src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/profiler_collect_serializer_data.php rename to src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/json_streamer.php index 99e2a52cf611f..844b72004d6a6 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/profiler_collect_serializer_data.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/json_streamer.php @@ -5,11 +5,10 @@ 'http_method_override' => false, 'handle_all_throwables' => true, 'php_errors' => ['log' => true], - 'profiler' => [ + 'type_info' => [ 'enabled' => true, - 'collect_serializer_data' => true, ], - 'serializer' => [ + 'json_streamer' => [ 'enabled' => true, ], ]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/messenger_multiple_buses_with_deduplicate_middleware.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/messenger_multiple_buses_with_deduplicate_middleware.php new file mode 100644 index 0000000000000..f9b3767c0fc7b --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/messenger_multiple_buses_with_deduplicate_middleware.php @@ -0,0 +1,27 @@ +loadFromExtension('framework', [ + 'annotations' => false, + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + 'lock' => null, + 'messenger' => [ + 'default_bus' => 'messenger.bus.commands', + 'buses' => [ + 'messenger.bus.commands' => null, + 'messenger.bus.events' => [ + 'middleware' => [ + ['with_factory' => ['foo', true, ['bar' => 'baz']]], + ], + ], + 'messenger.bus.queries' => [ + 'default_middleware' => false, + 'middleware' => [ + 'send_message', + 'handle_message', + ], + ], + ], + ], +]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/messenger_multiple_buses.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/messenger_multiple_buses_without_deduplicate_middleware.php similarity index 100% rename from src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/messenger_multiple_buses.php rename to src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/messenger_multiple_buses_without_deduplicate_middleware.php diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/profiler.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/profiler.php index faf76bbc76a8f..99e2a52cf611f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/profiler.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/profiler.php @@ -7,6 +7,7 @@ 'php_errors' => ['log' => true], 'profiler' => [ 'enabled' => true, + 'collect_serializer_data' => true, ], 'serializer' => [ 'enabled' => true, diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/property_info.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/property_info.php index b234c452756e1..e2437e2c2aa83 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/property_info.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/property_info.php @@ -7,5 +7,6 @@ 'php_errors' => ['log' => true], 'property_info' => [ 'enabled' => true, + 'with_constructor_extractor' => false, ], ]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/property_info_with_constructor_extractor.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/property_info_with_constructor_extractor.php new file mode 100644 index 0000000000000..fa143d2e1f57d --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/property_info_with_constructor_extractor.php @@ -0,0 +1,12 @@ +loadFromExtension('framework', [ + 'annotations' => false, + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + 'property_info' => [ + 'enabled' => true, + 'with_constructor_extractor' => true, + ], +]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/translator_globals.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/translator_globals.php new file mode 100644 index 0000000000000..8ee438ff906d1 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/translator_globals.php @@ -0,0 +1,15 @@ +loadFromExtension('framework', [ + 'annotations' => false, + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + 'translator' => [ + 'globals' => [ + '%%app_name%%' => 'My application', + '{app_version}' => '1.2.3', + '{url}' => ['message' => 'url', 'parameters' => ['scheme' => 'https://'], 'domain' => 'global'], + ], + ], +]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/translator_without_globals.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/translator_without_globals.php new file mode 100644 index 0000000000000..fcc65c9682650 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/translator_without_globals.php @@ -0,0 +1,9 @@ +loadFromExtension('framework', [ + 'annotations' => false, + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + 'translator' => ['globals' => []], +]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/validation_auto_mapping.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/validation_auto_mapping.php index ae5bea2ea5389..67bac4a326c8d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/validation_auto_mapping.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/validation_auto_mapping.php @@ -5,7 +5,10 @@ 'http_method_override' => false, 'handle_all_throwables' => true, 'php_errors' => ['log' => true], - 'property_info' => ['enabled' => true], + 'property_info' => [ + 'enabled' => true, + 'with_constructor_extractor' => true, + ], 'validation' => [ 'email_validation_mode' => 'html5', 'auto_mapping' => [ diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/workflows.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/workflows.php index 118a627c7c05b..2c29b848901eb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/workflows.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/workflows.php @@ -13,6 +13,9 @@ 'supports' => [ FrameworkExtensionTestCase::class, ], + 'definition_validators' => [ + Symfony\Bundle\FrameworkBundle\Tests\DependencyInjection\Fixtures\Workflow\Validator\DefinitionValidator::class, + ], 'initial_marking' => ['draft'], 'metadata' => [ 'title' => 'article workflow', diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/form_csrf_sets_field_name.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/form_csrf_field_attr.xml similarity index 67% rename from src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/form_csrf_sets_field_name.xml rename to src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/form_csrf_field_attr.xml index 4a05e9d33294e..1889703bec2a9 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/form_csrf_sets_field_name.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/form_csrf_field_attr.xml @@ -9,7 +9,13 @@ - + + + + bar + baz + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/full.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/full.xml index c01e857838bc3..07faf22ab2ef1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/full.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/full.xml @@ -44,7 +44,8 @@ - + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/form_csrf_under_form_sets_field_name.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/json_streamer.xml similarity index 63% rename from src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/form_csrf_under_form_sets_field_name.xml rename to src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/json_streamer.xml index 09ef0ee167eb4..5c79cb8401642 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/form_csrf_under_form_sets_field_name.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/json_streamer.xml @@ -1,15 +1,14 @@ - - + + http://symfony.com/schema/dic/symfony https://symfony.com/schema/dic/symfony/symfony-1.0.xsd"> - - + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/messenger_multiple_buses_with_deduplicate_middleware.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/messenger_multiple_buses_with_deduplicate_middleware.xml new file mode 100644 index 0000000000000..67decad203092 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/messenger_multiple_buses_with_deduplicate_middleware.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + foo + true + + baz + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/messenger_multiple_buses.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/messenger_multiple_buses_without_deduplicate_middleware.xml similarity index 100% rename from src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/messenger_multiple_buses.xml rename to src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/messenger_multiple_buses_without_deduplicate_middleware.xml diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/profiler.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/profiler.xml index ffbff7f21e1bb..34d44d91ce1bd 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/profiler.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/profiler.xml @@ -9,7 +9,7 @@ - + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/property_info.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/property_info.xml index 5f49aabaa9ed4..19bac44d96f90 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/property_info.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/property_info.xml @@ -8,6 +8,6 @@ - + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/property_info_with_constructor_extractor.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/property_info_with_constructor_extractor.xml new file mode 100644 index 0000000000000..df8dabe0b63fc --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/property_info_with_constructor_extractor.xml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/translator_globals.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/translator_globals.xml new file mode 100644 index 0000000000000..017fd9393b85c --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/translator_globals.xml @@ -0,0 +1,20 @@ + + + + + + + + + My application + + + https:// + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/profiler_collect_serializer_data.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/translator_without_globals.xml similarity index 73% rename from src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/profiler_collect_serializer_data.xml rename to src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/translator_without_globals.xml index 34d44d91ce1bd..6c686bd30b210 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/profiler_collect_serializer_data.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/translator_without_globals.xml @@ -6,10 +6,9 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd http://symfony.com/schema/dic/symfony https://symfony.com/schema/dic/symfony/symfony-1.0.xsd"> - + - - + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/validation_auto_mapping.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/validation_auto_mapping.xml index c60691b0b61a3..96659809137a3 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/validation_auto_mapping.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/validation_auto_mapping.xml @@ -6,7 +6,7 @@ - + foo diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/workflows.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/workflows.xml index 76b4f07a87a44..c5dae479d3d63 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/workflows.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/workflows.xml @@ -13,6 +13,7 @@ draft Symfony\Bundle\FrameworkBundle\Tests\DependencyInjection\FrameworkExtensionTestCase + Symfony\Bundle\FrameworkBundle\Tests\DependencyInjection\Fixtures\Workflow\Validator\DefinitionValidator diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/form_csrf_field_attr.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/form_csrf_field_attr.yml new file mode 100644 index 0000000000000..db519977548c4 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/form_csrf_field_attr.yml @@ -0,0 +1,16 @@ +framework: + annotations: false + http_method_override: false + handle_all_throwables: true + php_errors: + log: true + csrf_protection: + enabled: true + form: + csrf_protection: + enabled: true + field_attr: + data-foo: bar + data-bar: baz + session: + storage_factory_id: session.storage.factory.native diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/full.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/full.yml index 7550749eb1a1e..8a1a3834ba719 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/full.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/full.yml @@ -64,9 +64,11 @@ framework: default_context: enable_max_depth: false type_info: ~ - property_info: ~ + property_info: + with_constructor_extractor: true ide: file%%link%%format request: formats: csv: ['text/csv', 'text/plain'] pdf: 'application/pdf' + json_streamer: ~ diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/profiler_collect_serializer_data.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/json_streamer.yml similarity index 72% rename from src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/profiler_collect_serializer_data.yml rename to src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/json_streamer.yml index 5fe74b290568a..8873fea97a8ef 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/profiler_collect_serializer_data.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/json_streamer.yml @@ -4,8 +4,7 @@ framework: handle_all_throwables: true php_errors: log: true - serializer: + type_info: enabled: true - profiler: + json_streamer: enabled: true - collect_serializer_data: true diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/messenger_multiple_buses_with_deduplicate_middleware.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/messenger_multiple_buses_with_deduplicate_middleware.yml new file mode 100644 index 0000000000000..ed52564c7264d --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/messenger_multiple_buses_with_deduplicate_middleware.yml @@ -0,0 +1,19 @@ +framework: + annotations: false + http_method_override: false + handle_all_throwables: true + php_errors: + log: true + lock: ~ + messenger: + default_bus: messenger.bus.commands + buses: + messenger.bus.commands: ~ + messenger.bus.events: + middleware: + - with_factory: [foo, true, { bar: baz }] + messenger.bus.queries: + default_middleware: false + middleware: + - "send_message" + - "handle_message" diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/messenger_multiple_buses.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/messenger_multiple_buses_without_deduplicate_middleware.yml similarity index 100% rename from src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/messenger_multiple_buses.yml rename to src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/messenger_multiple_buses_without_deduplicate_middleware.yml diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/profiler.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/profiler.yml index 5c867fc8907db..2ccec1685c6b1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/profiler.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/profiler.yml @@ -6,5 +6,6 @@ framework: log: true profiler: enabled: true + collect_serializer_data: true serializer: enabled: true diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/property_info.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/property_info.yml index de05e6bb7a480..4fde73710a56f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/property_info.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/property_info.yml @@ -6,3 +6,4 @@ framework: log: true property_info: enabled: true + with_constructor_extractor: false diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/property_info_with_constructor_extractor.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/property_info_with_constructor_extractor.yml new file mode 100644 index 0000000000000..a43762df335e7 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/property_info_with_constructor_extractor.yml @@ -0,0 +1,9 @@ +framework: + annotations: false + http_method_override: false + handle_all_throwables: true + php_errors: + log: true + property_info: + enabled: true + with_constructor_extractor: true diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/translator_globals.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/translator_globals.yml new file mode 100644 index 0000000000000..ed42b676c8fd5 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/translator_globals.yml @@ -0,0 +1,11 @@ +framework: + annotations: false + http_method_override: false + handle_all_throwables: true + php_errors: + log: true + translator: + globals: + '%%app_name%%': 'My application' + '{app_version}': '1.2.3' + '{url}': { message: 'url', parameters: { scheme: 'https://' }, domain: 'global' } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/translator_without_globals.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/translator_without_globals.yml new file mode 100644 index 0000000000000..dc7323868d762 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/translator_without_globals.yml @@ -0,0 +1,8 @@ +framework: + annotations: false + http_method_override: false + handle_all_throwables: true + php_errors: + log: true + translator: + globals: [] diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/validation_auto_mapping.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/validation_auto_mapping.yml index 55a43886fc67b..e81203e245727 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/validation_auto_mapping.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/validation_auto_mapping.yml @@ -4,7 +4,9 @@ framework: handle_all_throwables: true php_errors: log: true - property_info: { enabled: true } + property_info: + enabled: true + with_constructor_extractor: true validation: email_validation_mode: html5 auto_mapping: diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/workflows.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/workflows.yml index a9b427d89408a..cac5f6f230f92 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/workflows.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/workflows.yml @@ -9,6 +9,8 @@ framework: type: workflow supports: - Symfony\Bundle\FrameworkBundle\Tests\DependencyInjection\FrameworkExtensionTestCase + definition_validators: + - Symfony\Bundle\FrameworkBundle\Tests\DependencyInjection\Fixtures\Workflow\Validator\DefinitionValidator initial_marking: [draft] metadata: title: article workflow diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php index 37cdc4cf60859..cbaf5caaf036d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php @@ -15,6 +15,7 @@ use Psr\Log\LoggerAwareInterface; use Symfony\Bundle\FrameworkBundle\DependencyInjection\FrameworkExtension; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\FrameworkBundle\Tests\DependencyInjection\Fixtures\Workflow\Validator\DefinitionValidator; use Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Messenger\DummyMessage; use Symfony\Bundle\FrameworkBundle\Tests\TestCase; use Symfony\Bundle\FullStack; @@ -33,6 +34,7 @@ use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\Compiler\ResolveBindingsPass; use Symfony\Component\DependencyInjection\Compiler\ResolveChildDefinitionsPass; use Symfony\Component\DependencyInjection\Compiler\ResolveInstanceofConditionalsPass; use Symfony\Component\DependencyInjection\Compiler\ResolveTaggedIteratorArgumentPass; @@ -62,11 +64,13 @@ use Symfony\Component\Messenger\Bridge\Amqp\Transport\AmqpTransportFactory; use Symfony\Component\Messenger\Bridge\Beanstalkd\Transport\BeanstalkdTransportFactory; use Symfony\Component\Messenger\Bridge\Redis\Transport\RedisTransportFactory; +use Symfony\Component\Messenger\Middleware\DeduplicateMiddleware; use Symfony\Component\Messenger\Transport\TransportFactory; use Symfony\Component\Notifier\ChatterInterface; use Symfony\Component\Notifier\TexterInterface; use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\Security\Core\AuthenticationEvents; +use Symfony\Component\Serializer\DependencyInjection\SerializerPass; use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; use Symfony\Component\Serializer\Mapping\Loader\XmlFileLoader; use Symfony\Component\Serializer\Mapping\Loader\YamlFileLoader; @@ -82,11 +86,13 @@ use Symfony\Component\Serializer\Serializer; use Symfony\Component\Translation\DependencyInjection\TranslatorPass; use Symfony\Component\Translation\LocaleSwitcher; +use Symfony\Component\Translation\TranslatableMessage; use Symfony\Component\Validator\DependencyInjection\AddConstraintValidatorsPass; use Symfony\Component\Validator\Validation; use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\Webhook\Client\RequestParser; use Symfony\Component\Webhook\Controller\WebhookController; +use Symfony\Component\Workflow\DependencyInjection\WorkflowValidatorPass; use Symfony\Component\Workflow\Exception\InvalidDefinitionException; use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore; use Symfony\Component\Workflow\WorkflowEvents; @@ -274,25 +280,20 @@ public function testDisabledProfiler() public function testProfilerCollectSerializerDataEnabled() { - $container = $this->createContainerFromFile('profiler_collect_serializer_data'); + $container = $this->createContainerFromFile('profiler'); $this->assertTrue($container->hasDefinition('profiler')); $this->assertTrue($container->hasDefinition('serializer.data_collector')); $this->assertTrue($container->hasDefinition('debug.serializer')); } - public function testProfilerCollectSerializerDataDefaultDisabled() - { - $container = $this->createContainerFromFile('profiler'); - - $this->assertTrue($container->hasDefinition('profiler')); - $this->assertFalse($container->hasDefinition('serializer.data_collector')); - $this->assertFalse($container->hasDefinition('debug.serializer')); - } - public function testWorkflows() { - $container = $this->createContainerFromFile('workflows'); + DefinitionValidator::$called = false; + + $container = $this->createContainerFromFile('workflows', compile: false); + $container->addCompilerPass(new WorkflowValidatorPass()); + $container->compile(); $this->assertTrue($container->hasDefinition('workflow.article'), 'Workflow is registered as a service'); $this->assertSame('workflow.abstract', $container->getDefinition('workflow.article')->getParent()); @@ -315,6 +316,7 @@ public function testWorkflows() ], $tags['workflow'][0]['metadata'] ?? null); $this->assertTrue($container->hasDefinition('workflow.article.definition'), 'Workflow definition is registered as a service'); + $this->assertTrue(DefinitionValidator::$called, 'DefinitionValidator is called'); $workflowDefinition = $container->getDefinition('workflow.article.definition'); @@ -408,7 +410,9 @@ public function testWorkflowAreValidated() { $this->expectException(InvalidDefinitionException::class); $this->expectExceptionMessage('A transition from a place/state must have an unique name. Multiple transitions named "go" from place/state "first" were found on StateMachine "my_workflow".'); - $this->createContainerFromFile('workflow_not_valid'); + $container = $this->createContainerFromFile('workflow_not_valid', compile: false); + $container->addCompilerPass(new WorkflowValidatorPass()); + $container->compile(); } public function testWorkflowCannotHaveBothSupportsAndSupportStrategy() @@ -613,21 +617,25 @@ public function testExceptionsConfig() ], array_keys($configuration)); $this->assertEqualsCanonicalizing([ + 'log_channel' => null, 'log_level' => 'info', 'status_code' => 422, ], $configuration[\Symfony\Component\HttpKernel\Exception\BadRequestHttpException::class]); $this->assertEqualsCanonicalizing([ + 'log_channel' => null, 'log_level' => 'info', 'status_code' => null, ], $configuration[\Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class]); $this->assertEqualsCanonicalizing([ + 'log_channel' => null, 'log_level' => 'info', 'status_code' => null, ], $configuration[\Symfony\Component\HttpKernel\Exception\ConflictHttpException::class]); $this->assertEqualsCanonicalizing([ + 'log_channel' => null, 'log_level' => null, 'status_code' => 500, ], $configuration[\Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException::class]); @@ -795,7 +803,7 @@ public function testMessengerServicesRemovedWhenDisabled() \ARRAY_FILTER_USE_KEY ); - $this->assertEmpty($messengerDefinitions); + $this->assertSame([], $messengerDefinitions); $this->assertFalse($container->hasDefinition('console.command.messenger_consume_messages')); $this->assertFalse($container->hasDefinition('console.command.messenger_debug')); $this->assertFalse($container->hasDefinition('console.command.messenger_stop_workers')); @@ -1055,9 +1063,9 @@ public function testMessengerTransportConfiguration() $this->assertSame(['enable_max_depth' => true], $serializerTransportDefinition->getArgument(2)); } - public function testMessengerWithMultipleBuses() + public function testMessengerWithMultipleBusesWithoutDeduplicateMiddleware() { - $container = $this->createContainerFromFile('messenger_multiple_buses'); + $container = $this->createContainerFromFile('messenger_multiple_buses_without_deduplicate_middleware'); $this->assertTrue($container->has('messenger.bus.commands')); $this->assertSame([], $container->getDefinition('messenger.bus.commands')->getArgument(0)); @@ -1091,6 +1099,48 @@ public function testMessengerWithMultipleBuses() $this->assertSame('messenger.bus.commands', (string) $container->getAlias('messenger.default_bus')); } + public function testMessengerWithMultipleBusesWithDeduplicateMiddleware() + { + if (!class_exists(DeduplicateMiddleware::class)) { + $this->markTestSkipped('DeduplicateMiddleware not available.'); + } + + $container = $this->createContainerFromFile('messenger_multiple_buses_with_deduplicate_middleware'); + + $this->assertTrue($container->has('messenger.bus.commands')); + $this->assertSame([], $container->getDefinition('messenger.bus.commands')->getArgument(0)); + $this->assertEquals([ + ['id' => 'add_bus_name_stamp_middleware', 'arguments' => ['messenger.bus.commands']], + ['id' => 'reject_redelivered_message_middleware'], + ['id' => 'dispatch_after_current_bus'], + ['id' => 'failed_message_processing_middleware'], + ['id' => 'deduplicate_middleware'], + ['id' => 'send_message', 'arguments' => [true]], + ['id' => 'handle_message', 'arguments' => [false]], + ], $container->getParameter('messenger.bus.commands.middleware')); + $this->assertTrue($container->has('messenger.bus.events')); + $this->assertSame([], $container->getDefinition('messenger.bus.events')->getArgument(0)); + $this->assertEquals([ + ['id' => 'add_bus_name_stamp_middleware', 'arguments' => ['messenger.bus.events']], + ['id' => 'reject_redelivered_message_middleware'], + ['id' => 'dispatch_after_current_bus'], + ['id' => 'failed_message_processing_middleware'], + ['id' => 'deduplicate_middleware'], + ['id' => 'with_factory', 'arguments' => ['foo', true, ['bar' => 'baz']]], + ['id' => 'send_message', 'arguments' => [true]], + ['id' => 'handle_message', 'arguments' => [false]], + ], $container->getParameter('messenger.bus.events.middleware')); + $this->assertTrue($container->has('messenger.bus.queries')); + $this->assertSame([], $container->getDefinition('messenger.bus.queries')->getArgument(0)); + $this->assertEquals([ + ['id' => 'send_message', 'arguments' => []], + ['id' => 'handle_message', 'arguments' => []], + ], $container->getParameter('messenger.bus.queries.middleware')); + + $this->assertTrue($container->hasAlias('messenger.default_bus')); + $this->assertSame('messenger.bus.commands', (string) $container->getAlias('messenger.default_bus')); + } + public function testMessengerMiddlewareFactoryErroneousFormat() { $this->expectException(\InvalidArgumentException::class); @@ -1198,6 +1248,36 @@ public function testTranslatorCacheDirDisabled() $this->assertNull($options['cache_dir']); } + public function testTranslatorGlobals() + { + $container = $this->createContainerFromFile('translator_globals'); + + $calls = $container->getDefinition('translator.default')->getMethodCalls(); + + $this->assertCount(5, $calls); + $this->assertSame( + ['addGlobalParameter', ['%%app_name%%', 'My application']], + $calls[2], + ); + $this->assertSame( + ['addGlobalParameter', ['{app_version}', '1.2.3']], + $calls[3], + ); + $this->assertEquals( + ['addGlobalParameter', ['{url}', new Definition(TranslatableMessage::class, ['url', ['scheme' => 'https://'], 'global'])]], + $calls[4], + ); + } + + public function testTranslatorWithoutGlobals() + { + $container = $this->createContainerFromFile('translator_without_globals'); + + $calls = $container->getDefinition('translator.default')->getMethodCalls(); + + $this->assertCount(2, $calls); + } + public function testValidation() { $container = $this->createContainerFromFile('full'); @@ -1411,6 +1491,17 @@ public function testFormsCanBeEnabledWithoutCsrfProtection() $this->assertFalse($container->getParameter('form.type_extension.csrf.enabled')); } + public function testFormCsrfFieldAttr() + { + $container = $this->createContainerFromFile('form_csrf_field_attr'); + + $expected = [ + 'data-foo' => 'bar', + 'data-bar' => 'baz', + ]; + $this->assertSame($expected, $container->getParameter('form.type_extension.csrf.field_attr')); + } + public function testStopwatchEnabledWithDebugModeEnabled() { $container = $this->createContainerFromFile('default_config', [ @@ -1447,9 +1538,6 @@ public function testSerializerEnabled() $this->assertEquals(AttributeLoader::class, $argument[0]->getClass()); $this->assertEquals(new Reference('serializer.name_converter.camel_case_to_snake_case'), $container->getDefinition('serializer.name_converter.metadata_aware')->getArgument(1)); $this->assertEquals(new Reference('property_info', ContainerBuilder::IGNORE_ON_INVALID_REFERENCE), $container->getDefinition('serializer.normalizer.object')->getArgument(3)); - $this->assertArrayHasKey('circular_reference_handler', $container->getDefinition('serializer.normalizer.object')->getArgument(6)); - $this->assertArrayHasKey('max_depth_handler', $container->getDefinition('serializer.normalizer.object')->getArgument(6)); - $this->assertEquals($container->getDefinition('serializer.normalizer.object')->getArgument(6)['max_depth_handler'], new Reference('my.max.depth.handler')); } public function testSerializerWithoutTranslator() @@ -1547,13 +1635,22 @@ public function testJsonSerializableNormalizerRegistered() public function testObjectNormalizerRegistered() { - $container = $this->createContainerFromFile('full'); + $container = $this->createContainerFromFile('full', compile: false); + $container->addCompilerPass(new SerializerPass()); + $container->addCompilerPass(new ResolveBindingsPass()); + $container->compile(); $definition = $container->getDefinition('serializer.normalizer.object'); $tag = $definition->getTag('serializer.normalizer'); $this->assertEquals(ObjectNormalizer::class, $definition->getClass()); $this->assertEquals(-1000, $tag[0]['priority']); + + $this->assertEquals([ + 'enable_max_depth' => true, + 'circular_reference_handler' => new Reference('my.circular.reference.handler'), + 'max_depth_handler' => new Reference('my.max.depth.handler'), + ], $definition->getArgument(6)); } public function testConstraintViolationListNormalizerRegistered() @@ -1676,6 +1773,14 @@ public function testPropertyInfoEnabled() { $container = $this->createContainerFromFile('property_info'); $this->assertTrue($container->has('property_info')); + $this->assertFalse($container->has('property_info.constructor_extractor')); + } + + public function testPropertyInfoWithConstructorExtractorEnabled() + { + $container = $this->createContainerFromFile('property_info_with_constructor_extractor'); + $this->assertTrue($container->has('property_info')); + $this->assertTrue($container->has('property_info.constructor_extractor')); } public function testPropertyInfoCacheActivated() @@ -1890,7 +1995,7 @@ public function testRemovesResourceCheckerConfigCacheFactoryArgumentOnlyIfNoDebu $container = $this->createContainer(['kernel.debug' => false]); (new FrameworkExtension())->load([['annotations' => false, 'http_method_override' => false, 'handle_all_throwables' => true, 'php_errors' => ['log' => true]]], $container); - $this->assertEmpty($container->getDefinition('config_cache_factory')->getArguments()); + $this->assertSame([], $container->getDefinition('config_cache_factory')->getArguments()); } public function testLoggerAwareRegistration() @@ -2105,8 +2210,7 @@ public function testMailer(string $configFile, array $expectedTransports, array $this->assertTrue($container->hasAlias('mailer')); $this->assertTrue($container->hasDefinition('mailer.transports')); $this->assertSame($expectedTransports, $container->getDefinition('mailer.transports')->getArgument(0)); - $this->assertTrue($container->hasDefinition('mailer.default_transport')); - $this->assertSame(current($expectedTransports), $container->getDefinition('mailer.default_transport')->getArgument(0)); + $this->assertTrue($container->hasAlias('mailer.default_transport')); $this->assertTrue($container->hasDefinition('mailer.envelope_listener')); $l = $container->getDefinition('mailer.envelope_listener'); $this->assertSame('sender@example.org', $l->getArgument(0)); @@ -2501,6 +2605,12 @@ public function testSemaphoreWithService() self::assertEquals(new Reference('my_service'), $storeDef->getArgument(0)); } + public function testJsonStreamerEnabled() + { + $container = $this->createContainerFromFile('json_streamer'); + $this->assertTrue($container->has('json_streamer.stream_writer')); + } + protected function createContainer(array $data = []) { return new ContainerBuilder(new EnvPlaceholderParameterBag(array_merge([ diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php index deac159b6f9b0..f69a53932711c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php @@ -17,7 +17,13 @@ use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\DependencyInjection\Exception\OutOfBoundsException; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\RateLimiter\CompoundRateLimiterFactory; +use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; +use Symfony\Component\Validator\Constraints\Email; +use Symfony\Component\Workflow\Definition; +use Symfony\Component\Workflow\DependencyInjection\WorkflowValidatorPass; use Symfony\Component\Workflow\Exception\InvalidDefinitionException; +use Symfony\Component\Workflow\Validator\DefinitionValidatorInterface; class PhpFrameworkExtensionTest extends FrameworkExtensionTestCase { @@ -99,7 +105,7 @@ public function testWorkflowValidationStateMachine() { $this->expectException(InvalidDefinitionException::class); $this->expectExceptionMessage('A transition from a place/state must have an unique name. Multiple transitions named "a_to_b" from place/state "a" were found on StateMachine "article".'); - $this->createContainerFromClosure(function ($container) { + $this->createContainerFromClosure(function (ContainerBuilder $container) { $container->loadFromExtension('framework', [ 'annotations' => false, 'http_method_override' => false, @@ -125,9 +131,57 @@ public function testWorkflowValidationStateMachine() ], ], ]); + $container->addCompilerPass(new WorkflowValidatorPass()); }); } + /** + * @dataProvider provideWorkflowValidationCustomTests + */ + public function testWorkflowValidationCustomBroken(string $class, string $message) + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage($message); + $this->createContainerFromClosure(function ($container) use ($class) { + $container->loadFromExtension('framework', [ + 'annotations' => false, + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + 'workflows' => [ + 'article' => [ + 'type' => 'state_machine', + 'supports' => [ + __CLASS__, + ], + 'places' => [ + 'a', + 'b', + ], + 'transitions' => [ + 'a_to_b' => [ + 'from' => ['a'], + 'to' => ['b'], + ], + ], + 'definition_validators' => [ + $class, + ], + ], + ], + ]); + }); + } + + public static function provideWorkflowValidationCustomTests() + { + yield ['classDoesNotExist', 'Invalid configuration for path "framework.workflows.workflows.article.definition_validators.0": The validation class "classDoesNotExist" does not exist.']; + + yield [\DateTime::class, 'Invalid configuration for path "framework.workflows.workflows.article.definition_validators.0": The validation class "DateTime" is not an instance of "Symfony\Component\Workflow\Validator\DefinitionValidatorInterface".']; + + yield [WorkflowValidatorWithConstructor::class, 'Invalid configuration for path "framework.workflows.workflows.article.definition_validators.0": The "Symfony\\\\Bundle\\\\FrameworkBundle\\\\Tests\\\\DependencyInjection\\\\WorkflowValidatorWithConstructor" validation class constructor must not have any arguments.']; + } + public function testWorkflowDefaultMarkingStoreDefinition() { $container = $this->createContainerFromClosure(function ($container) { @@ -188,7 +242,7 @@ public function testWorkflowDefaultMarkingStoreDefinition() $this->assertNull($argumentsB['index_1'], 'workflow_b marking_store argument is null'); } - public function testRateLimiterWithLockFactory() + public function testRateLimiterLockFactoryWithLockDisabled() { try { $this->createContainerFromClosure(function (ContainerBuilder $container) { @@ -199,7 +253,7 @@ public function testRateLimiterWithLockFactory() 'php_errors' => ['log' => true], 'lock' => false, 'rate_limiter' => [ - 'with_lock' => ['policy' => 'fixed_window', 'limit' => 10, 'interval' => '1 hour'], + 'with_lock' => ['policy' => 'fixed_window', 'limit' => 10, 'interval' => '1 hour', 'lock_factory' => 'lock.factory'], ], ]); }); @@ -208,7 +262,10 @@ public function testRateLimiterWithLockFactory() } catch (LogicException $e) { $this->assertEquals('Rate limiter "with_lock" requires the Lock component to be configured.', $e->getMessage()); } + } + public function testRateLimiterAutoLockFactoryWithLockEnabled() + { $container = $this->createContainerFromClosure(function (ContainerBuilder $container) { $container->loadFromExtension('framework', [ 'annotations' => false, @@ -226,13 +283,35 @@ public function testRateLimiterWithLockFactory() $this->assertEquals('lock.factory', (string) $withLock->getArgument(2)); } - public function testRateLimiterLockFactory() + public function testRateLimiterAutoLockFactoryWithLockDisabled() { $container = $this->createContainerFromClosure(function (ContainerBuilder $container) { $container->loadFromExtension('framework', [ 'annotations' => false, 'http_method_override' => false, 'handle_all_throwables' => true, + 'lock' => false, + 'php_errors' => ['log' => true], + 'rate_limiter' => [ + 'without_lock' => ['policy' => 'fixed_window', 'limit' => 10, 'interval' => '1 hour'], + ], + ]); + }); + + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessageMatches('/^The argument "2" doesn\'t exist.*\.$/'); + + $container->getDefinition('limiter.without_lock')->getArgument(2); + } + + public function testRateLimiterDisableLockFactory() + { + $container = $this->createContainerFromClosure(function (ContainerBuilder $container) { + $container->loadFromExtension('framework', [ + 'annotations' => false, + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'lock' => true, 'php_errors' => ['log' => true], 'rate_limiter' => [ 'without_lock' => ['policy' => 'fixed_window', 'limit' => 10, 'interval' => '1 hour', 'lock_factory' => null], @@ -265,4 +344,128 @@ public function testRateLimiterIsTagged() $this->assertSame('first', $container->getDefinition('limiter.first')->getTag('rate_limiter')[0]['name']); $this->assertSame('second', $container->getDefinition('limiter.second')->getTag('rate_limiter')[0]['name']); } + + public function testRateLimiterCompoundPolicy() + { + if (!class_exists(CompoundRateLimiterFactory::class)) { + $this->markTestSkipped('CompoundRateLimiterFactory is not available.'); + } + + $container = $this->createContainerFromClosure(function (ContainerBuilder $container) { + $container->loadFromExtension('framework', [ + 'annotations' => false, + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + 'lock' => true, + 'rate_limiter' => [ + 'first' => ['policy' => 'fixed_window', 'limit' => 10, 'interval' => '1 hour'], + 'second' => ['policy' => 'sliding_window', 'limit' => 10, 'interval' => '1 hour'], + 'compound' => ['policy' => 'compound', 'limiters' => ['first', 'second']], + ], + ]); + }); + + $this->assertSame([ + 'policy' => 'fixed_window', + 'limit' => 10, + 'interval' => '1 hour', + 'id' => 'first', + ], $container->getDefinition('limiter.first')->getArgument(0)); + $this->assertSame([ + 'policy' => 'sliding_window', + 'limit' => 10, + 'interval' => '1 hour', + 'id' => 'second', + ], $container->getDefinition('limiter.second')->getArgument(0)); + + $definition = $container->getDefinition('limiter.compound'); + $this->assertSame(CompoundRateLimiterFactory::class, $definition->getClass()); + $this->assertEquals( + [ + 'limiter.first', + 'limiter.second', + ], + $definition->getArgument(0)->getValues() + ); + $this->assertSame('limiter.compound', (string) $container->getAlias(RateLimiterFactoryInterface::class.' $compoundLimiter')); + } + + public function testRateLimiterCompoundPolicyNoLimiters() + { + if (!class_exists(CompoundRateLimiterFactory::class)) { + $this->markTestSkipped('CompoundRateLimiterFactory is not available.'); + } + + $this->expectException(\LogicException::class); + $this->createContainerFromClosure(function ($container) { + $container->loadFromExtension('framework', [ + 'annotations' => false, + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + 'rate_limiter' => [ + 'compound' => ['policy' => 'compound'], + ], + ]); + }); + } + + public function testRateLimiterCompoundPolicyInvalidLimiters() + { + if (!class_exists(CompoundRateLimiterFactory::class)) { + $this->markTestSkipped('CompoundRateLimiterFactory is not available.'); + } + + $this->expectException(\LogicException::class); + $this->createContainerFromClosure(function ($container) { + $container->loadFromExtension('framework', [ + 'annotations' => false, + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + 'rate_limiter' => [ + 'compound' => ['policy' => 'compound', 'limiters' => ['invalid1', 'invalid2']], + ], + ]); + }); + } + + /** + * @dataProvider emailValidationModeProvider + */ + public function testValidatorEmailValidationMode(string $mode) + { + $this->expectNotToPerformAssertions(); + + $this->createContainerFromClosure(function (ContainerBuilder $container) use ($mode) { + $container->loadFromExtension('framework', [ + 'annotations' => false, + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + 'validation' => [ + 'email_validation_mode' => $mode, + ], + ]); + }); + } + + public static function emailValidationModeProvider() + { + foreach (Email::VALIDATION_MODES as $mode) { + yield [$mode]; + } + } +} + +class WorkflowValidatorWithConstructor implements DefinitionValidatorInterface +{ + public function __construct(bool $enabled) + { + } + + public function validate(Definition $definition, string $name): void + { + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_1.json b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_1.json index a2c015faa0bb6..e4acc2a832697 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_1.json +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_1.json @@ -13,6 +13,71 @@ "autowire": false, "autoconfigure": false, "deprecated": false, + "arguments": [ + { + "type": "service", + "id": ".definition_2" + }, + "%parameter%", + { + "class": "inline_service", + "public": false, + "synthetic": false, + "lazy": false, + "shared": true, + "abstract": false, + "autowire": false, + "autoconfigure": false, + "deprecated": false, + "arguments": [ + "arg1", + "arg2" + ], + "file": null, + "tags": [], + "usages": [ + "alias_1" + ] + }, + [ + "foo", + { + "type": "service", + "id": ".definition_2" + }, + { + "class": "inline_service", + "public": false, + "synthetic": false, + "lazy": false, + "shared": true, + "abstract": false, + "autowire": false, + "autoconfigure": false, + "deprecated": false, + "arguments": [], + "file": null, + "tags": [], + "usages": [ + "alias_1" + ] + } + ], + [ + { + "type": "service", + "id": "definition_1" + }, + { + "type": "service", + "id": ".definition_2" + } + ], + { + "type": "abstract", + "text": "placeholder" + } + ], "file": null, "factory_class": "Full\\Qualified\\FactoryClass", "factory_method": "get", diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_1.md b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_1.md index c92c8435ff847..fd94e43e9762e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_1.md +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_1.md @@ -14,6 +14,7 @@ - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: yes - Factory Class: `Full\Qualified\FactoryClass` - Factory Method: `get` - Usages: alias_1 diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_1.txt b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_1.txt index 7883d51c07300..eea6c70b11794 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_1.txt +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_1.txt @@ -3,20 +3,28 @@ Information for Service "service_1" =================================== - ---------------- ----------------------------- -  Option   Value  - ---------------- ----------------------------- - Service ID service_1 - Class Full\Qualified\Class1 - Tags - - Public yes - Synthetic no - Lazy yes - Shared yes - Abstract yes - Autowired no - Autoconfigured no - Factory Class Full\Qualified\FactoryClass - Factory Method get - Usages alias_1 - ---------------- ----------------------------- + ---------------- --------------------------------- +  Option   Value  + ---------------- --------------------------------- + Service ID service_1 + Class Full\Qualified\Class1 + Tags - + Public yes + Synthetic no + Lazy yes + Shared yes + Abstract yes + Autowired no + Autoconfigured no + Factory Class Full\Qualified\FactoryClass + Factory Method get + Arguments Service(.definition_2) + %parameter% + Inlined Service + Array (3 element(s)) + Iterator (2 element(s)) + - Service(definition_1) + - Service(.definition_2) + Abstract argument (placeholder) + Usages alias_1 + ---------------- --------------------------------- diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_1.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_1.xml index 06c8406da051b..3eab915abf4f5 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_1.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_1.xml @@ -2,6 +2,26 @@ + + %parameter% + + + arg1 + arg2 + + + + foo + + + + + + + + + + placeholder alias_1 diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_2.json b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_2.json index f3b930983ab3e..e59ff8524dd29 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_2.json +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_2.json @@ -13,6 +13,7 @@ "autowire": false, "autoconfigure": false, "deprecated": false, + "arguments": [], "file": "\/path\/to\/file", "factory_service": "factory.service", "factory_method": "get", diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_2.md b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_2.md index 3ec9516a398ce..045da01b0db26 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_2.md +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/alias_with_definition_2.md @@ -14,6 +14,7 @@ - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: no - File: `/path/to/file` - Factory Service: `factory.service` - Factory Method: `get` diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_public.json b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_public.json index 0d6198b07e3a2..28d64c611753a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_public.json +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_public.json @@ -10,6 +10,67 @@ "autowire": false, "autoconfigure": false, "deprecated": false, + "arguments": [ + { + "type": "service", + "id": ".definition_2" + }, + "%parameter%", + { + "class": "inline_service", + "public": false, + "synthetic": false, + "lazy": false, + "shared": true, + "abstract": false, + "autowire": false, + "autoconfigure": false, + "deprecated": false, + "arguments": [ + "arg1", + "arg2" + ], + "file": null, + "tags": [], + "usages": [] + }, + [ + "foo", + { + "type": "service", + "id": ".definition_2" + }, + { + "class": "inline_service", + "public": false, + "synthetic": false, + "lazy": false, + "shared": true, + "abstract": false, + "autowire": false, + "autoconfigure": false, + "deprecated": false, + "arguments": [], + "file": null, + "tags": [], + "usages": [] + } + ], + [ + { + "type": "service", + "id": "definition_1" + }, + { + "type": "service", + "id": ".definition_2" + } + ], + { + "type": "abstract", + "text": "placeholder" + } + ], "file": null, "factory_class": "Full\\Qualified\\FactoryClass", "factory_method": "get", @@ -26,6 +87,7 @@ "autowire": false, "autoconfigure": false, "deprecated": false, + "arguments": [], "file": null, "tags": [], "usages": [] @@ -41,6 +103,7 @@ "autoconfigure": false, "deprecated": false, "description": "ContainerInterface is the interface implemented by service container classes.", + "arguments": [], "file": null, "tags": [], "usages": [] diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_public.md b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_public.md index 2532a2c4eea58..57a209ecb95cc 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_public.md +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_public.md @@ -15,6 +15,7 @@ Definitions - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: yes - Factory Class: `Full\Qualified\FactoryClass` - Factory Method: `get` - Usages: none @@ -30,6 +31,7 @@ Definitions - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: no - Usages: none ### service_container @@ -44,6 +46,7 @@ Definitions - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: no - Usages: none diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_public.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_public.xml index 3b13b72643b76..fdddad6537440 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_public.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_public.xml @@ -3,6 +3,26 @@ + + %parameter% + + + arg1 + arg2 + + + + foo + + + + + + + + + + placeholder diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_services.json b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_services.json index ac6d122ce4539..473709247839b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_services.json +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_services.json @@ -10,6 +10,7 @@ "autowire": false, "autoconfigure": false, "deprecated": false, + "arguments": [], "file": "\/path\/to\/file", "factory_service": "factory.service", "factory_method": "get", @@ -65,6 +66,7 @@ "autowire": false, "autoconfigure": false, "deprecated": false, + "arguments": [], "file": "\/path\/to\/file", "factory_service": "inline factory service (Full\\Qualified\\FactoryClass)", "factory_method": "get", diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_services.md b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_services.md index 6dfab327d037a..64801e03b66d5 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_services.md +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_services.md @@ -15,6 +15,7 @@ Definitions - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: no - File: `/path/to/file` - Factory Service: `factory.service` - Factory Method: `get` @@ -40,6 +41,7 @@ Definitions - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: no - File: `/path/to/file` - Factory Service: inline factory service (`Full\Qualified\FactoryClass`) - Factory Method: `get` diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_tag1.json b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_tag1.json index 5e60f26d170b7..cead51aa9232a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_tag1.json +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_tag1.json @@ -10,6 +10,7 @@ "autowire": false, "autoconfigure": false, "deprecated": false, + "arguments": [], "file": "\/path\/to\/file", "factory_service": "factory.service", "factory_method": "get", diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_tag1.md b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_tag1.md index aeae0d9f294ce..8e92293499652 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_tag1.md +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_tag1.md @@ -15,6 +15,7 @@ Definitions - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: no - File: `/path/to/file` - Factory Service: `factory.service` - Factory Method: `get` diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_tags.json b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_tags.json index 518f694ea3451..6775a0e36167b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_tags.json +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_tags.json @@ -10,6 +10,7 @@ "autowire": false, "autoconfigure": false, "deprecated": false, + "arguments": [], "file": "\/path\/to\/file", "factory_service": "factory.service", "factory_method": "get", @@ -30,6 +31,7 @@ "autowire": false, "autoconfigure": false, "deprecated": false, + "arguments": [], "file": "\/path\/to\/file", "factory_service": "factory.service", "factory_method": "get", @@ -50,6 +52,7 @@ "autowire": false, "autoconfigure": false, "deprecated": false, + "arguments": [], "file": "\/path\/to\/file", "factory_service": "factory.service", "factory_method": "get", diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_tags.md b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_tags.md index 80da2ddafd560..cc0496e28e770 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_tags.md +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_1_tags.md @@ -15,6 +15,7 @@ tag1 - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: no - File: `/path/to/file` - Factory Service: `factory.service` - Factory Method: `get` @@ -36,6 +37,7 @@ tag2 - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: no - File: `/path/to/file` - Factory Service: `factory.service` - Factory Method: `get` @@ -57,6 +59,7 @@ tag3 - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: no - File: `/path/to/file` - Factory Service: `factory.service` - Factory Method: `get` diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_priority_tag.json b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_priority_tag.json index 75d893297cd24..d9c3d050c11bc 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_priority_tag.json +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_priority_tag.json @@ -10,6 +10,7 @@ "autowire": false, "autoconfigure": false, "deprecated": false, + "arguments": [], "file": "\/path\/to\/file", "tags": [ { @@ -40,6 +41,7 @@ "autowire": false, "autoconfigure": false, "deprecated": false, + "arguments": [], "file": "\/path\/to\/file", "factory_service": "factory.service", "factory_method": "get", @@ -77,6 +79,7 @@ "autowire": false, "autoconfigure": false, "deprecated": false, + "arguments": [], "file": "\/path\/to\/file", "tags": [ { @@ -98,6 +101,7 @@ "autowire": false, "autoconfigure": false, "deprecated": false, + "arguments": [], "file": "\/path\/to\/file", "tags": [ { diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_priority_tag.md b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_priority_tag.md index 7137e1b1d81d1..90ef56ee45973 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_priority_tag.md +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/builder_priority_tag.md @@ -15,6 +15,7 @@ Definitions - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: no - File: `/path/to/file` - Tag: `tag1` - Attr3: val3 @@ -36,6 +37,7 @@ Definitions - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: no - File: `/path/to/file` - Factory Service: `factory.service` - Factory Method: `get` @@ -59,6 +61,7 @@ Definitions - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: no - File: `/path/to/file` - Tag: `tag1` - Priority: 0 @@ -75,6 +78,7 @@ Definitions - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: no - File: `/path/to/file` - Tag: `tag1` - Attr1: val1 diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_1.json b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_1.json index 735b3df470887..b0a612030cae1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_1.json +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_1.json @@ -8,6 +8,67 @@ "autowire": false, "autoconfigure": false, "deprecated": false, + "arguments": [ + { + "type": "service", + "id": ".definition_2" + }, + "%parameter%", + { + "class": "inline_service", + "public": false, + "synthetic": false, + "lazy": false, + "shared": true, + "abstract": false, + "autowire": false, + "autoconfigure": false, + "deprecated": false, + "arguments": [ + "arg1", + "arg2" + ], + "file": null, + "tags": [], + "usages": [] + }, + [ + "foo", + { + "type": "service", + "id": ".definition_2" + }, + { + "class": "inline_service", + "public": false, + "synthetic": false, + "lazy": false, + "shared": true, + "abstract": false, + "autowire": false, + "autoconfigure": false, + "deprecated": false, + "arguments": [], + "file": null, + "tags": [], + "usages": [] + } + ], + [ + { + "type": "service", + "id": "definition_1" + }, + { + "type": "service", + "id": ".definition_2" + } + ], + { + "type": "abstract", + "text": "placeholder" + } + ], "file": null, "factory_class": "Full\\Qualified\\FactoryClass", "factory_method": "get", diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_1.md b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_1.md index c7ad62954ebc3..b99162bbf439d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_1.md +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_1.md @@ -7,6 +7,7 @@ - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: yes - Factory Class: `Full\Qualified\FactoryClass` - Factory Method: `get` - Usages: none diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_1.txt b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_1.txt index 8ec7be868ca65..775a04c843e2b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_1.txt +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_1.txt @@ -1,18 +1,25 @@ - ---------------- ----------------------------- -  Option   Value  - ---------------- ----------------------------- - Service ID - - Class Full\Qualified\Class1 - Tags - - Public yes - Synthetic no - Lazy yes - Shared yes - Abstract yes - Autowired no - Autoconfigured no - Factory Class Full\Qualified\FactoryClass - Factory Method get - Usages none - ---------------- ----------------------------- - + ---------------- --------------------------------- +  Option   Value  + ---------------- --------------------------------- + Service ID - + Class Full\Qualified\Class1 + Tags - + Public yes + Synthetic no + Lazy yes + Shared yes + Abstract yes + Autowired no + Autoconfigured no + Factory Class Full\Qualified\FactoryClass + Factory Method get + Arguments Service(.definition_2) + %parameter% + Inlined Service + Array (3 element(s)) + Iterator (2 element(s)) + - Service(definition_1) + - Service(.definition_2) + Abstract argument (placeholder) + Usages none + ---------------- --------------------------------- diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_1.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_1.xml index be2b16b57ffa7..eba7e7bbdcd7f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_1.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_1.xml @@ -1,4 +1,24 @@ + + %parameter% + + + arg1 + arg2 + + + + foo + + + + + + + + + + placeholder diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_2.json b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_2.json index a661428c9cb08..eeeb6f44a448b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_2.json +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_2.json @@ -8,6 +8,7 @@ "autowire": false, "autoconfigure": false, "deprecated": false, + "arguments": [], "file": "\/path\/to\/file", "factory_service": "factory.service", "factory_method": "get", diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_2.md b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_2.md index 486f35fb77a27..5b427bff5a26f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_2.md +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_2.md @@ -7,6 +7,7 @@ - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: no - File: `/path/to/file` - Factory Service: `factory.service` - Factory Method: `get` diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_3.json b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_3.json index 11768d0de1a45..c96c06d63ad0e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_3.json +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_3.json @@ -8,6 +8,7 @@ "autowire": false, "autoconfigure": false, "deprecated": false, + "arguments": [], "file": "\/path\/to\/file", "factory_service": "inline factory service (Full\\Qualified\\FactoryClass)", "factory_method": "get", diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_3.md b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_3.md index 8a9651641d747..5bfafe3d0e28a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_3.md +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_3.md @@ -7,6 +7,7 @@ - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: no - File: `/path/to/file` - Factory Service: inline factory service (`Full\Qualified\FactoryClass`) - Factory Method: `get` diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_without_class.json b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_without_class.json index 078f7cdca6b4b..c1305ac0c56c1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_without_class.json +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_without_class.json @@ -8,6 +8,7 @@ "autowire": false, "autoconfigure": false, "deprecated": false, + "arguments": [], "file": null, "tags": [], "usages": [] diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_without_class.md b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_without_class.md index be221535f9889..7c7bad74dcf06 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_without_class.md +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/definition_without_class.md @@ -7,4 +7,5 @@ - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: no - Usages: none diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/existing_class_def_1.json b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/existing_class_def_1.json index c6de89ce5cd94..00c8a5be07a08 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/existing_class_def_1.json +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/existing_class_def_1.json @@ -9,6 +9,7 @@ "autoconfigure": false, "deprecated": false, "description": "This is a class with a doc comment.", + "arguments": [], "file": null, "tags": [], "usages": [] diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/existing_class_def_1.md b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/existing_class_def_1.md index 132147324bceb..907f694608347 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/existing_class_def_1.md +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/existing_class_def_1.md @@ -8,4 +8,5 @@ - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: no - Usages: none diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/existing_class_def_2.json b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/existing_class_def_2.json index 7b387fd8683c1..88a59851a784b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/existing_class_def_2.json +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/existing_class_def_2.json @@ -8,6 +8,7 @@ "autowire": false, "autoconfigure": false, "deprecated": false, + "arguments": [], "file": null, "tags": [], "usages": [] diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/existing_class_def_2.md b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/existing_class_def_2.md index 0526ba117ecaa..8fd89fb0f1fd9 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/existing_class_def_2.md +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/existing_class_def_2.md @@ -7,4 +7,5 @@ - Autowired: no - Autoconfigured: no - Deprecated: no +- Arguments: no - Usages: none diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_2.json b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_2.json new file mode 100644 index 0000000000000..8f5d2c743eb02 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_2.json @@ -0,0 +1,38 @@ +{ + "route_1": { + "path": "\/hello\/{name}", + "pathRegex": "#PATH_REGEX#", + "host": "localhost", + "hostRegex": "#HOST_REGEX#", + "scheme": "http|https", + "method": "GET|HEAD", + "class": "Symfony\\Bundle\\FrameworkBundle\\Tests\\Console\\Descriptor\\RouteStub", + "defaults": { + "name": "Joseph" + }, + "requirements": { + "name": "[a-z]+" + }, + "options": { + "compiler_class": "Symfony\\Component\\Routing\\RouteCompiler", + "opt1": "val1", + "opt2": "val2" + } + }, + "route_3": { + "path": "\/other\/route", + "pathRegex": "#PATH_REGEX#", + "host": "localhost", + "hostRegex": "#HOST_REGEX#", + "scheme": "http|https", + "method": "ANY", + "class": "Symfony\\Bundle\\FrameworkBundle\\Tests\\Console\\Descriptor\\RouteStub", + "defaults": [], + "requirements": "NO CUSTOM", + "options": { + "compiler_class": "Symfony\\Component\\Routing\\RouteCompiler", + "opt1": "val1", + "opt2": "val2" + } + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_2.md b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_2.md new file mode 100644 index 0000000000000..e1b11e4a499e2 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_2.md @@ -0,0 +1,37 @@ +route_1 +------- + +- Path: /hello/{name} +- Path Regex: #PATH_REGEX# +- Host: localhost +- Host Regex: #HOST_REGEX# +- Scheme: http|https +- Method: GET|HEAD +- Class: Symfony\Bundle\FrameworkBundle\Tests\Console\Descriptor\RouteStub +- Defaults: + - `name`: Joseph +- Requirements: + - `name`: [a-z]+ +- Options: + - `compiler_class`: Symfony\Component\Routing\RouteCompiler + - `opt1`: val1 + - `opt2`: val2 + + +route_3 +------- + +- Path: /other/route +- Path Regex: #PATH_REGEX# +- Host: localhost +- Host Regex: #HOST_REGEX# +- Scheme: http|https +- Method: ANY +- Class: Symfony\Bundle\FrameworkBundle\Tests\Console\Descriptor\RouteStub +- Defaults: NONE +- Requirements: NO CUSTOM +- Options: + - `compiler_class`: Symfony\Component\Routing\RouteCompiler + - `opt1`: val1 + - `opt2`: val2 + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_2.txt b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_2.txt new file mode 100644 index 0000000000000..a9f9ee21b7497 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_2.txt @@ -0,0 +1,7 @@ + --------- ---------- ------------ ----------- --------------- +  Name   Method   Scheme   Host   Path  + --------- ---------- ------------ ----------- --------------- + route_1 GET|HEAD http|https localhost /hello/{name} + route_3 ANY http|https localhost /other/route + --------- ---------- ------------ ----------- --------------- + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_2.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_2.xml new file mode 100644 index 0000000000000..18c41deb79990 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_2.xml @@ -0,0 +1,33 @@ + + + + /hello/{name} + localhost + http + https + GET + HEAD + + Joseph + + + [a-z]+ + + + + + + + + + /other/route + localhost + http + https + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_3.json b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_3.json new file mode 100644 index 0000000000000..cabc8e0a71955 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_3.json @@ -0,0 +1,19 @@ +{ + "route_2": { + "path": "\/name\/add", + "pathRegex": "#PATH_REGEX#", + "host": "localhost", + "hostRegex": "#HOST_REGEX#", + "scheme": "http|https", + "method": "PUT|POST", + "class": "Symfony\\Bundle\\FrameworkBundle\\Tests\\Console\\Descriptor\\RouteStub", + "defaults": [], + "requirements": "NO CUSTOM", + "options": { + "compiler_class": "Symfony\\Component\\Routing\\RouteCompiler", + "opt1": "val1", + "opt2": "val2" + }, + "condition": "context.getMethod() in ['GET', 'HEAD', 'POST']" + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_3.md b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_3.md new file mode 100644 index 0000000000000..20fdabb958098 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_3.md @@ -0,0 +1,18 @@ +route_2 +------- + +- Path: /name/add +- Path Regex: #PATH_REGEX# +- Host: localhost +- Host Regex: #HOST_REGEX# +- Scheme: http|https +- Method: PUT|POST +- Class: Symfony\Bundle\FrameworkBundle\Tests\Console\Descriptor\RouteStub +- Defaults: NONE +- Requirements: NO CUSTOM +- Options: + - `compiler_class`: Symfony\Component\Routing\RouteCompiler + - `opt1`: val1 + - `opt2`: val2 +- Condition: context.getMethod() in ['GET', 'HEAD', 'POST'] + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_3.txt b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_3.txt new file mode 100644 index 0000000000000..8822b3c40793a --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_3.txt @@ -0,0 +1,6 @@ + --------- ---------- ------------ ----------- ----------- +  Name   Method   Scheme   Host   Path  + --------- ---------- ------------ ----------- ----------- + route_2 PUT|POST http|https localhost /name/add + --------- ---------- ------------ ----------- ----------- + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_3.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_3.xml new file mode 100644 index 0000000000000..57a05d4c10bd5 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Descriptor/route_collection_3.xml @@ -0,0 +1,17 @@ + + + + /name/add + localhost + http + https + PUT + POST + + + + + + context.getMethod() in ['GET', 'HEAD', 'POST'] + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/ObjectMapper/ObjectMapped.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/ObjectMapper/ObjectMapped.php new file mode 100644 index 0000000000000..17edc9dcef465 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/ObjectMapper/ObjectMapped.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Fixtures\ObjectMapper; + +final class ObjectMapped +{ + public string $a; +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/ObjectMapper/ObjectToBeMapped.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/ObjectMapper/ObjectToBeMapped.php new file mode 100644 index 0000000000000..fc5b7080ad11a --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/ObjectMapper/ObjectToBeMapped.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Fixtures\ObjectMapper; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(target: ObjectMapped::class)] +final class ObjectToBeMapped +{ + #[Map(transform: TransformCallable::class)] + public string $a = 'nottransformed'; +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/ObjectMapper/TransformCallable.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/ObjectMapper/TransformCallable.php new file mode 100644 index 0000000000000..3321e28d1ac67 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/ObjectMapper/TransformCallable.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Fixtures\ObjectMapper; + +use Symfony\Component\ObjectMapper\TransformCallableInterface; + +/** + * @implements TransformCallableInterface + */ +final class TransformCallable implements TransformCallableInterface +{ + public function __invoke(mixed $value, object $source, ?object $target): mixed + { + return 'transformed'; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ApiAttributesTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ApiAttributesTest.php index cf5c384ba2578..0dcfeaeba5ce2 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ApiAttributesTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ApiAttributesTest.php @@ -34,7 +34,7 @@ public function testMapQueryString(string $uri, array $query, string $expectedRe if ($expectedResponse) { self::assertJsonStringEqualsJsonString($expectedResponse, $response->getContent()); } else { - self::assertEmpty($response->getContent()); + self::assertSame('', $response->getContent()); } self::assertSame($expectedStatusCode, $response->getStatusCode()); } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ContainerDebugCommandTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ContainerDebugCommandTest.php index 5067a880ddbf8..8d3f15ba61680 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ContainerDebugCommandTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ContainerDebugCommandTest.php @@ -345,4 +345,22 @@ public static function provideCompletionSuggestions(): iterable ['txt', 'xml', 'json', 'md'], ]; } + + public function testShowArgumentsProvidedShouldTriggerDeprecation() + { + static::bootKernel(['test_case' => 'ContainerDebug', 'root_config' => 'config.yml', 'debug' => true]); + $path = \sprintf('%s/%sDeprecations.log', static::$kernel->getContainer()->getParameter('kernel.build_dir'), static::$kernel->getContainer()->getParameter('kernel.container_class')); + @unlink($path); + + $application = new Application(static::$kernel); + $application->setAutoExit(false); + + @unlink(static::getContainer()->getParameter('debug.container.dump')); + + $tester = new ApplicationTester($application); + $tester->run(['command' => 'debug:container', 'name' => 'router', '--show-arguments' => true]); + + $tester->assertCommandIsSuccessful(); + $this->assertStringContainsString('[WARNING] The "--show-arguments" option is deprecated.', $tester->getDisplay()); + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/JsonStreamerTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/JsonStreamerTest.php new file mode 100644 index 0000000000000..9816015b4484e --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/JsonStreamerTest.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Functional; + +use Symfony\Bundle\FrameworkBundle\Tests\Functional\app\JsonStreamer\Dto\Dummy; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\JsonStreamer\StreamReaderInterface; +use Symfony\Component\JsonStreamer\StreamWriterInterface; +use Symfony\Component\TypeInfo\Type; + +/** + * @author Mathias Arlaud + */ +class JsonStreamerTest extends AbstractWebTestCase +{ + protected function setUp(): void + { + static::bootKernel(['test_case' => 'JsonStreamer']); + } + + public function testWrite() + { + /** @var StreamWriterInterface $writer */ + $writer = static::getContainer()->get('json_streamer.stream_writer.alias'); + + $this->assertSame('{"@name":"DUMMY","range":"10..20"}', (string) $writer->write(new Dummy(), Type::object(Dummy::class))); + } + + public function testRead() + { + /** @var StreamReaderInterface $reader */ + $reader = static::getContainer()->get('json_streamer.stream_reader.alias'); + + $expected = new Dummy(); + $expected->name = 'dummy'; + $expected->range = [0, 1]; + + $this->assertEquals($expected, $reader->read('{"@name": "DUMMY", "range": "0..1"}', Type::object(Dummy::class))); + } + + public function testWarmupStreamableClasses() + { + /** @var Filesystem $fs */ + $fs = static::getContainer()->get('filesystem'); + + $streamWritersDir = \sprintf('%s/json_streamer/stream_writer/', static::getContainer()->getParameter('kernel.cache_dir')); + + // clear already created stream writers + if ($fs->exists($streamWritersDir)) { + $fs->remove($streamWritersDir); + } + + static::getContainer()->get('json_streamer.cache_warmer.streamer.alias')->warmUp(static::getContainer()->getParameter('kernel.cache_dir')); + + $this->assertFileExists($streamWritersDir); + $this->assertCount(2, glob($streamWritersDir.'/*')); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ObjectMapperTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ObjectMapperTest.php new file mode 100644 index 0000000000000..e314ee1b029e5 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ObjectMapperTest.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Functional; + +use Symfony\Bundle\FrameworkBundle\Tests\Fixtures\ObjectMapper\ObjectMapped; +use Symfony\Bundle\FrameworkBundle\Tests\Fixtures\ObjectMapper\ObjectToBeMapped; + +/** + * @author Kévin Dunglas + */ +class ObjectMapperTest extends AbstractWebTestCase +{ + public function testObjectMapper() + { + static::bootKernel(['test_case' => 'ObjectMapper']); + + /** @var Symfony\Component\ObjectMapper\ObjectMapperInterface */ + $objectMapper = static::getContainer()->get('object_mapper.alias'); + $mapped = $objectMapper->map(new ObjectToBeMapped()); + $this->assertSame($mapped->a, 'transformed'); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/config.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/config.yml index 8b218d48cbb06..00bdd8ab9df96 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/config.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/config.yml @@ -5,4 +5,6 @@ framework: serializer: enabled: true validation: true - property_info: { enabled: true } + property_info: + enabled: true + with_constructor_extractor: true diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ContainerDump/config.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ContainerDump/config.yml index 3efa5f950450e..48bff32400cdb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ContainerDump/config.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ContainerDump/config.yml @@ -15,6 +15,8 @@ framework: translator: true validation: true serializer: true - property_info: true + property_info: + enabled: true + with_constructor_extractor: true csrf_protection: true form: true diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/JsonStreamer/Dto/Dummy.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/JsonStreamer/Dto/Dummy.php new file mode 100644 index 0000000000000..d1f1ca67a2a9a --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/JsonStreamer/Dto/Dummy.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Functional\app\JsonStreamer\Dto; + +use Symfony\Bundle\FrameworkBundle\Tests\Functional\app\JsonStreamer\RangeToStringValueTransformer; +use Symfony\Bundle\FrameworkBundle\Tests\Functional\app\JsonStreamer\StringToRangeValueTransformer; +use Symfony\Component\JsonStreamer\Attribute\JsonStreamable; +use Symfony\Component\JsonStreamer\Attribute\StreamedName; +use Symfony\Component\JsonStreamer\Attribute\ValueTransformer; + +/** + * @author Mathias Arlaud + */ +#[JsonStreamable] +class Dummy +{ + #[StreamedName('@name')] + #[ValueTransformer( + nativeToStream: 'strtoupper', + streamToNative: 'strtolower', + )] + public string $name = 'dummy'; + + #[ValueTransformer( + nativeToStream: RangeToStringValueTransformer::class, + streamToNative: StringToRangeValueTransformer::class, + )] + public array $range = [10, 20]; +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/JsonStreamer/RangeToStringValueTransformer.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/JsonStreamer/RangeToStringValueTransformer.php new file mode 100644 index 0000000000000..6d21f2d2f834e --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/JsonStreamer/RangeToStringValueTransformer.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Functional\app\JsonStreamer; + +use Symfony\Component\JsonStreamer\ValueTransformer\ValueTransformerInterface; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; + +/** + * @author Mathias Arlaud + */ +class RangeToStringValueTransformer implements ValueTransformerInterface +{ + public function transform(mixed $value, array $options = []): string + { + return $value[0].'..'.$value[1]; + } + + public static function getStreamValueType(): BuiltinType + { + return Type::string(); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/JsonStreamer/StringToRangeValueTransformer.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/JsonStreamer/StringToRangeValueTransformer.php new file mode 100644 index 0000000000000..398beb2ffab1d --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/JsonStreamer/StringToRangeValueTransformer.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Functional\app\JsonStreamer; + +use Symfony\Component\JsonStreamer\ValueTransformer\ValueTransformerInterface; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; + +/** + * @author Mathias Arlaud + */ +class StringToRangeValueTransformer implements ValueTransformerInterface +{ + public function transform(mixed $value, array $options = []): array + { + return array_map(static fn (string $v): int => (int) $v, explode('..', $value)); + } + + public static function getStreamValueType(): BuiltinType + { + return Type::string(); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/JsonStreamer/bundles.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/JsonStreamer/bundles.php new file mode 100644 index 0000000000000..15ff182c6fed5 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/JsonStreamer/bundles.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\FrameworkBundle\Tests\Functional\Bundle\TestBundle\TestBundle; + +return [ + new FrameworkBundle(), + new TestBundle(), +]; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/JsonStreamer/config.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/JsonStreamer/config.yml new file mode 100644 index 0000000000000..188869b8269f6 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/JsonStreamer/config.yml @@ -0,0 +1,27 @@ +imports: + - { resource: ../config/default.yml } + +framework: + http_method_override: false + type_info: ~ + json_streamer: ~ + +services: + _defaults: + autoconfigure: true + + json_streamer.stream_writer.alias: + alias: json_streamer.stream_writer + public: true + + json_streamer.stream_reader.alias: + alias: json_streamer.stream_reader + public: true + + json_streamer.cache_warmer.streamer.alias: + alias: .json_streamer.cache_warmer.streamer + public: true + + Symfony\Bundle\FrameworkBundle\Tests\Functional\app\JsonStreamer\Dto\Dummy: ~ + Symfony\Bundle\FrameworkBundle\Tests\Functional\app\JsonStreamer\StringToRangeValueTransformer: ~ + Symfony\Bundle\FrameworkBundle\Tests\Functional\app\JsonStreamer\RangeToStringValueTransformer: ~ diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ObjectMapper/bundles.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ObjectMapper/bundles.php new file mode 100644 index 0000000000000..13ab9fddee4a6 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ObjectMapper/bundles.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; + +return [ + new FrameworkBundle(), +]; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ObjectMapper/config.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ObjectMapper/config.yml new file mode 100644 index 0000000000000..3e3bd8702c6f6 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ObjectMapper/config.yml @@ -0,0 +1,9 @@ +imports: + - { resource: ../config/default.yml } + +services: + object_mapper.alias: + alias: object_mapper + public: true + Symfony\Bundle\FrameworkBundle\Tests\Fixtures\ObjectMapper\TransformCallable: + autoconfigure: true diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Serializer/config.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Serializer/config.yml index 2f20dab9e8bc3..3c0c354174fbd 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Serializer/config.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/Serializer/config.yml @@ -10,7 +10,9 @@ framework: max_depth_handler: Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Serializer\MaxDepthHandler default_context: enable_max_depth: true - property_info: { enabled: true } + property_info: + enabled: true + with_constructor_extractor: true services: serializer.alias: diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/config/framework.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/config/framework.yml index 1eaee513c899b..ac051614bdd55 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/config/framework.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/config/framework.yml @@ -18,6 +18,8 @@ framework: cookie_samesite: lax php_errors: log: true + profiler: + collect_serializer_data: true services: logger: { class: Psr\Log\NullLogger } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Kernel/KernelCommand.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Kernel/KernelCommand.php new file mode 100644 index 0000000000000..4c9a5d85adcc2 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Kernel/KernelCommand.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Kernel; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand(name: 'kernel:hello')] +final class KernelCommand extends MinimalKernel +{ + public function __invoke(OutputInterface $output): int + { + $output->write('Hello Kernel!'); + + return 0; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Kernel/MicroKernelTraitTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Kernel/MicroKernelTraitTest.php index a9d2ae7209efe..5c7161124bda5 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Kernel/MicroKernelTraitTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Kernel/MicroKernelTraitTest.php @@ -13,7 +13,11 @@ use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; +use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; use Symfony\Component\DependencyInjection\Loader\ClosureLoader; @@ -152,6 +156,23 @@ public function testSimpleKernel() $this->assertSame('Hello World!', $response->getContent()); } + public function testKernelCommand() + { + if (!property_exists(AsCommand::class, 'help')) { + $this->markTestSkipped('Invokable command no available.'); + } + + $kernel = $this->kernel = new KernelCommand('kernel_command'); + $application = new Application($kernel); + + $input = new ArrayInput(['command' => 'kernel:hello']); + $output = new BufferedOutput(); + + $this->assertTrue($application->has('kernel:hello')); + $this->assertSame(0, $application->doRun($input, $output)); + $this->assertSame('Hello Kernel!', $output->fetch()); + } + public function testDefaultKernel() { $kernel = $this->kernel = new DefaultKernel('test', false); diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 9b3e7c86ea3ff..316c595ffa2bb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -20,12 +20,12 @@ "composer-runtime-api": ">=2.1", "ext-xml": "*", "symfony/cache": "^6.4|^7.0", - "symfony/config": "^6.4|^7.0", + "symfony/config": "^7.3", "symfony/dependency-injection": "^7.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0", + "symfony/error-handler": "^7.3", "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-foundation": "^7.3", "symfony/http-kernel": "^7.2", "symfony/polyfill-mbstring": "~1.0", "symfony/filesystem": "^7.1", @@ -54,21 +54,23 @@ "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/process": "^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.1", + "symfony/serializer": "^7.2.5", "symfony/stopwatch": "^6.4|^7.0", "symfony/string": "^6.4|^7.0", - "symfony/translation": "^6.4|^7.0", + "symfony/translation": "^7.3", "symfony/twig-bundle": "^6.4|^7.0", "symfony/type-info": "^7.1", "symfony/validator": "^6.4|^7.0", - "symfony/workflow": "^6.4|^7.0", + "symfony/workflow": "^7.3", "symfony/yaml": "^6.4|^7.0", "symfony/property-info": "^6.4|^7.0", + "symfony/json-streamer": "7.3.*", "symfony/uid": "^6.4|^7.0", "symfony/web-link": "^6.4|^7.0", "symfony/webhook": "^7.2", @@ -87,6 +89,7 @@ "symfony/dom-crawler": "<6.4", "symfony/http-client": "<6.4", "symfony/form": "<6.4", + "symfony/json-streamer": ">=7.4", "symfony/lock": "<6.4", "symfony/mailer": "<6.4", "symfony/messenger": "<6.4", @@ -97,15 +100,15 @@ "symfony/scheduler": "<6.4.4|>=7.0.0,<7.0.4", "symfony/security-csrf": "<7.2", "symfony/security-core": "<6.4", - "symfony/serializer": "<7.1", + "symfony/serializer": "<7.2.5", "symfony/stopwatch": "<6.4", - "symfony/translation": "<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": "<6.4" + "symfony/workflow": "<7.3.0-beta2" }, "autoload": { "psr-4": { "Symfony\\Bundle\\FrameworkBundle\\": "" }, diff --git a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md index 43c17dc20ef5d..77aa957331bd1 100644 --- a/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/SecurityBundle/CHANGELOG.md @@ -1,6 +1,17 @@ CHANGELOG ========= +7.3 +--- + + * Add `Security::isGrantedForUser()` to test user authorization without relying on the session. For example, users not currently logged in, or while processing a message from a message queue + * Add encryption support to `OidcTokenHandler` (JWE) + * Add `expose_security_errors` config option to display `AccountStatusException` + * Deprecate the `security.hide_user_not_found` config option in favor of `security.expose_security_errors` + * Add ability to fetch LDAP roles + * Add `OAuth2TokenHandlerFactory` for `AccessTokenFactory` + * Add discovery support to `OidcTokenHandler` and `OidcUserInfoTokenHandler` + 7.2 --- diff --git a/src/Symfony/Bundle/SecurityBundle/DataCollector/SecurityDataCollector.php b/src/Symfony/Bundle/SecurityBundle/DataCollector/SecurityDataCollector.php index f3c1cd1fe34af..aa6e8d9a4a8a7 100644 --- a/src/Symfony/Bundle/SecurityBundle/DataCollector/SecurityDataCollector.php +++ b/src/Symfony/Bundle/SecurityBundle/DataCollector/SecurityDataCollector.php @@ -138,6 +138,7 @@ public function collect(Request $request, Response $response, ?\Throwable $excep // collect voter details $decisionLog = $this->accessDecisionManager->getDecisionLog(); + foreach ($decisionLog as $key => $log) { $decisionLog[$key]['voter_details'] = []; foreach ($log['voterDetails'] as $voterDetail) { @@ -147,6 +148,7 @@ public function collect(Request $request, Response $response, ?\Throwable $excep 'class' => $classData, 'attributes' => $voterDetail['attributes'], // Only displayed for unanimous strategy 'vote' => $voterDetail['vote'], + 'reasons' => $voterDetail['reasons'] ?? [], ]; } unset($decisionLog[$key]['voterDetails']); diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php index a45276066484c..0a2d32c9f3f4d 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/MainConfiguration.php @@ -17,6 +17,7 @@ use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; +use Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel; use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategy; @@ -54,13 +55,35 @@ public function getConfigTreeBuilder(): TreeBuilder $rootNode = $tb->getRootNode(); $rootNode + ->docUrl('https://symfony.com/doc/{version:major}.{version:minor}/reference/configuration/security.html', 'symfony/security-bundle') + ->beforeNormalization() + ->always() + ->then(function ($v) { + if (isset($v['hide_user_not_found']) && isset($v['expose_security_errors'])) { + throw new InvalidConfigurationException('You cannot use both "hide_user_not_found" and "expose_security_errors" at the same time.'); + } + + if (isset($v['hide_user_not_found']) && !isset($v['expose_security_errors'])) { + $v['expose_security_errors'] = $v['hide_user_not_found'] ? ExposeSecurityLevel::None : ExposeSecurityLevel::All; + } + + return $v; + }) + ->end() ->children() ->scalarNode('access_denied_url')->defaultNull()->example('/foo/error403')->end() ->enumNode('session_fixation_strategy') ->values([SessionAuthenticationStrategy::NONE, SessionAuthenticationStrategy::MIGRATE, SessionAuthenticationStrategy::INVALIDATE]) ->defaultValue(SessionAuthenticationStrategy::MIGRATE) ->end() - ->booleanNode('hide_user_not_found')->defaultTrue()->end() + ->booleanNode('hide_user_not_found') + ->setDeprecated('symfony/security-bundle', '7.3', 'The "%node%" option is deprecated and will be removed in 8.0. Use the "expose_security_errors" option instead.') + ->end() + ->enumNode('expose_security_errors') + ->beforeNormalization()->ifString()->then(fn ($v) => ExposeSecurityLevel::tryFrom($v))->end() + ->values(ExposeSecurityLevel::cases()) + ->defaultValue(ExposeSecurityLevel::None) + ->end() ->booleanNode('erase_credentials')->defaultTrue()->end() ->arrayNode('access_decision_manager') ->addDefaultsIfNotSet() diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OAuth2TokenHandlerFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OAuth2TokenHandlerFactory.php new file mode 100644 index 0000000000000..fb2a964358d22 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OAuth2TokenHandlerFactory.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken; + +use Symfony\Component\Config\Definition\Builder\NodeBuilder; +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * Configures a token handler for an OAuth2 Token Introspection endpoint. + * + * @internal + */ +class OAuth2TokenHandlerFactory implements TokenHandlerFactoryInterface +{ + public function create(ContainerBuilder $container, string $id, array|string $config): void + { + $container->setDefinition($id, new ChildDefinition('security.access_token_handler.oauth2')); + } + + public function getKey(): string + { + return 'oauth2'; + } + + public function addConfiguration(NodeBuilder $node): void + { + $node->scalarNode($this->getKey())->end(); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php index e3d8db49e14be..de53d5e89bc26 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcTokenHandlerFactory.php @@ -17,6 +17,8 @@ use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\LogicException; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Contracts\HttpClient\HttpClientInterface; /** * Configures a token handler for decoding and validating an OIDC token. @@ -38,9 +40,45 @@ public function create(ContainerBuilder $container, string $id, array|string $co $tokenHandlerDefinition->replaceArgument(0, (new ChildDefinition('security.access_token_handler.oidc.signature')) ->replaceArgument(0, $config['algorithms'])); + if (isset($config['discovery'])) { + if (!ContainerBuilder::willBeAvailable('symfony/http-client', HttpClientInterface::class, ['symfony/security-bundle'])) { + throw new LogicException('You cannot use the "oidc" token handler with "discovery" since the HttpClient component is not installed. Try running "composer require symfony/http-client".'); + } + + // disable JWKSet argument + $tokenHandlerDefinition->replaceArgument(1, null); + $tokenHandlerDefinition->addMethodCall( + 'enableDiscovery', + [ + new Reference($config['discovery']['cache']['id']), + (new ChildDefinition('security.access_token_handler.oidc_discovery.http_client')) + ->replaceArgument(0, ['base_uri' => $config['discovery']['base_uri']]), + "$id.oidc_configuration", + "$id.oidc_jwk_set", + ] + ); + + return; + } + $tokenHandlerDefinition->replaceArgument(1, (new ChildDefinition('security.access_token_handler.oidc.jwkset')) - ->replaceArgument(0, $config['keyset']) - ); + ->replaceArgument(0, $config['keyset'])); + + if ($config['encryption']['enabled']) { + $algorithmManager = (new ChildDefinition('security.access_token_handler.oidc.encryption')) + ->replaceArgument(0, $config['encryption']['algorithms']); + $keyset = (new ChildDefinition('security.access_token_handler.oidc.jwkset')) + ->replaceArgument(0, $config['encryption']['keyset']); + + $tokenHandlerDefinition->addMethodCall( + 'enableJweSupport', + [ + $keyset, + $algorithmManager, + $config['encryption']['enforce'], + ] + ); + } } public function getKey(): string @@ -58,8 +96,8 @@ public function addConfiguration(NodeBuilder $node): void ->thenInvalid('You must set either "algorithm" or "algorithms".') ->end() ->validate() - ->ifTrue(static fn ($v) => !isset($v['key']) && !isset($v['keyset'])) - ->thenInvalid('You must set either "key" or "keyset".') + ->ifTrue(static fn ($v) => !isset($v['discovery']) && !isset($v['key']) && !isset($v['keyset'])) + ->thenInvalid('You must set either "discovery" or "key" or "keyset".') ->end() ->beforeNormalization() ->ifTrue(static fn ($v) => isset($v['algorithm']) && \is_string($v['algorithm'])) @@ -85,6 +123,25 @@ public function addConfiguration(NodeBuilder $node): void }) ->end() ->children() + ->arrayNode('discovery') + ->info('Enable the OIDC discovery.') + ->children() + ->scalarNode('base_uri') + ->info('Base URI of the OIDC server.') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->arrayNode('cache') + ->children() + ->scalarNode('id') + ->info('Cache service id to use to cache the OIDC discovery configuration.') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->end() + ->end() + ->end() + ->end() ->scalarNode('claim') ->info('Claim which contains the user identifier (e.g.: sub, email..).') ->defaultValue('sub') @@ -112,8 +169,26 @@ public function addConfiguration(NodeBuilder $node): void ->setDeprecated('symfony/security-bundle', '7.1', 'The "%node%" option is deprecated and will be removed in 8.0. Use the "keyset" option instead.') ->end() ->scalarNode('keyset') - ->info('JSON-encoded JWKSet used to sign the token (must contain a list of valid keys).') - ->isRequired() + ->info('JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys).') + ->end() + ->arrayNode('encryption') + ->canBeEnabled() + ->children() + ->booleanNode('enforce') + ->info('When enabled, the token shall be encrypted.') + ->defaultFalse() + ->end() + ->arrayNode('algorithms') + ->info('Algorithms used to decrypt the token.') + ->isRequired() + ->requiresAtLeastOneElement() + ->scalarPrototype()->end() + ->end() + ->scalarNode('keyset') + ->info('JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys).') + ->isRequired() + ->end() + ->end() ->end() ->end() ->end() diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcUserInfoTokenHandlerFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcUserInfoTokenHandlerFactory.php index 3e30acabaf5dd..c6308ff342242 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcUserInfoTokenHandlerFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/OidcUserInfoTokenHandlerFactory.php @@ -16,6 +16,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; /** @@ -34,9 +35,23 @@ public function create(ContainerBuilder $container, string $id, array|string $co throw new LogicException('You cannot use the "oidc_user_info" token handler since the HttpClient component is not installed. Try running "composer require symfony/http-client".'); } - $container->setDefinition($id, new ChildDefinition('security.access_token_handler.oidc_user_info')) + $tokenHandlerDefinition = $container->setDefinition($id, new ChildDefinition('security.access_token_handler.oidc_user_info')) ->replaceArgument(0, $clientDefinition) ->replaceArgument(2, $config['claim']); + + if (isset($config['discovery'])) { + if (!ContainerBuilder::willBeAvailable('symfony/cache', CacheInterface::class, ['symfony/security-bundle'])) { + throw new LogicException('You cannot use the "oidc_user_info" token handler with "discovery" since the Cache component is not installed. Try running "composer require symfony/cache".'); + } + + $tokenHandlerDefinition->addMethodCall( + 'enableDiscovery', + [ + new Reference($config['discovery']['cache']['id']), + "$id.oidc_configuration", + ] + ); + } } public function getKey(): string @@ -55,10 +70,24 @@ public function addConfiguration(NodeBuilder $node): void ->end() ->children() ->scalarNode('base_uri') - ->info('Base URI of the userinfo endpoint on the OIDC server.') + ->info('Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured).') ->isRequired() ->cannotBeEmpty() ->end() + ->arrayNode('discovery') + ->info('Enable the OIDC discovery.') + ->children() + ->arrayNode('cache') + ->children() + ->scalarNode('id') + ->info('Cache service id to use to cache the OIDC discovery configuration.') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->end() + ->end() + ->end() + ->end() ->scalarNode('claim') ->info('Claim which contains the user identifier (e.g. sub, email, etc.).') ->defaultValue('sub') diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/UserProvider/LdapFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/UserProvider/LdapFactory.php index b8d442fd99251..1efa2c642fdbc 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/UserProvider/LdapFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/UserProvider/LdapFactory.php @@ -32,7 +32,7 @@ public function create(ContainerBuilder $container, string $id, array $config): ->replaceArgument(1, $config['base_dn']) ->replaceArgument(2, $config['search_dn']) ->replaceArgument(3, $config['search_password']) - ->replaceArgument(4, $config['default_roles']) + ->replaceArgument(4, $config['role_fetcher'] ? new Reference($config['role_fetcher']) : $config['default_roles']) ->replaceArgument(5, $config['uid_key']) ->replaceArgument(6, $config['filter']) ->replaceArgument(7, $config['password_attribute']) @@ -63,6 +63,7 @@ public function addConfiguration(NodeDefinition $node): void ->requiresAtLeastOneElement() ->prototype('scalar')->end() ->end() + ->scalarNode('role_fetcher')->defaultNull()->end() ->scalarNode('uid_key')->defaultValue('sAMAccountName')->end() ->scalarNode('filter')->defaultValue('({uid_key}={user_identifier})')->end() ->scalarNode('password_attribute')->defaultNull()->end() diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index f454b9318c183..f1888bd7a2928 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -11,6 +11,7 @@ namespace Symfony\Bundle\SecurityBundle\DependencyInjection; +use Composer\InstalledVersions; use Symfony\Bridge\Twig\Extension\LogoutUrlExtension; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AuthenticatorFactoryInterface; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\FirewallListenerFactoryInterface; @@ -58,10 +59,10 @@ use Symfony\Component\Security\Core\User\ChainUserProvider; use Symfony\Component\Security\Core\User\UserCheckerInterface; use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel; use Symfony\Component\Security\Http\Authenticator\Debug\TraceableAuthenticator; use Symfony\Component\Security\Http\Authenticator\Debug\TraceableAuthenticatorManagerListener; use Symfony\Component\Security\Http\Event\CheckPassportEvent; -use Symfony\Flex\Command\InstallRecipesCommand; /** * SecurityExtension. @@ -92,7 +93,7 @@ public function prepend(ContainerBuilder $container): void public function load(array $configs, ContainerBuilder $container): void { if (!array_filter($configs)) { - $hint = class_exists(InstallRecipesCommand::class) ? 'Try running "composer symfony:recipes:install symfony/security-bundle".' : 'Please define your settings for the "security" config section.'; + $hint = class_exists(InstalledVersions::class) && InstalledVersions::isInstalled('symfony/flex') ? 'Try running "composer symfony:recipes:install symfony/security-bundle".' : 'Please define your settings for the "security" config section.'; throw new InvalidConfigurationException('The SecurityBundle is enabled but is not configured. '.$hint); } @@ -154,7 +155,8 @@ public function load(array $configs, ContainerBuilder $container): void )); } - $container->setParameter('security.authentication.hide_user_not_found', $config['hide_user_not_found']); + $container->setParameter('security.authentication.hide_user_not_found', ExposeSecurityLevel::All !== $config['expose_security_errors']); + $container->setParameter('.security.authentication.expose_security_errors', $config['expose_security_errors']); if (class_exists(Application::class)) { $loader->load('debug_console.php'); diff --git a/src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php b/src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php index 54eac4384542a..31e7efb83c35d 100644 --- a/src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php +++ b/src/Symfony/Bundle/SecurityBundle/EventListener/VoteListener.php @@ -31,7 +31,7 @@ public function __construct( public function onVoterVote(VoteEvent $event): void { - $this->traceableAccessDecisionManager->addVoterVote($event->getVoter(), $event->getAttributes(), $event->getVote()); + $this->traceableAccessDecisionManager->addVoterVote($event->getVoter(), $event->getAttributes(), $event->getVote(), $event->getReasons()); } public static function getSubscribedEvents(): array diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd b/src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd index a8623e0b50d84..692321a4907da 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/schema/security-1.0.xsd @@ -55,6 +55,7 @@ + @@ -68,6 +69,14 @@ + + + + + + + + @@ -329,6 +338,7 @@ + @@ -336,6 +346,21 @@ + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php index 7411c6dc5ceb2..7b08ebe5fa35d 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php @@ -31,7 +31,9 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationChecker; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Authorization\ExpressionLanguage; +use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface; use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter; +use Symfony\Component\Security\Core\Authorization\Voter\ClosureVoter; use Symfony\Component\Security\Core\Authorization\Voter\ExpressionVoter; use Symfony\Component\Security\Core\Authorization\Voter\RoleHierarchyVoter; use Symfony\Component\Security\Core\Authorization\Voter\RoleVoter; @@ -66,6 +68,7 @@ service('security.access.decision_manager'), ]) ->alias(AuthorizationCheckerInterface::class, 'security.authorization_checker') + ->alias(UserAuthorizationCheckerInterface::class, 'security.authorization_checker') ->set('security.token_storage', UsageTrackingTokenStorage::class) ->args([ @@ -85,6 +88,7 @@ service_locator([ 'security.token_storage' => service('security.token_storage'), 'security.authorization_checker' => service('security.authorization_checker'), + 'security.user_authorization_checker' => service('security.authorization_checker'), 'security.authenticator.managers_locator' => service('security.authenticator.managers_locator')->ignoreOnInvalid(), 'request_stack' => service('request_stack'), 'security.firewall.map' => service('security.firewall.map'), @@ -162,6 +166,12 @@ ]) ->tag('security.voter', ['priority' => 245]) + ->set('security.access.closure_voter', ClosureVoter::class) + ->args([ + service('security.authorization_checker'), + ]) + ->tag('security.voter', ['priority' => 245]) + ->set('security.impersonate_url_generator', ImpersonateUrlGenerator::class) ->args([ service('request_stack'), diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php index 1ea4ef5568fd3..babcdb8206df7 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator.php @@ -42,7 +42,7 @@ abstract_arg('provider key'), service('logger')->nullOnInvalid(), param('security.authentication.manager.erase_credentials'), - param('security.authentication.hide_user_not_found'), + param('.security.authentication.expose_security_errors'), abstract_arg('required badges'), ]) ->tag('monolog.logger', ['channel' => 'security']) diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_access_token.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_access_token.php index c0fced49ae9ca..9099bad41c385 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_access_token.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_authenticator_access_token.php @@ -15,6 +15,15 @@ use Jose\Component\Core\AlgorithmManagerFactory; use Jose\Component\Core\JWK; use Jose\Component\Core\JWKSet; +use Jose\Component\Encryption\Algorithm\ContentEncryption\A128CBCHS256; +use Jose\Component\Encryption\Algorithm\ContentEncryption\A128GCM; +use Jose\Component\Encryption\Algorithm\ContentEncryption\A192CBCHS384; +use Jose\Component\Encryption\Algorithm\ContentEncryption\A192GCM; +use Jose\Component\Encryption\Algorithm\ContentEncryption\A256CBCHS512; +use Jose\Component\Encryption\Algorithm\ContentEncryption\A256GCM; +use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHES; +use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHSS; +use Jose\Component\Encryption\Algorithm\KeyEncryption\RSAOAEP; use Jose\Component\Signature\Algorithm\ES256; use Jose\Component\Signature\Algorithm\ES384; use Jose\Component\Signature\Algorithm\ES512; @@ -27,6 +36,7 @@ use Symfony\Component\Security\Http\AccessToken\ChainAccessTokenExtractor; use Symfony\Component\Security\Http\AccessToken\FormEncodedBodyExtractor; use Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor; +use Symfony\Component\Security\Http\AccessToken\OAuth2\Oauth2TokenHandler; use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenHandler; use Symfony\Component\Security\Http\AccessToken\Oidc\OidcUserInfoTokenHandler; use Symfony\Component\Security\Http\AccessToken\QueryAccessTokenExtractor; @@ -82,6 +92,11 @@ service('clock'), ]) + ->set('security.access_token_handler.oidc_discovery.http_client', HttpClientInterface::class) + ->abstract() + ->factory([service('http_client'), 'withOptions']) + ->args([abstract_arg('http client options')]) + ->set('security.access_token_handler.oidc.jwk', JWK::class) ->abstract() ->deprecate('symfony/security-http', '7.1', 'The "%service_id%" service is deprecated. Please use "security.access_token_handler.oidc.jwkset" instead') @@ -135,5 +150,55 @@ ->set('security.access_token_handler.oidc.signature.PS512', PS512::class) ->tag('security.access_token_handler.oidc.signature_algorithm') + + // Encryption + // Note that - all xxxKW algorithms are not defined as an extra dependency is required + // - The RSA_1.5 is missing as deprecated + ->set('security.access_token_handler.oidc.encryption_algorithm_manager_factory', AlgorithmManagerFactory::class) + ->args([ + tagged_iterator('security.access_token_handler.oidc.encryption_algorithm'), + ]) + + ->set('security.access_token_handler.oidc.encryption', AlgorithmManager::class) + ->abstract() + ->factory([service('security.access_token_handler.oidc.encryption_algorithm_manager_factory'), 'create']) + ->args([ + abstract_arg('encryption algorithms'), + ]) + + ->set('security.access_token_handler.oidc.encryption.RSAOAEP', RSAOAEP::class) + ->tag('security.access_token_handler.oidc.encryption_algorithm') + + ->set('security.access_token_handler.oidc.encryption.ECDHES', ECDHES::class) + ->tag('security.access_token_handler.oidc.encryption_algorithm') + + ->set('security.access_token_handler.oidc.encryption.ECDHSS', ECDHSS::class) + ->tag('security.access_token_handler.oidc.encryption_algorithm') + + ->set('security.access_token_handler.oidc.encryption.A128CBCHS256', A128CBCHS256::class) + ->tag('security.access_token_handler.oidc.encryption_algorithm') + + ->set('security.access_token_handler.oidc.encryption.A192CBCHS384', A192CBCHS384::class) + ->tag('security.access_token_handler.oidc.encryption_algorithm') + + ->set('security.access_token_handler.oidc.encryption.A256CBCHS512', A256CBCHS512::class) + ->tag('security.access_token_handler.oidc.encryption_algorithm') + + ->set('security.access_token_handler.oidc.encryption.A128GCM', A128GCM::class) + ->tag('security.access_token_handler.oidc.encryption_algorithm') + + ->set('security.access_token_handler.oidc.encryption.A192GCM', A192GCM::class) + ->tag('security.access_token_handler.oidc.encryption_algorithm') + + ->set('security.access_token_handler.oidc.encryption.A256GCM', A256GCM::class) + ->tag('security.access_token_handler.oidc.encryption_algorithm') + + // OAuth2 Introspection (RFC 7662) + ->set('security.access_token_handler.oauth2', Oauth2TokenHandler::class) + ->abstract() + ->args([ + service('http_client'), + service('logger')->nullOnInvalid(), + ]) ; }; diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_debug.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_debug.php index c98e3a6984672..76b4e31b1b5a8 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_debug.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_debug.php @@ -22,6 +22,7 @@ ->args([ service('debug.security.access.decision_manager.inner'), ]) + ->tag('kernel.reset', ['method' => 'reset', 'on_invalid' => 'ignore']) ->set('debug.security.voter.vote_listener', VoteListener::class) ->args([ diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig b/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig index 635d61e2dd2c8..f2706858e45cf 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig +++ b/src/Symfony/Bundle/SecurityBundle/Resources/views/Collector/security.html.twig @@ -571,14 +571,19 @@ {% endif %} {% if voter_detail['vote'] == constant('Symfony\\Component\\Security\\Core\\Authorization\\Voter\\VoterInterface::ACCESS_GRANTED') %} - ACCESS GRANTED + GRANTED {% elseif voter_detail['vote'] == constant('Symfony\\Component\\Security\\Core\\Authorization\\Voter\\VoterInterface::ACCESS_ABSTAIN') %} - ACCESS ABSTAIN + ABSTAIN {% elseif voter_detail['vote'] == constant('Symfony\\Component\\Security\\Core\\Authorization\\Voter\\VoterInterface::ACCESS_DENIED') %} - ACCESS DENIED + DENIED {% else %} unknown ({{ voter_detail['vote'] }}) {% endif %} + {% if voter_detail['reasons'] is not empty %} + {% for voter_reason in voter_detail['reasons'] %} +
{{ voter_reason }} + {% endfor %} + {% endif %} {% endfor %} diff --git a/src/Symfony/Bundle/SecurityBundle/Security.php b/src/Symfony/Bundle/SecurityBundle/Security.php index 915f766f5175b..2efbb67fc3de0 100644 --- a/src/Symfony/Bundle/SecurityBundle/Security.php +++ b/src/Symfony/Bundle/SecurityBundle/Security.php @@ -17,7 +17,9 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\AccessDecision; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface; use Symfony\Component\Security\Core\Exception\LogicException; use Symfony\Component\Security\Core\Exception\LogoutException; use Symfony\Component\Security\Core\User\UserInterface; @@ -37,7 +39,7 @@ * * @final */ -class Security implements AuthorizationCheckerInterface +class Security implements AuthorizationCheckerInterface, UserAuthorizationCheckerInterface { public function __construct( private readonly ContainerInterface $container, @@ -57,10 +59,21 @@ public function getUser(): ?UserInterface /** * Checks if the attributes are granted against the current authentication token and optionally supplied subject. */ - public function isGranted(mixed $attributes, mixed $subject = null): bool + public function isGranted(mixed $attributes, mixed $subject = null, ?AccessDecision $accessDecision = null): bool { return $this->container->get('security.authorization_checker') - ->isGranted($attributes, $subject); + ->isGranted($attributes, $subject, $accessDecision); + } + + /** + * Checks if the attribute is granted against the user and optionally supplied subject. + * + * This should be used over isGranted() when checking permissions against a user that is not currently logged in or while in a CLI context. + */ + public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool + { + return $this->container->get('security.user_authorization_checker') + ->isGrantedForUser($user, $attribute, $subject, $accessDecision); } public function getToken(): ?TokenInterface @@ -158,8 +171,7 @@ private function getAuthenticator(?string $authenticatorName, string $firewallNa $firewallAuthenticatorLocator = $this->authenticators[$firewallName]; if (!$authenticatorName) { - $authenticatorIds = array_keys($firewallAuthenticatorLocator->getProvidedServices()); - + $authenticatorIds = array_filter(array_keys($firewallAuthenticatorLocator->getProvidedServices()), fn (string $authenticatorId) => $authenticatorId !== \sprintf('security.authenticator.remember_me.%s', $firewallName)); if (!$authenticatorIds) { throw new LogicException(\sprintf('No authenticator was found for the firewall "%s".', $firewallName)); } diff --git a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php index 3247ff1276ffa..1433b5c90e001 100644 --- a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php +++ b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php @@ -24,6 +24,7 @@ use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\ReplaceDecoratedRememberMeHandlerPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\SortFirewallListenersPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\CasTokenHandlerFactory; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OAuth2TokenHandlerFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OidcTokenHandlerFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OidcUserInfoTokenHandlerFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\ServiceTokenHandlerFactory; @@ -80,6 +81,7 @@ public function build(ContainerBuilder $container): void new OidcUserInfoTokenHandlerFactory(), new OidcTokenHandlerFactory(), new CasTokenHandlerFactory(), + new OAuth2TokenHandlerFactory(), ])); $extension->addUserProviderFactory(new InMemoryFactory()); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DataCollector/SecurityDataCollectorTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DataCollector/SecurityDataCollectorTest.php index 21161d281eb92..5528c9b7a8fc7 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DataCollector/SecurityDataCollectorTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DataCollector/SecurityDataCollectorTest.php @@ -28,6 +28,7 @@ use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Authorization\TraceableAccessDecisionManager; use Symfony\Component\Security\Core\Authorization\Voter\TraceableVoter; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Role\RoleHierarchy; use Symfony\Component\Security\Core\User\InMemoryUser; @@ -53,7 +54,7 @@ public function testCollectWhenSecurityIsDisabled() $this->assertFalse($collector->supportsRoleHierarchy()); $this->assertCount(0, $collector->getRoles()); $this->assertCount(0, $collector->getInheritedRoles()); - $this->assertEmpty($collector->getUser()); + $this->assertSame('', $collector->getUser()); $this->assertNull($collector->getFirewall()); } @@ -72,7 +73,7 @@ public function testCollectWhenAuthenticationTokenIsNull() $this->assertTrue($collector->supportsRoleHierarchy()); $this->assertCount(0, $collector->getRoles()); $this->assertCount(0, $collector->getInheritedRoles()); - $this->assertEmpty($collector->getUser()); + $this->assertSame('', $collector->getUser()); $this->assertNull($collector->getFirewall()); } @@ -271,8 +272,8 @@ public function dispatch(object $event, ?string $eventName = null): object 'object' => new \stdClass(), 'result' => true, 'voter_details' => [ - ['class' => $voter1::class, 'attributes' => ['view'], 'vote' => VoterInterface::ACCESS_ABSTAIN], - ['class' => $voter2::class, 'attributes' => ['view'], 'vote' => VoterInterface::ACCESS_ABSTAIN], + ['class' => $voter1::class, 'attributes' => ['view'], 'vote' => VoterInterface::ACCESS_ABSTAIN, 'reasons' => []], + ['class' => $voter2::class, 'attributes' => ['view'], 'vote' => VoterInterface::ACCESS_ABSTAIN, 'reasons' => []], ], ]]; @@ -360,10 +361,10 @@ public function dispatch(object $event, ?string $eventName = null): object 'object' => new \stdClass(), 'result' => false, 'voter_details' => [ - ['class' => $voter1::class, 'attributes' => ['view'], 'vote' => VoterInterface::ACCESS_DENIED], - ['class' => $voter1::class, 'attributes' => ['edit'], 'vote' => VoterInterface::ACCESS_DENIED], - ['class' => $voter2::class, 'attributes' => ['view'], 'vote' => VoterInterface::ACCESS_GRANTED], - ['class' => $voter2::class, 'attributes' => ['edit'], 'vote' => VoterInterface::ACCESS_GRANTED], + ['class' => $voter1::class, 'attributes' => ['view'], 'vote' => VoterInterface::ACCESS_DENIED, 'reasons' => []], + ['class' => $voter1::class, 'attributes' => ['edit'], 'vote' => VoterInterface::ACCESS_DENIED, 'reasons' => []], + ['class' => $voter2::class, 'attributes' => ['view'], 'vote' => VoterInterface::ACCESS_GRANTED, 'reasons' => []], + ['class' => $voter2::class, 'attributes' => ['edit'], 'vote' => VoterInterface::ACCESS_GRANTED, 'reasons' => []], ], ], [ @@ -371,8 +372,8 @@ public function dispatch(object $event, ?string $eventName = null): object 'object' => new \stdClass(), 'result' => true, 'voter_details' => [ - ['class' => $voter1::class, 'attributes' => ['update'], 'vote' => VoterInterface::ACCESS_GRANTED], - ['class' => $voter2::class, 'attributes' => ['update'], 'vote' => VoterInterface::ACCESS_GRANTED], + ['class' => $voter1::class, 'attributes' => ['update'], 'vote' => VoterInterface::ACCESS_GRANTED, 'reasons' => []], + ['class' => $voter2::class, 'attributes' => ['update'], 'vote' => VoterInterface::ACCESS_GRANTED, 'reasons' => []], ], ], ]; @@ -424,7 +425,7 @@ public function testGetVotersIfAccessDecisionManagerHasNoVoters() $dataCollector->collect(new Request(), new Response()); - $this->assertEmpty($dataCollector->getVoters()); + $this->assertSame([], $dataCollector->getVoters()); } public static function provideRoles(): array @@ -461,7 +462,7 @@ private function getRoleHierarchy() final class DummyVoter implements VoterInterface { - public function vote(TokenInterface $token, mixed $subject, array $attributes): int + public function vote(TokenInterface $token, mixed $subject, array $attributes, ?Vote $vote = null): int { } } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Debug/TraceableFirewallListenerTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Debug/TraceableFirewallListenerTest.php index cdf53c2007756..4ab483a28f38a 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Debug/TraceableFirewallListenerTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Debug/TraceableFirewallListenerTest.php @@ -19,6 +19,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Security\Core\Authentication\Token\AbstractToken; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Http\Authentication\AuthenticatorManager; @@ -89,7 +90,7 @@ public function testOnKernelRequestRecordsAuthenticatorsInfo() $supportingAuthenticator ->expects($this->once()) ->method('createToken') - ->willReturn($this->createMock(TokenInterface::class)); + ->willReturn(new class extends AbstractToken {}); $notSupportingAuthenticator = $this->createMock(DummyAuthenticator::class); $notSupportingAuthenticator diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/MainConfigurationTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/MainConfigurationTest.php index 8d3fed44695d2..6904a21b18113 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/MainConfigurationTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/MainConfigurationTest.php @@ -12,13 +12,17 @@ namespace Symfony\Bundle\SecurityBundle\Tests\DependencyInjection; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectUserDeprecationMessageTrait; use Symfony\Bundle\SecurityBundle\DependencyInjection\MainConfiguration; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AuthenticatorFactoryInterface; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\Config\Definition\Processor; +use Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel; class MainConfigurationTest extends TestCase { + use ExpectUserDeprecationMessageTrait; + /** * The minimal, required config needed to not have any required validation * issues. @@ -228,4 +232,69 @@ public function testFirewalls() $configuration = new MainConfiguration(['stub' => $factory], []); $configuration->getConfigTreeBuilder(); } + + /** + * @dataProvider provideHideUserNotFoundData + */ + public function testExposeSecurityErrors(array $config, ExposeSecurityLevel $expectedExposeSecurityErrors) + { + $config = array_merge(static::$minimalConfig, $config); + + $processor = new Processor(); + $configuration = new MainConfiguration([], []); + $processedConfig = $processor->processConfiguration($configuration, [$config]); + + $this->assertEquals($expectedExposeSecurityErrors, $processedConfig['expose_security_errors']); + $this->assertArrayNotHasKey('hide_user_not_found', $processedConfig); + } + + public static function provideHideUserNotFoundData(): iterable + { + yield [[], ExposeSecurityLevel::None]; + yield [['expose_security_errors' => ExposeSecurityLevel::None], ExposeSecurityLevel::None]; + yield [['expose_security_errors' => ExposeSecurityLevel::AccountStatus], ExposeSecurityLevel::AccountStatus]; + yield [['expose_security_errors' => ExposeSecurityLevel::All], ExposeSecurityLevel::All]; + yield [['expose_security_errors' => 'none'], ExposeSecurityLevel::None]; + yield [['expose_security_errors' => 'account_status'], ExposeSecurityLevel::AccountStatus]; + yield [['expose_security_errors' => 'all'], ExposeSecurityLevel::All]; + } + + /** + * @dataProvider provideHideUserNotFoundLegacyData + * + * @group legacy + */ + public function testExposeSecurityErrorsWithLegacyConfig(array $config, ExposeSecurityLevel $expectedExposeSecurityErrors, ?bool $expectedHideUserNotFound) + { + $this->expectUserDeprecationMessage('Since symfony/security-bundle 7.3: The "hide_user_not_found" option is deprecated and will be removed in 8.0. Use the "expose_security_errors" option instead.'); + + $config = array_merge(static::$minimalConfig, $config); + + $processor = new Processor(); + $configuration = new MainConfiguration([], []); + $processedConfig = $processor->processConfiguration($configuration, [$config]); + + $this->assertEquals($expectedExposeSecurityErrors, $processedConfig['expose_security_errors']); + $this->assertEquals($expectedHideUserNotFound, $processedConfig['hide_user_not_found']); + } + + public static function provideHideUserNotFoundLegacyData(): iterable + { + yield [['hide_user_not_found' => true], ExposeSecurityLevel::None, true]; + yield [['hide_user_not_found' => false], ExposeSecurityLevel::All, false]; + } + + public function testCannotUseHideUserNotFoundAndExposeSecurityErrorsAtTheSameTime() + { + $processor = new Processor(); + $configuration = new MainConfiguration([], []); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('You cannot use both "hide_user_not_found" and "expose_security_errors" at the same time.'); + + $processor->processConfiguration($configuration, [static::$minimalConfig + [ + 'hide_user_not_found' => true, + 'expose_security_errors' => ExposeSecurityLevel::None, + ]]); + } } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php index ce105759d71be..88b782363dbf9 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\CasTokenHandlerFactory; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OAuth2TokenHandlerFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OidcTokenHandlerFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OidcUserInfoTokenHandlerFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\ServiceTokenHandlerFactory; @@ -113,7 +114,7 @@ public function testInvalidOidcTokenHandlerConfigurationKeyMissing() $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); $this->expectException(InvalidConfigurationException::class); - $this->expectExceptionMessage('The child config "keyset" under "access_token.token_handler.oidc" must be configured: JSON-encoded JWKSet used to sign the token (must contain a list of valid keys).'); + $this->expectExceptionMessage('You must set either "discovery" or "key" or "keyset".'); $this->processConfig($config, $factory); } @@ -257,6 +258,140 @@ public function testOidcTokenHandlerConfigurationWithMultipleAlgorithms() $this->assertEquals($expected, $container->getDefinition('security.access_token_handler.firewall1')->getArguments()); } + public function testOidcTokenHandlerConfigurationWithEncryption() + { + $container = new ContainerBuilder(); + $jwkset = '{"keys":[{"kty":"EC","crv":"P-256","x":"FtgMtrsKDboRO-Zo0XC7tDJTATHVmwuf9GK409kkars","y":"rWDE0ERU2SfwGYCo1DWWdgFEbZ0MiAXLRBBOzBgs_jY","d":"4G7bRIiKih0qrFxc0dtvkHUll19tTyctoCR3eIbOrO0"},{"kty":"EC","crv":"P-256","x":"0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4","y":"KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo","d":"iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220"}]}'; + $config = [ + 'token_handler' => [ + 'oidc' => [ + 'algorithms' => ['RS256', 'ES256'], + 'issuers' => ['https://www.example.com'], + 'audience' => 'audience', + 'keyset' => $jwkset, + 'encryption' => [ + 'enabled' => true, + 'keyset' => $jwkset, + 'algorithms' => ['RSA-OAEP', 'RSA1_5'], + ], + ], + ], + ]; + + $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); + $finalizedConfig = $this->processConfig($config, $factory); + + $factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider'); + + $this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1')); + $this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1')); + } + + public function testInvalidOidcTokenHandlerConfigurationMissingEncryptionKeyset() + { + $jwkset = '{"keys":[{"kty":"EC","crv":"P-256","x":"FtgMtrsKDboRO-Zo0XC7tDJTATHVmwuf9GK409kkars","y":"rWDE0ERU2SfwGYCo1DWWdgFEbZ0MiAXLRBBOzBgs_jY","d":"4G7bRIiKih0qrFxc0dtvkHUll19tTyctoCR3eIbOrO0"},{"kty":"EC","crv":"P-256","x":"0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4","y":"KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo","d":"iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220"}]}'; + $config = [ + 'token_handler' => [ + 'oidc' => [ + 'algorithms' => ['RS256', 'ES256'], + 'issuers' => ['https://www.example.com'], + 'audience' => 'audience', + 'keyset' => $jwkset, + 'encryption' => [ + 'enabled' => true, + 'algorithms' => ['RSA-OAEP', 'RSA1_5'], + ], + ], + ], + ]; + + $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The child config "keyset" under "access_token.token_handler.oidc.encryption" must be configured: JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys).'); + + $this->processConfig($config, $factory); + } + + public function testInvalidOidcTokenHandlerConfigurationMissingAlgorithm() + { + $jwkset = '{"keys":[{"kty":"EC","crv":"P-256","x":"FtgMtrsKDboRO-Zo0XC7tDJTATHVmwuf9GK409kkars","y":"rWDE0ERU2SfwGYCo1DWWdgFEbZ0MiAXLRBBOzBgs_jY","d":"4G7bRIiKih0qrFxc0dtvkHUll19tTyctoCR3eIbOrO0"},{"kty":"EC","crv":"P-256","x":"0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4","y":"KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo","d":"iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220"}]}'; + $config = [ + 'token_handler' => [ + 'oidc' => [ + 'algorithms' => ['RS256', 'ES256'], + 'issuers' => ['https://www.example.com'], + 'audience' => 'audience', + 'keyset' => $jwkset, + 'encryption' => [ + 'enabled' => true, + 'keyset' => $jwkset, + 'algorithms' => [], + ], + ], + ], + ]; + + $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('The path "access_token.token_handler.oidc.encryption.algorithms" should have at least 1 element(s) defined.'); + + $this->processConfig($config, $factory); + } + + public function testOidcTokenHandlerConfigurationWithDiscovery() + { + $container = new ContainerBuilder(); + $jwkset = '{"keys":[{"kty":"EC","crv":"P-256","x":"FtgMtrsKDboRO-Zo0XC7tDJTATHVmwuf9GK409kkars","y":"rWDE0ERU2SfwGYCo1DWWdgFEbZ0MiAXLRBBOzBgs_jY","d":"4G7bRIiKih0qrFxc0dtvkHUll19tTyctoCR3eIbOrO0"},{"kty":"EC","crv":"P-256","x":"0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4","y":"KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo","d":"iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220"}]}'; + $config = [ + 'token_handler' => [ + 'oidc' => [ + 'discovery' => [ + 'base_uri' => 'https://www.example.com/realms/demo/', + 'cache' => [ + 'id' => 'oidc_cache', + ], + ], + 'algorithms' => ['RS256', 'ES256'], + 'issuers' => ['https://www.example.com'], + 'audience' => 'audience', + ], + ], + ]; + + $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); + $finalizedConfig = $this->processConfig($config, $factory); + + $factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider'); + + $this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1')); + $this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1')); + + $expectedArgs = [ + 'index_0' => (new ChildDefinition('security.access_token_handler.oidc.signature')) + ->replaceArgument(0, ['RS256', 'ES256']), + 'index_1' => null, + 'index_2' => 'audience', + 'index_3' => ['https://www.example.com'], + 'index_4' => 'sub', + ]; + $expectedCalls = [ + [ + 'enableDiscovery', + [ + new Reference('oidc_cache'), + (new ChildDefinition('security.access_token_handler.oidc_discovery.http_client')) + ->replaceArgument(0, ['base_uri' => 'https://www.example.com/realms/demo/']), + 'security.access_token_handler.firewall1.oidc_configuration', + 'security.access_token_handler.firewall1.oidc_jwk_set', + ], + ], + ]; + $this->assertEquals($expectedArgs, $container->getDefinition('security.access_token_handler.firewall1')->getArguments()); + $this->assertEquals($expectedCalls, $container->getDefinition('security.access_token_handler.firewall1')->getMethodCalls()); + } + public function testOidcUserInfoTokenHandlerConfigurationWithExistingClient() { $container = new ContainerBuilder(); @@ -324,6 +459,48 @@ public static function getOidcUserInfoConfiguration(): iterable yield ['https://www.example.com/realms/demo/protocol/openid-connect/userinfo']; } + public function testOidcUserInfoTokenHandlerConfigurationWithDiscovery() + { + $container = new ContainerBuilder(); + $config = [ + 'token_handler' => [ + 'oidc_user_info' => [ + 'discovery' => [ + 'cache' => [ + 'id' => 'oidc_cache', + ], + ], + 'base_uri' => 'https://www.example.com/realms/demo/protocol/openid-connect/userinfo', + ], + ], + ]; + + $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); + $finalizedConfig = $this->processConfig($config, $factory); + + $factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider'); + + $this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1')); + $this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1')); + + $expectedArgs = [ + 'index_0' => (new ChildDefinition('security.access_token_handler.oidc_user_info.http_client')) + ->replaceArgument(0, ['base_uri' => 'https://www.example.com/realms/demo/protocol/openid-connect/userinfo']), + 'index_2' => 'sub', + ]; + $expectedCalls = [ + [ + 'enableDiscovery', + [ + new Reference('oidc_cache'), + 'security.access_token_handler.firewall1.oidc_configuration', + ], + ], + ]; + $this->assertEquals($expectedArgs, $container->getDefinition('security.access_token_handler.firewall1')->getArguments()); + $this->assertEquals($expectedCalls, $container->getDefinition('security.access_token_handler.firewall1')->getMethodCalls()); + } + public function testMultipleTokenHandlersSet() { $config = [ @@ -341,6 +518,22 @@ public function testMultipleTokenHandlersSet() $this->processConfig($config, $factory); } + public function testOAuth2TokenHandlerConfiguration() + { + $container = new ContainerBuilder(); + $config = [ + 'token_handler' => ['oauth2' => true], + ]; + + $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); + $finalizedConfig = $this->processConfig($config, $factory); + + $factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider'); + + $this->assertTrue($container->hasDefinition('security.authenticator.access_token.firewall1')); + $this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1')); + } + public function testNoTokenHandlerSet() { $this->expectException(InvalidConfigurationException::class); @@ -400,6 +593,7 @@ private function createTokenHandlerFactories(): array new OidcUserInfoTokenHandlerFactory(), new OidcTokenHandlerFactory(), new CasTokenHandlerFactory(), + new OAuth2TokenHandlerFactory(), ]; } } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php index 8e87cd5495412..75adf296110da 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php @@ -13,9 +13,13 @@ use Jose\Component\Core\AlgorithmManager; use Jose\Component\Core\JWK; +use Jose\Component\Encryption\Algorithm\ContentEncryption\A128GCM; +use Jose\Component\Encryption\Algorithm\KeyEncryption\ECDHES; +use Jose\Component\Encryption\JWEBuilder; +use Jose\Component\Encryption\Serializer\CompactSerializer as JweCompactSerializer; use Jose\Component\Signature\Algorithm\ES256; use Jose\Component\Signature\JWSBuilder; -use Jose\Component\Signature\Serializer\CompactSerializer; +use Jose\Component\Signature\Serializer\CompactSerializer as JwsCompactSerializer; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; @@ -348,34 +352,17 @@ public function testCustomUserLoader() } /** + * @dataProvider validAccessTokens + * * @requires extension openssl */ - public function testOidcSuccess() + public function testOidcSuccess(callable $tokenFactory) { - $time = time(); - $claims = [ - 'iat' => $time, - 'nbf' => $time, - 'exp' => $time + 3600, - 'iss' => 'https://www.example.com', - 'aud' => 'Symfony OIDC', - 'sub' => 'e21bf182-1538-406e-8ccb-e25a17aba39f', - 'username' => 'dunglas', - ]; - $token = (new CompactSerializer())->serialize((new JWSBuilder(new AlgorithmManager([ - new ES256(), - ])))->create() - ->withPayload(json_encode($claims)) - // tip: use https://mkjwk.org/ to generate a JWK - ->addSignature(new JWK([ - 'kty' => 'EC', - 'crv' => 'P-256', - 'x' => '0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4', - 'y' => 'KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo', - 'd' => 'iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220', - ]), ['alg' => 'ES256']) - ->build() - ); + try { + $token = $tokenFactory(); + } catch (\RuntimeException $e) { + $this->markTestSkipped($e->getMessage()); + } $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_oidc.yml']); $client->request('GET', '/foo', [], [], ['HTTP_AUTHORIZATION' => \sprintf('Bearer %s', $token)]); @@ -386,6 +373,51 @@ public function testOidcSuccess() $this->assertSame(['message' => 'Welcome @dunglas!'], json_decode($response->getContent(), true)); } + /** + * @dataProvider invalidAccessTokens + * + * @requires extension openssl + */ + public function testOidcFailure(callable $tokenFactory) + { + try { + $token = $tokenFactory(); + } catch (\RuntimeException $e) { + $this->markTestSkipped($e->getMessage()); + } + + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_oidc.yml']); + $client->request('GET', '/foo', [], [], ['HTTP_AUTHORIZATION' => \sprintf('Bearer %s', $token)]); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame('Bearer realm="My API",error="invalid_token",error_description="Invalid credentials."', $response->headers->get('WWW-Authenticate')); + } + + /** + * @requires extension openssl + */ + public function testOidcFailureWithJweEnforced() + { + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_oidc_jwe.yml']); + $token = self::createJws([ + 'iat' => time() - 1, + 'nbf' => time() - 1, + 'exp' => time() + 3600, + 'iss' => 'https://www.example.com', + 'aud' => 'Symfony OIDC', + 'sub' => 'e21bf182-1538-406e-8ccb-e25a17aba39f', + 'username' => 'dunglas', + ]); + $client->request('GET', '/foo', [], [], ['HTTP_AUTHORIZATION' => \sprintf('Bearer %s', $token)]); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame('Bearer realm="My API",error="invalid_token",error_description="Invalid credentials."', $response->headers->get('WWW-Authenticate')); + } + public function testCasSuccess() { $casResponse = new MockResponse(<<assertSame(200, $response->getStatusCode()); $this->assertSame(['message' => 'Welcome @dunglas!'], json_decode($response->getContent(), true)); } + + public static function validAccessTokens(): array + { + if (!\extension_loaded('openssl')) { + return []; + } + $time = time(); + $claims = [ + 'iat' => $time, + 'nbf' => $time, + 'exp' => $time + 3600, + 'iss' => 'https://www.example.com', + 'aud' => 'Symfony OIDC', + 'sub' => 'e21bf182-1538-406e-8ccb-e25a17aba39f', + 'username' => 'dunglas', + ]; + + return [ + [fn () => self::createJws($claims)], + [fn () => self::createJwe(self::createJws($claims))], + ]; + } + + public static function invalidAccessTokens(): array + { + if (!\extension_loaded('openssl')) { + return []; + } + $time = time(); + $claims = [ + 'iat' => $time, + 'nbf' => $time, + 'exp' => $time + 3600, + 'iss' => 'https://www.example.com', + 'aud' => 'Symfony OIDC', + 'sub' => 'e21bf182-1538-406e-8ccb-e25a17aba39f', + 'username' => 'dunglas', + ]; + + return [ + [fn () => self::createJws([...$claims, 'aud' => 'Invalid Audience'])], + [fn () => self::createJws([...$claims, 'iss' => 'Invalid Issuer'])], + [fn () => self::createJws([...$claims, 'exp' => $time - 3600])], + [fn () => self::createJws([...$claims, 'nbf' => $time + 3600])], + [fn () => self::createJws([...$claims, 'iat' => $time + 3600])], + [fn () => self::createJws([...$claims, 'username' => 'Invalid Username'])], + [fn () => self::createJwe(self::createJws($claims), ['exp' => $time - 3600])], + [fn () => self::createJwe(self::createJws($claims), ['cty' => 'x-specific'])], + ]; + } + + private static function createJws(array $claims, array $header = []): string + { + return (new JwsCompactSerializer())->serialize((new JWSBuilder(new AlgorithmManager([ + new ES256(), + ])))->create() + ->withPayload(json_encode($claims)) + // tip: use https://mkjwk.org/ to generate a JWK + ->addSignature(new JWK([ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => '0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4', + 'y' => 'KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo', + 'd' => 'iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220', + ]), [...$header, 'alg' => 'ES256']) + ->build() + ); + } + + private static function createJwe(string $input, array $header = []): string + { + $jwk = new JWK([ + 'kty' => 'EC', + 'use' => 'enc', + 'crv' => 'P-256', + 'kid' => 'enc-1720876375', + 'x' => '4P27-OB2s5ZP3Zt5ExxQ9uFrgnGaMK6wT1oqd5bJozQ', + 'y' => 'CNh-ZbKJBvz6hJ8JOulXclACP2OuoO2PtqT6WC8tLcU', + ]); + + return (new JweCompactSerializer())->serialize( + (new JWEBuilder(new AlgorithmManager([ + new ECDHES(), new A128GCM(), + ]), null))->create() + ->withPayload($input) + ->withSharedProtectedHeader(['alg' => 'ECDH-ES', 'enc' => 'A128GCM', ...$header]) + // tip: use https://mkjwk.org/ to generate a JWK + ->addRecipient($jwk) + ->build() + ); + } } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/JsonLdapLoginBundle/Controller/TestController.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/JsonLdapLoginBundle/Controller/TestController.php new file mode 100644 index 0000000000000..3bf5e6e43dd85 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/JsonLdapLoginBundle/Controller/TestController.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\JsonLdapLoginBundle\Controller; + +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\Security\Core\User\UserInterface; + +class TestController +{ + public function loginCheckAction(UserInterface $user) + { + return new JsonResponse([ + 'message' => \sprintf('Welcome @%s!', $user->getUserIdentifier()), + 'roles' => $user->getRoles(), + ]); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/JsonLdapLoginBundle/Security/Ldap/DummyRoleFetcher.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/JsonLdapLoginBundle/Security/Ldap/DummyRoleFetcher.php new file mode 100644 index 0000000000000..417c8afe8061c --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/JsonLdapLoginBundle/Security/Ldap/DummyRoleFetcher.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\JsonLdapLoginBundle\Security\Ldap; + +use Symfony\Component\Ldap\Entry; +use Symfony\Component\Ldap\Security\RoleFetcherInterface; + +class DummyRoleFetcher implements RoleFetcherInterface +{ + public function fetchRoles(Entry $entry): array + { + if ($entry->getAttribute('uid') === ['spomky']) { + return ['ROLE_SUPER_ADMIN', 'ROLE_USER']; + } + + return ['ROLE_LDAP_USER_42', 'ROLE_USER']; + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/JsonLoginLdapTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/JsonLoginLdapTest.php index 583e153695fed..f11908299834f 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/JsonLoginLdapTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/JsonLoginLdapTest.php @@ -11,7 +11,14 @@ namespace Symfony\Bundle\SecurityBundle\Tests\Functional; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\Kernel; +use Symfony\Component\Ldap\Adapter\AdapterInterface; +use Symfony\Component\Ldap\Adapter\CollectionInterface; +use Symfony\Component\Ldap\Adapter\ConnectionInterface; +use Symfony\Component\Ldap\Adapter\ExtLdap\Adapter; +use Symfony\Component\Ldap\Adapter\QueryInterface; +use Symfony\Component\Ldap\Entry; class JsonLoginLdapTest extends AbstractWebTestCase { @@ -22,4 +29,45 @@ public function testKernelBoot() $this->assertInstanceOf(Kernel::class, $kernel); } + + public function testDefaultJsonLdapLoginSuccess() + { + if (!interface_exists(\Symfony\Component\Ldap\Security\RoleFetcherInterface::class)) { + $this->markTestSkipped('The "LDAP" component does not support LDAP roles.'); + } + // Given + $client = $this->createClient(['test_case' => 'JsonLoginLdap', 'root_config' => 'config.yml', 'debug' => true]); + $container = $client->getContainer(); + $connectionMock = $this->createMock(ConnectionInterface::class); + $collection = new class([new Entry('', ['uid' => ['spomky']])]) extends \ArrayObject implements CollectionInterface { + public function toArray(): array + { + return $this->getArrayCopy(); + } + }; + $queryMock = $this->createMock(QueryInterface::class); + $queryMock + ->method('execute') + ->willReturn($collection) + ; + $ldapAdapterMock = $this->createMock(AdapterInterface::class); + $ldapAdapterMock + ->method('getConnection') + ->willReturn($connectionMock) + ; + $ldapAdapterMock + ->method('createQuery') + ->willReturn($queryMock) + ; + $container->set(Adapter::class, $ldapAdapterMock); + + // When + $client->request('POST', '/login', [], [], ['CONTENT_TYPE' => 'application/json'], '{"user": {"login": "spomky", "password": "foo"}}'); + $response = $client->getResponse(); + + // Then + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['message' => 'Welcome @spomky!', 'roles' => ['ROLE_SUPER_ADMIN', 'ROLE_USER']], json_decode($response->getContent(), true)); + } } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityTest.php index dadd0d69db0aa..76987173bed5c 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/SecurityTest.php @@ -47,6 +47,24 @@ public function testServiceIsFunctional() $this->assertSame('main', $firewallConfig->getName()); } + public function testUserAuthorizationChecker() + { + $kernel = self::createKernel(['test_case' => 'SecurityHelper', 'root_config' => 'config.yml']); + $kernel->boot(); + $container = $kernel->getContainer(); + + $loggedInUser = new InMemoryUser('foo', 'pass', ['ROLE_USER', 'ROLE_FOO']); + $offlineUser = new InMemoryUser('bar', 'pass', ['ROLE_USER', 'ROLE_BAR']); + $token = new UsernamePasswordToken($loggedInUser, 'provider', $loggedInUser->getRoles()); + $container->get('functional.test.security.token_storage')->setToken($token); + + $security = $container->get('functional_test.security.helper'); + $this->assertTrue($security->isGranted('ROLE_FOO')); + $this->assertFalse($security->isGranted('ROLE_BAR')); + $this->assertTrue($security->isGrantedForUser($offlineUser, 'ROLE_BAR')); + $this->assertFalse($security->isGrantedForUser($offlineUser, 'ROLE_FOO')); + } + /** * @dataProvider userWillBeMarkedAsChangedIfRolesHasChangedProvider */ @@ -232,6 +250,7 @@ public function isEnabled(): bool return $this->enabled; } + #[\Deprecated] public function eraseCredentials(): void { } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oauth2.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oauth2.yml new file mode 100644 index 0000000000000..9e4f6cceae76b --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oauth2.yml @@ -0,0 +1,34 @@ +imports: + - { resource: ./../config/framework.yml } + +framework: + http_method_override: false + serializer: ~ + http_client: + scoped_clients: + oauth2.client: + scope: 'https://authorization-server\.example\.com' + headers: + Authorization: 'Basic Y2xpZW50OnBhc3N3b3Jk' + +security: + password_hashers: + Symfony\Component\Security\Core\User\InMemoryUser: plaintext + + providers: + in_memory: + memory: + users: + dunglas: { password: foo, roles: [ROLE_USER] } + + firewalls: + main: + pattern: ^/ + access_token: + token_handler: + oauth2: ~ + token_extractors: 'header' + realm: 'My API' + + access_control: + - { path: ^/foo, roles: ROLE_USER } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oidc.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oidc.yml index 68f8a1f9dd47a..a087604782bec 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oidc.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oidc.yml @@ -24,9 +24,13 @@ security: claim: 'username' audience: 'Symfony OIDC' issuers: [ 'https://www.example.com' ] - algorithm: 'ES256' + algorithms: [ 'ES256' ] # tip: use https://mkjwk.org/ to generate a JWK keyset: '{"keys":[{"kty":"EC","d":"iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220","crv":"P-256","x":"0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4","y":"KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo"}]}' + encryption: + enabled: true + algorithms: ['ECDH-ES', 'A128GCM'] + keyset: '{"keys": [{"kty": "EC","d": "YG0HnRsaYv2cUj7TpgHcRX1poL9l4cskIuOi1gXv0Dg","use": "enc","crv": "P-256","kid": "enc-1720876375","x": "4P27-OB2s5ZP3Zt5ExxQ9uFrgnGaMK6wT1oqd5bJozQ","y": "CNh-ZbKJBvz6hJ8JOulXclACP2OuoO2PtqT6WC8tLcU","alg": "ECDH-ES"}]}' token_extractors: 'header' realm: 'My API' diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oidc_jwe.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oidc_jwe.yml new file mode 100644 index 0000000000000..7d17d073df9cc --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_oidc_jwe.yml @@ -0,0 +1,39 @@ +imports: + - { resource: ./../config/framework.yml } + +framework: + http_method_override: false + serializer: ~ + +security: + password_hashers: + Symfony\Component\Security\Core\User\InMemoryUser: plaintext + + providers: + in_memory: + memory: + users: + dunglas: { password: foo, roles: [ROLE_USER] } + + firewalls: + main: + pattern: ^/ + access_token: + token_handler: + oidc: + claim: 'username' + audience: 'Symfony OIDC' + issuers: [ 'https://www.example.com' ] + algorithm: 'ES256' + # tip: use https://mkjwk.org/ to generate a JWK + keyset: '{"keys":[{"kty":"EC","d":"iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220","crv":"P-256","x":"0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4","y":"KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo"}]}' + encryption: + enabled: true + enforce: true + algorithms: ['ECDH-ES', 'A128GCM'] + keyset: '{"keys": [{"kty": "EC","d": "YG0HnRsaYv2cUj7TpgHcRX1poL9l4cskIuOi1gXv0Dg","use": "enc","crv": "P-256","kid": "enc-1720876375","x": "4P27-OB2s5ZP3Zt5ExxQ9uFrgnGaMK6wT1oqd5bJozQ","y": "CNh-ZbKJBvz6hJ8JOulXclACP2OuoO2PtqT6WC8tLcU","alg": "ECDH-ES"}]}' + token_extractors: 'header' + realm: 'My API' + + access_control: + - { path: ^/foo, roles: ROLE_USER } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/FirewallEntryPoint/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/FirewallEntryPoint/config.yml index 9d6b4caee1707..31b0af34088a3 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/FirewallEntryPoint/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/FirewallEntryPoint/config.yml @@ -17,7 +17,9 @@ framework: cookie_samesite: lax php_errors: log: true - profiler: { only_exceptions: false } + profiler: + only_exceptions: false + collect_serializer_data: true services: logger: { class: Psr\Log\NullLogger } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLoginLdap/config.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLoginLdap/config.yml index 71e107b126e54..c75c1a79673d1 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLoginLdap/config.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLoginLdap/config.yml @@ -3,6 +3,9 @@ imports: services: Symfony\Component\Ldap\Ldap: arguments: ['@Symfony\Component\Ldap\Adapter\ExtLdap\Adapter'] + tags: [ 'ldap' ] + dummy_role_fetcher: + class: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\JsonLdapLoginBundle\Security\Ldap\DummyRoleFetcher Symfony\Component\Ldap\Adapter\ExtLdap\Adapter: arguments: @@ -19,9 +22,8 @@ security: base_dn: 'dc=onfroy,dc=net' search_dn: '' search_password: '' - default_roles: ROLE_USER + role_fetcher: dummy_role_fetcher uid_key: uid - extra_fields: ['email'] firewalls: main: diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLoginLdap/routing.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLoginLdap/routing.yml new file mode 100644 index 0000000000000..bbec958cd8dae --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/JsonLoginLdap/routing.yml @@ -0,0 +1,3 @@ +login_check: + path: /login + defaults: { _controller: Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\JsonLdapLoginBundle\Controller\TestController::loginCheckAction } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/config/framework.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/config/framework.yml index c197fcaa4c25e..0f2e1344d0e71 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/config/framework.yml +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/config/framework.yml @@ -18,7 +18,9 @@ framework: cookie_samesite: lax php_errors: log: true - profiler: { only_exceptions: false } + profiler: + only_exceptions: false + collect_serializer_data: true services: logger: { class: Psr\Log\NullLogger } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/SecurityTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/SecurityTest.php index d4b336b4eaa70..82a444ef10358 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/SecurityTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/SecurityTest.php @@ -152,7 +152,10 @@ public function testLogin() $firewallAuthenticatorLocator ->expects($this->once()) ->method('getProvidedServices') - ->willReturn(['security.authenticator.custom.dev' => $authenticator]) + ->willReturn([ + 'security.authenticator.custom.dev' => $authenticator, + 'security.authenticator.remember_me.main' => $authenticator + ]) ; $firewallAuthenticatorLocator ->expects($this->once()) @@ -252,6 +255,49 @@ public function testLoginWithoutRequestContext() $security->login($user); } + public function testLoginFailsWhenTooManyAuthenticatorsFound() + { + $request = new Request(); + $authenticator = $this->createMock(AuthenticatorInterface::class); + $requestStack = $this->createMock(RequestStack::class); + $firewallMap = $this->createMock(FirewallMap::class); + $firewall = new FirewallConfig('main', 'main'); + $userAuthenticator = $this->createMock(UserAuthenticatorInterface::class); + $user = $this->createMock(UserInterface::class); + $userChecker = $this->createMock(UserCheckerInterface::class); + + $container = $this->createMock(ContainerInterface::class); + $container + ->expects($this->atLeastOnce()) + ->method('get') + ->willReturnMap([ + ['request_stack', $requestStack], + ['security.firewall.map', $firewallMap], + ['security.authenticator.managers_locator', $this->createContainer('main', $userAuthenticator)], + ['security.user_checker_locator', $this->createContainer('main', $userChecker)], + ]) + ; + + $requestStack->expects($this->once())->method('getCurrentRequest')->willReturn($request); + $firewallMap->expects($this->once())->method('getFirewallConfig')->willReturn($firewall); + + $firewallAuthenticatorLocator = $this->createMock(ServiceProviderInterface::class); + $firewallAuthenticatorLocator + ->expects($this->once()) + ->method('getProvidedServices') + ->willReturn([ + 'security.authenticator.custom.main' => $authenticator, + 'security.authenticator.other.main' => $authenticator + ]) + ; + + $security = new Security($container, ['main' => $firewallAuthenticatorLocator]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Too many authenticators were found for the current firewall "main". You must provide an instance of "Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface" to login programmatically. The available authenticators for the firewall "main" are "security.authenticator.custom.main" ,"security.authenticator.other.main'); + $security->login($user); + } + public function testLogout() { $request = new Request(); diff --git a/src/Symfony/Bundle/SecurityBundle/composer.json b/src/Symfony/Bundle/SecurityBundle/composer.json index 8660196a11cf2..7459b0175b95f 100644 --- a/src/Symfony/Bundle/SecurityBundle/composer.json +++ b/src/Symfony/Bundle/SecurityBundle/composer.json @@ -20,15 +20,15 @@ "composer-runtime-api": ">=2.1", "ext-xml": "*", "symfony/clock": "^6.4|^7.0", - "symfony/config": "^6.4|^7.0", + "symfony/config": "^7.3", "symfony/dependency-injection": "^6.4.11|^7.1.4", "symfony/event-dispatcher": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", "symfony/http-foundation": "^6.4|^7.0", "symfony/password-hasher": "^6.4|^7.0", - "symfony/security-core": "^7.2", + "symfony/security-core": "^7.3", "symfony/security-csrf": "^6.4|^7.0", - "symfony/security-http": "^7.2", + "symfony/security-http": "^7.3", "symfony/service-contracts": "^2.5|^3" }, "require-dev": { diff --git a/src/Symfony/Bundle/TwigBundle/CHANGELOG.md b/src/Symfony/Bundle/TwigBundle/CHANGELOG.md index 91722564d0bd1..40d5be350afe7 100644 --- a/src/Symfony/Bundle/TwigBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/TwigBundle/CHANGELOG.md @@ -1,6 +1,15 @@ CHANGELOG ========= +7.3 +--- + + * Enable `#[AsTwigFilter]`, `#[AsTwigFunction]` and `#[AsTwigTest]` attributes + to configure extensions on runtime classes + * Add support for a `twig` validator + * Use `ChainCache` to store warmed-up cache in `kernel.build_dir` and runtime cache in `kernel.cache_dir` + * Make `TemplateCacheWarmer` use `kernel.build_dir` instead of `kernel.cache_dir` + 7.1 --- diff --git a/src/Symfony/Bundle/TwigBundle/CacheWarmer/TemplateCacheWarmer.php b/src/Symfony/Bundle/TwigBundle/CacheWarmer/TemplateCacheWarmer.php index 868dc076cfd9e..3bb89760f3a6f 100644 --- a/src/Symfony/Bundle/TwigBundle/CacheWarmer/TemplateCacheWarmer.php +++ b/src/Symfony/Bundle/TwigBundle/CacheWarmer/TemplateCacheWarmer.php @@ -14,6 +14,8 @@ use Psr\Container\ContainerInterface; use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface; +use Twig\Cache\CacheInterface; +use Twig\Cache\NullCache; use Twig\Environment; use Twig\Error\Error; @@ -34,6 +36,7 @@ class TemplateCacheWarmer implements CacheWarmerInterface, ServiceSubscriberInte public function __construct( private ContainerInterface $container, private iterable $iterator, + private ?CacheInterface $cache = null, ) { } @@ -41,19 +44,40 @@ public function warmUp(string $cacheDir, ?string $buildDir = null): array { $this->twig ??= $this->container->get('twig'); - foreach ($this->iterator as $template) { - try { - $this->twig->load($template); - } catch (Error) { + $originalCache = $this->twig->getCache(); + if ($originalCache instanceof NullCache) { + // There's no point to warm up a cache that won't be used afterward + return []; + } + + if (null !== $this->cache) { + if (!$buildDir) { /* - * Problem during compilation, give up for this template (e.g. syntax errors). - * Failing silently here allows to ignore templates that rely on functions that aren't available in - * the current environment. For example, the WebProfilerBundle shouldn't be available in the prod - * environment, but some templates that are never used in prod might rely on functions the bundle provides. - * As we can't detect which templates are "really" important, we try to load all of them and ignore - * errors. Error checks may be performed by calling the lint:twig command. + * The cache has already been warmup during the build of the container, when $buildDir was set. */ + return []; + } + // Swap the cache for the warmup as the Twig Environment has the ChainCache injected + $this->twig->setCache($this->cache); + } + + try { + foreach ($this->iterator as $template) { + try { + $this->twig->load($template); + } catch (Error) { + /* + * Problem during compilation, give up for this template (e.g. syntax errors). + * Failing silently here allows to ignore templates that rely on functions that aren't available in + * the current environment. For example, the WebProfilerBundle shouldn't be available in the prod + * environment, but some templates that are never used in prod might rely on functions the bundle provides. + * As we can't detect which templates are "really" important, we try to load all of them and ignore + * errors. Error checks may be performed by calling the lint:twig command. + */ + } } + } finally { + $this->twig->setCache($originalCache); } return []; diff --git a/src/Symfony/Bundle/TwigBundle/DependencyInjection/Compiler/AttributeExtensionPass.php b/src/Symfony/Bundle/TwigBundle/DependencyInjection/Compiler/AttributeExtensionPass.php new file mode 100644 index 0000000000000..354874866a0ae --- /dev/null +++ b/src/Symfony/Bundle/TwigBundle/DependencyInjection/Compiler/AttributeExtensionPass.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\TwigBundle\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\LogicException; +use Twig\Attribute\AsTwigFilter; +use Twig\Attribute\AsTwigFunction; +use Twig\Attribute\AsTwigTest; +use Twig\Extension\AbstractExtension; +use Twig\Extension\AttributeExtension; +use Twig\Extension\ExtensionInterface; + +/** + * Register an instance of AttributeExtension for each service using the + * PHP attributes to declare Twig callables. + * + * @author Jérôme Tamarelle + * + * @internal + */ +final class AttributeExtensionPass implements CompilerPassInterface +{ + private const TAG = 'twig.attribute_extension'; + + public static function autoconfigureFromAttribute(ChildDefinition $definition, AsTwigFilter|AsTwigFunction|AsTwigTest $attribute, \ReflectionMethod $reflector): void + { + $class = $reflector->getDeclaringClass(); + if ($class->implementsInterface(ExtensionInterface::class)) { + if ($class->isSubclassOf(AbstractExtension::class)) { + throw new LogicException(\sprintf('The class "%s" cannot extend "%s" and use the "#[%s]" attribute on method "%s()", choose one or the other.', $class->name, AbstractExtension::class, $attribute::class, $reflector->name)); + } + throw new LogicException(\sprintf('The class "%s" cannot implement "%s" and use the "#[%s]" attribute on method "%s()", choose one or the other.', $class->name, ExtensionInterface::class, $attribute::class, $reflector->name)); + } + + $definition->addTag(self::TAG); + + // The service must be tagged as a runtime to call non-static methods + if (!$reflector->isStatic()) { + $definition->addTag('twig.runtime'); + } + } + + public function process(ContainerBuilder $container): void + { + foreach ($container->findTaggedServiceIds(self::TAG, true) as $id => $tags) { + $container->register('.twig.extension.'.$id, AttributeExtension::class) + ->setArguments([$container->getDefinition($id)->getClass()]) + ->addTag('twig.extension'); + } + } +} diff --git a/src/Symfony/Bundle/TwigBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/TwigBundle/DependencyInjection/Configuration.php index 32a4bb318fea4..354e1a4e85a0a 100644 --- a/src/Symfony/Bundle/TwigBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/TwigBundle/DependencyInjection/Configuration.php @@ -32,7 +32,9 @@ public function getConfigTreeBuilder(): TreeBuilder $treeBuilder = new TreeBuilder('twig'); $rootNode = $treeBuilder->getRootNode(); - $rootNode->beforeNormalization() + $rootNode + ->docUrl('https://symfony.com/doc/{version:major}.{version:minor}/reference/configuration/twig.html', 'symfony/twig-bundle') + ->beforeNormalization() ->ifTrue(fn ($v) => \is_array($v) && \array_key_exists('exception_controller', $v)) ->then(function ($v) { if (isset($v['exception_controller'])) { @@ -134,7 +136,7 @@ private function addTwigOptions(ArrayNodeDefinition $rootNode): void ->example('Twig\Template') ->cannotBeEmpty() ->end() - ->scalarNode('cache')->defaultValue('%kernel.cache_dir%/twig')->end() + ->scalarNode('cache')->defaultTrue()->end() ->scalarNode('charset')->defaultValue('%kernel.charset%')->end() ->booleanNode('debug')->defaultValue('%kernel.debug%')->end() ->booleanNode('strict_variables')->defaultValue('%kernel.debug%')->end() diff --git a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php index a3563b2d0d0ec..418172956391b 100644 --- a/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php +++ b/src/Symfony/Bundle/TwigBundle/DependencyInjection/TwigExtension.php @@ -11,6 +11,7 @@ namespace Symfony\Bundle\TwigBundle\DependencyInjection; +use Symfony\Bundle\TwigBundle\DependencyInjection\Compiler\AttributeExtensionPass; use Symfony\Component\AssetMapper\AssetMapper; use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\Resource\FileExistenceResource; @@ -24,7 +25,12 @@ use Symfony\Component\Mailer\Mailer; use Symfony\Component\Translation\LocaleSwitcher; use Symfony\Component\Translation\Translator; +use Symfony\Component\Validator\Constraint; use Symfony\Contracts\Service\ResetInterface; +use Twig\Attribute\AsTwigFilter; +use Twig\Attribute\AsTwigFunction; +use Twig\Attribute\AsTwigTest; +use Twig\Cache\FilesystemCache; use Twig\Environment; use Twig\Extension\ExtensionInterface; use Twig\Extension\RuntimeExtensionInterface; @@ -65,6 +71,10 @@ public function load(array $configs, ContainerBuilder $container): void $container->removeDefinition('twig.translation.extractor'); } + if ($container::willBeAvailable('symfony/validator', Constraint::class, ['symfony/twig-bundle'])) { + $loader->load('validator.php'); + } + foreach ($configs as $key => $config) { if (isset($config['globals'])) { foreach ($config['globals'] as $name => $value) { @@ -158,6 +168,31 @@ public function load(array $configs, ContainerBuilder $container): void } } + if (true === $config['cache']) { + $autoReloadOrDefault = $container->getParameterBag()->resolveValue($config['auto_reload'] ?? $config['debug']); + $buildDir = $container->getParameter('kernel.build_dir'); + $cacheDir = $container->getParameter('kernel.cache_dir'); + + if ($autoReloadOrDefault || $cacheDir === $buildDir) { + $config['cache'] = '%kernel.cache_dir%/twig'; + } + } + + if (true === $config['cache']) { + $config['cache'] = new Reference('twig.template_cache.chain'); + } else { + $container->removeDefinition('twig.template_cache.chain'); + $container->removeDefinition('twig.template_cache.runtime_cache'); + $container->removeDefinition('twig.template_cache.readonly_cache'); + $container->removeDefinition('twig.template_cache.warmup_cache'); + + if (false === $config['cache']) { + $container->removeDefinition('twig.template_cache_warmer'); + } else { + $container->getDefinition('twig.template_cache_warmer')->replaceArgument(2, null); + } + } + if (isset($config['autoescape_service'])) { $config['autoescape'] = [new Reference($config['autoescape_service']), $config['autoescape_service_method'] ?? '__invoke']; } else { @@ -179,9 +214,9 @@ public function load(array $configs, ContainerBuilder $container): void $container->registerForAutoconfiguration(LoaderInterface::class)->addTag('twig.loader'); $container->registerForAutoconfiguration(RuntimeExtensionInterface::class)->addTag('twig.runtime'); - if (false === $config['cache']) { - $container->removeDefinition('twig.template_cache_warmer'); - } + $container->registerAttributeForAutoconfiguration(AsTwigFilter::class, AttributeExtensionPass::autoconfigureFromAttribute(...)); + $container->registerAttributeForAutoconfiguration(AsTwigFunction::class, AttributeExtensionPass::autoconfigureFromAttribute(...)); + $container->registerAttributeForAutoconfiguration(AsTwigTest::class, AttributeExtensionPass::autoconfigureFromAttribute(...)); } private function getBundleTemplatePaths(ContainerBuilder $container, array $config): array diff --git a/src/Symfony/Bundle/TwigBundle/Resources/config/twig.php b/src/Symfony/Bundle/TwigBundle/Resources/config/twig.php index 02631d28c39a4..812ac1f666978 100644 --- a/src/Symfony/Bundle/TwigBundle/Resources/config/twig.php +++ b/src/Symfony/Bundle/TwigBundle/Resources/config/twig.php @@ -36,7 +36,9 @@ use Symfony\Bundle\TwigBundle\CacheWarmer\TemplateCacheWarmer; use Symfony\Bundle\TwigBundle\DependencyInjection\Configurator\EnvironmentConfigurator; use Symfony\Bundle\TwigBundle\TemplateIterator; +use Twig\Cache\ChainCache; use Twig\Cache\FilesystemCache; +use Twig\Cache\ReadOnlyFilesystemCache; use Twig\Environment; use Twig\Extension\CoreExtension; use Twig\Extension\DebugExtension; @@ -79,8 +81,24 @@ ->set('twig.template_iterator', TemplateIterator::class) ->args([service('kernel'), abstract_arg('Twig paths'), param('twig.default_path'), abstract_arg('File name pattern')]) + ->set('twig.template_cache.runtime_cache', FilesystemCache::class) + ->args([param('kernel.cache_dir').'/twig']) + + ->set('twig.template_cache.readonly_cache', ReadOnlyFilesystemCache::class) + ->args([param('kernel.build_dir').'/twig']) + + ->set('twig.template_cache.warmup_cache', FilesystemCache::class) + ->args([param('kernel.build_dir').'/twig']) + + ->set('twig.template_cache.chain', ChainCache::class) + ->args([[service('twig.template_cache.readonly_cache'), service('twig.template_cache.runtime_cache')]]) + ->set('twig.template_cache_warmer', TemplateCacheWarmer::class) - ->args([service(ContainerInterface::class), service('twig.template_iterator')]) + ->args([ + service(ContainerInterface::class), + service('twig.template_iterator'), + service('twig.template_cache.warmup_cache'), + ]) ->tag('kernel.cache_warmer') ->tag('container.service_subscriber', ['id' => 'twig']) diff --git a/src/Symfony/Bundle/TwigBundle/Resources/config/validator.php b/src/Symfony/Bundle/TwigBundle/Resources/config/validator.php new file mode 100644 index 0000000000000..1c0e8dd474ee6 --- /dev/null +++ b/src/Symfony/Bundle/TwigBundle/Resources/config/validator.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Bridge\Twig\Validator\Constraints\TwigValidator; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('twig.validator', TwigValidator::class) + ->args([service('twig')]) + ->tag('validator.constraint_validator') + ; +}; diff --git a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/php/full.php b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/php/full.php index f87af5a1baba4..68c7f5a304218 100644 --- a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/php/full.php +++ b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/php/full.php @@ -10,8 +10,7 @@ 'pi' => 3.14, 'bad' => ['key' => 'foo'], ], - 'auto_reload' => true, - 'cache' => '/tmp', + 'auto_reload' => false, 'charset' => 'ISO-8859-1', 'debug' => true, 'strict_variables' => true, diff --git a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/php/no-cache.php b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/php/no-cache.php new file mode 100644 index 0000000000000..df1ae5c6bd63b --- /dev/null +++ b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/php/no-cache.php @@ -0,0 +1,5 @@ +loadFromExtension('twig', [ + 'cache' => false, +]); diff --git a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/php/path-cache.php b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/php/path-cache.php new file mode 100644 index 0000000000000..f0701a57d8c88 --- /dev/null +++ b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/php/path-cache.php @@ -0,0 +1,5 @@ +loadFromExtension('twig', [ + 'cache' => 'random-path', +]); diff --git a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/php/prod-cache.php b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/php/prod-cache.php new file mode 100644 index 0000000000000..628854601a960 --- /dev/null +++ b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/php/prod-cache.php @@ -0,0 +1,6 @@ +loadFromExtension('twig', [ + 'cache' => true, + 'auto_reload' => false, +]); diff --git a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/xml/extra.xml b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/xml/extra.xml index f1cf8985329d0..df02c9dc05f91 100644 --- a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/xml/extra.xml +++ b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/xml/extra.xml @@ -6,7 +6,7 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd http://symfony.com/schema/dic/twig https://symfony.com/schema/dic/twig/twig-1.0.xsd"> - + namespaced_path3 diff --git a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/xml/full.xml b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/xml/full.xml index 528a466b0452c..3349e0d5fa744 100644 --- a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/xml/full.xml +++ b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/xml/full.xml @@ -6,7 +6,7 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd http://symfony.com/schema/dic/twig https://symfony.com/schema/dic/twig/twig-1.0.xsd"> - + MyBundle::form.html.twig @@qux diff --git a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/xml/no-cache.xml b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/xml/no-cache.xml new file mode 100644 index 0000000000000..f6fa72c747893 --- /dev/null +++ b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/xml/no-cache.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/xml/path-cache.xml b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/xml/path-cache.xml new file mode 100644 index 0000000000000..9caf2fc0452b0 --- /dev/null +++ b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/xml/path-cache.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/xml/prod-cache.xml b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/xml/prod-cache.xml new file mode 100644 index 0000000000000..6ee9f38506252 --- /dev/null +++ b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/xml/prod-cache.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/yml/full.yml b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/yml/full.yml index 6c249d378ff22..ab19cbf0bff8f 100644 --- a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/yml/full.yml +++ b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/yml/full.yml @@ -6,8 +6,7 @@ twig: baz: "@@qux" pi: 3.14 bad: {key: foo} - auto_reload: true - cache: /tmp + auto_reload: false charset: ISO-8859-1 debug: true strict_variables: true diff --git a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/yml/no-cache.yml b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/yml/no-cache.yml new file mode 100644 index 0000000000000..c1e9f184bd336 --- /dev/null +++ b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/yml/no-cache.yml @@ -0,0 +1,2 @@ +twig: + cache: false diff --git a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/yml/path-cache.yml b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/yml/path-cache.yml new file mode 100644 index 0000000000000..04e9d1dc61b06 --- /dev/null +++ b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/yml/path-cache.yml @@ -0,0 +1,2 @@ +twig: + cache: random-path diff --git a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/yml/prod-cache.yml b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/yml/prod-cache.yml new file mode 100644 index 0000000000000..82a1dd9e100d3 --- /dev/null +++ b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/Fixtures/yml/prod-cache.yml @@ -0,0 +1,3 @@ +twig: + cache: true + auto_reload: false diff --git a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/TwigExtensionTest.php b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/TwigExtensionTest.php index ffe772a28861d..086a4cdd6e1e8 100644 --- a/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/TwigExtensionTest.php +++ b/src/Symfony/Bundle/TwigBundle/Tests/DependencyInjection/TwigExtensionTest.php @@ -28,6 +28,7 @@ use Symfony\Component\Form\FormRenderer; use Symfony\Component\Mailer\Mailer; use Symfony\Component\Stopwatch\Stopwatch; +use Symfony\Component\Validator\Validator\ValidatorInterface; use Twig\Environment; class TwigExtensionTest extends TestCase @@ -54,14 +55,20 @@ public function testLoadEmptyConfiguration() if (class_exists(Mailer::class)) { $this->assertCount(2, $container->getDefinition('twig.mime_body_renderer')->getArguments()); } + + if (interface_exists(ValidatorInterface::class)) { + $this->assertTrue($container->hasDefinition('twig.validator')); + } else { + $this->assertFalse($container->hasDefinition('twig.validator')); + } } /** - * @dataProvider getFormats + * @dataProvider getFormatsAndBuildDir */ - public function testLoadFullConfiguration(string $format) + public function testLoadFullConfiguration(string $format, ?string $buildDir) { - $container = $this->createContainer(); + $container = $this->createContainer($buildDir); $container->registerExtension(new TwigExtension()); $this->loadFromFile($container, 'full', $format); $this->compileContainer($container); @@ -92,13 +99,64 @@ public function testLoadFullConfiguration(string $format) // Twig options $options = $container->getDefinition('twig')->getArgument(1); - $this->assertTrue($options['auto_reload'], '->load() sets the auto_reload option'); + $this->assertFalse($options['auto_reload'], '->load() sets the auto_reload option'); $this->assertSame('name', $options['autoescape'], '->load() sets the autoescape option'); $this->assertArrayNotHasKey('base_template_class', $options, '->load() does not set the base_template_class if none is provided'); - $this->assertEquals('/tmp', $options['cache'], '->load() sets the cache option'); $this->assertEquals('ISO-8859-1', $options['charset'], '->load() sets the charset option'); $this->assertTrue($options['debug'], '->load() sets the debug option'); $this->assertTrue($options['strict_variables'], '->load() sets the strict_variables option'); + $this->assertEquals($buildDir !== null ? new Reference('twig.template_cache.chain') : '%kernel.cache_dir%/twig', $options['cache'], '->load() sets the cache option'); + } + + /** + * @dataProvider getFormatsAndBuildDir + */ + public function testLoadNoCacheConfiguration(string $format, ?string $buildDir) + { + $container = $this->createContainer($buildDir); + $container->registerExtension(new TwigExtension()); + $this->loadFromFile($container, 'no-cache', $format); + $this->compileContainer($container); + + $this->assertEquals(Environment::class, $container->getDefinition('twig')->getClass(), '->load() loads the twig.xml file'); + + // Twig options + $options = $container->getDefinition('twig')->getArgument(1); + $this->assertFalse($options['cache'], '->load() sets cache option to false'); + } + + /** + * @dataProvider getFormatsAndBuildDir + */ + public function testLoadPathCacheConfiguration(string $format, ?string $buildDir) + { + $container = $this->createContainer($buildDir); + $container->registerExtension(new TwigExtension()); + $this->loadFromFile($container, 'path-cache', $format); + $this->compileContainer($container); + + $this->assertEquals(Environment::class, $container->getDefinition('twig')->getClass(), '->load() loads the twig.xml file'); + + // Twig options + $options = $container->getDefinition('twig')->getArgument(1); + $this->assertSame('random-path', $options['cache'], '->load() sets cache option to string path'); + } + + /** + * @dataProvider getFormatsAndBuildDir + */ + public function testLoadProdCacheConfiguration(string $format, ?string $buildDir) + { + $container = $this->createContainer($buildDir); + $container->registerExtension(new TwigExtension()); + $this->loadFromFile($container, 'prod-cache', $format); + $this->compileContainer($container); + + $this->assertEquals(Environment::class, $container->getDefinition('twig')->getClass(), '->load() loads the twig.xml file'); + + // Twig options + $options = $container->getDefinition('twig')->getArgument(1); + $this->assertEquals($buildDir !== null ? new Reference('twig.template_cache.chain') : '%kernel.cache_dir%/twig', $options['cache'], '->load() sets cache option to CacheChain reference'); } /** @@ -238,6 +296,19 @@ public static function getFormats(): array ]; } + public static function getFormatsAndBuildDir(): array + { + return [ + ['php', null], + ['php', __DIR__.'/build'], + ['yml', null], + ['yml', __DIR__.'/build'], + ['xml', null], + ['xml', __DIR__.'/build'], + ]; + } + + /** * @dataProvider stopwatchExtensionAvailabilityProvider */ @@ -312,10 +383,11 @@ public function testCustomHtmlToTextConverterService(string $format) $this->assertEquals(new Reference('my_converter'), $bodyRenderer->getArgument('$converter')); } - private function createContainer(): ContainerBuilder + private function createContainer(?string $buildDir = null): ContainerBuilder { $container = new ContainerBuilder(new ParameterBag([ 'kernel.cache_dir' => __DIR__, + 'kernel.build_dir' => $buildDir ?? __DIR__, 'kernel.project_dir' => __DIR__, 'kernel.charset' => 'UTF-8', 'kernel.debug' => false, diff --git a/src/Symfony/Bundle/TwigBundle/Tests/Functional/AttributeExtensionTest.php b/src/Symfony/Bundle/TwigBundle/Tests/Functional/AttributeExtensionTest.php new file mode 100644 index 0000000000000..8b4e4555f36a0 --- /dev/null +++ b/src/Symfony/Bundle/TwigBundle/Tests/Functional/AttributeExtensionTest.php @@ -0,0 +1,167 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\TwigBundle\Tests\Functional; + +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; +use PHPUnit\Framework\Attributes\BeforeClass; +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\TwigBundle\Tests\TestCase; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\LogicException; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpKernel\Kernel; +use Twig\Attribute\AsTwigFilter; +use Twig\Attribute\AsTwigFunction; +use Twig\Attribute\AsTwigTest; +use Twig\Environment; +use Twig\Error\RuntimeError; +use Twig\Extension\AbstractExtension; +use Twig\Extension\AttributeExtension; + +class AttributeExtensionTest extends TestCase +{ + /** @beforeClass */ + #[BeforeClass] + public static function assertTwigVersion(): void + { + if (!class_exists(AttributeExtension::class)) { + self::markTestSkipped('Twig 3.21 is required.'); + } + } + + public function testExtensionWithAttributes() + { + $kernel = new class extends AttributeExtensionKernel { + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(static function (ContainerBuilder $container) { + $container->setParameter('kernel.secret', 'secret'); + $container->register(StaticExtensionWithAttributes::class, StaticExtensionWithAttributes::class) + ->setAutoconfigured(true); + $container->register(RuntimeExtensionWithAttributes::class, RuntimeExtensionWithAttributes::class) + ->setArguments(['prefix_']) + ->setAutoconfigured(true); + + $container->setAlias('twig_test', 'twig')->setPublic(true); + }); + } + }; + + $kernel->boot(); + + /** @var Environment $twig */ + $twig = $kernel->getContainer()->get('twig_test'); + + self::assertInstanceOf(AttributeExtension::class, $twig->getExtension(StaticExtensionWithAttributes::class)); + self::assertInstanceOf(AttributeExtension::class, $twig->getExtension(RuntimeExtensionWithAttributes::class)); + self::assertInstanceOf(RuntimeExtensionWithAttributes::class, $twig->getRuntime(RuntimeExtensionWithAttributes::class)); + + self::expectException(RuntimeError::class); + $twig->getRuntime(StaticExtensionWithAttributes::class); + } + + public function testInvalidExtensionClass() + { + $kernel = new class extends AttributeExtensionKernel { + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(static function (ContainerBuilder $container) { + $container->register(InvalidExtensionWithAttributes::class, InvalidExtensionWithAttributes::class) + ->setAutoconfigured(true); + }); + } + }; + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The class "Symfony\Bundle\TwigBundle\Tests\Functional\InvalidExtensionWithAttributes" cannot extend "Twig\Extension\AbstractExtension" and use the "#[Twig\Attribute\AsTwigFilter]" attribute on method "funFilter()", choose one or the other.'); + + $kernel->boot(); + } + + + /** + * @before + * @after + */ + #[Before, After] + protected function deleteTempDir() + { + if (file_exists($dir = sys_get_temp_dir().'/'.Kernel::VERSION.'/AttributeExtension')) { + (new Filesystem())->remove($dir); + } + } +} + +abstract class AttributeExtensionKernel extends Kernel +{ + public function __construct() + { + parent::__construct('test', true); + } + + public function registerBundles(): iterable + { + return [new FrameworkBundle(), new TwigBundle()]; + } + + public function getProjectDir(): string + { + return sys_get_temp_dir().'/'.Kernel::VERSION.'/AttributeExtension'; + } +} + +class StaticExtensionWithAttributes +{ + #[AsTwigFilter('foo')] + public static function fooFilter(string $value): string + { + return $value; + } + + #[AsTwigFunction('foo')] + public static function fooFunction(string $value): string + { + return $value; + } + + #[AsTwigTest('foo')] + public static function fooTest(bool $value): bool + { + return $value; + } +} + +class RuntimeExtensionWithAttributes +{ + public function __construct(private bool $prefix) + { + } + + #[AsTwigFilter('prefix_foo')] + #[AsTwigFunction('prefix_foo')] + public function prefix(string $value): string + { + return $this->prefix.$value; + } +} + +class InvalidExtensionWithAttributes extends AbstractExtension +{ + #[AsTwigFilter('fun')] + public function funFilter(): string + { + return 'fun'; + } +} diff --git a/src/Symfony/Bundle/TwigBundle/TwigBundle.php b/src/Symfony/Bundle/TwigBundle/TwigBundle.php index 5ff13b1bc8687..d9bc6078689b3 100644 --- a/src/Symfony/Bundle/TwigBundle/TwigBundle.php +++ b/src/Symfony/Bundle/TwigBundle/TwigBundle.php @@ -11,6 +11,7 @@ namespace Symfony\Bundle\TwigBundle; +use Symfony\Bundle\TwigBundle\DependencyInjection\Compiler\AttributeExtensionPass; use Symfony\Bundle\TwigBundle\DependencyInjection\Compiler\ExtensionPass; use Symfony\Bundle\TwigBundle\DependencyInjection\Compiler\RuntimeLoaderPass; use Symfony\Bundle\TwigBundle\DependencyInjection\Compiler\TwigEnvironmentPass; @@ -33,6 +34,7 @@ public function build(ContainerBuilder $container): void // ExtensionPass must be run before the FragmentRendererPass as it adds tags that are processed later $container->addCompilerPass(new ExtensionPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 10); + $container->addCompilerPass(new AttributeExtensionPass()); $container->addCompilerPass(new TwigEnvironmentPass()); $container->addCompilerPass(new TwigLoaderPass()); $container->addCompilerPass(new RuntimeLoaderPass(), PassConfig::TYPE_BEFORE_REMOVING); diff --git a/src/Symfony/Bundle/TwigBundle/composer.json b/src/Symfony/Bundle/TwigBundle/composer.json index f6e0e110cc686..221a7f471290e 100644 --- a/src/Symfony/Bundle/TwigBundle/composer.json +++ b/src/Symfony/Bundle/TwigBundle/composer.json @@ -18,9 +18,9 @@ "require": { "php": ">=8.2", "composer-runtime-api": ">=2.1", - "symfony/config": "^6.4|^7.0", + "symfony/config": "^7.3", "symfony/dependency-injection": "^6.4|^7.0", - "symfony/twig-bridge": "^6.4|^7.0", + "symfony/twig-bridge": "^7.3", "symfony/http-foundation": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", "twig/twig": "^3.12" diff --git a/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md b/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md index 6d2f8eb554644..5e5e8db36e233 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md @@ -1,6 +1,39 @@ CHANGELOG ========= +7.3 +--- + + * Add `profiler.php` and `wdt.php` routing configuration files (use them instead of their XML equivalent) + + Before: + + ```yaml + when@dev: + web_profiler_wdt: + resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' + prefix: /_wdt + + web_profiler_profiler: + resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml' + prefix: /_profiler + ``` + + After: + + ```yaml + when@dev: + web_profiler_wdt: + resource: '@WebProfilerBundle/Resources/config/routing/wdt.php' + prefix: /_wdt + + web_profiler_profiler: + resource: '@WebProfilerBundle/Resources/config/routing/profiler.php + prefix: /_profiler + ``` + + * Add `ajax_replace` option for replacing toolbar on AJAX requests + 7.2 --- @@ -65,7 +98,7 @@ CHANGELOG ----- * added information about orphaned events - * made the toolbar auto-update with info from ajax reponses when they set the + * made the toolbar auto-update with info from ajax responses when they set the `Symfony-Debug-Toolbar-Replace header` to `1` 4.0.0 diff --git a/src/Symfony/Bundle/WebProfilerBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/WebProfilerBundle/DependencyInjection/Configuration.php index 51ddad76fdbea..649bf459e8fed 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/WebProfilerBundle/DependencyInjection/Configuration.php @@ -31,9 +31,20 @@ public function getConfigTreeBuilder(): TreeBuilder { $treeBuilder = new TreeBuilder('web_profiler'); - $treeBuilder->getRootNode() + $treeBuilder + ->getRootNode() + ->docUrl('https://symfony.com/doc/{version:major}.{version:minor}/reference/configuration/web_profiler.html', 'symfony/web-profiler-bundle') ->children() - ->booleanNode('toolbar')->defaultFalse()->end() + ->arrayNode('toolbar') + ->info('Profiler toolbar configuration') + ->canBeEnabled() + ->children() + ->booleanNode('ajax_replace') + ->defaultFalse() + ->info('Replace toolbar on AJAX requests') + ->end() + ->end() + ->end() ->booleanNode('intercept_redirects')->defaultFalse()->end() ->scalarNode('excluded_ajax_paths')->defaultValue('^/((index|app(_[\w]+)?)\.php/)?_wdt')->end() ->end() diff --git a/src/Symfony/Bundle/WebProfilerBundle/DependencyInjection/WebProfilerExtension.php b/src/Symfony/Bundle/WebProfilerBundle/DependencyInjection/WebProfilerExtension.php index 6ad6982ce487b..d1867029d7ecd 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/DependencyInjection/WebProfilerExtension.php +++ b/src/Symfony/Bundle/WebProfilerBundle/DependencyInjection/WebProfilerExtension.php @@ -46,11 +46,12 @@ public function load(array $configs, ContainerBuilder $container): void $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); $loader->load('profiler.php'); - if ($config['toolbar'] || $config['intercept_redirects']) { + if ($config['toolbar']['enabled'] || $config['intercept_redirects']) { $loader->load('toolbar.php'); $container->getDefinition('web_profiler.debug_toolbar')->replaceArgument(4, $config['excluded_ajax_paths']); + $container->getDefinition('web_profiler.debug_toolbar')->replaceArgument(7, $config['toolbar']['ajax_replace']); $container->setParameter('web_profiler.debug_toolbar.intercept_redirects', $config['intercept_redirects']); - $container->setParameter('web_profiler.debug_toolbar.mode', $config['toolbar'] ? WebDebugToolbarListener::ENABLED : WebDebugToolbarListener::DISABLED); + $container->setParameter('web_profiler.debug_toolbar.mode', $config['toolbar']['enabled'] ? WebDebugToolbarListener::ENABLED : WebDebugToolbarListener::DISABLED); } $container->getDefinition('debug.file_link_formatter') diff --git a/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php b/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php index a13421e7ac63f..2ad19250a39c6 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php +++ b/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php @@ -48,6 +48,7 @@ public function __construct( private string $excludedAjaxPaths = '^/bundles|^/_wdt', private ?ContentSecurityPolicyHandler $cspHandler = null, private ?DumpDataCollector $dumpDataCollector = null, + private bool $ajaxReplace = false, ) { } @@ -96,10 +97,14 @@ public function onKernelResponse(ResponseEvent $event): void // do not capture redirects or modify XML HTTP Requests if ($request->isXmlHttpRequest()) { + if (self::ENABLED === $this->mode && $this->ajaxReplace && !$response->headers->has('Symfony-Debug-Toolbar-Replace')) { + $response->headers->set('Symfony-Debug-Toolbar-Replace', '1'); + } + return; } - if ($response->headers->has('X-Debug-Token') && $response->isRedirect() && $this->interceptRedirects && 'html' === $request->getRequestFormat()) { + if ($response->headers->has('X-Debug-Token') && $response->isRedirect() && $this->interceptRedirects && 'html' === $request->getRequestFormat() && $response->headers->has('Location')) { if ($request->hasSession() && ($session = $request->getSession())->isStarted() && $session->getFlashBag() instanceof AutoExpireFlashBag) { // keep current flashes for one more request if using AutoExpireFlashBag $session->getFlashBag()->setAll($session->getFlashBag()->peekAll()); diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/config/routing/profiler.php b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/routing/profiler.php new file mode 100644 index 0000000000000..46175d1d1f82e --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/routing/profiler.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; +use Symfony\Component\Routing\Loader\XmlFileLoader; + +return function (RoutingConfigurator $routes): void { + foreach (debug_backtrace(\DEBUG_BACKTRACE_PROVIDE_OBJECT) as $trace) { + if (isset($trace['object']) && $trace['object'] instanceof XmlFileLoader && 'doImport' === $trace['function']) { + if (__DIR__ === dirname(realpath($trace['args'][3]))) { + trigger_deprecation('symfony/routing', '7.3', 'The "profiler.xml" routing configuration file is deprecated, import "profile.php" instead.'); + + break; + } + } + } + + $routes->add('_profiler_home', '/') + ->controller('web_profiler.controller.profiler::homeAction') + ; + $routes->add('_profiler_search', '/search') + ->controller('web_profiler.controller.profiler::searchAction') + ; + $routes->add('_profiler_search_bar', '/search_bar') + ->controller('web_profiler.controller.profiler::searchBarAction') + ; + $routes->add('_profiler_phpinfo', '/phpinfo') + ->controller('web_profiler.controller.profiler::phpinfoAction') + ; + $routes->add('_profiler_xdebug', '/xdebug') + ->controller('web_profiler.controller.profiler::xdebugAction') + ; + $routes->add('_profiler_font', '/font/{fontName}.woff2') + ->controller('web_profiler.controller.profiler::fontAction') + ; + $routes->add('_profiler_search_results', '/{token}/search/results') + ->controller('web_profiler.controller.profiler::searchResultsAction') + ; + $routes->add('_profiler_open_file', '/open') + ->controller('web_profiler.controller.profiler::openAction') + ; + $routes->add('_profiler', '/{token}') + ->controller('web_profiler.controller.profiler::panelAction') + ; + $routes->add('_profiler_router', '/{token}/router') + ->controller('web_profiler.controller.router::panelAction') + ; + $routes->add('_profiler_exception', '/{token}/exception') + ->controller('web_profiler.controller.exception_panel::body') + ; + $routes->add('_profiler_exception_css', '/{token}/exception.css') + ->controller('web_profiler.controller.exception_panel::stylesheet') + ; +}; diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/config/routing/profiler.xml b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/routing/profiler.xml index 363b15d872b0c..8712f38774a74 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/config/routing/profiler.xml +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/routing/profiler.xml @@ -4,52 +4,5 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/routing https://symfony.com/schema/routing/routing-1.0.xsd"> - - web_profiler.controller.profiler::homeAction - - - - web_profiler.controller.profiler::searchAction - - - - web_profiler.controller.profiler::searchBarAction - - - - web_profiler.controller.profiler::phpinfoAction - - - - web_profiler.controller.profiler::xdebugAction - - - - web_profiler.controller.profiler::fontAction - - - - web_profiler.controller.profiler::searchResultsAction - - - - web_profiler.controller.profiler::openAction - - - - web_profiler.controller.profiler::panelAction - - - - web_profiler.controller.router::panelAction - - - - web_profiler.controller.exception_panel::body - - - - web_profiler.controller.exception_panel::stylesheet - - + diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/config/routing/wdt.php b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/routing/wdt.php new file mode 100644 index 0000000000000..81b471d228c05 --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/routing/wdt.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; +use Symfony\Component\Routing\Loader\XmlFileLoader; + +return function (RoutingConfigurator $routes): void { + foreach (debug_backtrace(\DEBUG_BACKTRACE_PROVIDE_OBJECT) as $trace) { + if (isset($trace['object']) && $trace['object'] instanceof XmlFileLoader && 'doImport' === $trace['function']) { + if (__DIR__ === dirname(realpath($trace['args'][3]))) { + trigger_deprecation('symfony/routing', '7.3', 'The "xdt.xml" routing configuration file is deprecated, import "xdt.php" instead.'); + + break; + } + } + } + + $routes->add('_wdt_stylesheet', '/styles') + ->controller('web_profiler.controller.profiler::toolbarStylesheetAction') + ; + $routes->add('_wdt', '/{token}') + ->controller('web_profiler.controller.profiler::toolbarAction') + ; +}; diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/config/routing/wdt.xml b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/routing/wdt.xml index 9f45f1b7490ae..04bddb4f3a1b9 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/config/routing/wdt.xml +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/routing/wdt.xml @@ -4,11 +4,5 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/routing https://symfony.com/schema/routing/routing-1.0.xsd"> - - web_profiler.controller.profiler::toolbarStylesheetAction - - - - web_profiler.controller.profiler::toolbarAction - + diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/config/schema/webprofiler-1.0.xsd b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/schema/webprofiler-1.0.xsd index e22105a178fa7..0a3a0767f176c 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/config/schema/webprofiler-1.0.xsd +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/schema/webprofiler-1.0.xsd @@ -9,6 +9,14 @@ + + + + + + + + diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/config/toolbar.php b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/toolbar.php index 473b3630f7dd4..c264b77d6f6e7 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/config/toolbar.php +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/toolbar.php @@ -25,6 +25,7 @@ abstract_arg('paths that should be excluded from the AJAX requests shown in the toolbar'), service('web_profiler.csp.handler'), service('data_collector.dump')->ignoreOnInvalid(), + abstract_arg('whether to replace toolbar on AJAX requests or not'), ]) ->tag('kernel.event_subscriber') ; diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/cache.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/cache.html.twig index d0bc96868e8e6..217ad78f2b412 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/cache.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/cache.html.twig @@ -93,7 +93,7 @@ {{ loop.index }} {{ '%0.2f'|format((call.end - call.start) * 1000) }} ms - {{ call.name }}() + {{ call.name }}({{ call.namespace|default('') }}) {{ profiler_dump(call.value.result, maxDepth=2) }} {% endfor %} diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/notifier.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/notifier.html.twig index 9de8d216e6d1f..ed363f1d92fe2 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/notifier.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/notifier.html.twig @@ -151,7 +151,7 @@ {%- if message.getOptions() is null %} {{- '(empty)' }} {%- else %} - {{- message.getOptions()|json_encode(constant('JSON_PRETTY_PRINT')) }} + {{- message.getOptions().toArray()|json_encode(constant('JSON_PRETTY_PRINT')) }} {%- endif %} diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/translation.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/translation.html.twig index eeb8a06a88dee..53560cf306713 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/translation.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/translation.html.twig @@ -156,6 +156,27 @@ {% endblock messages %} {% endif %} + {% if collector.globalParameters|default([]) %} +

Global parameters

+ + + + + + + + + + {% for id, value in collector.globalParameters %} + + + + + {% endfor %} + +
Message IDValue
{{ id }}{{ profiler_dump(value) }}
+ {% endif %} + {% endblock %} {% macro render_table(messages, is_fallback) %} diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/workflow.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/workflow.html.twig index 6f09b36355056..dfe7beac0932f 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/workflow.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/workflow.html.twig @@ -137,20 +137,22 @@ {{ source('@WebProfiler/Script/Mermaid/mermaid-flowchart-v2.min.js') }} const isDarkMode = document.querySelector('body').classList.contains('theme-dark'); mermaid.initialize({ - flowchart: { useMaxWidth: false }, + flowchart: { + useMaxWidth: true, + }, securityLevel: 'loose', - 'theme': 'base', - 'themeVariables': { + theme: 'base', + themeVariables: { darkMode: isDarkMode, - 'fontFamily': 'var(--font-family-system)', - 'fontSize': 'var(--font-size-body)', + fontFamily: 'var(--font-family-system)', + fontSize: 'var(--font-size-body)', // the properties below don't support CSS variables - 'primaryColor': isDarkMode ? 'lightsteelblue' : 'aliceblue', - 'primaryTextColor': isDarkMode ? '#000' : '#000', - 'primaryBorderColor': isDarkMode ? 'steelblue' : 'lightsteelblue', - 'lineColor': isDarkMode ? '#939393' : '#d4d4d4', - 'secondaryColor': isDarkMode ? 'lightyellow' : 'lightyellow', - 'tertiaryColor': isDarkMode ? 'lightSalmon' : 'lightSalmon', + primaryColor: isDarkMode ? 'lightsteelblue' : 'aliceblue', + primaryTextColor: isDarkMode ? '#000' : '#000', + primaryBorderColor: isDarkMode ? 'steelblue' : 'lightsteelblue', + lineColor: isDarkMode ? '#939393' : '#d4d4d4', + secondaryColor: isDarkMode ? 'lightyellow' : 'lightyellow', + tertiaryColor: isDarkMode ? 'lightSalmon' : 'lightSalmon', } }); @@ -275,6 +277,7 @@ click {{ nodeId }} showNodeDetails{{ collector.hash(name) }} {% endfor %} + View on mermaid.live

Calls

diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar.css.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar.css.twig index b61fa5e9f138f..148b7638ad2eb 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar.css.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar.css.twig @@ -46,45 +46,11 @@ --sf-toolbar-green-900: #030404; } -.sf-minitoolbar { - --sf-toolbar-gray-800: #262626; - - background-color: var(--sf-toolbar-gray-800); - border-top-left-radius: 4px; - bottom: 0; - box-sizing: border-box; - display: none; - height: 36px; - padding: 6px; - position: fixed; - right: 0; - z-index: 99999; -} - -.sf-minitoolbar button { - background-color: transparent; - padding: 0; - border: none; -} -.sf-minitoolbar svg, -.sf-minitoolbar img { - --sf-toolbar-gray-200: #e5e5e5; - - color: var(--sf-toolbar-gray-200); - max-height: 24px; - max-width: 24px; - display: inline; -} - .sf-toolbar-clearer { clear: both; height: 36px; } -.sf-display-none { - display: none; -} - .sf-toolbarreset *:not(svg rect) { box-sizing: content-box; vertical-align: baseline; @@ -127,27 +93,52 @@ color: var(--sf-toolbar-gray-700); } -.sf-toolbarreset .hide-button { +.sf-toolbarreset .sf-toolbar-toggle-button { background: var(--sf-toolbar-gray-800); color: var(--sf-toolbar-gray-300); display: block; position: absolute; - top: 2px; + top: 1px; right: 0; width: 36px; - height: 34px; + height: 35px; cursor: pointer; text-align: center; border: none; margin: 0; padding: 0; + outline: none; } -.sf-toolbarreset .hide-button:hover { +.sf-toolbarreset .sf-toolbar-toggle-button:hover { background: var(--sf-toolbar-gray-700); } -.sf-toolbarreset .hide-button svg { - max-height: 18px; - margin-top: 1px; + +.sf-toolbar.sf-toolbar-closed .sf-toolbarreset .sf-toolbar-block { + display: none; +} +.sf-toolbar.sf-toolbar-closed .sf-toolbarreset .sf-toolbar-toggle-button { + top: -37px; +} + +.sf-toolbar .sf-toolbar-toggle-button i { + display: block; + height: 35px; + place-content: center; +} +.sf-toolbar.sf-toolbar-opened .sf-toolbar-toggle-button .sf-toolbar-icon-closed { + display: none; +} +.sf-toolbar.sf-toolbar-opened .sf-toolbar-toggle-button .sf-toolbar-icon-opened { + display: block; +} +.sf-toolbar.sf-toolbar-closed .sf-toolbar-toggle-button .sf-toolbar-icon-closed { + display: block; +} +.sf-toolbar.sf-toolbar-closed .sf-toolbar-toggle-button .sf-toolbar-icon-opened { + display: none; +} +.sf-toolbar.sf-toolbar-closed .sf-toolbarreset .sf-toolbar-toggle-button { + border-top: 2px solid var(--sf-toolbar-gray-800); } .sf-toolbar-block { diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar.html.twig index 565ec39bfe2ef..b82f911c3ddc6 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar.html.twig @@ -1,11 +1,4 @@ - -
- -
-
{% for name, template in templates %} {% if block('toolbar', template) is defined %} @@ -39,8 +32,8 @@
{% endif %} - - diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar_js.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar_js.html.twig index eaf9329aadde7..91e6dc05e658c 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar_js.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/toolbar_js.html.twig @@ -1,4 +1,5 @@ -
+ +
{{ include('@WebProfiler/Profiler/toolbar.html.twig', { templates: { 'request': '@WebProfiler/Profiler/cancel.html.twig' @@ -453,60 +454,32 @@ showToolbar: function(token) { var sfwdt = this.getSfwdt(token); - removeClass(sfwdt, 'sf-display-none'); - if (getPreference('toolbar/displayState') == 'none') { - document.getElementById('sfToolbarMainContent-' + token).style.display = 'none'; - document.getElementById('sfToolbarClearer-' + token).style.display = 'none'; - document.getElementById('sfMiniToolbar-' + token).style.display = 'block'; + if ('closed' === getPreference('toolbar/displayState')) { + addClass(sfwdt, 'sf-toolbar-closed'); + removeClass(sfwdt, 'sf-toolbar-opened'); } else { - document.getElementById('sfToolbarMainContent-' + token).style.display = 'block'; - document.getElementById('sfToolbarClearer-' + token).style.display = 'block'; - document.getElementById('sfMiniToolbar-' + token).style.display = 'none'; + addClass(sfwdt, 'sf-toolbar-opened'); + removeClass(sfwdt, 'sf-toolbar-closed'); } }, hideToolbar: function(token) { var sfwdt = this.getSfwdt(token); - addClass(sfwdt, 'sf-display-none'); + addClass(sfwdt, 'sf-toolbar-closed'); + removeClass(sfwdt, 'sf-toolbar-opened'); }, initToolbar: function(token) { this.showToolbar(token); - var hideButton = document.getElementById('sfToolbarHideButton-' + token); - var hideButtonSvg = hideButton.querySelector('svg'); - hideButtonSvg.setAttribute('aria-hidden', 'true'); - hideButtonSvg.setAttribute('focusable', 'false'); - addEventListener(hideButton, 'click', function (event) { + var toggleButton = document.querySelector(`#sfToolbarToggleButton-${token}`); + addEventListener(toggleButton, 'click', function (event) { event.preventDefault(); - var p = this.parentNode; - p.style.display = 'none'; - (p.previousElementSibling || p.previousSibling).style.display = 'none'; - document.getElementById('sfMiniToolbar-' + token).style.display = 'block'; - setPreference('toolbar/displayState', 'none'); - }); - - var showButton = document.getElementById('sfToolbarMiniToggler-' + token); - var showButtonSvg = showButton.querySelector('svg'); - showButtonSvg.setAttribute('aria-hidden', 'true'); - showButtonSvg.setAttribute('focusable', 'false'); - addEventListener(showButton, 'click', function (event) { - event.preventDefault(); - - var elem = this.parentNode; - if (elem.style.display == 'none') { - document.getElementById('sfToolbarMainContent-' + token).style.display = 'none'; - document.getElementById('sfToolbarClearer-' + token).style.display = 'none'; - elem.style.display = 'block'; - } else { - document.getElementById('sfToolbarMainContent-' + token).style.display = 'block'; - document.getElementById('sfToolbarClearer-' + token).style.display = 'block'; - elem.style.display = 'none' - } - - setPreference('toolbar/displayState', 'block'); + const newState = 'opened' === getPreference('toolbar/displayState') ? 'closed' : 'opened'; + setPreference('toolbar/displayState', newState); + 'opened' === newState ? Sfjs.showToolbar(token) : Sfjs.hideToolbar(token); }); }, @@ -655,3 +628,4 @@ Sfjs.loadToolbar('{{ token }}'); /*]]>*/ + diff --git a/src/Symfony/Bundle/WebProfilerBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/WebProfilerBundle/Tests/DependencyInjection/ConfigurationTest.php index d957cafc48616..6a9fc99f10281 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/WebProfilerBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -36,7 +36,10 @@ public static function getDebugModes() 'options' => [], 'expectedResult' => [ 'intercept_redirects' => false, - 'toolbar' => false, + 'toolbar' => [ + 'enabled' => false, + 'ajax_replace' => false, + ], 'excluded_ajax_paths' => '^/((index|app(_[\w]+)?)\.php/)?_wdt', ], ], @@ -44,7 +47,10 @@ public static function getDebugModes() 'options' => ['toolbar' => true], 'expectedResult' => [ 'intercept_redirects' => false, - 'toolbar' => true, + 'toolbar' => [ + 'enabled' => true, + 'ajax_replace' => false, + ], 'excluded_ajax_paths' => '^/((index|app(_[\w]+)?)\.php/)?_wdt', ], ], @@ -52,10 +58,24 @@ public static function getDebugModes() 'options' => ['excluded_ajax_paths' => 'test'], 'expectedResult' => [ 'intercept_redirects' => false, - 'toolbar' => false, + 'toolbar' => [ + 'enabled' => false, + 'ajax_replace' => false, + ], 'excluded_ajax_paths' => 'test', ], ], + [ + 'options' => ['toolbar' => ['ajax_replace' => true]], + 'expectedResult' => [ + 'intercept_redirects' => false, + 'toolbar' => [ + 'enabled' => true, + 'ajax_replace' => true, + ], + 'excluded_ajax_paths' => '^/((index|app(_[\w]+)?)\.php/)?_wdt', + ], + ], ]; } @@ -78,7 +98,10 @@ public static function getInterceptRedirectsConfiguration() 'interceptRedirects' => true, 'expectedResult' => [ 'intercept_redirects' => true, - 'toolbar' => false, + 'toolbar' => [ + 'enabled' => false, + 'ajax_replace' => false, + ], 'excluded_ajax_paths' => '^/((index|app(_[\w]+)?)\.php/)?_wdt', ], ], @@ -86,7 +109,10 @@ public static function getInterceptRedirectsConfiguration() 'interceptRedirects' => false, 'expectedResult' => [ 'intercept_redirects' => false, - 'toolbar' => false, + 'toolbar' => [ + 'enabled' => false, + 'ajax_replace' => false, + ], 'excluded_ajax_paths' => '^/((index|app(_[\w]+)?)\.php/)?_wdt', ], ], diff --git a/src/Symfony/Bundle/WebProfilerBundle/Tests/EventListener/WebDebugToolbarListenerTest.php b/src/Symfony/Bundle/WebProfilerBundle/Tests/EventListener/WebDebugToolbarListenerTest.php index 33bf1a32d27f8..981c85beed41f 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Tests/EventListener/WebDebugToolbarListenerTest.php +++ b/src/Symfony/Bundle/WebProfilerBundle/Tests/EventListener/WebDebugToolbarListenerTest.php @@ -63,6 +63,7 @@ public static function getInjectToolbarTests() public function testHtmlRedirectionIsIntercepted($statusCode) { $response = new Response('Some content', $statusCode); + $response->headers->set('Location', 'https://example.com/'); $response->headers->set('X-Debug-Token', 'xxxxxxxx'); $event = new ResponseEvent($this->createMock(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); @@ -76,6 +77,7 @@ public function testHtmlRedirectionIsIntercepted($statusCode) public function testNonHtmlRedirectionIsNotIntercepted() { $response = new Response('Some content', '301'); + $response->headers->set('Location', 'https://example.com/'); $response->headers->set('X-Debug-Token', 'xxxxxxxx'); $event = new ResponseEvent($this->createMock(Kernel::class), new Request([], [], ['_format' => 'json']), HttpKernelInterface::MAIN_REQUEST, $response); @@ -139,6 +141,7 @@ public function testToolbarIsNotInjectedOnContentDispositionAttachment() public function testToolbarIsNotInjectedOnRedirection($statusCode) { $response = new Response('', $statusCode); + $response->headers->set('Location', 'https://example.com/'); $response->headers->set('X-Debug-Token', 'xxxxxxxx'); $event = new ResponseEvent($this->createMock(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); @@ -357,6 +360,66 @@ public function testNullContentTypeWithNoDebugEnv() $this->expectNotToPerformAssertions(); } + public function testAjaxReplaceHeaderOnDisabledToolbar() + { + $response = new Response(); + $event = new ResponseEvent($this->createMock(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock(), false, WebDebugToolbarListener::DISABLED, null, '', null, null, true); + $listener->onKernelResponse($event); + + $this->assertFalse($response->headers->has('Symfony-Debug-Toolbar-Replace')); + } + + public function testAjaxReplaceHeaderOnDisabledReplace() + { + $response = new Response(); + $event = new ResponseEvent($this->createMock(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock(), false, WebDebugToolbarListener::ENABLED, null, '', null, null); + $listener->onKernelResponse($event); + + $this->assertFalse($response->headers->has('Symfony-Debug-Toolbar-Replace')); + } + + public function testAjaxReplaceHeaderOnEnabledAndNonXHR() + { + $response = new Response(); + $event = new ResponseEvent($this->createMock(Kernel::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock(), false, WebDebugToolbarListener::ENABLED, null, '', null, null, true); + $listener->onKernelResponse($event); + + $this->assertFalse($response->headers->has('Symfony-Debug-Toolbar-Replace')); + } + + public function testAjaxReplaceHeaderOnEnabledAndXHR() + { + $request = new Request(); + $request->headers->set('X-Requested-With', 'XMLHttpRequest'); + $response = new Response(); + $event = new ResponseEvent($this->createMock(Kernel::class), $request, HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock(), false, WebDebugToolbarListener::ENABLED, null, '', null, null, true); + $listener->onKernelResponse($event); + + $this->assertSame('1', $response->headers->get('Symfony-Debug-Toolbar-Replace')); + } + + public function testAjaxReplaceHeaderOnEnabledAndXHRButPreviouslySet() + { + $request = new Request(); + $request->headers->set('X-Requested-With', 'XMLHttpRequest'); + $response = new Response(); + $response->headers->set('Symfony-Debug-Toolbar-Replace', '0'); + $event = new ResponseEvent($this->createMock(Kernel::class), $request, HttpKernelInterface::MAIN_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock(), false, WebDebugToolbarListener::ENABLED, null, '', null, null, true); + $listener->onKernelResponse($event); + + $this->assertSame('0', $response->headers->get('Symfony-Debug-Toolbar-Replace')); + } + protected function getTwigMock($render = 'WDT') { $templating = $this->createMock(Environment::class); diff --git a/src/Symfony/Bundle/WebProfilerBundle/Tests/Functional/WebProfilerBundleKernel.php b/src/Symfony/Bundle/WebProfilerBundle/Tests/Functional/WebProfilerBundleKernel.php index 6438960287411..0447e5787401e 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Tests/Functional/WebProfilerBundleKernel.php +++ b/src/Symfony/Bundle/WebProfilerBundle/Tests/Functional/WebProfilerBundleKernel.php @@ -43,8 +43,8 @@ public function registerBundles(): iterable protected function configureRoutes(RoutingConfigurator $routes): void { - $routes->import(__DIR__.'/../../Resources/config/routing/profiler.xml')->prefix('/_profiler'); - $routes->import(__DIR__.'/../../Resources/config/routing/wdt.xml')->prefix('/_wdt'); + $routes->import(__DIR__.'/../../Resources/config/routing/profiler.php')->prefix('/_profiler'); + $routes->import(__DIR__.'/../../Resources/config/routing/wdt.php')->prefix('/_wdt'); $routes->add('_', '/')->controller('kernel::homepageController'); } @@ -55,7 +55,7 @@ protected function configureContainer(ContainerBuilder $container, LoaderInterfa 'http_method_override' => false, 'php_errors' => ['log' => true], 'secret' => 'foo-secret', - 'profiler' => ['only_exceptions' => false], + 'profiler' => ['only_exceptions' => false, 'collect_serializer_data' => true], 'session' => ['handler_id' => null, 'storage_factory_id' => 'session.storage.factory.mock_file', 'cookie-secure' => 'auto', 'cookie-samesite' => 'lax'], 'router' => ['utf8' => true], ]; diff --git a/src/Symfony/Bundle/WebProfilerBundle/composer.json b/src/Symfony/Bundle/WebProfilerBundle/composer.json index ce94b4b62ebbb..00269dd279d45 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/composer.json +++ b/src/Symfony/Bundle/WebProfilerBundle/composer.json @@ -17,7 +17,9 @@ ], "require": { "php": ">=8.2", - "symfony/config": "^6.4|^7.0", + "composer-runtime-api": ">=2.1", + "symfony/config": "^7.3", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/framework-bundle": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", "symfony/routing": "^6.4|^7.0", @@ -34,7 +36,8 @@ "symfony/form": "<6.4", "symfony/mailer": "<6.4", "symfony/messenger": "<6.4", - "symfony/serializer": "<7.2" + "symfony/serializer": "<7.2", + "symfony/workflow": "<7.3" }, "autoload": { "psr-4": { "Symfony\\Bundle\\WebProfilerBundle\\": "" }, diff --git a/src/Symfony/Component/Asset/Tests/Context/NullContextTest.php b/src/Symfony/Component/Asset/Tests/Context/NullContextTest.php index 4623412f57952..b363eb5c61a17 100644 --- a/src/Symfony/Component/Asset/Tests/Context/NullContextTest.php +++ b/src/Symfony/Component/Asset/Tests/Context/NullContextTest.php @@ -20,7 +20,7 @@ public function testGetBasePath() { $nullContext = new NullContext(); - $this->assertEmpty($nullContext->getBasePath()); + $this->assertSame('', $nullContext->getBasePath()); } public function testIsSecure() diff --git a/src/Symfony/Component/Asset/Tests/Context/RequestStackContextTest.php b/src/Symfony/Component/Asset/Tests/Context/RequestStackContextTest.php index cd1f5609eb43b..4a7f555979ac7 100644 --- a/src/Symfony/Component/Asset/Tests/Context/RequestStackContextTest.php +++ b/src/Symfony/Component/Asset/Tests/Context/RequestStackContextTest.php @@ -22,7 +22,7 @@ public function testGetBasePathEmpty() { $requestStackContext = new RequestStackContext(new RequestStack()); - $this->assertEmpty($requestStackContext->getBasePath()); + $this->assertSame('', $requestStackContext->getBasePath()); } public function testGetBasePathSet() diff --git a/src/Symfony/Component/Asset/Tests/VersionStrategy/EmptyVersionStrategyTest.php b/src/Symfony/Component/Asset/Tests/VersionStrategy/EmptyVersionStrategyTest.php index 1728c2e99b4d4..f19d6470eae91 100644 --- a/src/Symfony/Component/Asset/Tests/VersionStrategy/EmptyVersionStrategyTest.php +++ b/src/Symfony/Component/Asset/Tests/VersionStrategy/EmptyVersionStrategyTest.php @@ -21,7 +21,7 @@ public function testGetVersion() $emptyVersionStrategy = new EmptyVersionStrategy(); $path = 'test-path'; - $this->assertEmpty($emptyVersionStrategy->getVersion($path)); + $this->assertSame('', $emptyVersionStrategy->getVersion($path)); } public function testApplyVersion() diff --git a/src/Symfony/Component/AssetMapper/CHANGELOG.md b/src/Symfony/Component/AssetMapper/CHANGELOG.md index e0b43ebb5e691..93d622101c0c8 100644 --- a/src/Symfony/Component/AssetMapper/CHANGELOG.md +++ b/src/Symfony/Component/AssetMapper/CHANGELOG.md @@ -1,6 +1,13 @@ CHANGELOG ========= +7.3 +--- + + * Add support for pre-compressing assets with Brotli, Zstandard, Zopfli, and gzip + * Add option `--dry-run` to `importmap:require` command + * `ImportMapRequireCommand` now takes `projectDir` as a required third constructor argument + 7.2 --- diff --git a/src/Symfony/Component/AssetMapper/Command/CompressAssetsCommand.php b/src/Symfony/Component/AssetMapper/Command/CompressAssetsCommand.php new file mode 100644 index 0000000000000..008574e85dcd9 --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Command/CompressAssetsCommand.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Command; + +use Symfony\Component\AssetMapper\Compressor\CompressorInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * Pre-compresses files to serve through a web server. + * + * @author Kévin Dunglas + */ +#[AsCommand(name: 'assets:compress', description: 'Pre-compresses files to serve through a web server')] +final class CompressAssetsCommand extends Command +{ + public function __construct( + private readonly CompressorInterface $compressor, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('paths', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'The files to compress') + ->setHelp(<<<'EOT' +The %command.name% command compresses the given file in Brotli, Zstandard and gzip formats. +This is especially useful to serve pre-compressed files through a web server. + +The existing file will be kept. The compressed files will be created in the same directory. +The extension of the compression format will be appended to the original file name. +EOT + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $paths = $input->getArgument('paths'); + foreach ($paths as $path) { + $this->compressor->compress($path); + } + + $io->success(\sprintf('File%s compressed successfully.', \count($paths) > 1 ? 's' : '')); + + return Command::SUCCESS; + } +} diff --git a/src/Symfony/Component/AssetMapper/Command/ImportMapOutdatedCommand.php b/src/Symfony/Component/AssetMapper/Command/ImportMapOutdatedCommand.php index 39b5d669c5ce9..17a12da7ee38f 100644 --- a/src/Symfony/Component/AssetMapper/Command/ImportMapOutdatedCommand.php +++ b/src/Symfony/Component/AssetMapper/Command/ImportMapOutdatedCommand.php @@ -76,6 +76,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $packagesUpdateInfos = $this->updateChecker->getAvailableUpdates($packages); $packagesUpdateInfos = array_filter($packagesUpdateInfos, fn ($packageUpdateInfo) => $packageUpdateInfo->hasUpdate()); if (0 === \count($packagesUpdateInfos)) { + if ('json' === $input->getOption('format')) { + $io->writeln('[]'); + } else { + $io->writeln('No updates found.'); + } + return Command::SUCCESS; } diff --git a/src/Symfony/Component/AssetMapper/Command/ImportMapRequireCommand.php b/src/Symfony/Component/AssetMapper/Command/ImportMapRequireCommand.php index b3ccb1de2b96a..3a1efabc9cd7b 100644 --- a/src/Symfony/Component/AssetMapper/Command/ImportMapRequireCommand.php +++ b/src/Symfony/Component/AssetMapper/Command/ImportMapRequireCommand.php @@ -11,6 +11,7 @@ namespace Symfony\Component\AssetMapper\Command; +use Symfony\Component\AssetMapper\ImportMap\ImportMapEntries; use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry; use Symfony\Component\AssetMapper\ImportMap\ImportMapManager; use Symfony\Component\AssetMapper\ImportMap\ImportMapVersionChecker; @@ -22,6 +23,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Filesystem\Path; /** * @author Kévin Dunglas @@ -34,7 +36,12 @@ final class ImportMapRequireCommand extends Command public function __construct( private readonly ImportMapManager $importMapManager, private readonly ImportMapVersionChecker $importMapVersionChecker, + private readonly ?string $projectDir = null, ) { + if (null === $projectDir) { + trigger_deprecation('symfony/asset-mapper', '7.3', 'The "%s()" method will have a new `string $projectDir` argument in version 8.0, not defining it is deprecated.', __METHOD__); + } + parent::__construct(); } @@ -42,8 +49,9 @@ protected function configure(): void { $this ->addArgument('packages', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'The packages to add') - ->addOption('entrypoint', null, InputOption::VALUE_NONE, 'Make the package(s) an entrypoint?') + ->addOption('entrypoint', null, InputOption::VALUE_NONE, 'Make the packages an entrypoint?') ->addOption('path', null, InputOption::VALUE_REQUIRED, 'The local path where the package lives relative to the project root') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Simulate the installation of the packages') ->setHelp(<<<'EOT' The %command.name% command adds packages to importmap.php usually by finding a CDN URL for the given package and version. @@ -72,6 +80,11 @@ protected function configure(): void php %command.full_name% "any_module_name" --path=./assets/some_file.js +To simulate the installation, use the --dry-run option: + + php %command.full_name% "any_module_name" --dry-run -v + +When this option is enabled, this command does not perform any write operations to the filesystem. EOT ); } @@ -92,6 +105,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $path = $input->getOption('path'); } + if ($input->getOption('dry-run')) { + $io->writeln(['', '[DRY-RUN] No changes will apply to the importmap configuration.', '']); + } + $packages = []; foreach ($packageList as $packageName) { $parts = ImportMapManager::parsePackageName($packageName); @@ -110,21 +127,34 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); } - $newPackages = $this->importMapManager->require($packages); + if ($input->getOption('dry-run')) { + $newPackages = $this->importMapManager->requirePackages($packages, new ImportMapEntries()); + } else { + $newPackages = $this->importMapManager->require($packages); + } $this->renderVersionProblems($this->importMapVersionChecker, $output); - if (1 === \count($newPackages)) { - $newPackage = $newPackages[0]; - $message = \sprintf('Package "%s" added to importmap.php', $newPackage->importName); + $newPackageNames = array_map(fn (ImportMapEntry $package): string => $package->importName, $newPackages); - $message .= '.'; + if (1 === \count($newPackages)) { + $messages = [\sprintf('Package "%s" added to importmap.php.', $newPackageNames[0])]; } else { - $names = array_map(fn (ImportMapEntry $package) => $package->importName, $newPackages); - $message = \sprintf('%d new items (%s) added to the importmap.php!', \count($newPackages), implode(', ', $names)); + $messages = [\sprintf('%d new items (%s) added to the importmap.php!', \count($newPackages), implode(', ', $newPackageNames))]; } - $messages = [$message]; + if ($io->isVerbose()) { + $io->table( + ['Package', 'Version', 'Path'], + array_map(fn (ImportMapEntry $package): array => [ + $package->importName, + $package->version ?? '-', + // BC layer for AssetMapper < 7.3 + // When `projectDir` is not null, we use the absolute path of the package + null !== $this->projectDir ? Path::makeRelative($package->path, $this->projectDir) : $package->path, + ], $newPackages), + ); + } if (1 === \count($newPackages)) { $messages[] = \sprintf('Use the new package normally by importing "%s".', $newPackages[0]->importName); @@ -132,6 +162,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->success($messages); + if ($input->getOption('dry-run')) { + $io->writeln(['[DRY-RUN] No changes applied to the importmap configuration.', '']); + } + return Command::SUCCESS; } } diff --git a/src/Symfony/Component/AssetMapper/Compiler/JavaScriptImportPathCompiler.php b/src/Symfony/Component/AssetMapper/Compiler/JavaScriptImportPathCompiler.php index ef78cad44e8fc..413d8d6d67cd8 100644 --- a/src/Symfony/Component/AssetMapper/Compiler/JavaScriptImportPathCompiler.php +++ b/src/Symfony/Component/AssetMapper/Compiler/JavaScriptImportPathCompiler.php @@ -13,6 +13,7 @@ use Psr\Log\LoggerInterface; use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\AssetMapper\Compiler\Parser\JavascriptSequenceParser; use Symfony\Component\AssetMapper\Exception\CircularAssetsException; use Symfony\Component\AssetMapper\Exception\RuntimeException; use Symfony\Component\AssetMapper\ImportMap\ImportMapConfigReader; @@ -61,15 +62,13 @@ public function __construct( public function compile(string $content, MappedAsset $asset, AssetMapperInterface $assetMapper): string { - return preg_replace_callback(self::IMPORT_PATTERN, function ($matches) use ($asset, $assetMapper, $content) { - $fullImportString = $matches[0][0]; + $jsParser = new JavascriptSequenceParser($content); - // Ignore matches that did not capture import statements - if (!isset($matches[1][0])) { - return $fullImportString; - } + return preg_replace_callback(self::IMPORT_PATTERN, function ($matches) use ($asset, $assetMapper, $jsParser) { + $fullImportString = $matches[0][0]; - if ($this->isCommentedOut($matches[0][1], $content)) { + $jsParser->parseUntil($matches[0][1]); + if (!$jsParser->isExecutable()) { return $fullImportString; } @@ -146,33 +145,6 @@ private function handleMissingImport(string $message, ?\Throwable $e = null): vo }; } - /** - * Simple check for the most common types of comments. - * - * This is not a full parser, but should be good enough for most cases. - */ - private function isCommentedOut(mixed $offsetStart, string $fullContent): bool - { - $lineStart = strrpos($fullContent, "\n", $offsetStart - \strlen($fullContent)); - $lineContentBeforeImport = substr($fullContent, $lineStart, $offsetStart - $lineStart); - $firstTwoChars = substr(ltrim($lineContentBeforeImport), 0, 2); - if ('//' === $firstTwoChars) { - return true; - } - - if ('/*' === $firstTwoChars) { - $commentEnd = strpos($fullContent, '*/', $lineStart); - // if we can't find the end comment, be cautious: assume this is not a comment - if (false === $commentEnd) { - return false; - } - - return $offsetStart < $commentEnd; - } - - return false; - } - private function findAssetForBareImport(string $importedModule, AssetMapperInterface $assetMapper): ?MappedAsset { if (!$importMapEntry = $this->importMapConfigReader->findRootImportMapEntry($importedModule)) { diff --git a/src/Symfony/Component/AssetMapper/Compiler/Parser/JavascriptSequenceParser.php b/src/Symfony/Component/AssetMapper/Compiler/Parser/JavascriptSequenceParser.php new file mode 100644 index 0000000000000..943c0eea14f51 --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Compiler/Parser/JavascriptSequenceParser.php @@ -0,0 +1,187 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Compiler\Parser; + +/** + * Parses JavaScript content to identify sequences of strings, comments, etc. + * + * @author Simon André + * + * @internal + */ +final class JavascriptSequenceParser +{ + private const STATE_DEFAULT = 0; + private const STATE_COMMENT = 1; + private const STATE_STRING = 2; + + private int $cursor = 0; + + private int $contentEnd; + + private string $pattern; + + private int $currentSequenceType = self::STATE_DEFAULT; + + private ?int $currentSequenceEnd = null; + + private const COMMENT_SEPARATORS = [ + '/*', // Multi-line comment + '//', // Single-line comment + '"', // Double quote + '\'', // Single quote + '`', // Backtick + ]; + + public function __construct( + private readonly string $content, + ) { + $this->contentEnd = \strlen($content); + + $this->pattern ??= '/'.implode('|', array_map( + fn (string $ch): string => preg_quote($ch, '/'), + self::COMMENT_SEPARATORS + )).'/'; + } + + public function isString(): bool + { + return self::STATE_STRING === $this->currentSequenceType; + } + + public function isExecutable(): bool + { + return self::STATE_DEFAULT === $this->currentSequenceType; + } + + public function isComment(): bool + { + return self::STATE_COMMENT === $this->currentSequenceType; + } + + public function parseUntil(int $position): void + { + if ($position > $this->contentEnd) { + throw new \RuntimeException('Cannot parse beyond the end of the content.'); + } + if ($position < $this->cursor) { + throw new \RuntimeException('Cannot parse backwards.'); + } + + while ($this->cursor <= $position) { + // Current CodeSequence ? + if (null !== $this->currentSequenceEnd) { + if ($this->currentSequenceEnd > $position) { + $this->cursor = $position; + + return; + } + + $this->cursor = $this->currentSequenceEnd; + $this->setSequence(self::STATE_DEFAULT, null); + } + + preg_match($this->pattern, $this->content, $matches, \PREG_OFFSET_CAPTURE, $this->cursor); + if (!$matches) { + $this->endsWithSequence(self::STATE_DEFAULT, $position); + + return; + } + + $matchPos = (int) $matches[0][1]; + $matchChar = $matches[0][0]; + + if ($matchPos > $position) { + $this->setSequence(self::STATE_DEFAULT, $matchPos - 1); + $this->cursor = $position; + + return; + } + + // Multi-line comment + if ('/*' === $matchChar) { + if (false === $endPos = strpos($this->content, '*/', $matchPos + 2)) { + $this->endsWithSequence(self::STATE_COMMENT, $position); + + return; + } + + $this->cursor = min($endPos + 2, $position); + $this->setSequence(self::STATE_COMMENT, $endPos + 2); + continue; + } + + // Single-line comment + if ('//' === $matchChar) { + if (false === $endPos = strpos($this->content, "\n", $matchPos + 2)) { + $this->endsWithSequence(self::STATE_COMMENT, $position); + + return; + } + + $this->cursor = min($endPos + 1, $position); + $this->setSequence(self::STATE_COMMENT, $endPos + 1); + continue; + } + + // Single-line string + if ('"' === $matchChar || "'" === $matchChar) { + if (false === $endPos = strpos($this->content, $matchChar, $matchPos + 1)) { + $this->endsWithSequence(self::STATE_STRING, $position); + + return; + } + while (false !== $endPos && '\\' == $this->content[$endPos - 1]) { + $endPos = strpos($this->content, $matchChar, $endPos + 1); + } + + $this->cursor = min($endPos + 1, $position); + $this->setSequence(self::STATE_STRING, $endPos + 1); + continue; + } + + // Multi-line string + if ('`' === $matchChar) { + if (false === $endPos = strpos($this->content, $matchChar, $matchPos + 1)) { + $this->endsWithSequence(self::STATE_STRING, $position); + + return; + } + while (false !== $endPos && '\\' == $this->content[$endPos - 1]) { + $endPos = strpos($this->content, $matchChar, $endPos + 1); + } + + $this->cursor = min($endPos + 1, $position); + $this->setSequence(self::STATE_STRING, $endPos + 1); + } + } + } + + /** + * @param int $type + */ + private function endsWithSequence(int $type, int $cursor): void + { + $this->cursor = $cursor; + $this->currentSequenceType = $type; + $this->currentSequenceEnd = $this->contentEnd; + } + + /** + * @param int $type + */ + private function setSequence(int $type, ?int $end = null): void + { + $this->currentSequenceType = $type; + $this->currentSequenceEnd = $end; + } +} diff --git a/src/Symfony/Component/AssetMapper/Compressor/BrotliCompressor.php b/src/Symfony/Component/AssetMapper/Compressor/BrotliCompressor.php new file mode 100644 index 0000000000000..3849f02a3f294 --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Compressor/BrotliCompressor.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Compressor; + +use Symfony\Component\Process\Process; + +/** + * Compresses a file using Brotli. + * + * @author Kévin Dunglas + */ +final class BrotliCompressor implements SupportedCompressorInterface +{ + use CompressorTrait; + + private const WRAPPER = 'compress.brotli'; + private const COMMAND = 'brotli'; + private const PHP_EXTENSION = 'brotli'; + private const FILE_EXTENSION = 'br'; + + public function __construct( + ?string $executable = null, + ) { + $this->executable = $executable; + } + + /** + * @return resource + */ + private function createStreamContext() + { + return stream_context_create(['brotli' => ['level' => BROTLI_COMPRESS_LEVEL_MAX]]); + } + + private function compressWithBinary(string $path): void + { + (new Process([$this->executable, '--best', '--force', "--output=$path.".self::FILE_EXTENSION, '--', $path]))->mustRun(); + } +} diff --git a/src/Symfony/Component/AssetMapper/Compressor/ChainCompressor.php b/src/Symfony/Component/AssetMapper/Compressor/ChainCompressor.php new file mode 100644 index 0000000000000..bbc723b9ac57a --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Compressor/ChainCompressor.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Compressor; + +use Psr\Log\LoggerInterface; + +/** + * Calls multiple compressors in a chain. + * + * @author Kévin Dunglas + */ +final class ChainCompressor implements CompressorInterface +{ + /** + * @param CompressorInterface[] $compressors + */ + public function __construct( + private ?array $compressors = null, + private readonly ?LoggerInterface $logger = null, + ) { + } + + public function compress(string $path): void + { + if (null === $this->compressors) { + $this->compressors = []; + foreach ([new BrotliCompressor(), new ZstandardCompressor(), new GzipCompressor()] as $compressor) { + $unsupportedReason = $compressor->getUnsupportedReason(); + if (null === $unsupportedReason) { + $this->compressors[] = $compressor; + } else { + $this->logger?->warning($unsupportedReason); + } + } + } + + foreach ($this->compressors as $compressor) { + $compressor->compress($path); + } + } +} diff --git a/src/Symfony/Component/AssetMapper/Compressor/CompressorInterface.php b/src/Symfony/Component/AssetMapper/Compressor/CompressorInterface.php new file mode 100644 index 0000000000000..3ebffc55a7dc3 --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Compressor/CompressorInterface.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Compressor; + +/** + * Compresses a file. + * + * @author Kévin Dunglas + */ +interface CompressorInterface +{ + // Loosely based on https://caddyserver.com/docs/caddyfile/directives/encode#match + public const DEFAULT_EXTENSIONS = [ + 'css', + 'cur', + 'eot', + 'html', + 'js', + 'json', + 'md', + 'otc', + 'otf', + 'proto', + 'rss', + 'rtf', + 'svg', + 'ttc', + 'ttf', + 'txt', + 'wasm', + 'xml', + ]; + + public function compress(string $path): void; +} diff --git a/src/Symfony/Component/AssetMapper/Compressor/CompressorTrait.php b/src/Symfony/Component/AssetMapper/Compressor/CompressorTrait.php new file mode 100644 index 0000000000000..1f5765d995f38 --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Compressor/CompressorTrait.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Compressor; + +use Symfony\Component\Process\ExecutableFinder; +use Symfony\Component\Process\Process; + +/** + * @internal + * + * @author Kévin Dunglas + */ +trait CompressorTrait +{ + private ?\Closure $method = null; + private ?string $executable = null; + /** + * @var ?resource + */ + private $streamContext; + private ?string $unsupportedReason = null; + + private function initialize(): void + { + if ('' !== self::WRAPPER && \in_array(self::WRAPPER, stream_get_wrappers(), true)) { + $this->method = $this->compressWithExtension(...); + + return; + } + + if (!class_exists(Process::class)) { + if ('' === self::WRAPPER) { + $this->unsupportedReason = \sprintf('%s compression is unsupported. Run "composer require symfony/process" and install the "%s" command.', self::COMMAND, self::COMMAND); + } else { + $this->unsupportedReason = \sprintf('%s compression is unsupported. Install the "%s" extension or run "composer require symfony/process" and install the "%s" command.', self::COMMAND, self::PHP_EXTENSION, self::COMMAND); + } + + return; + } + + if (null === $this->executable) { + $executableFinder = new ExecutableFinder(); + $this->executable = $executableFinder->find(self::COMMAND); + + if (null === $this->executable) { + if (self::WRAPPER === '') { + $this->unsupportedReason = \sprintf('%s compression is unsupported. Install the "%s" command.', self::COMMAND, self::COMMAND); + } else { + $this->unsupportedReason = \sprintf('%s compression is unsupported. Install the "%s" extension or the "%s" command.', self::COMMAND, self::PHP_EXTENSION, self::COMMAND); + } + + return; + } + } + + $this->method = $this->compressWithBinary(...); + } + + public function compress(string $path): void + { + if (null === $this->method && null === $this->unsupportedReason) { + $this->initialize(); + } + if (null !== $this->unsupportedReason) { + throw new \RuntimeException($this->unsupportedReason); + } + + ($this->method)($path); + } + + public function getUnsupportedReason(): ?string + { + if (null !== $this->method) { + return null; + } + + $this->initialize(); + + return $this->unsupportedReason; + } + + abstract private function compressWithBinary(string $path): void; + + /** + * @return resource + */ + abstract private function createStreamContext(); + + private function compressWithExtension(string $path): void + { + if (null === $this->streamContext) { + $this->streamContext = $this->createStreamContext(); + } + + if (!copy($path, \sprintf('%s://%s.%s', self::WRAPPER, $path, self::FILE_EXTENSION), $this->streamContext)) { + throw new \RuntimeException(\sprintf('The compressed file "%s.%s" could not be written.', $path, self::FILE_EXTENSION)); + } + } +} diff --git a/src/Symfony/Component/AssetMapper/Compressor/GzipCompressor.php b/src/Symfony/Component/AssetMapper/Compressor/GzipCompressor.php new file mode 100644 index 0000000000000..d796fe85921c7 --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Compressor/GzipCompressor.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Compressor; + +use Psr\Log\LoggerInterface; +use Symfony\Component\Process\Process; + +/** + * Compresses a file using zopfli if possible, or fallback on gzip. + * + * @author Kévin Dunglas + */ +final class GzipCompressor implements SupportedCompressorInterface +{ + use CompressorTrait { + compress as private baseCompress; + getUnsupportedReason as private baseGetUnsupportedReason; + } + + private const WRAPPER = 'compress.zlib'; + private const COMMAND = 'gzip'; + private const PHP_EXTENSION = 'zlib'; + private const FILE_EXTENSION = 'gz'; + + public function __construct( + private readonly ZopfliCompressor $zopfliCompressor = new ZopfliCompressor(), + ?string $executable = null, + private ?LoggerInterface $logger = null, + ) { + $this->executable = $executable; + } + + public function compress(string $path): void + { + if (null === $reason = $this->zopfliCompressor->getUnsupportedReason()) { + $this->zopfliCompressor->compress($path); + + return; + } else { + $this->logger?->warning($reason); + } + + $this->baseCompress($path); + } + + public function getUnsupportedReason(): ?string + { + if (null === $this->zopfliCompressor->getUnsupportedReason()) { + return null; + } + + return $this->baseGetUnsupportedReason(); + } + + /** + * @return resource + */ + private function createStreamContext() + { + return stream_context_create(['zlib' => ['level' => 9]]); + } + + private function compressWithBinary(string $path): void + { + (new Process([$this->executable, '--best', '--force', '--keep', '--', $path]))->mustRun(); + } +} diff --git a/src/Symfony/Component/AssetMapper/Compressor/SupportedCompressorInterface.php b/src/Symfony/Component/AssetMapper/Compressor/SupportedCompressorInterface.php new file mode 100644 index 0000000000000..9b946561fc885 --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Compressor/SupportedCompressorInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Compressor; + +/** + * @internal + * + * @author Kévin Dunglas + */ +interface SupportedCompressorInterface extends CompressorInterface +{ + /** + * Returns null if the compressor is supported, or the reason why the compressor it is not. + */ + public function getUnsupportedReason(): ?string; +} diff --git a/src/Symfony/Component/AssetMapper/Compressor/ZopfliCompressor.php b/src/Symfony/Component/AssetMapper/Compressor/ZopfliCompressor.php new file mode 100644 index 0000000000000..2df66d874306f --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Compressor/ZopfliCompressor.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Compressor; + +use Symfony\Component\Process\Process; + +/** + * Compresses a file using zopfli. + * + * @author Kévin Dunglas + */ +final class ZopfliCompressor implements SupportedCompressorInterface +{ + use CompressorTrait; + + private const WRAPPER = ''; // not supported yet https://github.com/kjdev/php-ext-zopfli/issues/23 + private const COMMAND = 'zopfli'; + private const PHP_EXTENSION = ''; + private const FILE_EXTENSION = 'gz'; + + public function __construct( + ?string $executable = null, + ) { + $this->executable = $executable; + } + + private function compressWithBinary(string $path): void + { + (new Process([$this->executable, '--', $path]))->mustRun(); + } + + /** + * @return resource + */ + private function createStreamContext() + { + throw new \BadMethodCallException('Extension is not supported yet.'); + } +} diff --git a/src/Symfony/Component/AssetMapper/Compressor/ZstandardCompressor.php b/src/Symfony/Component/AssetMapper/Compressor/ZstandardCompressor.php new file mode 100644 index 0000000000000..ac7ddced2f566 --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Compressor/ZstandardCompressor.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Compressor; + +use Symfony\Component\Process\Process; + +/** + * Compresses a file using Zstandard. + * + * @author Kévin Dunglas + */ +final class ZstandardCompressor implements SupportedCompressorInterface +{ + use CompressorTrait; + + private const WRAPPER = 'compress.zstd'; + private const COMMAND = 'zstd'; + private const PHP_EXTENSION = 'zstd'; + private const FILE_EXTENSION = 'zst'; + + public function __construct( + ?string $executable = null, + ) { + $this->executable = $executable; + } + + /** + * @return resource + */ + private function createStreamContext() + { + return stream_context_create(['zstd' => ['level' => ZSTD_COMPRESS_LEVEL_MAX]]); + } + + private function compressWithBinary(string $path): void + { + (new Process([$this->executable, '-19', '--force', '-o', "$path.".self::FILE_EXTENSION, '--', $path]))->mustRun(); + } +} diff --git a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php index 4a12a6a083728..00c265bc4635d 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapManager.php @@ -128,13 +128,15 @@ private function updateImportMapConfig(bool $update, array $packagesToRequire, a } /** + * @internal + * * Gets information about (and optionally downloads) the packages & updates the entries. * * Returns an array of the entries that were added. * * @param PackageRequireOptions[] $packagesToRequire */ - private function requirePackages(array $packagesToRequire, ImportMapEntries $importMapEntries): array + public function requirePackages(array $packagesToRequire, ImportMapEntries $importMapEntries): array { if (!$packagesToRequire) { return []; diff --git a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapRenderer.php b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapRenderer.php index 48c869b00711c..87d557f6d422f 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/ImportMapRenderer.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/ImportMapRenderer.php @@ -125,11 +125,10 @@ public function render(string|array $entryPoint, array $attributes = []): string } $output .= << + if (!HTMLScriptElement.supports || !HTMLScriptElement.supports('importmap')) (function () { const script = document.createElement('script'); script.src = '{$this->escapeAttributeValue($polyfillPath, \ENT_NOQUOTES)}'; - script.setAttribute('async', 'async'); {$this->createAttributesString($polyfillAttributes, "script.setAttribute('%s', '%s');", "\n ", \ENT_NOQUOTES)} document.head.appendChild(script); })(); diff --git a/src/Symfony/Component/AssetMapper/ImportMap/Resolver/JsDelivrEsmResolver.php b/src/Symfony/Component/AssetMapper/ImportMap/Resolver/JsDelivrEsmResolver.php index f2e4da9b96b49..b88f0e792d44f 100644 --- a/src/Symfony/Component/AssetMapper/ImportMap/Resolver/JsDelivrEsmResolver.php +++ b/src/Symfony/Component/AssetMapper/ImportMap/Resolver/JsDelivrEsmResolver.php @@ -28,7 +28,7 @@ final class JsDelivrEsmResolver implements PackageResolverInterface public const URL_PATTERN_DIST = self::URL_PATTERN_DIST_CSS.'/+esm'; public const URL_PATTERN_ENTRYPOINT = 'https://data.jsdelivr.com/v1/packages/npm/%s@%s/entrypoints'; - public const IMPORT_REGEX = '#(?:import\s*(?:[\w$]+,)?(?:(?:\{[^}]*\}|[\w$]+|\*\s*as\s+[\w$]+)\s*\bfrom\s*)?|export\s*(?:\{[^}]*\}|\*)\s*from\s*)("/npm/((?:@[^/]+/)?[^@]+?)(?:@([^/]+))?((?:/[^/]+)*?)/\+esm")#'; + public const IMPORT_REGEX = '#(?:import\s*(?:[\w$]+,)?(?:(?:\{[^}]*\}|[\w$]+|\*\s*as\s+[\w$]+)\s*\bfrom\s*)?|export\s*(?:\{[^}]*\}|\*)\s*from\s*|await\simport\()("/npm/((?:@[^/]+/)?[^@]+?)(?:@([^/]+))?((?:/[^/]+)*?)/\+esm")(?:\)*)#'; private const ES_MODULE_SHIMS = 'es-module-shims'; diff --git a/src/Symfony/Component/AssetMapper/Path/LocalPublicAssetsFilesystem.php b/src/Symfony/Component/AssetMapper/Path/LocalPublicAssetsFilesystem.php index c6302515927f7..b7d13cc2e87cd 100644 --- a/src/Symfony/Component/AssetMapper/Path/LocalPublicAssetsFilesystem.php +++ b/src/Symfony/Component/AssetMapper/Path/LocalPublicAssetsFilesystem.php @@ -11,14 +11,21 @@ namespace Symfony\Component\AssetMapper\Path; +use Symfony\Component\AssetMapper\Compressor\CompressorInterface; use Symfony\Component\Filesystem\Filesystem; class LocalPublicAssetsFilesystem implements PublicAssetsFilesystemInterface { private Filesystem $filesystem; - public function __construct(private readonly string $publicDir) - { + /** + * @param string[] $extensionsToCompress + */ + public function __construct( + private readonly string $publicDir, + private readonly ?CompressorInterface $compressor = null, + private readonly array $extensionsToCompress = [], + ) { $this->filesystem = new Filesystem(); } @@ -27,6 +34,7 @@ public function write(string $path, string $contents): void $targetPath = $this->publicDir.'/'.ltrim($path, '/'); $this->filesystem->dumpFile($targetPath, $contents); + $this->compress($targetPath); } public function copy(string $originPath, string $path): void @@ -34,10 +42,24 @@ public function copy(string $originPath, string $path): void $targetPath = $this->publicDir.'/'.ltrim($path, '/'); $this->filesystem->copy($originPath, $targetPath, true); + $this->compress($targetPath); } public function getDestinationPath(): string { return $this->publicDir; } + + private function compress(string $targetPath): void + { + foreach ($this->extensionsToCompress as $ext) { + if (!str_ends_with($targetPath, ".$ext")) { + continue; + } + + $this->compressor?->compress($targetPath); + + return; + } + } } diff --git a/src/Symfony/Component/AssetMapper/Tests/Command/ImportMapRequireCommandTest.php b/src/Symfony/Component/AssetMapper/Tests/Command/ImportMapRequireCommandTest.php new file mode 100644 index 0000000000000..fb410bafab2a4 --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Tests/Command/ImportMapRequireCommandTest.php @@ -0,0 +1,218 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Tests\Command; + +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\AssetMapper\Command\ImportMapRequireCommand; +use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry; +use Symfony\Component\AssetMapper\ImportMap\ImportMapManager; +use Symfony\Component\AssetMapper\ImportMap\ImportMapType; +use Symfony\Component\AssetMapper\ImportMap\ImportMapVersionChecker; +use Symfony\Component\AssetMapper\Tests\Fixtures\ImportMapTestAppKernel; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Finder\Finder; + +class ImportMapRequireCommandTest extends KernelTestCase +{ + protected static function getKernelClass(): string + { + return ImportMapTestAppKernel::class; + } + + /** + * @dataProvider getRequirePackageTests + */ + public function testDryRunOptionToShowInformationBeforeApplyInstallation(int $verbosity, array $packageEntries, array $packagesToInstall, string $expected, ?string $path = null) + { + $importMapManager = $this->createMock(ImportMapManager::class); + $importMapManager + ->method('requirePackages') + ->willReturn($packageEntries) + ; + + $command = new ImportMapRequireCommand( + $importMapManager, + $this->createMock(ImportMapVersionChecker::class), + '/path/to/project/dir', + ); + + $args = [ + 'packages' => $packagesToInstall, + '--dry-run' => true, + ]; + if ($path) { + $args['--path'] = $path; + } + + $commandTester = new CommandTester($command); + $commandTester->execute($args, ['verbosity' => $verbosity]); + + $commandTester->assertCommandIsSuccessful(); + + $output = $commandTester->getDisplay(); + $this->assertEquals($this->trimBeginEndOfEachLine($expected), $this->trimBeginEndOfEachLine($output)); + } + + public static function getRequirePackageTests(): iterable + { + yield 'require package with dry run and normal verbosity options' => [ + OutputInterface::VERBOSITY_NORMAL, + [self::createRemoteEntry('bootstrap', '4.2.3', 'assets/vendor/bootstrap/bootstrap.js')], + ['bootstrap'], << [ + OutputInterface::VERBOSITY_VERBOSE, + [self::createRemoteEntry('bootstrap', '5.3.3', 'assets/vendor/bootstrap/bootstrap.js')], + ['bootstrap'], << [ + OutputInterface::VERBOSITY_VERBOSE, + [ImportMapEntry::createLocal('alice.js', ImportMapType::JS, 'assets/js/alice.js', false)], + ['alice.js'], << [ + OutputInterface::VERBOSITY_NORMAL, [ + self::createRemoteEntry('bootstrap', '5.3.3', 'assets/vendor/bootstrap/bootstrap.index.js'), + self::createRemoteEntry('lodash', '4.17.20', 'assets/vendor/lodash/lodash.index.js'), + ], + ['bootstrap lodash@4.17.21'], << [ + OutputInterface::VERBOSITY_VERBOSE, [ + self::createRemoteEntry('bootstrap', '5.3.3', 'assets/vendor/bootstrap/bootstrap.js'), + self::createRemoteEntry('lodash', '4.17.20', 'assets/vendor/lodash/lodash.index.js'), + ], + ['bootstrap lodash@4.17.21'], <<getProjectDir(); + + $fs = new Filesystem(); + $fs->mkdir($projectDir.'/public'); + + $fs->dumpFile($projectDir.'/public/assets/manifest.json', '{}'); + $fs->dumpFile($projectDir.'/public/assets/importmap.json', '{}'); + + $importMapManager = $this->createMock(ImportMapManager::class); + $importMapManager + ->expects($this->once()) + ->method('requirePackages') + ->willReturn([self::createRemoteEntry('bootstrap', '5.3.3', 'assets/vendor/bootstrap/bootstrap.index.js')]); + + self::getContainer()->set(ImportMapManager::class, $importMapManager); + + $application = new Application(self::$kernel); + $command = $application->find('importmap:require'); + + $importMapContentBefore = $fs->readFile($projectDir.'/importmap.php'); + $installedVendorBefore = $fs->readFile($projectDir.'/assets/vendor/installed.php'); + + $tester = new CommandTester($command); + $tester->execute(['packages' => ['bootstrap'], '--dry-run' => true]); + + $tester->assertCommandIsSuccessful(); + + $this->assertSame($importMapContentBefore, $fs->readFile($projectDir.'/importmap.php')); + $this->assertSame($installedVendorBefore, $fs->readFile($projectDir.'/assets/vendor/installed.php')); + + $this->assertSame('{}', $fs->readFile($projectDir.'/public/assets/manifest.json')); + $this->assertSame('{}', $fs->readFile($projectDir.'/public/assets/importmap.json')); + + $finder = new Finder(); + $finder->in($projectDir.'/public/assets')->files()->depth(0); + + $this->assertCount(2, $finder); // manifest.json + importmap.json + + $fs->remove($projectDir.'/public'); + $fs->remove($projectDir.'/var'); + + static::$kernel->shutdown(); + } + + private static function createRemoteEntry(string $importName, string $version, ?string $path = null): ImportMapEntry + { + return ImportMapEntry::createRemote($importName, ImportMapType::JS, path: $path, version: $version, packageModuleSpecifier: $importName, isEntrypoint: false); + } + + private function trimBeginEndOfEachLine(string $lines): string + { + return trim(implode("\n", array_map('trim', explode("\n", $lines)))); + } +} diff --git a/src/Symfony/Component/AssetMapper/Tests/Compiler/JavaScriptImportPathCompilerTest.php b/src/Symfony/Component/AssetMapper/Tests/Compiler/JavaScriptImportPathCompilerTest.php index 9b1b2377665b1..084b5eefaa216 100644 --- a/src/Symfony/Component/AssetMapper/Tests/Compiler/JavaScriptImportPathCompilerTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/Compiler/JavaScriptImportPathCompilerTest.php @@ -290,15 +290,6 @@ public static function provideCompileTests(): iterable 'expectedJavaScriptImports' => [], ]; - yield 'multi_line_comment_with_no_end_parsed_for_safety' => [ - 'input' => << ['/assets/other.js' => ['lazy' => true, 'asset' => 'other.js', 'add' => true]], - ]; - yield 'multi_line_comment_with_no_end_found_eventually_ignored' => [ 'input' => << + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Tests\Compiler\Parser; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\AssetMapper\Compiler\Parser\JavascriptSequenceParser; + +class JavascriptSequenceParserTest extends TestCase +{ + public function testParseEmptyContent() + { + $parser = new JavascriptSequenceParser(''); + + $this->assertTrue($parser->isExecutable()); + } + + public function testItThrowsWhenOutOfBounds() + { + $parser = new JavascriptSequenceParser(''); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot parse beyond the end of the content.'); + + $parser->parseUntil(1); + } + + public function testItThrowWhenBackward() + { + $parser = new JavascriptSequenceParser(' '); + + $parser->parseUntil(2); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot parse backwards.'); + + $parser->parseUntil(1); + } + + public function testParseToTheEnd() + { + $parser = new JavascriptSequenceParser('123'); + $parser->parseUntil(3); + + $this->assertTrue($parser->isExecutable()); + } + + /** + * @dataProvider provideSequenceCases + */ + public function testParseSequence(string $content, int $position, bool $isExcecutable) + { + $parser = new JavascriptSequenceParser($content); + $parser->parseUntil($position); + + $this->assertSame($isExcecutable, $parser->isExecutable()); + } + + /** + * @return iterable + */ + public static function provideSequenceCases(): iterable + { + yield 'empty' => [ + '', + 0, + true, + ]; + yield 'inline comment' => [ + '//', + 2, + false, + ]; + yield 'comment' => [ + '/* */', + 2, + false, + ]; + yield 'after comment' => [ + '/* */', + 5, + true, + ]; + yield 'multi-line comment' => [ + '/** + abc + */', + 2, + false, + ]; + yield 'after multi-line comment' => [ + "/** \n */ abc", + 8, + true, + ]; + } + + /** + * @dataProvider provideCommentCases + */ + public function testIdentifyComment(string $content, int $position, bool $isComment) + { + $parser = new JavascriptSequenceParser($content); + $parser->parseUntil($position); + + $this->assertSame($isComment, $parser->isComment()); + $this->assertSame(!$isComment, $parser->isExecutable()); + } + + /** + * @return iterable + */ + public static function provideCommentCases(): iterable + { + yield 'empty' => [ + '', + 0, + false, + ]; + yield 'inline comment' => [ + '//', + 2, + true, + ]; + yield 'comment' => [ + '/* */', + 2, + true, + ]; + yield 'multi-line comment' => [ + '/** + abc + */', + 2, + true, + ]; + yield 'after multi-line comment' => [ + "/** \n */ abc", + 8, + false, + ]; + yield 'after comment' => [ + '/* */', + 5, + false, + ]; + yield 'comment after comment' => [ + '/* */ //', + 7, + true, + ]; + yield 'comment after multi-line comment' => [ + '/* */ /**/', + 8, + true, + ]; + yield 'multi-line comment after comment' => [ + '// /* */', + 8, + true, + ]; + } + + /** + * @dataProvider provideStringCases + */ + public function testIdentifyStrings(string $content, int $position, bool $isString) + { + $parser = new JavascriptSequenceParser($content); + $parser->parseUntil($position); + + $this->assertSame($isString, $parser->isString()); + } + + /** + * @return iterable + */ + public static function provideStringCases(): iterable + { + yield 'empty' => [ + '', + 0, + false, + ]; + yield 'before single quote' => [ + " '", + 0, + false, + ]; + yield 'on single quote' => [ + "'", + 0, + true, + ]; + yield 'between single quotes' => [ + "' '", + 2, + true, + ]; + yield 'after single quote' => [ + "'' ", + 3, + false, + ]; + yield 'before double quote' => [ + ' "', + 0, + false, + ]; + yield 'on double quote' => [ + '"', + 0, + true, + ]; + yield 'between double quotes' => [ + '" "', + 2, + true, + ]; + yield 'after double quote' => [ + '"" ', + 3, + false, + ]; + } +} diff --git a/src/Symfony/Component/AssetMapper/Tests/Compressor/BrotliCompressorTest.php b/src/Symfony/Component/AssetMapper/Tests/Compressor/BrotliCompressorTest.php new file mode 100644 index 0000000000000..c56bbe71f760b --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Tests/Compressor/BrotliCompressorTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Tests\Compressor; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\AssetMapper\Compressor\BrotliCompressor; +use Symfony\Component\Filesystem\Filesystem; + +/** + * @author Kévin Dunglas + */ +class BrotliCompressorTest extends TestCase +{ + private const WRITABLE_ROOT = __DIR__.'/../Fixtures/brotli_compressor_filesystem'; + + private Filesystem $filesystem; + + protected function setUp(): void + { + if (null !== $reason = (new BrotliCompressor())->getUnsupportedReason()) { + $this->markTestSkipped($reason); + } + + $this->filesystem = new Filesystem(); + if (!file_exists(self::WRITABLE_ROOT)) { + $this->filesystem->mkdir(self::WRITABLE_ROOT); + } + } + + protected function tearDown(): void + { + $this->filesystem->remove(self::WRITABLE_ROOT); + } + + public function testCompress() + { + $this->filesystem->dumpFile(self::WRITABLE_ROOT.'/foo/bar.js', 'foobar'); + + (new BrotliCompressor())->compress(self::WRITABLE_ROOT.'/foo/bar.js'); + + $this->assertFileExists(self::WRITABLE_ROOT.'/foo/bar.js.br'); + } +} diff --git a/src/Symfony/Component/AssetMapper/Tests/Compressor/ChainCompressorTest.php b/src/Symfony/Component/AssetMapper/Tests/Compressor/ChainCompressorTest.php new file mode 100644 index 0000000000000..fc81c75229109 --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Tests/Compressor/ChainCompressorTest.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Tests\Compressor; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\AssetMapper\Compressor\BrotliCompressor; +use Symfony\Component\AssetMapper\Compressor\ChainCompressor; +use Symfony\Component\AssetMapper\Compressor\GzipCompressor; +use Symfony\Component\AssetMapper\Compressor\ZstandardCompressor; +use Symfony\Component\Filesystem\Filesystem; + +/** + * @author Kévin Dunglas + */ +class ChainCompressorTest extends TestCase +{ + private const WRITABLE_ROOT = __DIR__.'/../Fixtures/chain_compressor_filesystem'; + + private Filesystem $filesystem; + + protected function setUp(): void + { + $this->filesystem = new Filesystem(); + if (!file_exists(self::WRITABLE_ROOT)) { + $this->filesystem->mkdir(self::WRITABLE_ROOT); + } + } + + protected function tearDown(): void + { + $this->filesystem->remove(self::WRITABLE_ROOT); + } + + public function testCompress() + { + $extensions = []; + if (null === (new GzipCompressor())->getUnsupportedReason()) { + $extensions[] = 'gz'; + } + if (null === (new BrotliCompressor())->getUnsupportedReason()) { + $extensions[] = 'br'; + } + if (null === (new ZstandardCompressor())->getUnsupportedReason()) { + $extensions[] = 'zst'; + } + + if (!$extensions) { + $this->markTestSkipped('No supported compressors available.'); + } + + $this->filesystem->dumpFile(self::WRITABLE_ROOT.'/foo/bar.js', 'foobar'); + + (new ChainCompressor())->compress(self::WRITABLE_ROOT.'/foo/bar.js'); + + foreach ($extensions as $extension) { + $this->assertFileExists(self::WRITABLE_ROOT.'/foo/bar.js.'.$extension); + } + } +} diff --git a/src/Symfony/Component/AssetMapper/Tests/Compressor/GzipCompressorTest.php b/src/Symfony/Component/AssetMapper/Tests/Compressor/GzipCompressorTest.php new file mode 100644 index 0000000000000..a5e2c24404a62 --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Tests/Compressor/GzipCompressorTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Tests\Compressor; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\AssetMapper\Compressor\GzipCompressor; +use Symfony\Component\Filesystem\Filesystem; + +/** + * @author Kévin Dunglas + */ +class GzipCompressorTest extends TestCase +{ + private const WRITABLE_ROOT = __DIR__.'/../Fixtures/gzip_compressor_filesystem'; + + private Filesystem $filesystem; + + protected function setUp(): void + { + $this->filesystem = new Filesystem(); + if (!file_exists(self::WRITABLE_ROOT)) { + $this->filesystem->mkdir(self::WRITABLE_ROOT); + } + } + + protected function tearDown(): void + { + $this->filesystem->remove(self::WRITABLE_ROOT); + } + + public function testCompress() + { + $this->filesystem->dumpFile(self::WRITABLE_ROOT.'/foo/bar.js', 'foobar'); + + (new GzipCompressor())->compress(self::WRITABLE_ROOT.'/foo/bar.js'); + + $this->assertFileExists(self::WRITABLE_ROOT.'/foo/bar.js.gz'); + } +} diff --git a/src/Symfony/Component/AssetMapper/Tests/Compressor/ZstandardCompressorTest.php b/src/Symfony/Component/AssetMapper/Tests/Compressor/ZstandardCompressorTest.php new file mode 100644 index 0000000000000..cd6968cbebba6 --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Tests/Compressor/ZstandardCompressorTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Tests\Compressor; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\AssetMapper\Compressor\ZstandardCompressor; +use Symfony\Component\Filesystem\Filesystem; + +/** + * @author Kévin Dunglas + */ +class ZstandardCompressorTest extends TestCase +{ + private const WRITABLE_ROOT = __DIR__.'/../Fixtures/zstandard_compressor_filesystem'; + + private Filesystem $filesystem; + + protected function setUp(): void + { + if (null !== $reason = (new ZstandardCompressor())->getUnsupportedReason()) { + $this->markTestSkipped($reason); + } + + $this->filesystem = new Filesystem(); + if (!file_exists(self::WRITABLE_ROOT)) { + $this->filesystem->mkdir(self::WRITABLE_ROOT); + } + } + + protected function tearDown(): void + { + $this->filesystem->remove(self::WRITABLE_ROOT); + } + + public function testCompress() + { + $this->filesystem->dumpFile(self::WRITABLE_ROOT.'/foo/bar.js', 'foobar'); + + (new ZstandardCompressor())->compress(self::WRITABLE_ROOT.'/foo/bar.js'); + + $this->assertFileExists(self::WRITABLE_ROOT.'/foo/bar.js.zst'); + } +} diff --git a/src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php b/src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php index d5cb45036e81a..f63edd6a7dfd6 100644 --- a/src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/Factory/MappedAssetFactoryTest.php @@ -26,7 +26,8 @@ class MappedAssetFactoryTest extends TestCase { - private const DEFAULT_FIXTURES = __DIR__.'/../Fixtures/assets/vendor'; + private const FIXTURES_DIR = __DIR__.'/../Fixtures'; + private const VENDOR_FIXTURES_DIR = self::FIXTURES_DIR.'/assets/vendor'; private AssetMapperInterface&MockObject $assetMapper; @@ -34,7 +35,7 @@ public function testCreateMappedAsset() { $factory = $this->createFactory(); - $asset = $factory->createMappedAsset('file2.js', __DIR__.'/../Fixtures/dir1/file2.js'); + $asset = $factory->createMappedAsset('file2.js', self::FIXTURES_DIR.'/dir1/file2.js'); $this->assertSame('file2.js', $asset->logicalPath); $this->assertMatchesRegularExpression('/^\/final-assets\/file2-[a-zA-Z0-9]{7,128}\.js$/', $asset->publicPath); $this->assertSame('/final-assets/file2.js', $asset->publicPathWithoutDigest); @@ -43,7 +44,7 @@ public function testCreateMappedAsset() public function testCreateMappedAssetRespectsPreDigestedPaths() { $assetMapper = $this->createFactory(); - $asset = $assetMapper->createMappedAsset('already-abcdefVWXYZ0123456789.digested.css', __DIR__.'/../Fixtures/dir2/already-abcdefVWXYZ0123456789.digested.css'); + $asset = $assetMapper->createMappedAsset('already-abcdefVWXYZ0123456789.digested.css', self::FIXTURES_DIR.'/dir2/already-abcdefVWXYZ0123456789.digested.css'); $this->assertSame('already-abcdefVWXYZ0123456789.digested.css', $asset->logicalPath); $this->assertSame('/final-assets/already-abcdefVWXYZ0123456789.digested.css', $asset->publicPath); // for pre-digested files, the digest *is* part of the public path @@ -67,18 +68,18 @@ public function compile(string $content, MappedAsset $asset, AssetMapperInterfac $assetMapper = $this->createFactory($file1Compiler); $expected = 'totally changed'; - $asset = $assetMapper->createMappedAsset('file1.css', __DIR__.'/../Fixtures/dir1/file1.css'); + $asset = $assetMapper->createMappedAsset('file1.css', self::FIXTURES_DIR.'/dir1/file1.css'); $this->assertSame($expected, $asset->content); // verify internal caching doesn't cause issues - $asset = $assetMapper->createMappedAsset('file1.css', __DIR__.'/../Fixtures/dir1/file1.css'); + $asset = $assetMapper->createMappedAsset('file1.css', self::FIXTURES_DIR.'/dir1/file1.css'); $this->assertSame($expected, $asset->content); } public function testCreateMappedAssetWithContentThatDoesNotChange() { $assetMapper = $this->createFactory(); - $asset = $assetMapper->createMappedAsset('file1.css', __DIR__.'/../Fixtures/dir1/file1.css'); + $asset = $assetMapper->createMappedAsset('file1.css', self::FIXTURES_DIR.'/dir1/file1.css'); // null content because the final content matches the file source $this->assertNull($asset->content); } @@ -89,7 +90,7 @@ public function testCreateMappedAssetWithContentErrorsOnCircularReferences() $this->expectException(CircularAssetsException::class); $this->expectExceptionMessage('Circular reference detected while creating asset for "circular1.css": "circular1.css -> circular2.css -> circular1.css".'); - $factory->createMappedAsset('circular1.css', __DIR__.'/../Fixtures/circular_dir/circular1.css'); + $factory->createMappedAsset('circular1.css', self::FIXTURES_DIR.'/circular_dir/circular1.css'); } public function testCreateMappedAssetWithDigest() @@ -111,7 +112,7 @@ public function compile(string $content, MappedAsset $asset, AssetMapperInterfac }; $factory = $this->createFactory(); - $asset = $factory->createMappedAsset('subdir/file6.js', __DIR__.'/../Fixtures/dir2/subdir/file6.js'); + $asset = $factory->createMappedAsset('subdir/file6.js', self::FIXTURES_DIR.'/dir2/subdir/file6.js'); $this->assertSame('7f983f4053a57f07551fed6099c0da4e', $asset->digest); $this->assertFalse($asset->isPredigested); @@ -119,14 +120,14 @@ public function compile(string $content, MappedAsset $asset, AssetMapperInterfac // since file6.js imports file5.js, the digest for file6 should change, // because, internally, the file path in file6.js to file5.js will need to change $factory = $this->createFactory($file6Compiler); - $asset = $factory->createMappedAsset('subdir/file6.js', __DIR__.'/../Fixtures/dir2/subdir/file6.js'); + $asset = $factory->createMappedAsset('subdir/file6.js', self::FIXTURES_DIR.'/dir2/subdir/file6.js'); $this->assertSame('7e4f24ebddd4ab2a3bcf0d89270b9f30', $asset->digest); } public function testCreateMappedAssetWithPredigested() { $assetMapper = $this->createFactory(); - $asset = $assetMapper->createMappedAsset('already-abcdefVWXYZ0123456789.digested.css', __DIR__.'/../Fixtures/dir2/already-abcdefVWXYZ0123456789.digested.css'); + $asset = $assetMapper->createMappedAsset('already-abcdefVWXYZ0123456789.digested.css', self::FIXTURES_DIR.'/dir2/already-abcdefVWXYZ0123456789.digested.css'); $this->assertSame('abcdefVWXYZ0123456789.digested', $asset->digest); $this->assertTrue($asset->isPredigested); } @@ -134,7 +135,7 @@ public function testCreateMappedAssetWithPredigested() public function testCreateMappedAssetInVendor() { $assetMapper = $this->createFactory(); - $asset = $assetMapper->createMappedAsset('lodash.js', __DIR__.'/../Fixtures/assets/vendor/lodash/lodash.index.js'); + $asset = $assetMapper->createMappedAsset('lodash.js', self::VENDOR_FIXTURES_DIR.'/lodash/lodash.index.js'); $this->assertSame('lodash.js', $asset->logicalPath); $this->assertTrue($asset->isVendor); } @@ -142,12 +143,12 @@ public function testCreateMappedAssetInVendor() public function testCreateMappedAssetInMissingVendor() { $assetMapper = $this->createFactory(null, '/this-path-does-not-exist/'); - $asset = $assetMapper->createMappedAsset('lodash.js', __DIR__.'/../Fixtures/assets/vendor/lodash/lodash.index.js'); + $asset = $assetMapper->createMappedAsset('lodash.js', self::VENDOR_FIXTURES_DIR.'/lodash/lodash.index.js'); $this->assertSame('lodash.js', $asset->logicalPath); $this->assertFalse($asset->isVendor); } - private function createFactory(?AssetCompilerInterface $extraCompiler = null, ?string $vendorDir = self::DEFAULT_FIXTURES): MappedAssetFactory + private function createFactory(?AssetCompilerInterface $extraCompiler = null, ?string $vendorDir = self::VENDOR_FIXTURES_DIR): MappedAssetFactory { $compilers = [ new JavaScriptImportPathCompiler($this->createMock(ImportMapConfigReader::class)), diff --git a/src/Symfony/Component/AssetMapper/Tests/Fixtures/AssetMapperTestAppKernel.php b/src/Symfony/Component/AssetMapper/Tests/Fixtures/AssetMapperTestAppKernel.php index 48958274572d3..426e97b810cfd 100644 --- a/src/Symfony/Component/AssetMapper/Tests/Fixtures/AssetMapperTestAppKernel.php +++ b/src/Symfony/Component/AssetMapper/Tests/Fixtures/AssetMapperTestAppKernel.php @@ -44,7 +44,7 @@ public function registerContainerConfiguration(LoaderInterface $loader): void 'assets' => null, 'asset_mapper' => [ 'paths' => ['dir1', 'dir2', 'non_ascii', 'assets'], - 'public_prefix' => 'assets' + 'public_prefix' => 'assets', ], 'test' => true, ]); diff --git a/src/Symfony/Component/AssetMapper/Tests/Fixtures/ImportMapTestAppKernel.php b/src/Symfony/Component/AssetMapper/Tests/Fixtures/ImportMapTestAppKernel.php new file mode 100644 index 0000000000000..42d4b65d2af6a --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Tests/Fixtures/ImportMapTestAppKernel.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Tests\Fixtures; + +use Psr\Log\NullLogger; +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\Kernel; + +class ImportMapTestAppKernel extends Kernel +{ + public function registerBundles(): iterable + { + return [ + new FrameworkBundle(), + ]; + } + + public function getProjectDir(): string + { + return __DIR__; + } + + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(static function (ContainerBuilder $container) { + $container->loadFromExtension('framework', [ + 'annotations' => false, + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + 'http_client' => true, + 'assets' => null, + 'asset_mapper' => [ + 'paths' => ['assets'], + ], + 'test' => true, + ]); + }); + } + + protected function build(ContainerBuilder $container): void + { + $container->register('logger', NullLogger::class); + } +} diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapOutdatedCommandTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapOutdatedCommandTest.php new file mode 100644 index 0000000000000..cc53f7beeebdd --- /dev/null +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapOutdatedCommandTest.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\AssetMapper\Tests\ImportMap; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\AssetMapper\Command\ImportMapOutdatedCommand; +use Symfony\Component\AssetMapper\ImportMap\ImportMapUpdateChecker; +use Symfony\Component\Console\Tester\CommandTester; + +class ImportMapOutdatedCommandTest extends TestCase +{ + /** + * @dataProvider provideNoOutdatedPackageCases + */ + public function testCommandWhenNoOutdatedPackages(string $display, ?string $format = null) + { + $updateChecker = $this->createMock(ImportMapUpdateChecker::class); + $command = new ImportMapOutdatedCommand($updateChecker); + + $commandTester = new CommandTester($command); + $commandTester->execute(\is_string($format) ? ['--format' => $format] : []); + + $commandTester->assertCommandIsSuccessful(); + $this->assertEquals($display, trim($commandTester->getDisplay(true))); + } + + /** + * @return iterable + */ + public static function provideNoOutdatedPackageCases(): iterable + { + yield 'default' => ['No updates found.', null]; + yield 'txt' => ['No updates found.', 'txt']; + yield 'json' => ['[]', 'json']; + } +} diff --git a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapRendererTest.php b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapRendererTest.php index a4770635c4e6d..ef519ff719b4b 100644 --- a/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapRendererTest.php +++ b/src/Symfony/Component/AssetMapper/Tests/ImportMap/ImportMapRendererTest.php @@ -132,10 +132,9 @@ public function testCustomScriptAttributes() ]); $html = $renderer->render([]); $this->assertStringContainsString('