From c0faf046dbcc8c792956491bd9879bcce57eb9d5 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 15 Jan 2024 22:04:56 +0100 Subject: [PATCH] feat(symfony): request and view kernel listeners --- .github/workflows/ci.yml | 71 +++++ behat.yml.dist | 36 ++- features/jsonld/input_output.feature | 2 + ...tion.feature => custom_controller.feature} | 1 + phpstan.neon.dist | 1 + src/Documentation/Action/EntrypointAction.php | 8 +- .../EventListener/AddLinkHeaderListener.php | 2 + src/State/Processor/SerializeProcessor.php | 10 +- src/State/Processor/WriteProcessor.php | 12 +- .../Provider/ContentNegotiationProvider.php | 6 +- src/State/Provider/DeserializeProvider.php | 8 +- .../ApiPlatformExtension.php | 103 +++++-- .../DependencyInjection/Configuration.php | 4 +- src/Symfony/Bundle/Resources/config/api.xml | 51 +--- .../Resources/config/http_cache_purger.xml | 6 - src/Symfony/Bundle/Resources/config/hydra.xml | 9 - .../Bundle/Resources/config/jsonapi.xml | 5 - .../Bundle/Resources/config/jsonld.xml | 11 - .../Resources/config/metadata/resource.xml | 2 +- .../Bundle/Resources/config/problem.xml | 7 + .../Bundle/Resources/config/security.xml | 11 - .../config/state/http_cache_purger.xml | 12 + .../Bundle/Resources/config/state/hydra.xml | 12 + .../Bundle/Resources/config/state/jsonapi.xml | 12 + .../Bundle/Resources/config/state/jsonld.xml | 16 + .../Resources/config/{ => state}/mercure.xml | 4 +- .../Resources/config/state/processor.xml | 30 ++ .../Resources/config/state/provider.xml | 42 +++ .../Resources/config/state/security.xml | 18 ++ .../{symfony => state}/security_validator.xml | 0 .../Resources/config/{ => state}/state.xml | 44 --- .../Resources/config/state/swagger_ui.xml | 12 + .../Bundle/Resources/config/swagger_ui.xml | 5 - .../Resources/config/symfony/controller.xml | 19 ++ .../Resources/config/symfony/events.xml | 162 ++++++++-- .../Resources/config/symfony/jsonld.xml | 15 + .../Resources/config/symfony/swagger_ui.xml | 12 + .../Resources/config/symfony/symfony.xml | 38 +++ .../Resources/config/symfony/validator.xml | 40 --- .../Resources/config/validator/events.xml | 35 +++ .../Resources/config/validator/state.xml | 18 ++ .../Resources/config/validator/validator.xml | 22 ++ .../Bundle/SwaggerUi/SwaggerUiProvider.php | 5 +- .../EventListener/AddFormatListener.php | 45 ++- .../EventListener/AddHeadersListener.php | 2 + .../EventListener/AddLinkHeaderListener.php | 2 + src/Symfony/EventListener/AddTagsListener.php | 1 + .../EventListener/DenyAccessListener.php | 2 + .../EventListener/DeserializeListener.php | 53 +++- src/Symfony/EventListener/ErrorListener.php | 103 ++++--- .../QueryParameterValidateListener.php | 28 +- src/Symfony/EventListener/ReadListener.php | 39 ++- src/Symfony/EventListener/RespondListener.php | 37 ++- .../EventListener/SerializeListener.php | 55 +++- .../EventListener/ValidateListener.php | 28 +- src/Symfony/EventListener/WriteListener.php | 65 +++- .../ApiPlatformExtensionTest.php | 286 ++++++++++++++++++ .../EventListener/AddFormatListenerTest.php | 92 ++++++ .../EventListener/DeserializeListenerTest.php | 129 ++++++++ .../EventListener/ErrorListenerTest.php | 117 +++---- .../QueryParameterValidateListenerTest.php | 43 +++ .../Tests/EventListener/ReadListenerTest.php | 150 +++++++++ .../EventListener/RespondListenerTest.php | 123 ++++++++ .../EventListener/SerializeListenerTest.php | 122 ++++++++ .../EventListener/ValidateListenerTest.php | 167 ++++++++++ .../Tests/EventListener/WriteListenerTest.php | 150 +++++++++ .../Exception/ValidationException.php | 3 +- .../State/QueryParameterValidateProvider.php | 8 +- .../Validator/State/ValidateProvider.php | 4 +- src/Symfony/composer.json | 15 +- src/Symfony/phpunit.xml.dist | 46 ++- tests/.ignored-deprecations-legacy-events | 22 ++ tests/Fixtures/app/AppKernel.php | 10 +- tests/Fixtures/app/config/config_common.yml | 1 - .../ApiPlatformExtensionTest.php | 7 + .../DependencyInjection/ConfigurationTest.php | 3 +- .../EventListener/AddFormatListenerTest.php | 2 + .../EventListener/DeserializeListenerTest.php | 2 + .../EventListener/ReadListenerTest.php | 10 +- .../EventListener/RespondListenerTest.php | 2 + .../EventListener/SerializeListenerTest.php | 2 + .../EventListener/WriteListenerTest.php | 3 + 82 files changed, 2512 insertions(+), 406 deletions(-) rename features/main/{custom_operation.feature => custom_controller.feature} (99%) create mode 100644 src/Symfony/Bundle/Resources/config/state/http_cache_purger.xml create mode 100644 src/Symfony/Bundle/Resources/config/state/hydra.xml create mode 100644 src/Symfony/Bundle/Resources/config/state/jsonapi.xml create mode 100644 src/Symfony/Bundle/Resources/config/state/jsonld.xml rename src/Symfony/Bundle/Resources/config/{ => state}/mercure.xml (86%) create mode 100644 src/Symfony/Bundle/Resources/config/state/processor.xml create mode 100644 src/Symfony/Bundle/Resources/config/state/provider.xml create mode 100644 src/Symfony/Bundle/Resources/config/state/security.xml rename src/Symfony/Bundle/Resources/config/{symfony => state}/security_validator.xml (100%) rename src/Symfony/Bundle/Resources/config/{ => state}/state.xml (54%) create mode 100644 src/Symfony/Bundle/Resources/config/state/swagger_ui.xml create mode 100644 src/Symfony/Bundle/Resources/config/symfony/jsonld.xml create mode 100644 src/Symfony/Bundle/Resources/config/symfony/swagger_ui.xml create mode 100644 src/Symfony/Bundle/Resources/config/symfony/symfony.xml delete mode 100644 src/Symfony/Bundle/Resources/config/symfony/validator.xml create mode 100644 src/Symfony/Bundle/Resources/config/validator/events.xml create mode 100644 src/Symfony/Bundle/Resources/config/validator/state.xml create mode 100644 src/Symfony/Bundle/Resources/config/validator/validator.xml create mode 100644 src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php create mode 100644 src/Symfony/Tests/EventListener/AddFormatListenerTest.php create mode 100644 src/Symfony/Tests/EventListener/DeserializeListenerTest.php rename {tests/Symfony => src/Symfony/Tests}/EventListener/ErrorListenerTest.php (52%) create mode 100644 src/Symfony/Tests/EventListener/ReadListenerTest.php create mode 100644 src/Symfony/Tests/EventListener/RespondListenerTest.php create mode 100644 src/Symfony/Tests/EventListener/SerializeListenerTest.php create mode 100644 src/Symfony/Tests/EventListener/ValidateListenerTest.php create mode 100644 src/Symfony/Tests/EventListener/WriteListenerTest.php create mode 100644 tests/.ignored-deprecations-legacy-events diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6659b7a832f..bea441d55b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1127,6 +1127,8 @@ jobs: run: vendor/bin/simple-phpunit --version - name: Clear test app cache run: tests/Fixtures/app/console cache:clear --ansi + - name: Use legacy ignored deprecations + run: cp tests/.ignored-deprecations-legacy-events tests/.ignored-deprecations - name: Run PHPUnit tests run: | mkdir -p build/logs/phpunit @@ -1229,3 +1231,72 @@ jobs: name: openapi-docs-php${{ matrix.php }} path: build/out/openapi continue-on-error: true + + behat_listeners: + name: Behat event listeners (PHP ${{ matrix.php }}) + env: + USE_SYMFONY_LISTENERS: 1 + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + matrix: + php: + - '8.3' + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: pecl, composer + extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite + coverage: pcov + ini-values: memory_limit=-1 + - name: Get composer cache directory + id: composercache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: ${{ steps.composercache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + - name: Update project dependencies + run: composer update --no-interaction --no-progress --ansi + - name: Install PHPUnit + run: vendor/bin/simple-phpunit --version + - name: Clear test app cache + run: tests/Fixtures/app/console cache:clear --ansi + - name: Run Behat tests (PHP 8) + run: | + mkdir -p build/logs/behat + vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --profile=symfony_listeners --no-interaction + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@v3 + with: + name: behat-logs-php${{ matrix.php }} + path: build/logs/behat + continue-on-error: true + - name: Export OpenAPI documents + run: | + mkdir -p build/out/openapi + tests/Fixtures/app/console api:openapi:export -o build/out/openapi/openapi_v3.json + tests/Fixtures/app/console api:openapi:export --yaml -o build/out/openapi/openapi_v3.yaml + - name: Setup node + uses: actions/setup-node@v3 + with: + node-version: '14' + - name: Validate OpenAPI documents + run: | + npx git+https://github.com/soyuka/swagger-cli#master validate build/out/openapi/openapi_v3.json + npx git+https://github.com/soyuka/swagger-cli#master validate build/out/openapi/openapi_v3.yaml + - name: Upload OpenAPI artifacts + if: always() + uses: actions/upload-artifact@v3 + with: + name: openapi-docs-php${{ matrix.php }} + path: build/out/openapi + continue-on-error: true diff --git a/behat.yml.dist b/behat.yml.dist index 85d96bf6747..4a6d7330aff 100644 --- a/behat.yml.dist +++ b/behat.yml.dist @@ -73,7 +73,7 @@ mongodb: - 'Behat\MinkExtension\Context\MinkContext' - 'behatch:context:rest' filters: - tags: '~@sqlite&&~@elasticsearch&&~@!mongodb&&~@mercure' + tags: '~@sqlite&&~@elasticsearch&&~@!mongodb&&~@mercure&&~@controller' mercure: suites: @@ -220,3 +220,37 @@ legacy: symfony: ~ 'Behatch\Extension': ~ +symfony_listeners: + suites: + default: + contexts: + - 'ApiPlatform\Tests\Behat\CommandContext' + - 'ApiPlatform\Tests\Behat\DoctrineContext' + - 'ApiPlatform\Tests\Behat\GraphqlContext' + - 'ApiPlatform\Tests\Behat\JsonContext' + - 'ApiPlatform\Tests\Behat\HydraContext' + - 'ApiPlatform\Tests\Behat\OpenApiContext' + - 'ApiPlatform\Tests\Behat\HttpCacheContext' + - 'ApiPlatform\Tests\Behat\JsonApiContext' + - 'ApiPlatform\Tests\Behat\JsonHalContext' + - 'ApiPlatform\Tests\Behat\MercureContext' + - 'ApiPlatform\Tests\Behat\XmlContext' + - 'Behat\MinkExtension\Context\MinkContext' + - 'behatch:context:rest' + filters: + tags: '~@postgres&&~@mongodb&&~@elasticsearch&&~@mercure' + extensions: + 'FriendsOfBehat\SymfonyExtension': + bootstrap: 'tests/Fixtures/app/bootstrap.php' + kernel: + environment: 'test' + debug: true + class: AppKernel + path: 'tests/Fixtures/app/AppKernel.php' + 'Behat\MinkExtension': + base_url: 'http://example.com/' + files_path: 'features/files' + sessions: + default: + symfony: ~ + 'Behatch\Extension': ~ diff --git a/features/jsonld/input_output.feature b/features/jsonld/input_output.feature index 4cf2004fa56..73b1ba6e34c 100644 --- a/features/jsonld/input_output.feature +++ b/features/jsonld/input_output.feature @@ -186,6 +186,7 @@ Feature: JSON-LD DTO input and output """ @createSchema + @controller Scenario: Create a resource with no input When I send a "POST" request to "/dummy_dto_no_inputs" Then the response status code should be 201 @@ -210,6 +211,7 @@ Feature: JSON-LD DTO input and output } """ + @controller Scenario: Update a resource with no input When I send a "POST" request to "/dummy_dto_no_inputs/1/double_bat" Then the response status code should be 200 diff --git a/features/main/custom_operation.feature b/features/main/custom_controller.feature similarity index 99% rename from features/main/custom_operation.feature rename to features/main/custom_controller.feature index bcea29860cc..16516099e53 100644 --- a/features/main/custom_operation.feature +++ b/features/main/custom_controller.feature @@ -1,3 +1,4 @@ +@controller Feature: Custom operation As a client software developer I need to be able to create custom operations diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 705a297ce85..baf9ea0b4bd 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -100,3 +100,4 @@ parameters: - '#Call to method hasCacheableSupportsMethod\(\) on an unknown class Symfony\\Component\\Serializer\\Normalizer\\CacheableSupportsMethodInterface\.#' - '#Class Symfony\\Component\\Serializer\\Normalizer\\CacheableSupportsMethodInterface not found\.#' - '#Access to undefined constant Symfony\\Component\\HttpKernel\\HttpKernelInterface::MASTER_REQUEST\.#' + - '#Attribute class PHPUnit\\Framework\\Attributes\\DataProvider does not exist.#' diff --git a/src/Documentation/Action/EntrypointAction.php b/src/Documentation/Action/EntrypointAction.php index d146720a3df..dd6204f67d1 100644 --- a/src/Documentation/Action/EntrypointAction.php +++ b/src/Documentation/Action/EntrypointAction.php @@ -47,7 +47,13 @@ public function __invoke(Request $request) 'spec_version' => (string) $request->query->get(LegacyOpenApiNormalizer::SPEC_VERSION), ]; $request->attributes->set('_api_platform_disable_listeners', true); - $operation = new Get(outputFormats: $this->documentationFormats, read: true, serialize: true, class: Entrypoint::class, provider: [self::class, 'provide']); + $operation = new Get( + outputFormats: $this->documentationFormats, + read: true, + serialize: true, + class: Entrypoint::class, + provider: [self::class, 'provide'] + ); $request->attributes->set('_api_operation', $operation); $body = $this->provider->provide($operation, [], $context); $operation = $request->attributes->get('_api_operation'); diff --git a/src/Hydra/EventListener/AddLinkHeaderListener.php b/src/Hydra/EventListener/AddLinkHeaderListener.php index 45bd56a0ba6..397ab5ea2c4 100644 --- a/src/Hydra/EventListener/AddLinkHeaderListener.php +++ b/src/Hydra/EventListener/AddLinkHeaderListener.php @@ -25,6 +25,8 @@ /** * Adds the HTTP Link header pointing to the Hydra documentation. * + * @deprecated use ApiPlatform\Hydra\State\HydraLinkProcessor instead + * * @author Kévin Dunglas */ final class AddLinkHeaderListener diff --git a/src/State/Processor/SerializeProcessor.php b/src/State/Processor/SerializeProcessor.php index a2cc9da24dd..63d395773f8 100644 --- a/src/State/Processor/SerializeProcessor.php +++ b/src/State/Processor/SerializeProcessor.php @@ -36,16 +36,16 @@ final class SerializeProcessor implements ProcessorInterface { /** - * @param ProcessorInterface $processor + * @param ProcessorInterface|null $processor */ - public function __construct(private readonly ProcessorInterface $processor, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder) + public function __construct(private readonly ?ProcessorInterface $processor, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder) { } public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) { if ($data instanceof Response || !$operation->canSerialize() || !($request = $context['request'] ?? null)) { - return $this->processor->process($data, $operation, $uriVariables, $context); + return $this->processor ? $this->processor->process($data, $operation, $uriVariables, $context) : $data; } // @see ApiPlatform\State\Processor\RespondProcessor @@ -59,7 +59,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $serializerContext['uri_variables'] = $uriVariables; if (isset($serializerContext['output']) && \array_key_exists('class', $serializerContext['output']) && null === $serializerContext['output']['class']) { - return $this->processor->process(null, $operation, $uriVariables, $context); + return $this->processor ? $this->processor->process(null, $operation, $uriVariables, $context) : null; } $resources = new ResourceList(); @@ -80,6 +80,6 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $request->attributes->set('_api_platform_links', $linkProvider); } - return $this->processor->process($serialized, $operation, $uriVariables, $context); + return $this->processor ? $this->processor->process($serialized, $operation, $uriVariables, $context) : $serialized; } } diff --git a/src/State/Processor/WriteProcessor.php b/src/State/Processor/WriteProcessor.php index b449d875c13..ed378372743 100644 --- a/src/State/Processor/WriteProcessor.php +++ b/src/State/Processor/WriteProcessor.php @@ -34,10 +34,10 @@ final class WriteProcessor implements ProcessorInterface use ClassInfoTrait; /** - * @param ProcessorInterface $processor - * @param ProcessorInterface $callableProcessor + * @param ProcessorInterface $processor + * @param ProcessorInterface $callableProcessor */ - public function __construct(private readonly ProcessorInterface $processor, private readonly ProcessorInterface $callableProcessor) + public function __construct(private readonly ?ProcessorInterface $processor, private readonly ProcessorInterface $callableProcessor) { } @@ -48,9 +48,11 @@ public function process(mixed $data, Operation $operation, array $uriVariables = || !($operation->canWrite() ?? true) || !$operation->getProcessor() ) { - return $this->processor->process($data, $operation, $uriVariables, $context); + return $this->processor ? $this->processor->process($data, $operation, $uriVariables, $context) : $data; } - return $this->processor->process($this->callableProcessor->process($data, $operation, $uriVariables, $context), $operation, $uriVariables, $context); + $data = $this->callableProcessor->process($data, $operation, $uriVariables, $context); + + return $this->processor ? $this->processor->process($data, $operation, $uriVariables, $context) : $data; } } diff --git a/src/State/Provider/ContentNegotiationProvider.php b/src/State/Provider/ContentNegotiationProvider.php index 05da2a02b65..8f2b7e87aa0 100644 --- a/src/State/Provider/ContentNegotiationProvider.php +++ b/src/State/Provider/ContentNegotiationProvider.php @@ -30,7 +30,7 @@ final class ContentNegotiationProvider implements ProviderInterface * @param array $formats * @param array $errorFormats */ - public function __construct(private readonly ProviderInterface $decorated, Negotiator $negotiator = null, private readonly array $formats = [], private readonly array $errorFormats = []) + public function __construct(private readonly ?ProviderInterface $decorated = null, Negotiator $negotiator = null, private readonly array $formats = [], private readonly array $errorFormats = []) { $this->negotiator = $negotiator ?? new Negotiator(); } @@ -38,7 +38,7 @@ public function __construct(private readonly ProviderInterface $decorated, Negot public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { if (!($request = $context['request'] ?? null) || !$operation instanceof HttpOperation) { - return $this->decorated->provide($operation, $uriVariables, $context); + return $this->decorated?->provide($operation, $uriVariables, $context); } $isErrorOperation = $operation instanceof ErrorOperation; @@ -53,7 +53,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $request->setRequestFormat($this->getRequestFormat($request, $formats, false)); } - return $this->decorated->provide($operation, $uriVariables, $context); + return $this->decorated?->provide($operation, $uriVariables, $context); } /** diff --git a/src/State/Provider/DeserializeProvider.php b/src/State/Provider/DeserializeProvider.php index 09a3f7b7f51..b4b4131d47e 100644 --- a/src/State/Provider/DeserializeProvider.php +++ b/src/State/Provider/DeserializeProvider.php @@ -32,7 +32,7 @@ final class DeserializeProvider implements ProviderInterface { - public function __construct(private readonly ProviderInterface $decorated, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder, private ?TranslatorInterface $translator = null) + public function __construct(private readonly ?ProviderInterface $decorated, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder, private ?TranslatorInterface $translator = null) { if (null === $this->translator) { $this->translator = new class() implements TranslatorInterface, LocaleAwareInterface { @@ -44,13 +44,13 @@ public function __construct(private readonly ProviderInterface $decorated, priva public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - $data = $this->decorated->provide($operation, $uriVariables, $context); - // We need request content if (!$operation instanceof HttpOperation || !($request = $context['request'] ?? null)) { - return $data; + return $this->decorated?->provide($operation, $uriVariables, $context); } + $data = $this->decorated ? $this->decorated->provide($operation, $uriVariables, $context) : $request->attributes->get('data'); + if (!$operation->canDeserialize()) { return $data; } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 97af318774e..521ac8c773e 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -29,6 +29,7 @@ use ApiPlatform\GraphQl\Resolver\QueryCollectionResolverInterface; use ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface; use ApiPlatform\GraphQl\Type\Definition\TypeInterface as GraphQlTypeInterface; +use ApiPlatform\Hydra\EventListener\AddLinkHeaderListener as HydraAddLinkHeaderListener; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\FilterInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; @@ -36,6 +37,10 @@ use ApiPlatform\State\ApiResource\Error; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Symfony\EventListener\AddHeadersListener; +use ApiPlatform\Symfony\EventListener\AddLinkHeaderListener; +use ApiPlatform\Symfony\EventListener\AddTagsListener; +use ApiPlatform\Symfony\EventListener\DenyAccessListener; use ApiPlatform\Symfony\GraphQl\Resolver\Factory\DataCollectorResolverFactory; use ApiPlatform\Symfony\Validator\Exception\ValidationException; use ApiPlatform\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRestrictionMetadataInterface; @@ -182,10 +187,9 @@ public function load(array $configs, ContainerBuilder $container): void private function registerCommonConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader, array $formats, array $patchFormats, array $errorFormats, array $docsFormats): void { - $loader->load('symfony/events.xml'); - $loader->load('symfony/controller.xml'); + $loader->load('state/state.xml'); + $loader->load('symfony/symfony.xml'); $loader->load('api.xml'); - $loader->load('state.xml'); $loader->load('filter.xml'); if (class_exists(Uuid::class)) { @@ -198,7 +202,29 @@ private function registerCommonConfiguration(ContainerBuilder $container, array // TODO: remove in 4.x $container->setParameter('api_platform.event_listeners_backward_compatibility_layer', $config['event_listeners_backward_compatibility_layer']); - $loader->load('legacy/events.xml'); + $container->setParameter('api_platform.use_symfony_listeners', $config['use_symfony_listeners']); + + if ($config['event_listeners_backward_compatibility_layer']) { + trigger_deprecation('api-platform/core', '3.3', sprintf('The "event_listeners_backward_compatibility_layer" will be removed in 4.0. Use the configuration "use_symfony_listeners" to use Symfony listeners. The following listeners are deprecated and will be removed in API Platform 4.0: "%s"', implode(', ', [ + AddHeadersListener::class, + AddTagsListener::class, + AddLinkHeaderListener::class, + HydraAddLinkHeaderListener::class, + DenyAccessListener::class, + ]))); + } + + if ($config['event_listeners_backward_compatibility_layer']) { + $loader->load('legacy/events.xml'); + } + + if ($config['use_symfony_listeners']) { + $loader->load('symfony/events.xml'); + } else { + $loader->load('symfony/controller.xml'); + $loader->load('state/provider.xml'); + $loader->load('state/processor.xml'); + } $container->setParameter('api_platform.enable_entrypoint', $config['enable_entrypoint']); $container->setParameter('api_platform.enable_docs', $config['enable_docs']); @@ -476,7 +502,15 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array $loader->load('openapi.xml'); $loader->load('swagger_ui.xml'); - $loader->load('legacy/swagger_ui.xml'); + if ($config['event_listeners_backward_compatibility_layer']) { + $loader->load('legacy/swagger_ui.xml'); + } + + if ($config['use_symfony_listeners']) { + $loader->load('symfony/swagger_ui.xml'); + } + + $loader->load('state/swagger_ui.xml'); if (!$config['enable_swagger_ui'] && !$config['enable_re_doc']) { // Remove the listener but keep the controller to allow customizing the path of the UI @@ -498,8 +532,12 @@ private function registerJsonApiConfiguration(array $formats, XmlFileLoader $loa return; } + if ($config['event_listeners_backward_compatibility_layer']) { + $loader->load('legacy/jsonapi.xml'); + } + $loader->load('jsonapi.xml'); - $loader->load('legacy/jsonapi.xml'); + $loader->load('state/jsonapi.xml'); } private function registerJsonLdHydraConfiguration(ContainerBuilder $container, array $formats, XmlFileLoader $loader, array $config): void @@ -508,8 +546,18 @@ private function registerJsonLdHydraConfiguration(ContainerBuilder $container, a return; } + if ($config['use_symfony_listeners']) { + $loader->load('symfony/jsonld.xml'); + } else { + $loader->load('state/jsonld.xml'); + } + + if ($config['event_listeners_backward_compatibility_layer']) { + $loader->load('legacy/hydra.xml'); + } + + $loader->load('state/hydra.xml'); $loader->load('jsonld.xml'); - $loader->load('legacy/hydra.xml'); $loader->load('hydra.xml'); if (!$container->has('api_platform.json_schema.schema_factory')) { @@ -588,7 +636,7 @@ private function registerGraphQlConfiguration(ContainerBuilder $container, array ->addTag('api_platform.graphql.error_handler'); /* TODO: remove these in 4.x only one resolver factory is used and we're using providers/processors */ - if ($config['event_listeners_backward_compatibility_layer'] ?? true) { + if ($config['event_listeners_backward_compatibility_layer']) { // @TODO: API Platform 3.3 trigger_deprecation('api-platform/core', '3.3', 'In API Platform 4 only one factory "api_platform.graphql.resolver.factory.item" will remain. Stages are deprecated in favor of using a provider/processor.'); // + deprecate every service from legacy/graphql.xml $loader->load('legacy/graphql.xml'); @@ -683,7 +731,10 @@ private function registerDoctrineMongoDbOdmConfiguration(ContainerBuilder $conta private function registerHttpCacheConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader): void { $loader->load('http_cache.xml'); - $loader->load('legacy/http_cache.xml'); + + if ($config['event_listeners_backward_compatibility_layer']) { + $loader->load('legacy/http_cache.xml'); + } if (!$this->isConfigEnabled($container, $config['http_cache']['invalidation'])) { return; @@ -693,8 +744,12 @@ private function registerHttpCacheConfiguration(ContainerBuilder $container, arr $loader->load('doctrine_orm_http_cache_purger.xml'); } + if ($config['event_listeners_backward_compatibility_layer']) { + $loader->load('legacy/http_cache_purger.xml'); + } + + $loader->load('state/http_cache_purger.xml'); $loader->load('http_cache_purger.xml'); - $loader->load('legacy/http_cache_purger.xml'); foreach ($config['http_cache']['invalidation']['scoped_clients'] as $client) { $definition = $container->getDefinition($client); @@ -738,18 +793,22 @@ private function registerValidatorConfiguration(ContainerBuilder $container, arr { if (interface_exists(ValidatorInterface::class)) { $loader->load('metadata/validator.xml'); - $loader->load('symfony/validator.xml'); + $loader->load('validator/validator.xml'); if ($this->isConfigEnabled($container, $config['graphql'])) { $loader->load('graphql/validator.xml'); } + if ($config['event_listeners_backward_compatibility_layer']) { + $loader->load('legacy/validator.xml'); + } + + $loader->load($config['use_symfony_listeners'] ? 'validator/events.xml' : 'validator/state.xml'); + $container->registerForAutoconfiguration(ValidationGroupsGeneratorInterface::class) ->addTag('api_platform.validation_groups_generator'); $container->registerForAutoconfiguration(PropertySchemaRestrictionMetadataInterface::class) ->addTag('api_platform.metadata.property_schema_restriction'); - - $loader->load('legacy/validator.xml'); } if (!$config['validator']) { @@ -786,8 +845,11 @@ private function registerMercureConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.mercure.include_type', $config['mercure']['include_type']); - $loader->load('legacy/mercure.xml'); - $loader->load('mercure.xml'); + if ($config['event_listeners_backward_compatibility_layer']) { + $loader->load('legacy/mercure.xml'); + } + + $loader->load('state/mercure.xml'); if ($this->isConfigEnabled($container, $config['doctrine'])) { $loader->load('doctrine_orm_mercure_publisher.xml'); @@ -847,10 +909,15 @@ private function registerSecurityConfiguration(ContainerBuilder $container, arra } $loader->load('security.xml'); - $loader->load('legacy/security.xml'); - if (interface_exists(ValidatorInterface::class)) { - $loader->load('symfony/security_validator.xml'); + if ($config['event_listeners_backward_compatibility_layer']) { + $loader->load('legacy/security.xml'); + } + + $loader->load('state/security.xml'); + + if (interface_exists(ValidatorInterface::class) && !$config['use_symfony_listeners']) { + $loader->load('state/security_validator.xml'); } if ($this->isConfigEnabled($container, $config['graphql'])) { diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 33e4b9f5070..459b0869cd3 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; use ApiPlatform\ParameterValidator\Exception\ValidationExceptionInterface; +use ApiPlatform\Symfony\Controller\MainController; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Doctrine\Bundle\MongoDBBundle\DoctrineMongoDBBundle; use Doctrine\ORM\EntityManagerInterface; @@ -83,7 +84,8 @@ public function getConfigTreeBuilder(): TreeBuilder ->defaultValue('0.0.0') ->end() ->booleanNode('show_webby')->defaultTrue()->info('If true, show Webby on the documentation page')->end() - ->booleanNode('event_listeners_backward_compatibility_layer')->defaultTrue()->info('If true API Platform uses Symfony event listeners instead of providers and processors.')->end() // TODO: Add link to the documentation + ->booleanNode('event_listeners_backward_compatibility_layer')->defaultNull()->info('If true API Platform uses Symfony event listeners instead of providers and processors.')->end() // TODO: Add link to the documentation + ->booleanNode('use_symfony_listeners')->defaultFalse()->info(sprintf('Uses Symfony event listeners instead of the %s.', MainController::class))->end() // TODO: Add link to the documentation ->scalarNode('name_converter')->defaultNull()->info('Specify a name converter to use.')->end() ->scalarNode('asset_package')->defaultNull()->info('Specify an asset package name to use.')->end() ->scalarNode('path_segment_name_generator')->defaultValue('api_platform.metadata.path_segment_name_generator.underscore')->info('Specify a path name generator to use.')->end() diff --git a/src/Symfony/Bundle/Resources/config/api.xml b/src/Symfony/Bundle/Resources/config/api.xml index a6261004a66..7efa4318d10 100644 --- a/src/Symfony/Bundle/Resources/config/api.xml +++ b/src/Symfony/Bundle/Resources/config/api.xml @@ -5,6 +5,10 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> + + + + @@ -84,39 +88,6 @@ - - - - - - - - - - - - - - - - - - - %api_platform.docs_formats% - - - - - %api_platform.title% - %api_platform.description% - %api_platform.version% - - - - - %api_platform.docs_formats% - - %api_platform.error_formats% @@ -191,20 +162,6 @@ - - api_platform.symfony.main_controller - - %kernel.debug% - - - %api_platform.error_formats% - %api_platform.exception_to_status% - null - - null - %api_platform.rfc_7807_compliant_errors% - - %kernel.debug% diff --git a/src/Symfony/Bundle/Resources/config/http_cache_purger.xml b/src/Symfony/Bundle/Resources/config/http_cache_purger.xml index 5dc9c5068e2..f320deffcf1 100644 --- a/src/Symfony/Bundle/Resources/config/http_cache_purger.xml +++ b/src/Symfony/Bundle/Resources/config/http_cache_purger.xml @@ -18,11 +18,5 @@ %api_platform.http_cache.invalidation.max_header_length% - - - - - - diff --git a/src/Symfony/Bundle/Resources/config/hydra.xml b/src/Symfony/Bundle/Resources/config/hydra.xml index 9e57f41afe3..bb8aedf625c 100644 --- a/src/Symfony/Bundle/Resources/config/hydra.xml +++ b/src/Symfony/Bundle/Resources/config/hydra.xml @@ -19,16 +19,7 @@ - - - - - - - - - %api_platform.validator.serialize_payload_fields% diff --git a/src/Symfony/Bundle/Resources/config/jsonapi.xml b/src/Symfony/Bundle/Resources/config/jsonapi.xml index 05575e32d87..671332e8784 100644 --- a/src/Symfony/Bundle/Resources/config/jsonapi.xml +++ b/src/Symfony/Bundle/Resources/config/jsonapi.xml @@ -15,11 +15,6 @@ - - - %api_platform.collection.order_parameter_name% - - diff --git a/src/Symfony/Bundle/Resources/config/jsonld.xml b/src/Symfony/Bundle/Resources/config/jsonld.xml index 72816fd74d7..cb44873f4d1 100644 --- a/src/Symfony/Bundle/Resources/config/jsonld.xml +++ b/src/Symfony/Bundle/Resources/config/jsonld.xml @@ -57,17 +57,6 @@ - - - - - - - - - - - diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource.xml b/src/Symfony/Bundle/Resources/config/metadata/resource.xml index 0228f9b09e0..55fc4c58a7a 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource.xml +++ b/src/Symfony/Bundle/Resources/config/metadata/resource.xml @@ -37,7 +37,7 @@ - %api_platform.event_listeners_backward_compatibility_layer% + %api_platform.use_symfony_listeners% diff --git a/src/Symfony/Bundle/Resources/config/problem.xml b/src/Symfony/Bundle/Resources/config/problem.xml index d0e3cd940a9..b0d8032b94f 100644 --- a/src/Symfony/Bundle/Resources/config/problem.xml +++ b/src/Symfony/Bundle/Resources/config/problem.xml @@ -19,6 +19,13 @@ + + + + + + + %kernel.debug% diff --git a/src/Symfony/Bundle/Resources/config/security.xml b/src/Symfony/Bundle/Resources/config/security.xml index 00b5ca0ec98..8841b9a27bb 100644 --- a/src/Symfony/Bundle/Resources/config/security.xml +++ b/src/Symfony/Bundle/Resources/config/security.xml @@ -16,17 +16,6 @@ - - - - - - - - - post_denormalize - - diff --git a/src/Symfony/Bundle/Resources/config/state/http_cache_purger.xml b/src/Symfony/Bundle/Resources/config/state/http_cache_purger.xml new file mode 100644 index 00000000000..487cd8625b2 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/state/http_cache_purger.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/state/hydra.xml b/src/Symfony/Bundle/Resources/config/state/hydra.xml new file mode 100644 index 00000000000..0ccca8450ca --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/state/hydra.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/state/jsonapi.xml b/src/Symfony/Bundle/Resources/config/state/jsonapi.xml new file mode 100644 index 00000000000..66343fa4fdf --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/state/jsonapi.xml @@ -0,0 +1,12 @@ + + + + + + + %api_platform.collection.order_parameter_name% + + + diff --git a/src/Symfony/Bundle/Resources/config/state/jsonld.xml b/src/Symfony/Bundle/Resources/config/state/jsonld.xml new file mode 100644 index 00000000000..4343e4be4e3 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/state/jsonld.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/mercure.xml b/src/Symfony/Bundle/Resources/config/state/mercure.xml similarity index 86% rename from src/Symfony/Bundle/Resources/config/mercure.xml rename to src/Symfony/Bundle/Resources/config/state/mercure.xml index b137f1ddc4c..605e3db069e 100644 --- a/src/Symfony/Bundle/Resources/config/mercure.xml +++ b/src/Symfony/Bundle/Resources/config/state/mercure.xml @@ -5,9 +5,7 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> - - - + diff --git a/src/Symfony/Bundle/Resources/config/state/processor.xml b/src/Symfony/Bundle/Resources/config/state/processor.xml new file mode 100644 index 00000000000..627d3742957 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/state/processor.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/state/provider.xml b/src/Symfony/Bundle/Resources/config/state/provider.xml new file mode 100644 index 00000000000..56a42f70f87 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/state/provider.xml @@ -0,0 +1,42 @@ + + + + + + + + + + %api_platform.formats% + %api_platform.error_formats% + + + + + + + + + + + + + + + + api_platform.symfony.main_controller + + %kernel.debug% + + + %api_platform.error_formats% + %api_platform.exception_to_status% + null + + null + %api_platform.rfc_7807_compliant_errors% + + + diff --git a/src/Symfony/Bundle/Resources/config/state/security.xml b/src/Symfony/Bundle/Resources/config/state/security.xml new file mode 100644 index 00000000000..52200e3e85f --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/state/security.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + post_denormalize + + + diff --git a/src/Symfony/Bundle/Resources/config/symfony/security_validator.xml b/src/Symfony/Bundle/Resources/config/state/security_validator.xml similarity index 100% rename from src/Symfony/Bundle/Resources/config/symfony/security_validator.xml rename to src/Symfony/Bundle/Resources/config/state/security_validator.xml diff --git a/src/Symfony/Bundle/Resources/config/state.xml b/src/Symfony/Bundle/Resources/config/state/state.xml similarity index 54% rename from src/Symfony/Bundle/Resources/config/state.xml rename to src/Symfony/Bundle/Resources/config/state/state.xml index 78ecc462abc..6ab53fe4714 100644 --- a/src/Symfony/Bundle/Resources/config/state.xml +++ b/src/Symfony/Bundle/Resources/config/state/state.xml @@ -9,53 +9,10 @@ - - - - - - %api_platform.formats% - %api_platform.error_formats% - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - %api_platform.collection.pagination% %api_platform.graphql.collection.pagination% @@ -97,6 +54,5 @@ - diff --git a/src/Symfony/Bundle/Resources/config/state/swagger_ui.xml b/src/Symfony/Bundle/Resources/config/state/swagger_ui.xml new file mode 100644 index 00000000000..058c6da364c --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/state/swagger_ui.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/swagger_ui.xml b/src/Symfony/Bundle/Resources/config/swagger_ui.xml index e6a3e59015e..4419cd53479 100644 --- a/src/Symfony/Bundle/Resources/config/swagger_ui.xml +++ b/src/Symfony/Bundle/Resources/config/swagger_ui.xml @@ -14,11 +14,6 @@ %api_platform.swagger_ui.extra_configuration% - - - - - diff --git a/src/Symfony/Bundle/Resources/config/symfony/controller.xml b/src/Symfony/Bundle/Resources/config/symfony/controller.xml index 91921be97f3..e37e87c2faf 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/controller.xml +++ b/src/Symfony/Bundle/Resources/config/symfony/controller.xml @@ -12,5 +12,24 @@ + + + + + + %api_platform.docs_formats% + + + + + %api_platform.title% + %api_platform.description% + %api_platform.version% + + + + + %api_platform.docs_formats% + diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.xml b/src/Symfony/Bundle/Resources/config/symfony/events.xml index f16daef8574..4e86b56780e 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.xml +++ b/src/Symfony/Bundle/Resources/config/symfony/events.xml @@ -4,25 +4,149 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> - - - %api_platform.handle_symfony_errors% - - - - - - - - - api_platform.cache.metadata.property - api_platform.cache.metadata.resource - api_platform.cache.metadata.resource_collection - api_platform.cache.route_name_resolver - api_platform.cache.identifiers_extractor - api_platform.elasticsearch.cache.metadata.document - - + + null + + %api_platform.formats% + %api_platform.error_formats% + + + + + + + + + + + + + + + + + + null + + + + + + + null + + + + + + + + + + + + + null + + + + + + null + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + api_platform.action.placeholder + + %kernel.debug% + + + %api_platform.error_formats% + %api_platform.exception_to_status% + null + + null + %api_platform.rfc_7807_compliant_errors% + + + + + + + + + + + + + + + + + + + + + %api_platform.formats% + %api_platform.error_formats% + + + + + + + + + + + + %api_platform.docs_formats% + + + + + %api_platform.title% + %api_platform.description% + %api_platform.version% + + + + + %api_platform.docs_formats% + + diff --git a/src/Symfony/Bundle/Resources/config/symfony/jsonld.xml b/src/Symfony/Bundle/Resources/config/symfony/jsonld.xml new file mode 100644 index 00000000000..f6b4b59b5c6 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/symfony/jsonld.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/symfony/swagger_ui.xml b/src/Symfony/Bundle/Resources/config/symfony/swagger_ui.xml new file mode 100644 index 00000000000..7b3a1346186 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/symfony/swagger_ui.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/symfony/symfony.xml b/src/Symfony/Bundle/Resources/config/symfony/symfony.xml new file mode 100644 index 00000000000..e5c2dec65cd --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/symfony/symfony.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + %api_platform.handle_symfony_errors% + + + + + + + + + api_platform.cache.metadata.property + api_platform.cache.metadata.resource + api_platform.cache.metadata.resource_collection + api_platform.cache.route_name_resolver + api_platform.cache.identifiers_extractor + api_platform.elasticsearch.cache.metadata.document + + + + + diff --git a/src/Symfony/Bundle/Resources/config/symfony/validator.xml b/src/Symfony/Bundle/Resources/config/symfony/validator.xml deleted file mode 100644 index ac2d10fea1b..00000000000 --- a/src/Symfony/Bundle/Resources/config/symfony/validator.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Symfony/Bundle/Resources/config/validator/events.xml b/src/Symfony/Bundle/Resources/config/validator/events.xml new file mode 100644 index 00000000000..86d5d2e5045 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/validator/events.xml @@ -0,0 +1,35 @@ + + + + + + + null + + + + + + + + + + + + null + + + + + + + %api_platform.validator.query_parameter_validation% + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/validator/state.xml b/src/Symfony/Bundle/Resources/config/validator/state.xml new file mode 100644 index 00000000000..0a1bc61f579 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/validator/state.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/validator/validator.xml b/src/Symfony/Bundle/Resources/config/validator/validator.xml new file mode 100644 index 00000000000..c5c66891d1c --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/validator/validator.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/SwaggerUi/SwaggerUiProvider.php b/src/Symfony/Bundle/SwaggerUi/SwaggerUiProvider.php index 773d1daec33..1304b852fdd 100644 --- a/src/Symfony/Bundle/SwaggerUi/SwaggerUiProvider.php +++ b/src/Symfony/Bundle/SwaggerUi/SwaggerUiProvider.php @@ -72,6 +72,9 @@ class: OpenApi::class, // save our operation $request->attributes->set('_api_operation', $swaggerUiOperation); - return $this->openApiFactory->__invoke(['base_url' => $request->getBaseUrl() ?: '/']); + $data = $this->openApiFactory->__invoke(['base_url' => $request->getBaseUrl() ?: '/']); + $request->attributes->set('data', $data); + + return $data; } } diff --git a/src/Symfony/EventListener/AddFormatListener.php b/src/Symfony/EventListener/AddFormatListener.php index 8ec2041e244..fe788318360 100644 --- a/src/Symfony/EventListener/AddFormatListener.php +++ b/src/Symfony/EventListener/AddFormatListener.php @@ -17,7 +17,9 @@ use ApiPlatform\Metadata\Error as ErrorOperation; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\OperationRequestInitiatorTrait; +use ApiPlatform\Symfony\Util\RequestAttributesExtractor; use Negotiation\Exception\InvalidArgument; use Negotiation\Negotiator; use Symfony\Component\HttpFoundation\Request; @@ -34,8 +36,21 @@ final class AddFormatListener { use OperationRequestInitiatorTrait; - public function __construct(private readonly Negotiator $negotiator, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, private readonly array $formats = [], private readonly array $errorFormats = [], private readonly array $docsFormats = [], private readonly ?bool $eventsBackwardCompatibility = null) // @phpstan-ignore-line + private ?Negotiator $negotiator; + private ?ProviderInterface $provider = null; + + /** + * @param ProviderInterface|Negotiator $negotiator + */ + public function __construct($negotiator, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, private readonly array $formats = [], private readonly array $errorFormats = [], private readonly array $docsFormats = [], private readonly ?bool $eventsBackwardCompatibility = null) // @phpstan-ignore-line { + if ($negotiator instanceof ProviderInterface) { + $this->provider = $negotiator; + } else { + trigger_deprecation('api-platform/core', '3.3', 'Use a "%s" as first argument in "%s" instead of "%s".', ProviderInterface::class, self::class, Negotiator::class); + $this->negotiator = $negotiator; + } + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; } @@ -50,6 +65,31 @@ public function onKernelRequest(RequestEvent $event): void $request = $event->getRequest(); $operation = $this->initializeOperation($request); + // TODO: legacy code + if ($request->attributes->get('_api_exception_action')) { + return; + } + + $attributes = RequestAttributesExtractor::extractAttributes($request); + if (!($attributes['respond'] ?? $request->attributes->getBoolean('_api_respond'))) { + return; + } + + if ($operation && $this->provider) { + $this->provider->provide($operation, $request->attributes->get('_api_uri_variables') ?? [], [ + 'request' => $request, + 'uri_variables' => $request->attributes->get('_api_uri_variables') ?? [], + 'resource_class' => $operation->getClass(), + ]); + + return; + } + + // TODO: the code below needs to be removed in 4.x + if ($this->provider && !$operation) { + return; + } + if ('api_platform.action.entrypoint' === $request->attributes->get('_controller')) { return; } @@ -62,7 +102,8 @@ public function onKernelRequest(RequestEvent $event): void return; } - if (!($request->attributes->has('_api_resource_class') + if (!( + $request->attributes->has('_api_resource_class') || $request->attributes->getBoolean('_api_respond', false) || $request->attributes->getBoolean('_graphql', false) )) { diff --git a/src/Symfony/EventListener/AddHeadersListener.php b/src/Symfony/EventListener/AddHeadersListener.php index 02663db3146..f7a854055e9 100644 --- a/src/Symfony/EventListener/AddHeadersListener.php +++ b/src/Symfony/EventListener/AddHeadersListener.php @@ -21,6 +21,8 @@ /** * Configures cache HTTP headers for the current response. * + * @deprecated use ApiPlatform\HttpCache\State\AddHeadersProcessor instead + * * @author Kévin Dunglas */ final class AddHeadersListener diff --git a/src/Symfony/EventListener/AddLinkHeaderListener.php b/src/Symfony/EventListener/AddLinkHeaderListener.php index fecd341729f..2063d250480 100644 --- a/src/Symfony/EventListener/AddLinkHeaderListener.php +++ b/src/Symfony/EventListener/AddLinkHeaderListener.php @@ -25,6 +25,8 @@ /** * Adds the HTTP Link header pointing to the Mercure hub for resources having their updates dispatched. * + * @deprecated use ApiPlatform\Symfony\State\MercureLinkProcessor instead + * * @author Kévin Dunglas */ final class AddLinkHeaderListener diff --git a/src/Symfony/EventListener/AddTagsListener.php b/src/Symfony/EventListener/AddTagsListener.php index 1281ab2bcb8..8d4b7cf7307 100644 --- a/src/Symfony/EventListener/AddTagsListener.php +++ b/src/Symfony/EventListener/AddTagsListener.php @@ -32,6 +32,7 @@ * * The "xkey" is used because it is supported by Varnish. * @see https://docs.varnish-software.com/varnish-cache-plus/vmods/ykey/ + * @deprecated use ApiPlatform\HttpCache\State\AddTagsProcessor instead * * @author Kévin Dunglas */ diff --git a/src/Symfony/EventListener/DenyAccessListener.php b/src/Symfony/EventListener/DenyAccessListener.php index fdaa9f9a881..d1913997da3 100644 --- a/src/Symfony/EventListener/DenyAccessListener.php +++ b/src/Symfony/EventListener/DenyAccessListener.php @@ -25,6 +25,8 @@ /** * Denies access to the current resource if the logged user doesn't have sufficient permissions. * + * @deprecated use ApiPlatform\Symfony\Security\State\AccessCheckerProvider instead + * * @author Kévin Dunglas */ final class DenyAccessListener diff --git a/src/Symfony/EventListener/DeserializeListener.php b/src/Symfony/EventListener/DeserializeListener.php index 00b53ec42eb..c3caffe5a1c 100644 --- a/src/Symfony/EventListener/DeserializeListener.php +++ b/src/Symfony/EventListener/DeserializeListener.php @@ -14,8 +14,10 @@ namespace ApiPlatform\Symfony\EventListener; use ApiPlatform\Api\FormatMatcher; +use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\OperationRequestInitiatorTrait; use ApiPlatform\Symfony\Util\RequestAttributesExtractor; use ApiPlatform\Symfony\Validator\Exception\ValidationException; @@ -43,9 +45,24 @@ final class DeserializeListener use OperationRequestInitiatorTrait; public const OPERATION_ATTRIBUTE_KEY = 'deserialize'; + private SerializerInterface $serializer; + private ?ProviderInterface $provider = null; - public function __construct(private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private ?TranslatorInterface $translator = null) + public function __construct(ProviderInterface|SerializerInterface $serializer, private readonly null|SerializerContextBuilderInterface|ResourceMetadataCollectionFactoryInterface $serializerContextBuilder = null, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private ?TranslatorInterface $translator = null) { + if ($serializer instanceof ProviderInterface) { + $this->provider = $serializer; + } else { + trigger_deprecation('api-platform/core', '3.3', 'Use a "%s" as first argument in "%s" instead of "%s".', ProviderInterface::class, self::class, SerializerInterface::class); + $this->serializer = $serializer; + } + + if ($serializerContextBuilder instanceof ResourceMetadataCollectionFactoryInterface) { + $resourceMetadataFactory = $serializerContextBuilder; + } else { + trigger_deprecation('api-platform/core', '3.3', 'Use a "%s" as second argument in "%s" instead of "%s".', ResourceMetadataCollectionFactoryInterface::class, self::class, SerializerContextBuilderInterface::class); + } + $this->resourceMetadataCollectionFactory = $resourceMetadataFactory; if (null === $this->translator) { $this->translator = new class() implements TranslatorInterface, LocaleAwareInterface { @@ -66,17 +83,43 @@ public function onKernelRequest(RequestEvent $event): void $method = $request->getMethod(); if ( - 'DELETE' === $method - || $request->isMethodSafe() - || !($attributes = RequestAttributesExtractor::extractAttributes($request)) + !($attributes = RequestAttributesExtractor::extractAttributes($request)) || !$attributes['receive'] - || $request->attributes->get('_api_platform_disable_listeners') ) { return; } $operation = $this->initializeOperation($request); + if ($operation && $this->provider) { + if (null === $operation->canDeserialize() && $operation instanceof HttpOperation) { + $operation = $operation->withDeserialize(\in_array($operation->getMethod(), ['POST', 'PUT', 'PATCH'], true)); + } + + if (!$operation->canDeserialize()) { + return; + } + + $data = $this->provider->provide($operation, $request->attributes->get('_api_uri_variables') ?? [], [ + 'request' => $request, + 'uri_variables' => $request->attributes->get('_api_uri_variables') ?? [], + 'resource_class' => $operation->getClass(), + ]); + + $request->attributes->set('data', $data); + + return; + } + + // TODO: the code below needs to be removed in 4.x + if ( + 'DELETE' === $method + || $request->isMethodSafe() + || $request->attributes->get('_api_platform_disable_listeners') + ) { + return; + } + if ('api_platform.symfony.main_controller' === $operation?->getController()) { return; } diff --git a/src/Symfony/EventListener/ErrorListener.php b/src/Symfony/EventListener/ErrorListener.php index a9ad9d7f16a..0918791e32e 100644 --- a/src/Symfony/EventListener/ErrorListener.php +++ b/src/Symfony/EventListener/ErrorListener.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IdentifiersExtractorInterface; +use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\ContentNegotiationTrait; @@ -102,52 +103,14 @@ protected function duplicateRequest(\Throwable $exception, Request $request): Re } $dup = parent::duplicateRequest($exception, $request); - if ($this->resourceMetadataCollectionFactory) { - if ($this->resourceClassResolver?->isResourceClass($exception::class)) { - $resourceCollection = $this->resourceMetadataCollectionFactory->create($exception::class); - - $operation = null; - foreach ($resourceCollection as $resource) { - foreach ($resource->getOperations() as $op) { - foreach ($op->getOutputFormats() as $key => $value) { - if ($key === $format) { - $operation = $op; - break 3; - } - } - } - } - - // No operation found for the requested format, we take the first available - if (!$operation) { - $operation = $resourceCollection->getOperation(); - } - if ($exception instanceof ProblemExceptionInterface && $operation instanceof HttpOperation) { - $statusCode = $this->getStatusCode($apiOperation, $request, $operation, $exception); - $operation = $operation->withStatus($statusCode); - } - } else { - // Create a generic, rfc7807 compatible error according to the wanted format - $operation = $this->resourceMetadataCollectionFactory->create(Error::class)->getOperation($this->getFormatOperation($format)); - // status code may be overriden by the exceptionToStatus option - $statusCode = 500; - if ($operation instanceof HttpOperation) { - $statusCode = $this->getStatusCode($apiOperation, $request, $operation, $exception); - $operation = $operation->withStatus($statusCode); - } - } - } else { - /** @var HttpOperation $operation */ - $operation = new ErrorOperation(name: '_api_errors_problem', class: Error::class, outputFormats: ['jsonld' => ['application/problem+json']], normalizationContext: ['groups' => ['jsonld'], 'skip_null_values' => true]); - $operation = $operation->withStatus($this->getStatusCode($apiOperation, $request, $operation, $exception)); - } + $operation = $this->initializeExceptionOperation($request, $exception, $format, $apiOperation); if (null === $operation->getProvider()) { $operation = $operation->withProvider('api_platform.state.error_provider'); } $normalizationContext = $operation->getNormalizationContext() ?? []; - if (!($normalizationContext['_api_error_resource'] ?? false)) { + if (!($normalizationContext['api_error_resource'] ?? false)) { $normalizationContext += ['api_error_resource' => true]; } @@ -171,6 +134,9 @@ protected function duplicateRequest(\Throwable $exception, Request $request): Re return $dup; } + /** + * @return array> + */ private function getOperationExceptionToStatus(Request $request): array { $attributes = RequestAttributesExtractor::extractAttributes($request); @@ -180,8 +146,12 @@ private function getOperationExceptionToStatus(Request $request): array } $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($attributes['resource_class']); - /** @var HttpOperation $operation */ $operation = $resourceMetadataCollection->getOperation($attributes['operation_name'] ?? null); + + if (!$operation instanceof HttpOperation) { + return []; + } + $exceptionToStatus = [$operation->getExceptionToStatus() ?: []]; foreach ($resourceMetadataCollection as $resourceMetadata) { @@ -244,4 +214,55 @@ private function getFormatOperation(?string $format): string default => '_api_errors_problem' }; } + + private function initializeExceptionOperation(?Request $request, \Throwable $exception, string $format, ?HttpOperation $apiOperation): Operation + { + if (!$this->resourceMetadataCollectionFactory) { + $operation = new ErrorOperation( + name: '_api_errors_problem', + class: Error::class, + outputFormats: ['jsonld' => ['application/problem+json']], + normalizationContext: ['groups' => ['jsonld'], 'skip_null_values' => true] + ); + + return $operation->withStatus($this->getStatusCode($apiOperation, $request, $operation, $exception)); + } + + if ($this->resourceClassResolver?->isResourceClass($exception::class)) { + $resourceCollection = $this->resourceMetadataCollectionFactory->create($exception::class); + + $operation = null; + // TODO: move this to ResourceMetadataCollection? + foreach ($resourceCollection as $resource) { + foreach ($resource->getOperations() as $op) { + foreach ($op->getOutputFormats() as $key => $value) { + if ($key === $format) { + $operation = $op; + break 3; + } + } + } + } + + // No operation found for the requested format, we take the first available + $operation ??= $resourceCollection->getOperation(); + + if ($exception instanceof ProblemExceptionInterface && $operation instanceof HttpOperation) { + return $operation->withStatus($this->getStatusCode($apiOperation, $request, $operation, $exception)); + } + + return $operation; + } + + // Create a generic, rfc7807 compatible error according to the wanted format + $operation = $this->resourceMetadataCollectionFactory->create(Error::class)->getOperation($this->getFormatOperation($format)); + // status code may be overriden by the exceptionToStatus option + $statusCode = 500; + if ($operation instanceof HttpOperation) { + $statusCode = $this->getStatusCode($apiOperation, $request, $operation, $exception); + $operation = $operation->withStatus($statusCode); + } + + return $operation; + } } diff --git a/src/Symfony/EventListener/QueryParameterValidateListener.php b/src/Symfony/EventListener/QueryParameterValidateListener.php index 9573e37cecc..8ef0ae347e7 100644 --- a/src/Symfony/EventListener/QueryParameterValidateListener.php +++ b/src/Symfony/EventListener/QueryParameterValidateListener.php @@ -18,6 +18,7 @@ use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\ParameterValidator\ParameterValidator; +use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\OperationRequestInitiatorTrait; use ApiPlatform\State\Util\RequestParser; use ApiPlatform\Symfony\Util\RequestAttributesExtractor; @@ -33,15 +34,39 @@ final class QueryParameterValidateListener use OperationRequestInitiatorTrait; public const OPERATION_ATTRIBUTE_KEY = 'query_parameter_validate'; + private ?ParameterValidator $queryParameterValidator = null; + private ?ProviderInterface $provider = null; - public function __construct(private readonly ParameterValidator $queryParameterValidator, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null) + public function __construct($queryParameterValidator, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null) { + if ($queryParameterValidator instanceof ProviderInterface) { + $this->provider = $queryParameterValidator; + } else { + trigger_deprecation('api-platform/core', '3.3', 'Use a "%s" as first argument in "%s" instead of "%s".', ProviderInterface::class, self::class, ParameterValidator::class); + $this->queryParameterValidator = $queryParameterValidator; + } + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; } public function onKernelRequest(RequestEvent $event): void { $request = $event->getRequest(); + $operation = $this->initializeOperation($request); + + if ($operation && $this->provider instanceof ProviderInterface) { + if (null === $operation->getQueryParameterValidationEnabled()) { + $operation = $operation->withQueryParameterValidationEnabled($request->isMethodSafe() && 'GET' === $request->getMethod() && $operation instanceof CollectionOperationInterface); + } + + $this->provider->provide($operation, $request->attributes->get('_api_uri_variables') ?? [], [ + 'request' => $request, + 'uri_variables' => $request->attributes->get('_api_uri_variables') ?? [], + 'resource_class' => $operation->getClass(), + ]); + + return; + } if ( !$request->isMethodSafe() @@ -52,7 +77,6 @@ public function onKernelRequest(RequestEvent $event): void return; } - $operation = $this->initializeOperation($request); if ('api_platform.symfony.main_controller' === $operation?->getController()) { return; } diff --git a/src/Symfony/EventListener/ReadListener.php b/src/Symfony/EventListener/ReadListener.php index 54f8c1d46b8..feee5886df0 100644 --- a/src/Symfony/EventListener/ReadListener.php +++ b/src/Symfony/EventListener/ReadListener.php @@ -16,12 +16,16 @@ use ApiPlatform\Api\UriVariablesConverterInterface as LegacyUriVariablesConverterInterface; use ApiPlatform\Exception\InvalidIdentifierException; use ApiPlatform\Exception\InvalidUriVariableException; +use ApiPlatform\Metadata\Error; +use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\UriVariablesConverterInterface; use ApiPlatform\Metadata\Util\CloneTrait; use ApiPlatform\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\State\CallableProvider; use ApiPlatform\State\Exception\ProviderNotFoundException; +use ApiPlatform\State\Provider\ReadProvider; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\UriVariablesResolverTrait; use ApiPlatform\State\Util\OperationRequestInitiatorTrait; @@ -49,6 +53,10 @@ public function __construct( ) { $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; $this->uriVariablesConverter = $uriVariablesConverter; + + if ($provider instanceof CallableProvider) { + trigger_deprecation('api-platform/core', '3.3', 'Use a "%s" as first argument in "%s" instead of "%s".', ReadProvider::class, self::class, $provider::class); + } } /** @@ -59,17 +67,42 @@ public function __construct( public function onKernelRequest(RequestEvent $event): void { $request = $event->getRequest(); + + if (!($attributes = RequestAttributesExtractor::extractAttributes($request)) || !$attributes['receive']) { + return; + } + $operation = $this->initializeOperation($request); - if ('api_platform.symfony.main_controller' === $operation?->getController() || $request->attributes->get('_api_platform_disable_listeners')) { + if ($operation && !$this->provider instanceof CallableProvider) { + if (null === $operation->canRead()) { + $operation = $operation->withRead($operation->getUriVariables() || $request->isMethodSafe()); + } + + $uriVariables = []; + if (!$operation instanceof Error && $operation instanceof HttpOperation) { + try { + $uriVariables = $this->getOperationUriVariables($operation, $request->attributes->all(), $operation->getClass()); + } catch (InvalidIdentifierException|InvalidUriVariableException $e) { + throw new NotFoundHttpException('Invalid identifier value or configuration.', $e); + } + } + + $request->attributes->set('_api_uri_variables', $uriVariables); + $this->provider->provide($operation, $uriVariables, [ + 'request' => $request, + 'uri_variables' => $uriVariables, + 'resource_class' => $operation->getClass(), + ]); + return; } - if (!($attributes = RequestAttributesExtractor::extractAttributes($request))) { + if ('api_platform.symfony.main_controller' === $operation?->getController() || $request->attributes->get('_api_platform_disable_listeners')) { return; } - if (!$attributes['receive'] || !$operation || !($operation->canRead() ?? true) || (!$operation->getUriVariables() && !$request->isMethodSafe())) { + if (!$operation || !($operation->canRead() ?? true) || (!$operation->getUriVariables() && !$request->isMethodSafe())) { return; } diff --git a/src/Symfony/EventListener/RespondListener.php b/src/Symfony/EventListener/RespondListener.php index cdf2fe0076b..98bddad870b 100644 --- a/src/Symfony/EventListener/RespondListener.php +++ b/src/Symfony/EventListener/RespondListener.php @@ -19,6 +19,7 @@ use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\Util\OperationRequestInitiatorTrait; use ApiPlatform\Symfony\Util\RequestAttributesExtractor; use Symfony\Component\HttpFoundation\Response; @@ -38,10 +39,18 @@ final class RespondListener 'DELETE' => Response::HTTP_NO_CONTENT, ]; - public function __construct( - ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, - private readonly null|IriConverterInterface|LegacyIriConverterInterface $iriConverter = null, - ) { + private null|IriConverterInterface|LegacyIriConverterInterface $iriConverter = null; + private ?ProcessorInterface $processor = null; + + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, IriConverterInterface|LegacyIriConverterInterface|ProcessorInterface $iriConverter = null) + { + if ($iriConverter instanceof ProcessorInterface) { + $this->processor = $iriConverter; + } else { + trigger_deprecation('api-platform/core', '3.3', 'Use a "%s" as second argument in "%s" instead of "%s".', ProcessorInterface::class, self::class, IriConverterInterface::class); + $this->iriConverter = $iriConverter; + } + $this->resourceMetadataCollectionFactory = $resourceMetadataFactory; } @@ -54,6 +63,26 @@ public function onKernelView(ViewEvent $event): void $controllerResult = $event->getControllerResult(); $operation = $this->initializeOperation($request); + $attributes = RequestAttributesExtractor::extractAttributes($request); + if (!($attributes['respond'] ?? $request->attributes->getBoolean('_api_respond'))) { + return; + } + + if ($operation && $this->processor instanceof ProcessorInterface) { + $uriVariables = $request->attributes->get('_api_uri_variables') ?? []; + $response = $this->processor->process($controllerResult, $operation, $uriVariables, [ + 'request' => $request, + 'uri_variables' => $uriVariables, + 'resource_class' => $operation->getClass(), + 'original_data' => $request->attributes->get('original_data'), + ]); + + $event->setResponse($response); + + return; + } + + // TODO: the code below needs to be removed in 4.x if ('api_platform.symfony.main_controller' === $operation?->getController() || $request->attributes->get('_api_platform_disable_listeners')) { return; } diff --git a/src/Symfony/EventListener/SerializeListener.php b/src/Symfony/EventListener/SerializeListener.php index aaa0775f02a..0a289983ea3 100644 --- a/src/Symfony/EventListener/SerializeListener.php +++ b/src/Symfony/EventListener/SerializeListener.php @@ -16,9 +16,11 @@ use ApiPlatform\Doctrine\Odm\State\Options as ODMOptions; use ApiPlatform\Doctrine\Orm\State\Options; use ApiPlatform\Exception\RuntimeException; +use ApiPlatform\Metadata\Error; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Serializer\ResourceList; use ApiPlatform\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\Util\OperationRequestInitiatorTrait; use ApiPlatform\Symfony\Util\RequestAttributesExtractor; use ApiPlatform\Util\ErrorFormatGuesser; @@ -42,15 +44,32 @@ final class SerializeListener use OperationRequestInitiatorTrait; public const OPERATION_ATTRIBUTE_KEY = 'serialize'; + private ?SerializerInterface $serializer = null; + private ?ProcessorInterface $processor = null; + private ?SerializerContextBuilderInterface $serializerContextBuilder = null; public function __construct( - private readonly SerializerInterface $serializer, - private readonly SerializerContextBuilderInterface $serializerContextBuilder, + SerializerInterface|ProcessorInterface $serializer, + SerializerContextBuilderInterface|ResourceMetadataCollectionFactoryInterface $serializerContextBuilder = null, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private readonly array $errorFormats = [], // @phpstan-ignore-next-line we don't need this anymore private readonly bool $debug = false, ) { + if ($serializer instanceof ProcessorInterface) { + $this->processor = $serializer; + } else { + $this->serializer = $serializer; + trigger_deprecation('api-platform/core', '3.3', 'Use a "%s" as first argument in "%s" instead of "%s".', ProcessorInterface::class, self::class, SerializerInterface::class); + } + + if ($serializerContextBuilder instanceof ResourceMetadataCollectionFactoryInterface) { + $resourceMetadataFactory = $serializerContextBuilder; + } else { + $this->serializerContextBuilder = $serializerContextBuilder; + trigger_deprecation('api-platform/core', '3.3', 'Use a "%s" as second argument in "%s" instead of "%s".', ResourceMetadataCollectionFactoryInterface::class, self::class, SerializerContextBuilderInterface::class); + } + $this->resourceMetadataCollectionFactory = $resourceMetadataFactory; } @@ -61,7 +80,37 @@ public function onKernelView(ViewEvent $event): void { $controllerResult = $event->getControllerResult(); $request = $event->getRequest(); + $operation = $this->initializeOperation($request); + + $attributes = RequestAttributesExtractor::extractAttributes($request); + + if (!($attributes['respond'] ?? $request->attributes->getBoolean('_api_respond', false))) { + return; + } + if ($operation && $this->processor instanceof ProcessorInterface) { + if (null === $operation->canSerialize()) { + $operation = $operation->withSerialize(true); + } + + if ($operation instanceof Error) { + // we don't want the FlattenException + $controllerResult = $request->attributes->get('data') ?? $controllerResult; + } + + $uriVariables = $request->attributes->get('_api_uri_variables') ?? []; + $serialized = $this->processor->process($controllerResult, $operation, $uriVariables, [ + 'request' => $request, + 'uri_variables' => $uriVariables, + 'resource_class' => $operation->getClass(), + ]); + + $event->setControllerResult($serialized); + + return; + } + + // TODO: the code below needs to be removed in 4.x if ($controllerResult instanceof Response) { return; } @@ -72,8 +121,6 @@ public function onKernelView(ViewEvent $event): void return; } - $operation = $this->initializeOperation($request); - if ('api_platform.symfony.main_controller' === $operation?->getController() || $request->attributes->get('_api_platform_disable_listeners')) { return; } diff --git a/src/Symfony/EventListener/ValidateListener.php b/src/Symfony/EventListener/ValidateListener.php index 6a69d6f45fe..d3f3110e8ae 100644 --- a/src/Symfony/EventListener/ValidateListener.php +++ b/src/Symfony/EventListener/ValidateListener.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Symfony\EventListener; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\OperationRequestInitiatorTrait; use ApiPlatform\Validator\Exception\ValidationException; use ApiPlatform\Validator\ValidatorInterface; @@ -31,8 +32,18 @@ final class ValidateListener public const OPERATION_ATTRIBUTE_KEY = 'validate'; - public function __construct(private readonly ValidatorInterface $validator, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory) + private ValidatorInterface $validator; + private ?ProviderInterface $provider = null; + + public function __construct(ProviderInterface|ValidatorInterface $validator, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory) { + if ($validator instanceof ProviderInterface) { + $this->provider = $validator; + } else { + trigger_deprecation('api-platform/core', '3.3', 'Use a "%s" as first argument in "%s" instead of "%s".', ProviderInterface::class, self::class, ValidatorInterface::class); + $this->validator = $validator; + } + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; } @@ -46,6 +57,21 @@ public function onKernelView(ViewEvent $event): void $controllerResult = $event->getControllerResult(); $request = $event->getRequest(); $operation = $this->initializeOperation($request); + + if ($operation && $this->provider instanceof ProviderInterface) { + if (null === $operation->canValidate()) { + $operation = $operation->withValidate(!$request->isMethodSafe() && !$request->isMethod('DELETE')); + } + + $this->provider->provide($operation, $request->attributes->get('_api_uri_variables') ?? [], [ + 'request' => $request, + 'uri_variables' => $request->attributes->get('_api_uri_variables') ?? [], + 'resource_class' => $operation->getClass(), + ]); + + return; + } + if ('api_platform.symfony.main_controller' === $operation?->getController() || $request->attributes->get('_api_platform_disable_listeners')) { return; } diff --git a/src/Symfony/EventListener/WriteListener.php b/src/Symfony/EventListener/WriteListener.php index 88559a323d7..7a8392af0e5 100644 --- a/src/Symfony/EventListener/WriteListener.php +++ b/src/Symfony/EventListener/WriteListener.php @@ -17,11 +17,17 @@ use ApiPlatform\Api\ResourceClassResolverInterface as LegacyResourceClassResolverInterface; use ApiPlatform\Api\UriVariablesConverterInterface as LegacyUriVariablesConverterInterface; use ApiPlatform\Exception\InvalidIdentifierException; +use ApiPlatform\Metadata\Error; +use ApiPlatform\Metadata\Exception\InvalidUriVariableException; +use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UriVariablesConverterInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\Metadata\Util\CloneTrait; +use ApiPlatform\State\CallableProcessor; +use ApiPlatform\State\Processor\WriteProcessor; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\UriVariablesResolverTrait; use ApiPlatform\State\Util\OperationRequestInitiatorTrait; @@ -39,18 +45,36 @@ final class WriteListener { use ClassInfoTrait; + use CloneTrait; use OperationRequestInitiatorTrait; use UriVariablesResolverTrait; + private LegacyIriConverterInterface|IriConverterInterface|null $iriConverter = null; + + /** + * @param ProcessorInterface $processor + */ public function __construct( private readonly ProcessorInterface $processor, - private readonly LegacyIriConverterInterface|IriConverterInterface $iriConverter, - private readonly ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver, + LegacyIriConverterInterface|IriConverterInterface|ResourceMetadataCollectionFactoryInterface $iriConverter = null, + private readonly null|ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver = null, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, LegacyUriVariablesConverterInterface|UriVariablesConverterInterface $uriVariablesConverter = null, ) { - $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; $this->uriVariablesConverter = $uriVariablesConverter; + + if ($processor instanceof CallableProcessor) { + trigger_deprecation('api-platform/core', '3.3', 'Use a "%s" as first argument in "%s" instead of "%s".', WriteProcessor::class, self::class, $processor::class); + } + + if ($iriConverter instanceof ResourceMetadataCollectionFactoryInterface) { + $resourceMetadataCollectionFactory = $iriConverter; + } else { + $this->iriConverter = $iriConverter; + trigger_deprecation('api-platform/core', '3.3', 'Use a "%s" as second argument in "%s" instead of "%s".', ResourceMetadataCollectionFactoryInterface::class, self::class, IriConverterInterface::class); + } + + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; } /** @@ -62,6 +86,41 @@ public function onKernelView(ViewEvent $event): void $request = $event->getRequest(); $operation = $this->initializeOperation($request); + if (!($attributes = RequestAttributesExtractor::extractAttributes($request)) || !$attributes['persist']) { + return; + } + + if ($operation && (!$this->processor instanceof CallableProcessor && !$this->iriConverter)) { + if (null === $operation->canWrite()) { + $operation = $operation->withWrite(!$request->isMethodSafe()); + } + + $uriVariables = $request->attributes->get('_api_uri_variables') ?? []; + if (!$uriVariables && !$operation instanceof Error && $operation instanceof HttpOperation) { + try { + $uriVariables = $this->getOperationUriVariables($operation, $request->attributes->all(), $operation->getClass()); + } catch (InvalidIdentifierException|InvalidUriVariableException $e) { + throw new NotFoundHttpException('Invalid identifier value or configuration.', $e); + } + } + + // $request->attributes->set('original_data', $this->clone($controllerResult)); + $data = $this->processor->process($controllerResult, $operation, $uriVariables, [ + 'request' => $request, + 'uri_variables' => $uriVariables, + 'resource_class' => $operation->getClass(), + 'previous_data' => false === $operation->canRead() ? null : $request->attributes->get('previous_data'), + ]); + + if ($data) { + $request->attributes->set('original_data', $this->clone($data)); + } + + $event->setControllerResult($data); + + return; + } + // API Platform 3.2 has a MainController where everything is handled by processors/providers if ('api_platform.symfony.main_controller' === $operation?->getController() || $request->attributes->get('_api_platform_disable_listeners')) { return; diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php new file mode 100644 index 00000000000..cd20a09cdf0 --- /dev/null +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -0,0 +1,286 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Tests\Bundle\DependencyInjection; + +use ApiPlatform\Action\NotFoundAction; +use ApiPlatform\Metadata\Exception\ExceptionInterface; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\IdentifiersExtractorInterface; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Serializer\Filter\GroupFilter; +use ApiPlatform\Serializer\Filter\PropertyFilter; +use ApiPlatform\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\State\Pagination\Pagination; +use ApiPlatform\State\Pagination\PaginationOptions; +use ApiPlatform\Symfony\Bundle\DependencyInjection\ApiPlatformExtension; +use ApiPlatform\Tests\Fixtures\TestBundle\TestBundle; +use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; +use Doctrine\ORM\OptimisticLockException; +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\SecurityBundle\SecurityBundle; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; +use Symfony\Component\HttpFoundation\Response; + +class ApiPlatformExtensionTest extends TestCase +{ + final public const DEFAULT_CONFIG = ['api_platform' => [ + 'title' => 'title', + 'description' => 'description', + 'version' => 'version', + 'formats' => [ + 'json' => ['mime_types' => ['json']], + 'jsonld' => ['mime_types' => ['application/ld+json']], + 'jsonhal' => ['mime_types' => ['application/hal+json']], + ], + 'http_cache' => ['invalidation' => [ + 'enabled' => true, + 'purger' => 'api_platform.http_cache.purger.varnish.ban', + 'request_options' => [ + 'allow_redirects' => [ + 'max' => 5, + 'protocols' => ['http', 'https'], + 'stric' => false, + 'referer' => false, + 'track_redirects' => false, + ], + 'http_errors' => true, + 'decode_content' => false, + 'verify' => false, + 'cookies' => true, + 'headers' => [ + 'User-Agent' => 'none', + ], + ], + ]], + 'doctrine_mongodb_odm' => [ + 'enabled' => true, + ], + 'defaults' => [ + 'extra_properties' => [], + 'url_generation_strategy' => UrlGeneratorInterface::ABS_URL, + ], + 'collection' => [ + 'exists_parameter_name' => 'exists', + 'order' => 'ASC', + 'order_parameter_name' => 'order', + 'order_nulls_comparison' => null, + 'pagination' => [ + 'page_parameter_name' => 'page', + 'enabled_parameter_name' => 'pagination', + 'items_per_page_parameter_name' => 'itemsPerPage', + 'partial_parameter_name' => 'partial', + ], + ], + 'error_formats' => [ + 'jsonproblem' => ['application/problem+json'], + 'jsonld' => ['application/ld+json'], + ], + 'patch_formats' => [], + 'exception_to_status' => [ + ExceptionInterface::class => Response::HTTP_BAD_REQUEST, + InvalidArgumentException::class => Response::HTTP_BAD_REQUEST, + OptimisticLockException::class => Response::HTTP_CONFLICT, + ], + 'show_webby' => true, + 'eager_loading' => [ + 'enabled' => true, + 'max_joins' => 30, + 'force_eager' => true, + 'fetch_partial' => false, + ], + 'asset_package' => null, + 'enable_entrypoint' => true, + 'enable_docs' => true, + 'graphql' => [ + 'graphql_playground' => ['enabled' => false], + ], + 'event_listeners_backward_compatibility_layer' => false, + 'use_symfony_listeners' => false, + 'keep_legacy_inflector' => false, + ]]; + + private ContainerBuilder $container; + + protected function setUp(): void + { + $containerParameterBag = new ParameterBag([ + 'kernel.bundles' => [ + 'DoctrineBundle' => DoctrineBundle::class, + 'SecurityBundle' => SecurityBundle::class, + 'TwigBundle' => TwigBundle::class, + ], + 'kernel.bundles_metadata' => [ + 'TestBundle' => [ + 'parent' => null, + 'path' => realpath(__DIR__.'/../../../Fixtures/TestBundle'), + 'namespace' => TestBundle::class, + ], + ], + 'kernel.project_dir' => __DIR__.'/../../../Fixtures/app', + 'kernel.debug' => false, + 'kernel.environment' => 'test', + ]); + + $this->container = new ContainerBuilder($containerParameterBag); + } + + private function assertContainerHas(array $services, array $aliases = []): void + { + foreach ($services as $service) { + $this->assertTrue($this->container->hasDefinition($service), sprintf('Definition "%s" not found.', $service)); + } + + foreach ($aliases as $alias) { + $this->assertContainerHasAlias($alias); + } + } + + private function assertNotContainerHasService(string $service): void + { + $this->assertFalse($this->container->hasDefinition($service), sprintf('Service "%s" found.', $service)); + } + + private function assertContainerHasAlias(string $alias): void + { + $this->assertTrue($this->container->hasAlias($alias), sprintf('Alias "%s" not found.', $alias)); + } + + private function assertServiceHasTags(string $service, array $tags = []): void + { + $serviceTags = $this->container->getDefinition($service)->getTags(); + + foreach ($tags as $tag) { + $this->assertArrayHasKey($tag, $serviceTags, sprintf('Tag "%s" not found on the service "%s".', $tag, $service)); + } + } + + public function testCommonConfiguration(): void + { + $config = self::DEFAULT_CONFIG; + (new ApiPlatformExtension())->load($config, $this->container); + + $services = [ + 'api_platform.action.documentation', + 'api_platform.action.entrypoint', + 'api_platform.action.exception', + 'api_platform.action.not_found', + 'api_platform.action.placeholder', + 'api_platform.api.identifiers_extractor', + 'api_platform.filter_locator', + 'api_platform.negotiator', + 'api_platform.pagination', + 'api_platform.pagination_options', + 'api_platform.path_segment_name_generator.dash', + 'api_platform.path_segment_name_generator.underscore', + 'api_platform.resource_class_resolver', + 'api_platform.route_loader', + 'api_platform.router', + 'api_platform.serializer.context_builder', + 'api_platform.serializer.context_builder.filter', + 'api_platform.serializer.group_filter', + 'api_platform.serializer.mapping.class_metadata_factory', + 'api_platform.serializer.normalizer.item', + 'api_platform.serializer.property_filter', + 'api_platform.serializer_locator', + 'api_platform.symfony.iri_converter', + 'api_platform.uri_variables.converter', + 'api_platform.uri_variables.transformer.date_time', + 'api_platform.uri_variables.transformer.integer', + + 'api_platform.state_provider.content_negotiation', + 'api_platform.state_provider.deserialize', + 'api_platform.state_processor.respond', + 'api_platform.state_processor.add_link_header', + 'api_platform.state_processor.serialize', + ]; + + $aliases = [ + NotFoundAction::class, + IdentifiersExtractorInterface::class, + IriConverterInterface::class, + ResourceClassResolverInterface::class, + UrlGeneratorInterface::class, + GroupFilter::class, + PropertyFilter::class, + SerializerContextBuilderInterface::class, + Pagination::class, + PaginationOptions::class, + 'api_platform.action.delete_item', + 'api_platform.action.get_collection', + 'api_platform.action.get_item', + 'api_platform.action.patch_item', + 'api_platform.action.post_collection', + 'api_platform.action.put_item', + 'api_platform.identifiers_extractor', + 'api_platform.iri_converter', + 'api_platform.path_segment_name_generator', + 'api_platform.property_accessor', + 'api_platform.property_info', + 'api_platform.serializer', + ]; + + $this->assertContainerHas($services, $aliases); + + $this->assertServiceHasTags('api_platform.cache.route_name_resolver', ['cache.pool']); + $this->assertServiceHasTags('api_platform.serializer.normalizer.item', ['serializer.normalizer']); + $this->assertServiceHasTags('api_platform.serializer_locator', ['container.service_locator']); + $this->assertServiceHasTags('api_platform.filter_locator', ['container.service_locator']); + + // api.xml + $this->assertServiceHasTags('api_platform.route_loader', ['routing.loader']); + $this->assertServiceHasTags('api_platform.uri_variables.transformer.integer', ['api_platform.uri_variables.transformer']); + $this->assertServiceHasTags('api_platform.uri_variables.transformer.date_time', ['api_platform.uri_variables.transformer']); + + $services = [ + 'api_platform.listener.request.read', + 'api_platform.listener.request.deserialize', + 'api_platform.listener.request.add_format', + 'api_platform.listener.view.write', + 'api_platform.listener.view.serialize', + 'api_platform.listener.view.respond', + ]; + + foreach ($services as $service) { + $this->assertNotContainerHasService($service); + } + } + + public function testEventListenersConfiguration(): void + { + $config = self::DEFAULT_CONFIG; + $config['api_platform']['use_symfony_listeners'] = true; + (new ApiPlatformExtension())->load($config, $this->container); + + $services = [ + 'api_platform.listener.request.read', + 'api_platform.listener.request.deserialize', + 'api_platform.listener.request.add_format', + 'api_platform.listener.view.write', + 'api_platform.listener.view.serialize', + 'api_platform.listener.view.respond', + + 'api_platform.state_provider.content_negotiation', + 'api_platform.state_provider.deserialize', + 'api_platform.state_processor.respond', + 'api_platform.state_processor.add_link_header', + 'api_platform.state_processor.serialize', + ]; + + $this->assertContainerHas($services, []); + } +} diff --git a/src/Symfony/Tests/EventListener/AddFormatListenerTest.php b/src/Symfony/Tests/EventListener/AddFormatListenerTest.php new file mode 100644 index 00000000000..11a70b10527 --- /dev/null +++ b/src/Symfony/Tests/EventListener/AddFormatListenerTest.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Tests\EventListener; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Symfony\EventListener\AddFormatListener; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +class AddFormatListenerTest extends TestCase +{ + public function testFetchOperation(): void + { + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once())->method('provide'); + $metadata = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $metadata->expects($this->once())->method('create')->with('class')->willReturn(new ResourceMetadataCollection('class', [ + new ApiResource(operations: [ + 'operation' => new Get(), + ]), + ])); + + $listener = new AddFormatListener($provider, $metadata); + $listener->onKernelRequest( + new RequestEvent( + $this->createStub(HttpKernelInterface::class), + new Request([], [], ['_api_operation_name' => 'operation', '_api_resource_class' => 'class']), + HttpKernelInterface::MAIN_REQUEST + ) + ); + } + + public function testCallProvider(): void + { + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once())->method('provide'); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + + $listener = new AddFormatListener($provider, $metadata); + $listener->onKernelRequest( + new RequestEvent( + $this->createStub(HttpKernelInterface::class), + new Request([], [], ['_api_operation' => new Get(), '_api_operation_name' => 'operation', '_api_resource_class' => 'class']), + HttpKernelInterface::MAIN_REQUEST + ) + ); + } + + #[DataProvider('provideNonApiAttributes')] + public function testNoCallProvider(...$attributes): void + { + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->never())->method('provide'); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $listener = new AddFormatListener($provider, $metadata); + $listener->onKernelRequest( + new RequestEvent( + $this->createStub(HttpKernelInterface::class), + new Request([], [], $attributes), + HttpKernelInterface::MAIN_REQUEST + ) + ); + } + + public static function provideNonApiAttributes(): array + { + return [ + ['_api_resource_class' => 'dummy'], + ['_api_resource_class' => 'dummy', '_api_operation_name' => 'dummy'], + ['_api_respond' => false, '_api_operation_name' => 'dummy'], + [], + ]; + } +} diff --git a/src/Symfony/Tests/EventListener/DeserializeListenerTest.php b/src/Symfony/Tests/EventListener/DeserializeListenerTest.php new file mode 100644 index 00000000000..4a2a66ba538 --- /dev/null +++ b/src/Symfony/Tests/EventListener/DeserializeListenerTest.php @@ -0,0 +1,129 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Tests\EventListener; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Symfony\EventListener\DeserializeListener; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +class DeserializeListenerTest extends TestCase +{ + public function testFetchOperation(): void + { + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once())->method('provide'); + $metadata = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $metadata->expects($this->once())->method('create')->with('class')->willReturn(new ResourceMetadataCollection('class', [ + new ApiResource(operations: [ + 'operation' => new Post(), + ]), + ])); + + $request = new Request([], [], ['_api_operation_name' => 'operation', '_api_resource_class' => 'class']); + $request->setMethod('POST'); + $listener = new DeserializeListener($provider, $metadata); + $listener->onKernelRequest( + new RequestEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST + ) + ); + } + + public function testCallProvider(): void + { + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once())->method('provide'); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $request = new Request([], [], ['_api_operation' => new Post(), '_api_operation_name' => 'operation', '_api_resource_class' => 'class']); + $request->setMethod('POST'); + $listener = new DeserializeListener($provider, $metadata); + $listener->onKernelRequest( + new RequestEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST + ) + ); + } + + #[DataProvider('provideNonApiAttributes')] + public function testNoCallProvider(...$attributes): void + { + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->never())->method('provide'); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $listener = new DeserializeListener($provider, $metadata); + $listener->onKernelRequest( + new RequestEvent( + $this->createStub(HttpKernelInterface::class), + new Request([], [], $attributes), + HttpKernelInterface::MAIN_REQUEST + ) + ); + } + + public function testDeserializeFalse(): void + { + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->never())->method('provide'); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $request = new Request([], [], ['_api_operation' => new Post(deserialize: false), '_api_operation_name' => 'operation', '_api_resource_class' => 'class']); + $request->setMethod('POST'); + $listener = new DeserializeListener($provider, $metadata); + $listener->onKernelRequest( + new RequestEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST + ) + ); + } + + public function testDeserializeNullWithGetMethod(): void + { + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->never())->method('provide'); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $request = new Request([], [], ['_api_operation' => new Get(), '_api_operation_name' => 'operation', '_api_resource_class' => 'class']); + $listener = new DeserializeListener($provider, $metadata); + $listener->onKernelRequest( + new RequestEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST + ) + ); + } + + public static function provideNonApiAttributes(): array + { + return [ + ['_api_resource_class' => 'dummy'], + ['_api_resource_class' => 'dummy', '_api_operation_name' => 'dummy'], + ['_api_receive' => false, '_api_operation_name' => 'dummy'], + [], + ]; + } +} diff --git a/tests/Symfony/EventListener/ErrorListenerTest.php b/src/Symfony/Tests/EventListener/ErrorListenerTest.php similarity index 52% rename from tests/Symfony/EventListener/ErrorListenerTest.php rename to src/Symfony/Tests/EventListener/ErrorListenerTest.php index 58d806af2bd..3bc622c1bdf 100644 --- a/tests/Symfony/EventListener/ErrorListenerTest.php +++ b/src/Symfony/Tests/EventListener/ErrorListenerTest.php @@ -11,52 +11,53 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Symfony\EventListener; +namespace ApiPlatform\Symfony\Tests\EventListener; -use ApiPlatform\Api\IdentifiersExtractorInterface; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\State\ApiResource\Error; use ApiPlatform\Symfony\EventListener\ErrorListener; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\KernelInterface; -final class ErrorListenerTest extends TestCase +class ErrorListenerTest extends TestCase { - use ProphecyTrait; - public function testDuplicateException(): void { $exception = new \Exception(); $operation = new Get(name: '_api_errors_problem', priority: 0, status: 400); - $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - $resourceMetadataCollectionFactory->create(Error::class) - ->willReturn(new ResourceMetadataCollection(Error::class, [new ApiResource(operations: [$operation])])); - $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolver->isResourceClass($exception::class)->willReturn(false); - $kernel = $this->prophesize(KernelInterface::class); - $kernel->handle(Argument::that(function ($request) { + $resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->expects($this->once())->method('create') + ->with(Error::class) + ->willReturn( + new ResourceMetadataCollection(Error::class, [new ApiResource(operations: [$operation])]) + ); + + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $resourceClassResolver->expects($this->once())->method('isResourceClass')->with($exception::class)->willReturn(false); + $kernel = $this->createStub(KernelInterface::class); + $kernel->method('handle')->willReturnCallback(function ($request) { $this->assertTrue($request->attributes->has('_api_original_route')); $this->assertTrue($request->attributes->has('_api_original_route_params')); $this->assertTrue($request->attributes->has('_api_requested_operation')); $this->assertTrue($request->attributes->has('_api_previous_operation')); $this->assertEquals('_api_errors_problem', $request->attributes->get('_api_operation_name')); - return true; - }), HttpKernelInterface::SUB_REQUEST, false)->willReturn(new Response()); + return new Response(); + }); + $request = Request::create('/'); $request->attributes->set('_api_operation', new Get(extraProperties: ['rfc_7807_compliant_errors' => true])); - $exceptionEvent = new ExceptionEvent($kernel->reveal(), $request, HttpKernelInterface::SUB_REQUEST, $exception); - $errorListener = new ErrorListener('action', null, true, [], $resourceMetadataCollectionFactory->reveal(), ['jsonproblem' => ['application/problem+json']], [], null, $resourceClassResolver->reveal(), problemCompliantErrors: true); + $exceptionEvent = new ExceptionEvent($kernel, $request, HttpKernelInterface::SUB_REQUEST, $exception); + $errorListener = new ErrorListener('action', null, true, [], $resourceMetadataCollectionFactory, ['jsonproblem' => ['application/problem+json']], [], null, $resourceClassResolver, problemCompliantErrors: true); $errorListener->onKernelException($exceptionEvent); } @@ -64,25 +65,29 @@ public function testDuplicateExceptionWithHydra(): void { $exception = new \Exception(); $operation = new Get(name: '_api_errors_hydra', priority: 0, status: 400); - $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - $resourceMetadataCollectionFactory->create(Error::class) - ->willReturn(new ResourceMetadataCollection(Error::class, [new ApiResource(operations: [$operation])])); - $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolver->isResourceClass($exception::class)->willReturn(false); - $kernel = $this->prophesize(KernelInterface::class); - $kernel->handle(Argument::that(function ($request) { + $resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->expects($this->once())->method('create') + ->with(Error::class) + ->willReturn( + new ResourceMetadataCollection(Error::class, [new ApiResource(operations: [$operation])]) + ); + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $resourceClassResolver->expects($this->once())->method('isResourceClass')->with($exception::class)->willReturn(false); + + $kernel = $this->createStub(KernelInterface::class); + $kernel->method('handle')->willReturnCallback(function ($request) { $this->assertTrue($request->attributes->has('_api_original_route')); $this->assertTrue($request->attributes->has('_api_original_route_params')); $this->assertTrue($request->attributes->has('_api_requested_operation')); $this->assertTrue($request->attributes->has('_api_previous_operation')); $this->assertEquals('_api_errors_hydra', $request->attributes->get('_api_operation_name')); - return true; - }), HttpKernelInterface::SUB_REQUEST, false)->willReturn(new Response()); + return new Response(); + }); $request = Request::create('/'); $request->attributes->set('_api_operation', new Get(extraProperties: ['rfc_7807_compliant_errors' => true])); - $exceptionEvent = new ExceptionEvent($kernel->reveal(), $request, HttpKernelInterface::SUB_REQUEST, $exception); - $errorListener = new ErrorListener('action', null, true, [], $resourceMetadataCollectionFactory->reveal(), ['jsonld' => ['application/ld+json']], [], null, $resourceClassResolver->reveal()); + $exceptionEvent = new ExceptionEvent($kernel, $request, HttpKernelInterface::SUB_REQUEST, $exception); + $errorListener = new ErrorListener('action', null, true, [], $resourceMetadataCollectionFactory, ['jsonld' => ['application/ld+json']], [], null, $resourceClassResolver); $errorListener->onKernelException($exceptionEvent); } @@ -90,13 +95,18 @@ public function testDuplicateExceptionWithErrorResource(): void { $exception = Error::createFromException(new \Exception(), 400); $operation = new Get(name: '_api_errors_hydra', priority: 0, status: 400, outputFormats: ['jsonld' => ['application/ld+json']]); - $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - $resourceMetadataCollectionFactory->create(Error::class) - ->willReturn(new ResourceMetadataCollection(Error::class, [new ApiResource(operations: [$operation])])); - $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolver->isResourceClass(Error::class)->willReturn(true); - $kernel = $this->prophesize(KernelInterface::class); - $kernel->handle(Argument::that(function ($request) { + $resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->expects($this->once())->method('create') + ->with(Error::class) + ->willReturn( + new ResourceMetadataCollection(Error::class, [new ApiResource(operations: [$operation])]) + ); + + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $resourceClassResolver->expects($this->once())->method('isResourceClass')->with(Error::class)->willReturn(true); + + $kernel = $this->createStub(KernelInterface::class); + $kernel->method('handle')->willReturnCallback(function ($request) { $this->assertTrue($request->attributes->has('_api_original_route')); $this->assertTrue($request->attributes->has('_api_original_route_params')); $this->assertTrue($request->attributes->has('_api_requested_operation')); @@ -117,33 +127,34 @@ public function testDuplicateExceptionWithErrorResource(): void ], ]); - return true; - }), HttpKernelInterface::SUB_REQUEST, false)->willReturn(new Response()); + return new Response(); + }); $request = Request::create('/'); $request->attributes->set('_api_operation', new Get(extraProperties: ['rfc_7807_compliant_errors' => true])); - $exceptionEvent = new ExceptionEvent($kernel->reveal(), $request, HttpKernelInterface::SUB_REQUEST, $exception); - $identifiersExtractor = $this->prophesize(IdentifiersExtractorInterface::class); - $identifiersExtractor->getIdentifiersFromItem($exception, Argument::any())->willReturn(['id' => 1]); - $errorListener = new ErrorListener('action', null, true, [], $resourceMetadataCollectionFactory->reveal(), ['jsonld' => ['application/ld+json']], [], $identifiersExtractor->reveal(), $resourceClassResolver->reveal()); + $exceptionEvent = new ExceptionEvent($kernel, $request, HttpKernelInterface::SUB_REQUEST, $exception); + $identifiersExtractor = $this->createStub(IdentifiersExtractorInterface::class); + $identifiersExtractor->method('getIdentifiersFromItem')->willReturn(['id' => 1]); + $errorListener = new ErrorListener('action', null, true, [], $resourceMetadataCollectionFactory, ['jsonld' => ['application/ld+json']], [], $identifiersExtractor, $resourceClassResolver); $errorListener->onKernelException($exceptionEvent); } public function testDisableErrorResourceHandling(): void { $exception = Error::createFromException(new \Exception(), 400); - $operation = new Get(name: '_api_errors_hydra', priority: 0, status: 400, outputFormats: ['jsonld' => ['application/ld+json']]); - $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); - $kernel = $this->prophesize(KernelInterface::class); - $kernel->handle(Argument::that(function ($request) { + $resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->expects($this->never())->method('create'); + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $resourceClassResolver->expects($this->never())->method('isResourceClass'); + $kernel = $this->createStub(KernelInterface::class); + $kernel->method('handle')->willReturnCallback(function ($request) { $this->assertEquals($request->attributes->get('_api_operation'), null); - return true; - }), HttpKernelInterface::SUB_REQUEST, false)->willReturn(new Response()); - $exceptionEvent = new ExceptionEvent($kernel->reveal(), Request::create('/'), HttpKernelInterface::SUB_REQUEST, $exception); - $identifiersExtractor = $this->prophesize(IdentifiersExtractorInterface::class); - $identifiersExtractor->getIdentifiersFromItem($exception, Argument::any())->willReturn(['id' => 1]); - $errorListener = new ErrorListener('action', null, true, [], $resourceMetadataCollectionFactory->reveal(), ['jsonld' => ['application/ld+json']], [], $identifiersExtractor->reveal(), $resourceClassResolver->reveal(), null, false); + return new Response(); + }); + + $exceptionEvent = new ExceptionEvent($kernel, Request::create('/'), HttpKernelInterface::SUB_REQUEST, $exception); + $identifiersExtractor = $this->createStub(IdentifiersExtractorInterface::class); + $errorListener = new ErrorListener('action', null, true, [], $resourceMetadataCollectionFactory, ['jsonld' => ['application/ld+json']], [], $identifiersExtractor, $resourceClassResolver, null, false); $errorListener->onKernelException($exceptionEvent); } } diff --git a/src/Symfony/Tests/EventListener/QueryParameterValidateListenerTest.php b/src/Symfony/Tests/EventListener/QueryParameterValidateListenerTest.php index cf9c7bb098a..17d4731e4ee 100644 --- a/src/Symfony/Tests/EventListener/QueryParameterValidateListenerTest.php +++ b/src/Symfony/Tests/EventListener/QueryParameterValidateListenerTest.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\ParameterValidator\Exception\ValidationException; use ApiPlatform\ParameterValidator\ParameterValidator; +use ApiPlatform\State\ProviderInterface; use ApiPlatform\Symfony\EventListener\QueryParameterValidateListener; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; use PHPUnit\Framework\TestCase; @@ -37,6 +38,7 @@ class QueryParameterValidateListenerTest extends TestCase private ObjectProphecy $queryParameterValidator; /** + * @group legacy * unsafe method should not use filter validations. */ public function testOnKernelRequestWithUnsafeMethod(): void @@ -54,6 +56,9 @@ public function testOnKernelRequestWithUnsafeMethod(): void $this->testedInstance->onKernelRequest($eventProphecy->reveal()); } + /** + * @group legacy + */ public function testDoNotValidateWhenDisabledGlobally(): void { $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); @@ -79,6 +84,9 @@ public function testDoNotValidateWhenDisabledGlobally(): void $listener->onKernelRequest($eventProphecy->reveal()); } + /** + * @group legacy + */ public function testDoNotValidateWhenDisabledInOperationAttribute(): void { $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); @@ -106,6 +114,8 @@ public function testDoNotValidateWhenDisabledInOperationAttribute(): void /** * If the tested filter is non-existent, then nothing should append. + * + * @group legacy */ public function testOnKernelRequestWithWrongFilter(): void { @@ -124,6 +134,8 @@ public function testOnKernelRequestWithWrongFilter(): void /** * if the required parameter is not set, throw an ValidationException. + * + * @group legacy */ public function testOnKernelRequestWithRequiredFilterNotSet(): void { @@ -146,6 +158,8 @@ public function testOnKernelRequestWithRequiredFilterNotSet(): void /** * if the required parameter is set, no exception should be thrown. + * + * @group legacy */ public function testOnKernelRequestWithRequiredFilter(): void { @@ -187,4 +201,33 @@ private function setUpWithFilters(array $filters = []): void $resourceMetadataFactoryProphecy->reveal(), ); } + + public function testOnKernelRequest(): void + { + $request = new Request( + [], + [], + ['_api_resource_class' => Dummy::class, '_api_operation' => new GetCollection()], + [], + [], + ['QUERY_STRING' => 'required=foo'] + ); + $request->setMethod('GET'); + + $event = $this->createMock(RequestEvent::class); + $event->expects($this->any()) + ->method('getRequest') + ->willReturn($request); + + $resourceMetadataFactoryProphecy = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once()) + ->method('provide'); + + $qp = new QueryParameterValidateListener( + $provider, + $resourceMetadataFactoryProphecy, + ); + $qp->onKernelRequest($event); + } } diff --git a/src/Symfony/Tests/EventListener/ReadListenerTest.php b/src/Symfony/Tests/EventListener/ReadListenerTest.php new file mode 100644 index 00000000000..26c05ae1fbd --- /dev/null +++ b/src/Symfony/Tests/EventListener/ReadListenerTest.php @@ -0,0 +1,150 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Tests\EventListener; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\UriVariablesConverterInterface; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Symfony\EventListener\ReadListener; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +class ReadListenerTest extends TestCase +{ + public function testFetchOperation(): void + { + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once())->method('provide'); + $metadata = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $metadata->expects($this->once())->method('create')->with('class')->willReturn(new ResourceMetadataCollection('class', [ + new ApiResource(operations: [ + 'operation' => new Get(), + ]), + ])); + + $request = new Request([], [], ['_api_operation_name' => 'operation', '_api_resource_class' => 'class']); + $listener = new ReadListener($provider, $metadata); + $listener->onKernelRequest( + new RequestEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST + ) + ); + } + + public function testCallProvider(): void + { + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once())->method('provide'); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $request = new Request([], [], ['_api_operation' => new Get(), '_api_operation_name' => 'operation', '_api_resource_class' => 'class']); + $listener = new ReadListener($provider, $metadata); + $listener->onKernelRequest( + new RequestEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST + ) + ); + } + + #[DataProvider('provideNonApiAttributes')] + public function testNoCallProvider(...$attributes): void + { + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->never())->method('provide'); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $listener = new ReadListener($provider, $metadata); + $listener->onKernelRequest( + new RequestEvent( + $this->createStub(HttpKernelInterface::class), + new Request([], [], $attributes), + HttpKernelInterface::MAIN_REQUEST + ) + ); + } + + public function testReadFalse(): void + { + $operation = new Get(read: false); + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once())->method('provide')->with($operation); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $request = new Request([], [], ['_api_operation' => $operation, '_api_operation_name' => 'operation', '_api_resource_class' => 'class']); + $listener = new ReadListener($provider, $metadata); + $listener->onKernelRequest( + new RequestEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST + ) + ); + } + + public function testReadWithUriVariables(): void + { + $operation = new Get(uriVariables: ['id' => new Link(identifiers: ['id'])], class: 'class'); + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once())->method('provide')->with($operation->withRead(true), ['id' => 3]); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $uriVariablesConverter = $this->createMock(UriVariablesConverterInterface::class); + $uriVariablesConverter->expects($this->once())->method('convert')->with(['id' => '3'], 'class')->willReturn(['id' => 3]); + $request = new Request([], [], ['_api_operation' => $operation, '_api_operation_name' => 'operation', '_api_resource_class' => 'class', 'id' => '3']); + $listener = new ReadListener($provider, $metadata, null, $uriVariablesConverter); + $listener->onKernelRequest( + new RequestEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST + ) + ); + } + + public function testReadNullWithPostMethod(): void + { + $operation = new Post(read: false); + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once())->method('provide')->with($operation); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $request = new Request([], [], ['_api_operation' => new Post(), '_api_operation_name' => 'operation', '_api_resource_class' => 'class']); + $request->setMethod('POST'); + $listener = new ReadListener($provider, $metadata); + $listener->onKernelRequest( + new RequestEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST + ) + ); + } + + public static function provideNonApiAttributes(): array + { + return [ + ['_api_resource_class' => 'dummy'], + ['_api_resource_class' => 'dummy', '_api_operation_name' => 'dummy'], + ['_api_receive' => false, '_api_operation_name' => 'dummy'], + [], + ]; + } +} diff --git a/src/Symfony/Tests/EventListener/RespondListenerTest.php b/src/Symfony/Tests/EventListener/RespondListenerTest.php new file mode 100644 index 00000000000..d897198dae3 --- /dev/null +++ b/src/Symfony/Tests/EventListener/RespondListenerTest.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Tests\EventListener; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\Symfony\EventListener\RespondListener; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\ViewEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +class RespondListenerTest extends TestCase +{ + public function testFetchOperation(): void + { + $controllerResult = new \stdClass(); + $processor = $this->createMock(ProcessorInterface::class); + $processor->expects($this->once())->method('process')->willReturn(new Response()); + $metadata = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $metadata->expects($this->once())->method('create')->with('class')->willReturn(new ResourceMetadataCollection('class', [ + new ApiResource(operations: [ + 'operation' => new Get(), + ]), + ])); + + $request = new Request([], [], ['_api_operation_name' => 'operation', '_api_resource_class' => 'class']); + $listener = new RespondListener($metadata, $processor); + $listener->onKernelView( + new ViewEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $controllerResult + ) + ); + } + + public function testCallProcessor(): void + { + $controllerResult = new \stdClass(); + $processor = $this->createMock(ProcessorInterface::class); + $processor->expects($this->once())->method('process')->willReturn(new Response()); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $request = new Request([], [], ['_api_operation' => new Get(), '_api_operation_name' => 'operation', '_api_resource_class' => 'class']); + $listener = new RespondListener($metadata, $processor); + $listener->onKernelView( + new ViewEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $controllerResult + ) + ); + } + + public function testCallProcessorContext(): void + { + $operation = new Get(class: 'class'); + $controllerResult = new \stdClass(); + $originalData = new \stdClass(); + $uriVariables = ['id' => 3]; + $request = new Request([], [], ['_api_operation' => $operation, '_api_operation_name' => 'operation', '_api_resource_class' => 'class', '_api_uri_variables' => $uriVariables, 'original_data' => $originalData]); + $processor = $this->createMock(ProcessorInterface::class); + $processor->expects($this->once())->method('process') + ->with($controllerResult, $operation, $uriVariables, ['request' => $request, 'uri_variables' => $uriVariables, 'resource_class' => 'class', 'original_data' => $originalData])->willReturn(new Response()); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $listener = new RespondListener($metadata, $processor); + $listener->onKernelView( + new ViewEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $controllerResult + ) + ); + } + + #[DataProvider('provideNonApiAttributes')] + public function testNoCallProcessor(...$attributes): void + { + $controllerResult = new \stdClass(); + $processor = $this->createMock(ProcessorInterface::class); + $processor->expects($this->never())->method('process')->willReturn(new Response()); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $request = new Request([], [], $attributes); + $listener = new RespondListener($metadata, $processor); + $listener->onKernelView( + new ViewEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $controllerResult + ) + ); + } + + public static function provideNonApiAttributes(): array + { + return [ + ['_api_resource_class' => 'dummy'], + ['_api_resource_class' => 'dummy', '_api_operation_name' => 'dummy'], + ['_api_respond' => false, '_api_operation_name' => 'dummy'], + [], + ]; + } +} diff --git a/src/Symfony/Tests/EventListener/SerializeListenerTest.php b/src/Symfony/Tests/EventListener/SerializeListenerTest.php new file mode 100644 index 00000000000..2ffdae3ebda --- /dev/null +++ b/src/Symfony/Tests/EventListener/SerializeListenerTest.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Tests\EventListener; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\Symfony\EventListener\SerializeListener; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\ViewEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +class SerializeListenerTest extends TestCase +{ + public function testFetchOperation(): void + { + $controllerResult = new \stdClass(); + $processor = $this->createMock(ProcessorInterface::class); + $processor->expects($this->once())->method('process')->willReturn(new Response()); + $metadata = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $metadata->expects($this->once())->method('create')->with('class')->willReturn(new ResourceMetadataCollection('class', [ + new ApiResource(operations: [ + 'operation' => new Get(), + ]), + ])); + + $request = new Request([], [], ['_api_operation_name' => 'operation', '_api_resource_class' => 'class']); + $listener = new SerializeListener($processor, $metadata); + $listener->onKernelView( + new ViewEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $controllerResult + ) + ); + } + + public function testCallProcessor(): void + { + $controllerResult = new \stdClass(); + $processor = $this->createMock(ProcessorInterface::class); + $processor->expects($this->once())->method('process')->willReturn(new Response()); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $request = new Request([], [], ['_api_operation' => new Get(), '_api_operation_name' => 'operation', '_api_resource_class' => 'class']); + $listener = new SerializeListener($processor, $metadata); + $listener->onKernelView( + new ViewEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $controllerResult + ) + ); + } + + public function testCallProcessorContext(): void + { + $operation = new Get(class: 'class'); + $controllerResult = new \stdClass(); + $uriVariables = ['id' => 3]; + $request = new Request([], [], ['_api_operation' => $operation, '_api_operation_name' => 'operation', '_api_resource_class' => 'class', '_api_uri_variables' => $uriVariables]); + $processor = $this->createMock(ProcessorInterface::class); + $processor->expects($this->once())->method('process') + ->with($controllerResult, $operation->withSerialize(true), $uriVariables, ['request' => $request, 'uri_variables' => $uriVariables, 'resource_class' => 'class'])->willReturn(new Response()); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $listener = new SerializeListener($processor, $metadata); + $listener->onKernelView( + new ViewEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $controllerResult + ) + ); + } + + #[DataProvider('provideNonApiAttributes')] + public function testNoCallProcessor(...$attributes): void + { + $controllerResult = new \stdClass(); + $processor = $this->createMock(ProcessorInterface::class); + $processor->expects($this->never())->method('process')->willReturn(new Response()); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $request = new Request([], [], $attributes); + $listener = new SerializeListener($processor, $metadata); + $listener->onKernelView( + new ViewEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $controllerResult + ) + ); + } + + public static function provideNonApiAttributes(): array + { + return [ + ['_api_resource_class' => 'dummy'], + ['_api_resource_class' => 'dummy', '_api_operation_name' => 'dummy'], + ['_api_respond' => false, '_api_operation_name' => 'dummy'], + [], + ]; + } +} diff --git a/src/Symfony/Tests/EventListener/ValidateListenerTest.php b/src/Symfony/Tests/EventListener/ValidateListenerTest.php new file mode 100644 index 00000000000..f686b5e40cf --- /dev/null +++ b/src/Symfony/Tests/EventListener/ValidateListenerTest.php @@ -0,0 +1,167 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Tests\EventListener; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Symfony\EventListener\ValidateListener; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\ViewEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +class ValidateListenerTest extends TestCase +{ + public function testFetchOperation(): void + { + $controllerResult = new \stdClass(); + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once())->method('provide'); + $metadata = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $metadata->expects($this->once())->method('create')->with('class')->willReturn(new ResourceMetadataCollection('class', [ + new ApiResource(operations: [ + 'operation' => new Post(), + ]), + ])); + + $request = new Request([], [], ['_api_operation_name' => 'operation', '_api_resource_class' => 'class']); + $listener = new ValidateListener($provider, $metadata); + $listener->onKernelView( + new ViewEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $controllerResult + ) + ); + } + + public function testCallprovider(): void + { + $controllerResult = new \stdClass(); + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once())->method('provide'); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $request = new Request([], [], ['_api_operation' => new Post(), '_api_operation_name' => 'operation', '_api_resource_class' => 'class']); + $listener = new ValidateListener($provider, $metadata); + $listener->onKernelView( + new ViewEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $controllerResult + ) + ); + } + + public function testCallproviderContext(): void + { + $operation = new Post(class: 'class'); + $controllerResult = new \stdClass(); + $uriVariables = ['id' => 3]; + $request = new Request([], [], ['_api_operation' => $operation, '_api_operation_name' => 'operation', '_api_resource_class' => 'class', '_api_uri_variables' => $uriVariables]); + $request->setMethod($operation->getMethod()); + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once())->method('provide') + ->with($operation->withValidate(true), $uriVariables, ['request' => $request, 'uri_variables' => $uriVariables, 'resource_class' => 'class']); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $listener = new ValidateListener($provider, $metadata); + $listener->onKernelView( + new ViewEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $controllerResult + ) + ); + } + + public function testDeleteNoValidate(): void + { + $operation = new Delete(class: 'class'); + $controllerResult = new \stdClass(); + $uriVariables = ['id' => 3]; + $request = new Request([], [], ['_api_operation' => $operation, '_api_operation_name' => 'operation', '_api_resource_class' => 'class', '_api_uri_variables' => $uriVariables]); + $request->setMethod($operation->getMethod()); + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once())->method('provide') + ->with($operation->withValidate(false), $uriVariables, ['request' => $request, 'uri_variables' => $uriVariables, 'resource_class' => 'class']); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $listener = new ValidateListener($provider, $metadata); + $listener->onKernelView( + new ViewEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $controllerResult + ) + ); + } + + public function testDeleteForceValidate(): void + { + $operation = new Delete(class: 'class', validate: true); + $controllerResult = new \stdClass(); + $uriVariables = ['id' => 3]; + $request = new Request([], [], ['_api_operation' => $operation, '_api_operation_name' => 'operation', '_api_resource_class' => 'class', '_api_uri_variables' => $uriVariables]); + $request->setMethod($operation->getMethod()); + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once())->method('provide') + ->with($operation->withValidate(true), $uriVariables, ['request' => $request, 'uri_variables' => $uriVariables, 'resource_class' => 'class']); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $listener = new ValidateListener($provider, $metadata); + $listener->onKernelView( + new ViewEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $controllerResult + ) + ); + } + + #[DataProvider('provideNonApiAttributes')] + public function testNoCallprovider(...$attributes): void + { + $controllerResult = new \stdClass(); + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->never())->method('provide'); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $request = new Request([], [], $attributes); + $listener = new ValidateListener($provider, $metadata); + $listener->onKernelView( + new ViewEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $controllerResult + ) + ); + } + + public static function provideNonApiAttributes(): array + { + return [ + ['_api_resource_class' => 'dummy'], + ['_api_resource_class' => 'dummy', '_api_operation_name' => 'dummy'], + ['_api_respond' => false, '_api_operation_name' => 'dummy'], + [], + ]; + } +} diff --git a/src/Symfony/Tests/EventListener/WriteListenerTest.php b/src/Symfony/Tests/EventListener/WriteListenerTest.php new file mode 100644 index 00000000000..98280b2f3d8 --- /dev/null +++ b/src/Symfony/Tests/EventListener/WriteListenerTest.php @@ -0,0 +1,150 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Tests\EventListener; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\UriVariablesConverterInterface; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\Symfony\EventListener\WriteListener; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\ViewEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +class WriteListenerTest extends TestCase +{ + public function testFetchOperation(): void + { + $controllerResult = new \stdClass(); + $processor = $this->createMock(ProcessorInterface::class); + $processor->expects($this->once())->method('process')->willReturn(new Response()); + $metadata = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $metadata->expects($this->once())->method('create')->with('class')->willReturn(new ResourceMetadataCollection('class', [ + new ApiResource(operations: [ + 'operation' => new Get(), + ]), + ])); + + $request = new Request([], [], ['_api_operation_name' => 'operation', '_api_resource_class' => 'class']); + $listener = new WriteListener($processor, $metadata); + $listener->onKernelView( + new ViewEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $controllerResult + ) + ); + } + + public function testCallProcessor(): void + { + $controllerResult = new \stdClass(); + $processor = $this->createMock(ProcessorInterface::class); + $processor->expects($this->once())->method('process')->willReturn(new Response()); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $request = new Request([], [], ['_api_operation' => new Get(), '_api_operation_name' => 'operation', '_api_resource_class' => 'class']); + $listener = new WriteListener($processor, $metadata); + $listener->onKernelView( + new ViewEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $controllerResult + ) + ); + } + + public function testCallProcessorContext(): void + { + $operation = new Get(class: 'class'); + $controllerResult = new \stdClass(); + $originalData = new \stdClass(); + $uriVariables = ['id' => 3]; + $returnValue = new \stdClass(); + $request = new Request([], [], ['_api_operation' => $operation, '_api_operation_name' => 'operation', '_api_resource_class' => 'class', '_api_uri_variables' => $uriVariables, 'previous_data' => $originalData]); + $processor = $this->createMock(ProcessorInterface::class); + $processor->expects($this->once())->method('process') + ->with($controllerResult, $operation, $uriVariables, ['request' => $request, 'uri_variables' => $uriVariables, 'resource_class' => 'class', 'previous_data' => $originalData])->willReturn($returnValue); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $listener = new WriteListener($processor, $metadata); + $listener->onKernelView( + new ViewEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $controllerResult + ) + ); + $this->assertEquals($returnValue, $request->attributes->get('original_data')); + } + + #[DataProvider('provideNonApiAttributes')] + public function testNoCallProcessor(...$attributes): void + { + $controllerResult = new \stdClass(); + $processor = $this->createMock(ProcessorInterface::class); + $processor->expects($this->never())->method('process')->willReturn(new Response()); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $request = new Request([], [], $attributes); + $listener = new WriteListener($processor, $metadata); + $listener->onKernelView( + new ViewEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $controllerResult + ) + ); + } + + public static function provideNonApiAttributes(): array + { + return [ + ['_api_resource_class' => 'dummy'], + ['_api_resource_class' => 'dummy', '_api_operation_name' => 'dummy'], + ['_api_persist' => false, '_api_operation_name' => 'dummy'], + [], + ]; + } + + public function testWriteWithUriVariables(): void + { + $controllerResult = new \stdClass(); + $operation = new Post(uriVariables: ['id' => new Link(identifiers: ['id'])], class: 'class'); + $provider = $this->createMock(ProcessorInterface::class); + $provider->expects($this->once())->method('process')->with($controllerResult, $operation->withWrite(true), ['id' => 3]); + $metadata = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $uriVariablesConverter = $this->createMock(UriVariablesConverterInterface::class); + $uriVariablesConverter->expects($this->once())->method('convert')->with(['id' => '3'], 'class')->willReturn(['id' => 3]); + $request = new Request([], [], ['_api_operation' => $operation, '_api_operation_name' => 'operation', '_api_resource_class' => 'class', 'id' => '3']); + $request->setMethod($operation->getMethod()); + $listener = new WriteListener($provider, $metadata, uriVariablesConverter: $uriVariablesConverter); + $listener->onKernelView( + new ViewEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $controllerResult + ) + ); + } +} diff --git a/src/Symfony/Validator/Exception/ValidationException.php b/src/Symfony/Validator/Exception/ValidationException.php index 839319dc928..cf3fdc8902c 100644 --- a/src/Symfony/Validator/Exception/ValidationException.php +++ b/src/Symfony/Validator/Exception/ValidationException.php @@ -44,7 +44,8 @@ normalizationContext: ['groups' => ['json'], 'skip_null_values' => true, 'rfc_7807_compliant_errors' => true, - ]), + ] + ), new ErrorOperation( name: '_api_validation_errors_hydra', outputFormats: ['jsonld' => ['application/problem+json']], diff --git a/src/Symfony/Validator/State/QueryParameterValidateProvider.php b/src/Symfony/Validator/State/QueryParameterValidateProvider.php index d0aab33a8cb..3e21eecd96d 100644 --- a/src/Symfony/Validator/State/QueryParameterValidateProvider.php +++ b/src/Symfony/Validator/State/QueryParameterValidateProvider.php @@ -23,7 +23,7 @@ final class QueryParameterValidateProvider implements ProviderInterface { - public function __construct(private readonly ProviderInterface $decorated, private readonly ParameterValidator $parameterValidator) + public function __construct(private readonly ?ProviderInterface $decorated, private readonly ParameterValidator $parameterValidator) { } @@ -35,11 +35,11 @@ public function provide(Operation $operation, array $uriVariables = [], array $c || !$request->isMethodSafe() || 'GET' !== $request->getMethod() ) { - return $this->decorated->provide($operation, $uriVariables, $context); + return $this->decorated?->provide($operation, $uriVariables, $context); } if (!($operation->getQueryParameterValidationEnabled() ?? true) || !$operation instanceof CollectionOperationInterface) { - return $this->decorated->provide($operation, $uriVariables, $context); + return $this->decorated?->provide($operation, $uriVariables, $context); } $queryString = RequestParser::getQueryString($request); @@ -51,6 +51,6 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $this->parameterValidator->validateFilters($class, $operation->getFilters() ?? [], $queryParameters); - return $this->decorated->provide($operation, $uriVariables, $context); + return $this->decorated?->provide($operation, $uriVariables, $context); } } diff --git a/src/Symfony/Validator/State/ValidateProvider.php b/src/Symfony/Validator/State/ValidateProvider.php index ec40eff9d81..aff0488ee7e 100644 --- a/src/Symfony/Validator/State/ValidateProvider.php +++ b/src/Symfony/Validator/State/ValidateProvider.php @@ -23,13 +23,13 @@ */ final class ValidateProvider implements ProviderInterface { - public function __construct(private readonly ProviderInterface $decorated, private readonly ValidatorInterface $validator) + public function __construct(private readonly ?ProviderInterface $decorated, private readonly ValidatorInterface $validator) { } public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - $body = $this->decorated->provide($operation, $uriVariables, $context); + $body = $this->decorated?->provide($operation, $uriVariables, $context) ?? ($context['request'] ?? null)?->attributes->get('data'); if ($body instanceof Response || !$body) { return $body; diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index e1672e58332..28c5bc3749d 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -31,18 +31,18 @@ "api-platform/serializer": "*@dev || ^3.1", "api-platform/state": "*@dev || ^3.1", "api-platform/validator": "*@dev || ^3.1", + "api-platform/doctrine-common": "*@dev || ^3.1", "symfony/property-info": "^6.1", "symfony/serializer": "^6.1", "symfony/security-core": "^6.1" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.0", - "symfony/phpunit-bridge": "^6.1", + "phpunit/phpunit": "^10", + "symfony/mercure-bundle": "*", "symfony/routing": "^6.1", "symfony/validator": "^6.1", - "symfony/mercure-bundle": "*", - "webonyx/graphql-php": "^14.0 || ^15.0", - "sebastian/comparator": "<5.0" + "webonyx/graphql-php": "^14.0 || ^15.0" }, "autoload": { "psr-4": { @@ -59,7 +59,8 @@ "sort-packages": true, "allow-plugins": { "composer/package-versions-deprecated": true, - "phpstan/extension-installer": true + "phpstan/extension-installer": true, + "php-http/discovery": false } }, "extra": { @@ -82,6 +83,10 @@ { "type": "path", "url": "../State" + }, + { + "type": "path", + "url": "../Doctrine/Common" } ] } diff --git a/src/Symfony/phpunit.xml.dist b/src/Symfony/phpunit.xml.dist index c04b1d7a3ee..b1a71f31cc1 100644 --- a/src/Symfony/phpunit.xml.dist +++ b/src/Symfony/phpunit.xml.dist @@ -1,30 +1,20 @@ - - - - - - - - - ./Tests/ - - - - - - ./ - - - ./Tests - ./vendor - - + + + + + + + ./Tests/ + + + + + ./ + + + ./Tests + ./vendor + + diff --git a/tests/.ignored-deprecations-legacy-events b/tests/.ignored-deprecations-legacy-events new file mode 100644 index 00000000000..d15238d82fc --- /dev/null +++ b/tests/.ignored-deprecations-legacy-events @@ -0,0 +1,22 @@ +# No fix available yet, see https://github.com/doctrine/dbal/issues/5784 +%Subscribing to onSchemaCreateTable events is deprecated\.% + +# Fixed by https://github.com/doctrine/orm/pull/10855 +%Column::setCustomSchemaOptions\(\) is deprecated\. Use setPlatformOptions\(\) instead\.% + +# Fixed in DoctrineMongoDBBundle 4.6 +%Accessing Doctrine\\Common\\Lexer\\Token properties via ArrayAccess is deprecated, use the value, type or position property instead% +%Do the same.*Doctrine\\Bundle\\MongoDBBundle% + +# These are expected for the events legacy layer +%Since api-platform/core 3.3: The "event_listeners_backward_compatibility_layer" will be removed in 4.0. Use the configuration "use_symfony_listeners" to use Symfony listeners. The following listeners are deprecated and will be removed in API Platform 4.0: "ApiPlatform\\Symfony\\EventListener\\AddHeadersListener, ApiPlatform\\Symfony\\EventListener\\AddTagsListener, ApiPlatform\\Symfony\\EventListener\\AddLinkHeaderListener, ApiPlatform\\Hydra\\EventListener\\AddLinkHeaderListener, ApiPlatform\\Symfony\\EventListener\\DenyAccessListener"% + +%Since api-platform/core 3.3: Use a "ApiPlatform\\State\\ProviderInterface" as first argument in "ApiPlatform\\Symfony\\EventListener\\DeserializeListener" instead of "Symfony\\Component\\Serializer\\SerializerInterface".% + +%Since api-platform/core 3.3: Use a "ApiPlatform\\State\\Provider\\ReadProvider" as first argument in "ApiPlatform\\Symfony\\EventListener\\ReadListener" instead of "ApiPlatform\\State\\CallableProvider".% + +%Since api-platform/core 3.3: Use a "ApiPlatform\\State\\ProviderInterface" as first argument in "ApiPlatform\\Symfony\\EventListener\\QueryParameterValidateListener" instead of "ApiPlatform\\ParameterValidator\\ParameterValidator".% + +%Since api-platform/core 3.3: Use a "ApiPlatform\\State\\ProviderInterface" as first argument in "ApiPlatform\\Symfony\\EventListener\\AddFormatListener" instead of "Negotiation\\Negotiator".% + +%Since api-platform/core 3.3: Use a "ApiPlatform\\Metadata\\Resource\\Factory\\ResourceMetadataCollectionFactoryInterface" as second argument in "ApiPlatform\\Symfony\\EventListener\\DeserializeListener" instead of "ApiPlatform\\Serializer\\SerializerContextBuilderInterface".% diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index b1b8b7dde18..3b740a41348 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -237,16 +237,22 @@ class_exists(NativePasswordHasher::class) ? 'password_hashers' : 'encoders' => [ $c->prependExtensionConfig('twig', $twigConfig); $metadataBackwardCompatibilityLayer = (bool) ($_SERVER['EVENT_LISTENERS_BACKWARD_COMPATIBILITY_LAYER'] ?? false); + $useSymfonyListeners = (bool) ($_SERVER['USE_SYMFONY_LISTENERS'] ?? false); $rfc7807CompliantErrors = (bool) ($_SERVER['RFC_7807_COMPLIANT_ERRORS'] ?? true); - $c->prependExtensionConfig('api_platform', [ + $legacyConfig = []; + if ($metadataBackwardCompatibilityLayer) { + $legacyConfig = ['event_listeners_backward_compatibility_layer' => $metadataBackwardCompatibilityLayer]; + } + + $c->prependExtensionConfig('api_platform', $legacyConfig + [ 'mapping' => [ 'paths' => ['%kernel.project_dir%/../TestBundle/Resources/config/api_resources'], ], 'graphql' => [ 'graphql_playground' => false, ], - 'event_listeners_backward_compatibility_layer' => $metadataBackwardCompatibilityLayer, + 'use_symfony_listeners' => $useSymfonyListeners, 'defaults' => [ 'pagination_client_enabled' => true, 'pagination_client_items_per_page' => true, diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 49db87d92cd..7141cb6051f 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -80,7 +80,6 @@ api_platform: keep_legacy_inflector: false enable_link_security: true # see also defaults in AppKernel - doctrine_mongodb_odm: false mapping: paths: diff --git a/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 0c355e9edef..144ed64eee3 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -78,6 +78,12 @@ use Symfony\Component\Uid\AbstractUid; use Symfony\Component\Validator\Validator\ValidatorInterface; +/** + * The target configuration for API Platform 4 is at src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php + * this holds tests for the legacy configuration having event_listeners_backward_compatibility_layer=true. + * + * @group legacy + */ class ApiPlatformExtensionTest extends TestCase { use ExpectDeprecationTrait; @@ -156,6 +162,7 @@ class ApiPlatformExtensionTest extends TestCase 'graphql_playground' => ['enabled' => false], ], 'keep_legacy_inflector' => false, + 'event_listeners_backward_compatibility_layer' => true, ]]; private ContainerBuilder $container; diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 432c3fc367d..99026c4f37e 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -225,7 +225,8 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'enabled' => true, ], 'keep_legacy_inflector' => true, - 'event_listeners_backward_compatibility_layer' => true, + 'event_listeners_backward_compatibility_layer' => null, + 'use_symfony_listeners' => false, 'handle_symfony_errors' => false, 'enable_link_security' => false, ], $config); diff --git a/tests/Symfony/EventListener/AddFormatListenerTest.php b/tests/Symfony/EventListener/AddFormatListenerTest.php index f1f9cb36a1e..2b8a431d324 100644 --- a/tests/Symfony/EventListener/AddFormatListenerTest.php +++ b/tests/Symfony/EventListener/AddFormatListenerTest.php @@ -29,6 +29,8 @@ /** * @author Kévin Dunglas + * + * @group legacy */ class AddFormatListenerTest extends TestCase { diff --git a/tests/Symfony/EventListener/DeserializeListenerTest.php b/tests/Symfony/EventListener/DeserializeListenerTest.php index f12489bb6e0..3766a7e2d72 100644 --- a/tests/Symfony/EventListener/DeserializeListenerTest.php +++ b/tests/Symfony/EventListener/DeserializeListenerTest.php @@ -37,6 +37,8 @@ /** * @author Kévin Dunglas + * + * @group legacy */ class DeserializeListenerTest extends TestCase { diff --git a/tests/Symfony/EventListener/ReadListenerTest.php b/tests/Symfony/EventListener/ReadListenerTest.php index 505c930ca51..fb78c7e3339 100644 --- a/tests/Symfony/EventListener/ReadListenerTest.php +++ b/tests/Symfony/EventListener/ReadListenerTest.php @@ -22,6 +22,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\State\CallableProvider; use ApiPlatform\State\ProviderInterface; use ApiPlatform\Symfony\EventListener\ReadListener; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; @@ -31,6 +32,9 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\RequestEvent; +/** + * @group legacy + */ class ReadListenerTest extends TestCase { use ProphecyTrait; @@ -79,8 +83,7 @@ public function testDoNotReadWhenReadIsFalse(): void $event = $this->prophesize(RequestEvent::class); $event->getRequest()->willReturn($request); - $provider = $this->prophesize(ProviderInterface::class); - $provider->provide()->shouldNotBeCalled(); + $provider = new CallableProvider(); $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ (new ApiResource())->withOperations(new Operations([ @@ -88,8 +91,7 @@ public function testDoNotReadWhenReadIsFalse(): void ])), ])); $serializerContextBuilder = $this->prophesize(SerializerContextBuilderInterface::class); - $uriVariablesConverter = $this->prophesize(UriVariablesConverterInterface::class); - $listener = new ReadListener($provider->reveal(), $resourceMetadataCollectionFactory->reveal(), $serializerContextBuilder->reveal(), $uriVariablesConverter->reveal()); + $listener = new ReadListener($provider, $resourceMetadataCollectionFactory->reveal(), $serializerContextBuilder->reveal()); $listener->onKernelRequest($event->reveal()); } diff --git a/tests/Symfony/EventListener/RespondListenerTest.php b/tests/Symfony/EventListener/RespondListenerTest.php index 457dc59c0c4..926a69e4160 100644 --- a/tests/Symfony/EventListener/RespondListenerTest.php +++ b/tests/Symfony/EventListener/RespondListenerTest.php @@ -30,6 +30,8 @@ /** * @author Kévin Dunglas + * + * @group legacy */ class RespondListenerTest extends TestCase { diff --git a/tests/Symfony/EventListener/SerializeListenerTest.php b/tests/Symfony/EventListener/SerializeListenerTest.php index 8ab2b6e21e9..f7f29ce8d7f 100644 --- a/tests/Symfony/EventListener/SerializeListenerTest.php +++ b/tests/Symfony/EventListener/SerializeListenerTest.php @@ -32,6 +32,8 @@ /** * @author Kévin Dunglas + * + * @group legacy */ class SerializeListenerTest extends TestCase { diff --git a/tests/Symfony/EventListener/WriteListenerTest.php b/tests/Symfony/EventListener/WriteListenerTest.php index ec1dac44258..ddd628f460f 100644 --- a/tests/Symfony/EventListener/WriteListenerTest.php +++ b/tests/Symfony/EventListener/WriteListenerTest.php @@ -40,6 +40,9 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\HttpKernelInterface; +/** + * @group legacy + */ class WriteListenerTest extends TestCase { use ProphecyTrait;