From afb739b392d337159f6475f7c3144cbac74b1067 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sat, 2 Sep 2023 09:26:22 +0200 Subject: [PATCH] refactor: use provider/processor instead of event listeners (#5657) * refactor: use provider/processor instead of event listeners * tests --- .github/workflows/ci.yml | 147 ++++++++++++ behat.yml.dist | 40 +++- features/graphql/mutation.feature | 3 + features/hydra/error.feature | 9 +- features/jsonld/non_resource.feature | 1 + features/main/attribute_resource.feature | 3 +- features/main/exception_to_status.feature | 6 +- features/main/not_exposed.feature | 5 +- features/main/relation.feature | 8 +- features/main/union_intersect_types.feature | 2 +- features/main/uuid.feature | 6 +- features/mercure/discover.feature | 4 +- features/mongodb/filters.feature | 4 +- .../security/send_security_headers.feature | 2 +- features/security/strong_typing.feature | 12 +- .../validate_incoming_content-types.feature | 3 +- features/serializer/vo_relations.feature | 2 +- phpstan.neon.dist | 1 + phpunit.xml.dist | 1 + src/Action/EntrypointAction.php | 25 +- src/Action/ExceptionAction.php | 2 + src/ApiResource/Error.php | 30 ++- .../Common/State/PersistProcessor.php | 6 +- .../Action/DocumentationAction.php | 79 +++++- .../InnerFieldsNameConverter.php | 6 +- .../InnerFieldsNameConverterTest.php | 16 +- src/GraphQl/Action/EntrypointAction.php | 42 +++- .../Resolver/Factory/ResolverFactory.php | 67 ++++++ src/GraphQl/Resolver/Util/IdentifierTrait.php | 16 ++ .../Serializer/SerializerContextBuilder.php | 4 +- .../State/Processor/NormalizeProcessor.php | 225 ++++++++++++++++++ .../State/Processor/SubscriptionProcessor.php | 56 +++++ .../State/Provider/DenormalizeProvider.php | 58 +++++ src/GraphQl/State/Provider/ReadProvider.php | 147 ++++++++++++ .../State/Provider/ResolverProvider.php | 78 ++++++ ...ationAwareSubscriptionManagerInterface.php | 26 ++ .../Subscription/SubscriptionManager.php | 18 +- .../Resolver/Factory/ResolverFactoryTest.php | 55 +++++ .../Resolver/Stage/SerializeStageTest.php | 2 +- .../SerializerContextBuilderTest.php | 5 +- .../Processor/NormalizeProcessorTest.php | 77 ++++++ .../Processor/SubscriptionProcessorTest.php | 66 +++++ .../Provider/DenormalizeProviderTest.php | 89 +++++++ .../Tests/State/Provider/ReadProviderTest.php | 53 +++++ .../State/Provider/ResolverProviderTest.php | 38 +++ .../GraphQl/Tests}/Util/ArrayTraitTest.php | 2 +- src/GraphQl/Type/FieldsBuilder.php | 25 +- src/GraphQl/Util/ArrayTrait.php | 43 ++++ src/GraphQl/composer.json | 3 +- .../EventListener/AddHeadersListener.php | 3 + .../EventListener/AddTagsListener.php | 3 + src/HttpCache/State/AddHeadersProcessor.php | 83 +++++++ src/HttpCache/State/AddTagsProcessor.php | 90 +++++++ .../Tests/State/AddHeadersProcessorTest.php | 56 +++++ .../Tests/State/AddTagsProcessorTest.php | 94 ++++++++ src/HttpCache/composer.json | 5 + .../EventListener/AddLinkHeaderListener.php | 21 +- .../CollectionFiltersNormalizer.php | 1 + .../PartialCollectionViewNormalizer.php | 3 + src/Hydra/State/HydraLinkProcessor.php | 51 ++++ src/JsonApi/State/DefaultErrorProvider.php | 35 --- src/JsonApi/State/JsonApiProvider.php | 117 +++++++++ src/JsonLd/Action/ContextAction.php | 47 +++- .../AnonymousContextBuilderInterface.php | 2 +- src/JsonLd/ContextBuilder.php | 4 +- src/JsonLd/ContextBuilderInterface.php | 5 +- src/Metadata/ApiResource.php | 17 ++ src/Metadata/Delete.php | 2 + src/Metadata/Error.php | 172 +++++++++++++ .../Extractor/XmlResourceExtractor.php | 20 ++ .../Extractor/YamlResourceExtractor.php | 19 ++ src/Metadata/Extractor/schema/resources.xsd | 14 ++ src/Metadata/Get.php | 2 + src/Metadata/GetCollection.php | 2 + src/Metadata/HttpOperation.php | 19 ++ src/Metadata/NotExposed.php | 2 + src/Metadata/Patch.php | 2 + src/Metadata/Post.php | 2 + src/Metadata/Put.php | 2 + ...ollerResourceMetadataCollectionFactory.php | 61 +++++ .../Extractor/Adapter/XmlResourceAdapter.php | 13 + .../Tests/Extractor/Adapter/resources.xml | 2 +- .../Tests/Extractor/Adapter/resources.yaml | 13 + .../ResourceMetadataCompatibilityTest.php | 22 ++ .../Tests/Extractor/XmlExtractorTest.php | 4 + .../Tests/Extractor/YamlExtractorTest.php | 6 + ...rResourceMetadataCollectionFactoryTest.php | 44 ++++ src/Metadata/Util/ContentNegotiationTrait.php | 138 +++++++++++ src/Metadata/composer.json | 14 +- src/OpenApi/Serializer/OpenApiNormalizer.php | 2 +- src/Serializer/SerializerContextBuilder.php | 36 ++- .../SerializerFilterContextBuilder.php | 6 +- .../Tests/SerializerContextBuilderTest.php | 18 +- src/State/CallableProcessor.php | 2 +- src/State/CreateProvider.php | 2 +- src/State/DefaultErrorProvider.php | 27 --- .../Processor/AddLinkHeaderProcessor.php | 48 ++++ src/State/Processor/RespondProcessor.php | 105 ++++++++ src/State/Processor/SerializeProcessor.php | 77 ++++++ src/State/Processor/WriteProcessor.php | 47 ++++ src/State/ProcessorInterface.php | 6 +- .../Provider/ContentNegotiationProvider.php | 123 ++++++++++ src/State/Provider/DeserializeProvider.php | 110 +++++++++ src/State/Provider/ReadProvider.php | 95 ++++++++ src/State/ProviderInterface.php | 4 +- src/Symfony/Bundle/ApiPlatformBundle.php | 4 + .../ApiPlatformExtension.php | 79 +++--- .../Compiler/GraphQlMutationResolverPass.php | 2 + .../Compiler/GraphQlQueryResolverPass.php | 2 + .../Compiler/GraphQlResolverPass.php | 72 ++++++ .../DependencyInjection/Configuration.php | 1 + .../EventListener/SwaggerUiListener.php | 4 + src/Symfony/Bundle/Resources/config/api.xml | 12 +- .../Bundle/Resources/config/graphql.xml | 108 ++++----- .../Bundle/Resources/config/http_cache.xml | 6 +- .../Resources/config/http_cache_purger.xml | 5 +- src/Symfony/Bundle/Resources/config/hydra.xml | 7 +- .../Bundle/Resources/config/jsonapi.xml | 31 +-- .../Bundle/Resources/config/jsonld.xml | 3 + .../Bundle/Resources/config/legacy/events.xml | 68 ++++++ .../Resources/config/legacy/graphql.xml | 81 +++++++ .../Resources/config/legacy/http_cache.xml | 17 ++ .../config/legacy/http_cache_purger.xml | 15 ++ .../Bundle/Resources/config/legacy/hydra.xml | 12 + .../Resources/config/legacy/jsonapi.xml | 29 +++ .../Resources/config/legacy/mercure.xml | 17 ++ .../Resources/config/legacy/security.xml | 19 ++ .../Resources/config/legacy/swagger_ui.xml | 11 + .../Resources/config/legacy/validator.xml | 21 ++ .../Bundle/Resources/config/mercure.xml | 6 +- .../Resources/config/metadata/resource.xml | 6 + .../Bundle/Resources/config/security.xml | 38 ++- src/Symfony/Bundle/Resources/config/state.xml | 55 ++++- .../Bundle/Resources/config/swagger_ui.xml | 24 +- .../Resources/config/symfony/controller.xml | 16 ++ .../Resources/config/symfony/events.xml | 64 ----- .../Resources/config/symfony/validator.xml | 23 +- .../Resources/public/init-swagger-ui.js | 2 +- .../Resources/views/SwaggerUi/index.html.twig | 2 +- .../Bundle/SwaggerUi/SwaggerUiAction.php | 11 +- .../Bundle/SwaggerUi/SwaggerUiProcessor.php | 111 +++++++++ .../Bundle/SwaggerUi/SwaggerUiProvider.php | 77 ++++++ src/Symfony/Controller/MainController.php | 89 +++++++ .../EventListener/AddFormatListener.php | 9 + .../EventListener/AddLinkHeaderListener.php | 5 + .../EventListener/DenyAccessListener.php | 4 + .../EventListener/DeserializeListener.php | 4 + src/Symfony/EventListener/ErrorListener.php | 75 +++--- .../EventListener/ExceptionListener.php | 9 +- .../TransformFieldsetsParametersListener.php | 3 + .../TransformFilteringParametersListener.php | 4 + .../TransformPaginationParametersListener.php | 4 + .../TransformSortingParametersListener.php | 4 + .../QueryParameterValidateListener.php | 3 + src/Symfony/EventListener/ReadListener.php | 4 + src/Symfony/EventListener/RespondListener.php | 6 +- .../EventListener/SerializeListener.php | 5 + .../EventListener/ValidateListener.php | 3 + src/Symfony/EventListener/WriteListener.php | 5 + .../Exception/AccessDeniedException.php | 30 +++ .../ResourceAccessCheckerInterface.php | 2 + .../Security/State/AccessCheckerProvider.php | 83 +++++++ src/Symfony/State/MercureLinkProcessor.php | 40 ++++ .../ValidationExceptionListener.php | 10 +- .../Exception/ValidationException.php | 21 +- .../State/QueryParameterValidateProvider.php | 56 +++++ .../Validator/State/ValidateProvider.php | 46 ++++ src/Util/ArrayTrait.php | 3 + src/Util/AttributesExtractor.php | 5 + tests/Action/EntrypointActionTest.php | 15 ++ .../Action/DocumentationActionTest.php | 47 +++- .../MongoDbOdm/DummyValidationController.php | 32 --- .../Orm/DummyValidationController.php | 32 --- .../TestBundle/Document/DummyValidation.php | 4 +- .../TestBundle/Entity/DummyValidation.php | 4 +- .../State/AttributeResourceProcessor.php | 10 +- .../State/AttributeResourceProvider.php | 4 +- .../State/CustomInputDtoProcessor.php | 3 +- .../State/DummyDtoInputOutputProcessor.php | 7 +- tests/Fixtures/app/AppKernel.php | 3 + tests/Fixtures/app/config/config_common.yml | 24 +- tests/Fixtures/app/config/config_mongodb.yml | 6 +- tests/Hydra/State/HydraLinkProcessorTest.php | 48 ++++ tests/JsonApi/State/JsonApiProviderTest.php | 40 ++++ tests/JsonLd/Action/ContextActionTest.php | 43 +++- .../Symfony/Bundle/ApiPlatformBundleTest.php | 2 + .../ApiPlatformExtensionTest.php | 31 +-- .../Compiler/GraphQlResolverPassTest.php | 80 +++++++ .../DependencyInjection/ConfigurationTest.php | 1 + .../Bundle/SwaggerUi/SwaggerUiActionTest.php | 6 + .../EventListener/ErrorListenerTest.php | 35 ++- .../State/AccessCheckerProviderTest.php | 92 +++++++ .../State/MercureLinkProcessorTest.php | 40 ++++ .../QueryParameterValidateProviderTest.php | 44 ++++ .../Validator/State/ValidateProviderTest.php | 46 ++++ 195 files changed, 5220 insertions(+), 610 deletions(-) create mode 100644 src/GraphQl/Resolver/Factory/ResolverFactory.php create mode 100644 src/GraphQl/State/Processor/NormalizeProcessor.php create mode 100644 src/GraphQl/State/Processor/SubscriptionProcessor.php create mode 100644 src/GraphQl/State/Provider/DenormalizeProvider.php create mode 100644 src/GraphQl/State/Provider/ReadProvider.php create mode 100644 src/GraphQl/State/Provider/ResolverProvider.php create mode 100644 src/GraphQl/Subscription/OperationAwareSubscriptionManagerInterface.php create mode 100644 src/GraphQl/Tests/Resolver/Factory/ResolverFactoryTest.php create mode 100644 src/GraphQl/Tests/State/Processor/NormalizeProcessorTest.php create mode 100644 src/GraphQl/Tests/State/Processor/SubscriptionProcessorTest.php create mode 100644 src/GraphQl/Tests/State/Provider/DenormalizeProviderTest.php create mode 100644 src/GraphQl/Tests/State/Provider/ReadProviderTest.php create mode 100644 src/GraphQl/Tests/State/Provider/ResolverProviderTest.php rename {tests => src/GraphQl/Tests}/Util/ArrayTraitTest.php (97%) create mode 100644 src/GraphQl/Util/ArrayTrait.php create mode 100644 src/HttpCache/State/AddHeadersProcessor.php create mode 100644 src/HttpCache/State/AddTagsProcessor.php create mode 100644 src/HttpCache/Tests/State/AddHeadersProcessorTest.php create mode 100644 src/HttpCache/Tests/State/AddTagsProcessorTest.php create mode 100644 src/Hydra/State/HydraLinkProcessor.php delete mode 100644 src/JsonApi/State/DefaultErrorProvider.php create mode 100644 src/JsonApi/State/JsonApiProvider.php create mode 100644 src/Metadata/Error.php create mode 100644 src/Metadata/Resource/Factory/MainControllerResourceMetadataCollectionFactory.php create mode 100644 src/Metadata/Tests/Resource/Factory/MainControllerResourceMetadataCollectionFactoryTest.php create mode 100644 src/Metadata/Util/ContentNegotiationTrait.php delete mode 100644 src/State/DefaultErrorProvider.php create mode 100644 src/State/Processor/AddLinkHeaderProcessor.php create mode 100644 src/State/Processor/RespondProcessor.php create mode 100644 src/State/Processor/SerializeProcessor.php create mode 100644 src/State/Processor/WriteProcessor.php create mode 100644 src/State/Provider/ContentNegotiationProvider.php create mode 100644 src/State/Provider/DeserializeProvider.php create mode 100644 src/State/Provider/ReadProvider.php create mode 100644 src/Symfony/Bundle/DependencyInjection/Compiler/GraphQlResolverPass.php create mode 100644 src/Symfony/Bundle/Resources/config/legacy/events.xml create mode 100644 src/Symfony/Bundle/Resources/config/legacy/graphql.xml create mode 100644 src/Symfony/Bundle/Resources/config/legacy/http_cache.xml create mode 100644 src/Symfony/Bundle/Resources/config/legacy/http_cache_purger.xml create mode 100644 src/Symfony/Bundle/Resources/config/legacy/hydra.xml create mode 100644 src/Symfony/Bundle/Resources/config/legacy/jsonapi.xml create mode 100644 src/Symfony/Bundle/Resources/config/legacy/mercure.xml create mode 100644 src/Symfony/Bundle/Resources/config/legacy/security.xml create mode 100644 src/Symfony/Bundle/Resources/config/legacy/swagger_ui.xml create mode 100644 src/Symfony/Bundle/Resources/config/legacy/validator.xml create mode 100644 src/Symfony/Bundle/Resources/config/symfony/controller.xml create mode 100644 src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php create mode 100644 src/Symfony/Bundle/SwaggerUi/SwaggerUiProvider.php create mode 100644 src/Symfony/Controller/MainController.php create mode 100644 src/Symfony/Security/Exception/AccessDeniedException.php create mode 100644 src/Symfony/Security/State/AccessCheckerProvider.php create mode 100644 src/Symfony/State/MercureLinkProcessor.php create mode 100644 src/Symfony/Validator/State/QueryParameterValidateProvider.php create mode 100644 src/Symfony/Validator/State/ValidateProvider.php delete mode 100644 tests/Fixtures/TestBundle/Controller/MongoDbOdm/DummyValidationController.php delete mode 100644 tests/Fixtures/TestBundle/Controller/Orm/DummyValidationController.php create mode 100644 tests/Hydra/State/HydraLinkProcessorTest.php create mode 100644 tests/JsonApi/State/JsonApiProviderTest.php create mode 100644 tests/Symfony/Bundle/DependencyInjection/Compiler/GraphQlResolverPassTest.php create mode 100644 tests/Symfony/Security/State/AccessCheckerProviderTest.php create mode 100644 tests/Symfony/State/MercureLinkProcessorTest.php create mode 100644 tests/Symfony/Validator/State/QueryParameterValidateProviderTest.php create mode 100644 tests/Symfony/Validator/State/ValidateProviderTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 347c9fc9911..f8ddb29fc1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -841,3 +841,150 @@ jobs: run: tests/Fixtures/app/console cache:clear --ansi - name: Run Behat tests run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction + + phpunit_legacy: + name: PHPUnit Legacy event listeners (PHP ${{ matrix.php }}) + env: + EVENT_LISTENERS_BACKWARD_COMPATIBILITY_LAYER: 1 + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + matrix: + php: + - '8.2' + include: + - php: '8.2' + coverage: true + 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: Enable code coverage + if: matrix.coverage + run: echo "COVERAGE=1" >> $GITHUB_ENV + - 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 PHPUnit tests + run: | + mkdir -p build/logs/phpunit + if [ "$COVERAGE" = '1' ]; then + vendor/bin/simple-phpunit --log-junit build/logs/phpunit/junit.xml --coverage-clover build/logs/phpunit/clover.xml + else + vendor/bin/simple-phpunit --log-junit build/logs/phpunit/junit.xml + fi + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@v3 + with: + name: phpunit-logs-php${{ matrix.php }} + path: build/logs/phpunit + continue-on-error: true + - name: Upload coverage results to Codecov + if: matrix.coverage + uses: codecov/codecov-action@v3 + with: + directory: build/logs/phpunit + name: phpunit-php${{ matrix.php }} + flags: phpunit + fail_ci_if_error: true + continue-on-error: true + - name: Upload coverage results to Coveralls + if: matrix.coverage + env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + composer global require --prefer-dist --no-interaction --no-progress --ansi php-coveralls/php-coveralls + export PATH="$PATH:$HOME/.composer/vendor/bin" + php-coveralls --coverage_clover=build/logs/phpunit/clover.xml + continue-on-error: true + + behat_legacy: + name: Behat Legacy event listeners (PHP ${{ matrix.php }}) + env: + EVENT_LISTENERS_BACKWARD_COMPATIBILITY_LAYER: 1 + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + matrix: + php: + - '8.2' + 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=legacy --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 7675928087b..6c40f85f2e5 100644 --- a/behat.yml.dist +++ b/behat.yml.dist @@ -16,7 +16,7 @@ default: - 'Behat\MinkExtension\Context\MinkContext' - 'behatch:context:rest' filters: - tags: '~@postgres&&~@mongodb&&~@elasticsearch' + tags: '~@postgres&&~@mongodb&&~@elasticsearch&&~@controller' extensions: 'FriendsOfBehat\SymfonyExtension': bootstrap: 'tests/Fixtures/app/bootstrap.php' @@ -52,7 +52,7 @@ postgres: - 'Behat\MinkExtension\Context\MinkContext' - 'behatch:context:rest' filters: - tags: '~@sqlite&&~@mongodb&&~@elasticsearch' + tags: '~@sqlite&&~@mongodb&&~@elasticsearch&&~@controller' mongodb: suites: @@ -142,3 +142,39 @@ elasticsearch-coverage: - 'ApiPlatform\Tests\Behat\CoverageContext' - 'Behat\MinkExtension\Context\MinkContext' - 'behatch:context:rest' + +legacy: + 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' + 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/graphql/mutation.feature b/features/graphql/mutation.feature index c8e290edbfa..8a5a09be3a9 100644 --- a/features/graphql/mutation.feature +++ b/features/graphql/mutation.feature @@ -794,6 +794,7 @@ Feature: GraphQL mutation support And the JSON node "errors[0].extensions.violations[0].path" should be equal to "name" And the JSON node "errors[0].extensions.violations[0].message" should be equal to "This value should not be blank." + @createSchema Scenario: Execute a custom mutation Given there are 1 dummyCustomMutation objects When I send the following GraphQL request: @@ -812,7 +813,9 @@ Feature: GraphQL mutation support And the header "Content-Type" should be equal to "application/json" And the JSON node "data.sumDummyCustomMutation.dummyCustomMutation.result" should be equal to "8" + @createSchema Scenario: Execute a not persisted custom mutation (resolver returns null) + Given there are 1 dummyCustomMutation objects When I send the following GraphQL request: """ mutation { diff --git a/features/hydra/error.feature b/features/hydra/error.feature index bafcf1f4b3c..689558730f5 100644 --- a/features/hydra/error.feature +++ b/features/hydra/error.feature @@ -42,12 +42,13 @@ Feature: Error handling """ Then the response status code should be 400 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON node "@context" should be equal to "/contexts/Error" And the JSON node "@type" should be equal to "hydra:Error" And the JSON node "hydra:title" should be equal to "An error occurred" And the JSON node "hydra:description" should be equal to 'Nested documents for attribute "relatedDummy" are not allowed. Use IRIs instead.' And the JSON node "trace" should exist + And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' Scenario: Get an error during deserialization of collection When I add "Content-Type" header equal to "application/ld+json" @@ -62,7 +63,7 @@ Feature: Error handling """ Then the response status code should be 400 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON node "@context" should be equal to "/contexts/Error" And the JSON node "@type" should be equal to "hydra:Error" And the JSON node "hydra:title" should be equal to "An error occurred" @@ -79,7 +80,7 @@ Feature: Error handling """ Then the response status code should be 400 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON node "@context" should be equal to "/contexts/Error" And the JSON node "@type" should be equal to "hydra:Error" And the JSON node "hydra:title" should be equal to "An error occurred" @@ -97,7 +98,7 @@ Feature: Error handling """ Then the response status code should be 400 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON node "@context" should be equal to "/contexts/Error" And the JSON node "@type" should be equal to "hydra:Error" And the JSON node "hydra:title" should be equal to "An error occurred" diff --git a/features/jsonld/non_resource.feature b/features/jsonld/non_resource.feature index c00016cbe36..083770c9a28 100644 --- a/features/jsonld/non_resource.feature +++ b/features/jsonld/non_resource.feature @@ -40,6 +40,7 @@ Feature: JSON-LD non-resource handling """ And the JSON node "notAResource.@id" should exist + @createSchema Scenario: Get a resource containing a raw object with selected properties Given there are 1 dummy objects with relatedDummy and its thirdLevel When I send a "GET" request to "/contain_non_resources/1?properties[]=id&properties[nested][notAResource][]=foo&properties[notAResource][]=bar" diff --git a/features/main/attribute_resource.feature b/features/main/attribute_resource.feature index 69224e54e9a..90a8bff1663 100644 --- a/features/main/attribute_resource.feature +++ b/features/main/attribute_resource.feature @@ -98,7 +98,8 @@ Feature: Resource attributes When I send a "GET" request to "/photos/1/resize/300/100" Then the response status code should be 400 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" + And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' And the JSON node "hydra:description" should be equal to 'Unable to generate an IRI for the item of type "ApiPlatform\Tests\Fixtures\TestBundle\Entity\IncompleteUriVariableConfigured"' Scenario: Uri variables with Post operation diff --git a/features/main/exception_to_status.feature b/features/main/exception_to_status.feature index 203e13ea198..019a5d77375 100644 --- a/features/main/exception_to_status.feature +++ b/features/main/exception_to_status.feature @@ -9,7 +9,7 @@ Feature: Using exception_to_status config And I send a "GET" request to "/dummy_exception_to_statuses/123" Then the response status code should be 404 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" @!mongodb Scenario: Configure status code via the resource exceptionToStatus to map custom NotFound error to 400 @@ -22,7 +22,7 @@ Feature: Using exception_to_status config """ Then the response status code should be 400 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" @!mongodb Scenario: Configure status code via the config file to map FilterValidationException to 400 @@ -30,4 +30,4 @@ Feature: Using exception_to_status config And I send a "GET" request to "/dummy_exception_to_statuses" Then the response status code should be 400 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" diff --git a/features/main/not_exposed.feature b/features/main/not_exposed.feature index 964e4fa45df..6c4b206c1ac 100644 --- a/features/main/not_exposed.feature +++ b/features/main/not_exposed.feature @@ -2,6 +2,9 @@ @v3 Feature: Expose only a collection of objects + Background: + Given I add "Accept" header equal to "application/ld+json" + # A NotExposed operation with "routeName: api_genid" is automatically added to this resource. Scenario: Get a collection of objects without identifiers from a single resource with a single collection When I send a "GET" request to "/chairs" @@ -168,7 +171,7 @@ Feature: Expose only a collection of objects When I send a "GET" request to "" Then the response status code should be 404 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON node "hydra:description" should be equal to "" Examples: | uri | hydra:description | diff --git a/features/main/relation.feature b/features/main/relation.feature index 0eb037c4ea4..fcc89f1b97c 100644 --- a/features/main/relation.feature +++ b/features/main/relation.feature @@ -351,7 +351,7 @@ Feature: Relations support """ Then the response status code should be 400 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" Scenario: Post a relation with a not existing IRI When I add "Content-Type" header equal to "application/ld+json" @@ -363,7 +363,7 @@ Feature: Relations support """ Then the response status code should be 400 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" Scenario: Update an embedded relation When I add "Content-Type" header equal to "application/ld+json" @@ -470,7 +470,7 @@ Feature: Relations support """ Then the response status code should be 400 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON node "hydra:description" should contain 'Invalid IRI "certainly not an IRI".' Scenario: Passing an invalid type to a relation @@ -483,7 +483,7 @@ Feature: Relations support """ Then the response status code should be 400 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON should be valid according to this schema: """ { diff --git a/features/main/union_intersect_types.feature b/features/main/union_intersect_types.feature index ba47388e554..97e415f60ea 100644 --- a/features/main/union_intersect_types.feature +++ b/features/main/union_intersect_types.feature @@ -117,5 +117,5 @@ Feature: Union/Intersect types """ Then the response status code should be 400 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON node "hydra:description" should be equal to 'Could not denormalize object of type "ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\ActivableInterface", no supporting normalizer found.' diff --git a/features/main/uuid.feature b/features/main/uuid.feature index dec423850a3..d0634b0bc94 100644 --- a/features/main/uuid.feature +++ b/features/main/uuid.feature @@ -127,7 +127,7 @@ Feature: Using uuid identifier on resource When I send a "GET" request to "/ramsey_uuid_dummies/41B29566-144B-E1D05DEFE78" Then the response status code should be 404 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" @!mongodb @createSchema @@ -180,7 +180,7 @@ Feature: Using uuid identifier on resource """ Then the response status code should be 400 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" @!mongodb Scenario: Update a resource with a bad Ramsey\Uuid\Uuid non-id field @@ -193,7 +193,7 @@ Feature: Using uuid identifier on resource """ Then the response status code should be 400 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" @!mongodb @createSchema diff --git a/features/mercure/discover.feature b/features/mercure/discover.feature index d3747ccb6d6..dc302e50043 100644 --- a/features/mercure/discover.feature +++ b/features/mercure/discover.feature @@ -6,8 +6,8 @@ Feature: Mercure discovery support @createSchema Scenario: Checks that the Mercure Link is added Given I send a "GET" request to "/dummy_mercures" - Then the header "Link" should be equal to '; rel="http://www.w3.org/ns/hydra/core#apiDocumentation",; rel="mercure"' + Then the header "Link" should contain '; rel="mercure"' Scenario: Checks that the Mercure Link is not added on endpoints where updates are not dispatched Given I send a "GET" request to "/" - Then the header "Link" should be equal to '; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"' + Then the header "Link" should not contain '; rel="mercure"' diff --git a/features/mongodb/filters.feature b/features/mongodb/filters.feature index 9e89239215d..aad34147c69 100644 --- a/features/mongodb/filters.feature +++ b/features/mongodb/filters.feature @@ -10,7 +10,7 @@ Feature: Filters on collections When I send a "GET" request to "/dummies?relatedDummy.thirdLevel.badFourthLevel.level=4" Then the response status code should be 500 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON node "@context" should be equal to "/contexts/Error" And the JSON node "@type" should be equal to "hydra:Error" And the JSON node "hydra:title" should be equal to "An error occurred" @@ -21,7 +21,7 @@ Feature: Filters on collections When I send a "GET" request to "/dummies?relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level=3" Then the response status code should be 500 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON node "@context" should be equal to "/contexts/Error" And the JSON node "@type" should be equal to "hydra:Error" And the JSON node "hydra:title" should be equal to "An error occurred" diff --git a/features/security/send_security_headers.feature b/features/security/send_security_headers.feature index b09afc7c316..41dc0bd4efb 100644 --- a/features/security/send_security_headers.feature +++ b/features/security/send_security_headers.feature @@ -17,7 +17,7 @@ Feature: Send security header {"name": 1} """ Then the response status code should be 400 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the header "X-Content-Type-Options" should be equal to "nosniff" And the header "X-Frame-Options" should be equal to "deny" diff --git a/features/security/strong_typing.feature b/features/security/strong_typing.feature index 3d4b7599a62..d69c670b3fe 100644 --- a/features/security/strong_typing.feature +++ b/features/security/strong_typing.feature @@ -52,7 +52,7 @@ Feature: Handle properly invalid data submitted to the API """ Then the response status code should be 400 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON node "@context" should be equal to "/contexts/Error" And the JSON node "@type" should be equal to "hydra:Error" And the JSON node "hydra:title" should be equal to "An error occurred" @@ -69,7 +69,7 @@ Feature: Handle properly invalid data submitted to the API """ Then the response status code should be 400 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON node "@context" should be equal to "/contexts/Error" And the JSON node "@type" should be equal to "hydra:Error" And the JSON node "hydra:title" should be equal to "An error occurred" @@ -87,7 +87,7 @@ Feature: Handle properly invalid data submitted to the API """ Then the response status code should be 400 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" Scenario: Send non-array data when an array is expected When I add "Content-Type" header equal to "application/ld+json" @@ -100,7 +100,7 @@ Feature: Handle properly invalid data submitted to the API """ Then the response status code should be 400 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON node "@context" should be equal to "/contexts/Error" And the JSON node "@type" should be equal to "hydra:Error" And the JSON node "hydra:title" should be equal to "An error occurred" @@ -118,7 +118,7 @@ Feature: Handle properly invalid data submitted to the API """ Then the response status code should be 400 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON node "@context" should be equal to "/contexts/Error" And the JSON node "@type" should be equal to "hydra:Error" And the JSON node "hydra:title" should be equal to "An error occurred" @@ -134,7 +134,7 @@ Feature: Handle properly invalid data submitted to the API """ Then the response status code should be 400 And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON node "@context" should be equal to "/contexts/Error" And the JSON node "@type" should be equal to "hydra:Error" And the JSON node "hydra:title" should be equal to "An error occurred" diff --git a/features/security/validate_incoming_content-types.feature b/features/security/validate_incoming_content-types.feature index 31e7b04a23e..bc947705479 100644 --- a/features/security/validate_incoming_content-types.feature +++ b/features/security/validate_incoming_content-types.feature @@ -7,10 +7,11 @@ Feature: Validate incoming content type Scenario: Send a document with a not supported content-type When I add "Content-Type" header equal to "text/plain" + And I add "Accept" header equal to "application/ld+json" And I send a "POST" request to "/dummies" with body: """ something """ Then the response status code should be 415 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON node "hydra:description" should be equal to 'The content-type "text/plain" is not supported. Supported MIME types are "application/ld+json", "application/hal+json", "application/vnd.api+json", "application/xml", "text/xml", "application/json", "text/html", "application/graphql", "multipart/form-data".' diff --git a/features/serializer/vo_relations.feature b/features/serializer/vo_relations.feature index d1b8aadffc6..30a50f7206b 100644 --- a/features/serializer/vo_relations.feature +++ b/features/serializer/vo_relations.feature @@ -139,7 +139,7 @@ Feature: Value object as ApiResource } """ Then the response status code should be 400 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON should be valid according to this schema: """ { diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 513a144518b..e9830fedd36 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -33,6 +33,7 @@ parameters: - handleNotFound ignoreErrors: # False positives + - message: '#Call to an undefined method Negotiation\\AcceptHeader::getType\(\).#' - message: '#but database expects#' paths: diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 42d3a440cc9..4cbdc54122d 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -24,6 +24,7 @@ src + src/Symfony/Maker/Resources/skeleton features tests vendor diff --git a/src/Action/EntrypointAction.php b/src/Action/EntrypointAction.php index 19d9b26d395..38c31c5f0ba 100644 --- a/src/Action/EntrypointAction.php +++ b/src/Action/EntrypointAction.php @@ -14,7 +14,12 @@ namespace ApiPlatform\Action; use ApiPlatform\Api\Entrypoint; +use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\ProviderInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; /** * Generates the API entrypoint. @@ -23,12 +28,26 @@ */ final class EntrypointAction { - public function __construct(private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory) - { + public function __construct( + private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, + private readonly ?ProviderInterface $provider = null, + private readonly ?ProcessorInterface $processor = null + ) { } - public function __invoke(): Entrypoint + /** + * @return Entrypoint|Response + */ + public function __invoke(Request $request = null) { + if ($this->provider && $this->processor) { + $context = ['request' => $request]; + $operation = new Get(class: Entrypoint::class, provider: fn () => new Entrypoint($this->resourceNameCollectionFactory->create())); + $body = $this->provider->provide($operation, [], $context); + + return $this->processor->process($body, $operation, [], $context); + } + return new Entrypoint($this->resourceNameCollectionFactory->create()); } } diff --git a/src/Action/ExceptionAction.php b/src/Action/ExceptionAction.php index 81db1eb7520..6381f2a4570 100644 --- a/src/Action/ExceptionAction.php +++ b/src/Action/ExceptionAction.php @@ -29,6 +29,8 @@ * * @author Baptiste Meyer * @author Kévin Dunglas + * + * @deprecated since API Platform 3 and Error resource is used {@see ApiPlatform\Symfony\EventListener\ErrorListener} */ final class ExceptionAction { diff --git a/src/ApiResource/Error.php b/src/ApiResource/Error.php index 5d6170c7d0f..5c4abdcc915 100644 --- a/src/ApiResource/Error.php +++ b/src/ApiResource/Error.php @@ -13,24 +13,34 @@ namespace ApiPlatform\ApiResource; +use ApiPlatform\JsonLd\ContextBuilderInterface; use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Error as Operation; use ApiPlatform\Metadata\ErrorResource; use ApiPlatform\Metadata\Exception\HttpExceptionInterface; use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; -use ApiPlatform\Metadata\Get; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Serializer\Annotation\Ignore; use Symfony\Component\Serializer\Annotation\SerializedName; +use Symfony\Component\WebLink\Link; #[ErrorResource( uriTemplate: '/errors/{status}', - provider: 'api_platform.state_provider.default_error', types: ['hydra:Error'], + openapi: false, operations: [ - new Get(name: '_api_errors_hydra', outputFormats: ['jsonld' => ['application/ld+json']], normalizationContext: ['groups' => ['jsonld'], 'skip_null_values' => true]), - new Get(name: '_api_errors_problem', outputFormats: ['json' => ['application/problem+json']], normalizationContext: ['groups' => ['jsonproblem'], 'skip_null_values' => true]), - new Get(name: '_api_errors_jsonapi', outputFormats: ['jsonapi' => ['application/vnd.api+json']], normalizationContext: ['groups' => ['jsonapi'], 'skip_null_values' => true], provider: 'api_platform.json_api.state_provider.default_error'), + new Operation(name: '_api_errors_problem', outputFormats: ['json' => ['application/problem+json']], normalizationContext: ['groups' => ['jsonproblem'], 'skip_null_values' => true]), + new Operation( + name: '_api_errors_hydra', + outputFormats: ['jsonld' => ['application/problem+json']], + normalizationContext: [ + 'groups' => ['jsonld'], + 'skip_null_values' => true, + ], + links: [new Link(rel: ContextBuilderInterface::JSONLD_NS.'error', href: 'http://www.w3.org/ns/hydra/error')] + ), + new Operation(name: '_api_errors_jsonapi', outputFormats: ['jsonapi' => ['application/vnd.api+json']], normalizationContext: ['groups' => ['jsonapi'], 'skip_null_values' => true]), ] )] class Error extends \Exception implements ProblemExceptionInterface, HttpExceptionInterface @@ -39,8 +49,7 @@ public function __construct( private readonly string $title, private readonly string $detail, #[ApiProperty(identifier: true)] private readonly int $status, - #[Groups(['trace'])] - public readonly array $trace, + private readonly array $originalTrace, private ?string $instance = null, private string $type = 'about:blank', private array $headers = [] @@ -55,6 +64,13 @@ public function getHydraTitle(): string return $this->title; } + #[SerializedName('trace')] + #[Groups(['trace'])] + public function getOriginalTrace(): array + { + return $this->originalTrace; + } + #[SerializedName('hydra:description')] #[Groups(['jsonld', 'legacy_jsonld'])] public function getHydraDescription(): string diff --git a/src/Doctrine/Common/State/PersistProcessor.php b/src/Doctrine/Common/State/PersistProcessor.php index 9cfe98a5838..e8f75390d35 100644 --- a/src/Doctrine/Common/State/PersistProcessor.php +++ b/src/Doctrine/Common/State/PersistProcessor.php @@ -52,15 +52,15 @@ public function process(mixed $data, Operation $operation, array $uriVariables = \assert(method_exists($manager, 'getReference')); // TODO: the call to getReference is most likely to fail with complex identifiers $newData = $data; - if (isset($context['previous_data'])) { - $newData = 1 === \count($uriVariables) ? $manager->getReference($class, current($uriVariables)) : clone $context['previous_data']; + if ($previousData = $context['previous_data']) { + $newData = 1 === \count($uriVariables) ? $manager->getReference($class, current($uriVariables)) : clone $previousData; } $identifiers = array_reverse($uriVariables); $links = $this->getLinks($class, $operation, $context); $reflectionProperties = $this->getReflectionProperties($data); - if (!isset($context['previous_data'])) { + if (!$previousData) { foreach (array_reverse($links) as $link) { if ($link->getExpandedValue() || !$link->getFromClass()) { continue; diff --git a/src/Documentation/Action/DocumentationAction.php b/src/Documentation/Action/DocumentationAction.php index 1bc5acf4c98..28ec97a02e8 100644 --- a/src/Documentation/Action/DocumentationAction.php +++ b/src/Documentation/Action/DocumentationAction.php @@ -15,10 +15,17 @@ use ApiPlatform\Documentation\Documentation; use ApiPlatform\Documentation\DocumentationInterface; +use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Util\ContentNegotiationTrait; use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface; use ApiPlatform\OpenApi\OpenApi; +use ApiPlatform\OpenApi\Serializer\ApiGatewayNormalizer; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\ProviderInterface; +use Negotiation\Negotiator; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; /** * Generates the API documentation. @@ -27,25 +34,75 @@ */ final class DocumentationAction { - public function __construct(private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, private readonly string $title = '', private readonly string $description = '', private readonly string $version = '', private readonly ?OpenApiFactoryInterface $openApiFactory = null) - { + use ContentNegotiationTrait; + + public function __construct( + private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, + private readonly string $title = '', + private readonly string $description = '', + private readonly string $version = '', + private readonly ?OpenApiFactoryInterface $openApiFactory = null, + private readonly ?ProviderInterface $provider = null, + private readonly ?ProcessorInterface $processor = null, + Negotiator $negotiator = null + ) { + $this->negotiator = $negotiator ?? new Negotiator(); } /** - * @return DocumentationInterface|OpenApi + * @return DocumentationInterface|OpenApi|Response */ public function __invoke(Request $request = null) { - if (null !== $request) { - $context = ['base_url' => $request->getBaseUrl()]; - if ($request->query->getBoolean('api_gateway')) { - $context['api_gateway'] = true; - } - $request->attributes->set('_api_normalization_context', $request->attributes->get('_api_normalization_context', []) + $context); + if (null === $request) { + return new Documentation($this->resourceNameCollectionFactory->create(), $this->title, $this->description, $this->version); + } + + $context = ['api_gateway' => $request->query->getBoolean(ApiGatewayNormalizer::API_GATEWAY), 'base_url' => $request->getBaseUrl()]; + $request->attributes->set('_api_normalization_context', $request->attributes->get('_api_normalization_context', []) + $context); + $format = $this->getRequestFormat($request, ['json' => ['application/json'], 'jsonld' => ['application/ld+json'], 'html' => ['text/html']]); - if ('json' === $request->getRequestFormat() && null !== $this->openApiFactory) { - return $this->openApiFactory->__invoke($context); + if (null !== $this->openApiFactory && ('html' === $format || 'json' === $format)) { + return $this->getOpenApiDocumentation($context, $format, $request); + } + + return $this->getHydraDocumentation($context, $request); + } + + /** + * @param array $context + */ + private function getOpenApiDocumentation(array $context, string $format, Request $request): OpenApi|Response + { + if ($this->provider && $this->processor) { + $context['request'] = $request; + $operation = new Get(class: OpenApi::class, provider: fn () => $this->openApiFactory->__invoke($context), normalizationContext: [ApiGatewayNormalizer::API_GATEWAY => $context['api_gateway'] ?? null]); + if ('html' === $format) { + $operation = $operation->withProcessor('api_platform.swagger_ui.processor')->withWrite(true); } + + return $this->processor->process($this->provider->provide($operation, [], $context), $operation, [], $context); + } + + return $this->openApiFactory->__invoke($context); + } + + /** + * TODO: the logic behind the Hydra Documentation is done in a ApiPlatform\Hydra\Serializer\DocumentationNormalizer. + * We should transform this to a provider, it'd improve performances also by a bit. + * + * @param array $context + */ + private function getHydraDocumentation(array $context, Request $request): DocumentationInterface|Response + { + if ($this->provider && $this->processor) { + $context['request'] = $request; + $operation = new Get( + class: Documentation::class, + provider: fn () => new Documentation($this->resourceNameCollectionFactory->create(), $this->title, $this->description, $this->version) + ); + + return $this->processor->process($this->provider->provide($operation, [], $context), $operation, [], $context); } return new Documentation($this->resourceNameCollectionFactory->create(), $this->title, $this->description, $this->version); diff --git a/src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php b/src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php index 34f7b3b781a..20df1dd28ba 100644 --- a/src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php +++ b/src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php @@ -18,7 +18,7 @@ use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** - * Converts inner fields with a decorated name converter. + * Converts inner fields with a inner name converter. * * @experimental * @@ -26,7 +26,7 @@ */ final class InnerFieldsNameConverter implements AdvancedNameConverterInterface { - public function __construct(private readonly NameConverterInterface $decorated = new CamelCaseToSnakeCaseNameConverter()) + public function __construct(private readonly NameConverterInterface $inner = new CamelCaseToSnakeCaseNameConverter()) { } @@ -51,7 +51,7 @@ private function convertInnerFields(string $propertyName, bool $normalization, s $convertedProperties = []; foreach (explode('.', $propertyName) as $decomposedProperty) { - $convertedProperties[] = $this->decorated->{$normalization ? 'normalize' : 'denormalize'}($decomposedProperty, $class, $format, $context); + $convertedProperties[] = $this->inner->{$normalization ? 'normalize' : 'denormalize'}($decomposedProperty, $class, $format, $context); } return implode('.', $convertedProperties); diff --git a/src/Elasticsearch/Tests/Serializer/NameConverter/InnerFieldsNameConverterTest.php b/src/Elasticsearch/Tests/Serializer/NameConverter/InnerFieldsNameConverterTest.php index e1c4ab31d6a..261a79fd632 100644 --- a/src/Elasticsearch/Tests/Serializer/NameConverter/InnerFieldsNameConverterTest.php +++ b/src/Elasticsearch/Tests/Serializer/NameConverter/InnerFieldsNameConverterTest.php @@ -32,22 +32,22 @@ public function testConstruct(): void public function testNormalize(): void { - $decoratedProphecy = $this->prophesize(AdvancedNameConverterInterface::class); - $decoratedProphecy->normalize('fooBar', null, null, [])->willReturn('foo_bar')->shouldBeCalled(); - $decoratedProphecy->normalize('bazQux', null, null, [])->willReturn('baz_qux')->shouldBeCalled(); + $innerProphecy = $this->prophesize(AdvancedNameConverterInterface::class); + $innerProphecy->normalize('fooBar', null, null, [])->willReturn('foo_bar')->shouldBeCalled(); + $innerProphecy->normalize('bazQux', null, null, [])->willReturn('baz_qux')->shouldBeCalled(); - $innerFieldsNameConverter = new InnerFieldsNameConverter($decoratedProphecy->reveal()); + $innerFieldsNameConverter = new InnerFieldsNameConverter($innerProphecy->reveal()); self::assertSame('foo_bar.baz_qux', $innerFieldsNameConverter->normalize('fooBar.bazQux')); } public function testDenormalize(): void { - $decoratedProphecy = $this->prophesize(AdvancedNameConverterInterface::class); - $decoratedProphecy->denormalize('foo_bar', null, null, [])->willReturn('fooBar')->shouldBeCalled(); - $decoratedProphecy->denormalize('baz_qux', null, null, [])->willReturn('bazQux')->shouldBeCalled(); + $innerProphecy = $this->prophesize(AdvancedNameConverterInterface::class); + $innerProphecy->denormalize('foo_bar', null, null, [])->willReturn('fooBar')->shouldBeCalled(); + $innerProphecy->denormalize('baz_qux', null, null, [])->willReturn('bazQux')->shouldBeCalled(); - $innerFieldsNameConverter = new InnerFieldsNameConverter($decoratedProphecy->reveal()); + $innerFieldsNameConverter = new InnerFieldsNameConverter($innerProphecy->reveal()); self::assertSame('fooBar.bazQux', $innerFieldsNameConverter->denormalize('foo_bar.baz_qux')); } diff --git a/src/GraphQl/Action/EntrypointAction.php b/src/GraphQl/Action/EntrypointAction.php index f0b4eec54e4..1a2ece85578 100644 --- a/src/GraphQl/Action/EntrypointAction.php +++ b/src/GraphQl/Action/EntrypointAction.php @@ -16,9 +16,11 @@ use ApiPlatform\GraphQl\Error\ErrorHandlerInterface; use ApiPlatform\GraphQl\ExecutorInterface; use ApiPlatform\GraphQl\Type\SchemaBuilderInterface; +use ApiPlatform\Metadata\Util\ContentNegotiationTrait; use GraphQL\Error\DebugFlag; use GraphQL\Error\Error; use GraphQL\Executor\ExecutionResult; +use Negotiation\Negotiator; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -32,17 +34,33 @@ */ final class EntrypointAction { + use ContentNegotiationTrait; private int $debug; - public function __construct(private readonly SchemaBuilderInterface $schemaBuilder, private readonly ExecutorInterface $executor, private readonly ?GraphiQlAction $graphiQlAction, private readonly ?GraphQlPlaygroundAction $graphQlPlaygroundAction, private readonly NormalizerInterface $normalizer, private readonly ErrorHandlerInterface $errorHandler, bool $debug = false, private readonly bool $graphiqlEnabled = false, private readonly bool $graphQlPlaygroundEnabled = false, private readonly ?string $defaultIde = null) - { + public function __construct( + private readonly SchemaBuilderInterface $schemaBuilder, + private readonly ExecutorInterface $executor, + private readonly ?GraphiQlAction $graphiQlAction, + private readonly ?GraphQlPlaygroundAction $graphQlPlaygroundAction, + private readonly NormalizerInterface $normalizer, + private readonly ErrorHandlerInterface $errorHandler, + bool $debug = false, + private readonly bool $graphiqlEnabled = false, + private readonly bool $graphQlPlaygroundEnabled = false, + private readonly ?string $defaultIde = null, + Negotiator $negotiator = null + ) { $this->debug = $debug ? DebugFlag::INCLUDE_DEBUG_MESSAGE | DebugFlag::INCLUDE_TRACE : DebugFlag::NONE; + $this->negotiator = $negotiator ?? new Negotiator(); } public function __invoke(Request $request): Response { + $formats = ['json' => ['application/json'], 'html' => ['text/html']]; + $format = $this->getRequestFormat($request, $formats, false); + try { - if ($request->isMethod('GET') && 'html' === $request->getRequestFormat()) { + if ($request->isMethod('GET') && 'html' === $format) { if ('graphiql' === $this->defaultIde && $this->graphiqlEnabled && $this->graphiQlAction) { return ($this->graphiQlAction)($request); } @@ -72,6 +90,8 @@ public function __invoke(Request $request): Response /** * @throws BadRequestHttpException + * + * @return array{0: array|null, 1: string, 2: array} */ private function parseRequest(Request $request): array { @@ -103,7 +123,11 @@ private function parseRequest(Request $request): array } /** + * @param array $variables + * * @throws BadRequestHttpException + * + * @return array{0: array, 1: string, 2: array} */ private function parseData(?string $query, ?string $operationName, array $variables, string $jsonContent): array { @@ -127,7 +151,13 @@ private function parseData(?string $query, ?string $operationName, array $variab } /** + * @param array $variables + * @param array $bodyParameters + * @param array $files + * * @throws BadRequestHttpException + * + * @return array{0: array, 1: string, 2: array} */ private function parseMultipartRequest(?string $query, ?string $operationName, array $variables, array $bodyParameters, array $files): array { @@ -148,6 +178,10 @@ private function parseMultipartRequest(?string $query, ?string $operationName, a } /** + * @param array $map + * @param array $variables + * @param array $files + * * @throws BadRequestHttpException */ private function applyMapToVariables(array $map, array $variables, array $files): array @@ -185,6 +219,8 @@ private function applyMapToVariables(array $map, array $variables, array $files) /** * @throws BadRequestHttpException + * + * @return array */ private function decodeVariables(string $variables): array { diff --git a/src/GraphQl/Resolver/Factory/ResolverFactory.php b/src/GraphQl/Resolver/Factory/ResolverFactory.php new file mode 100644 index 00000000000..ba7dee32f67 --- /dev/null +++ b/src/GraphQl/Resolver/Factory/ResolverFactory.php @@ -0,0 +1,67 @@ + + * + * 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\GraphQl\Resolver\Factory; + +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Operation; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\ProviderInterface; +use GraphQL\Type\Definition\ResolveInfo; + +class ResolverFactory implements ResolverFactoryInterface +{ + public function __construct( + private readonly ProviderInterface $provider, + private readonly ProcessorInterface $processor + ) { + } + + public function __invoke(string $resourceClass = null, string $rootClass = null, Operation $operation = null): callable + { + return function (?array $source, array $args, $context, ResolveInfo $info) use ($resourceClass, $rootClass, $operation) { + // Data already fetched and normalized (field or nested resource) + if ($body = $source[$info->fieldName] ?? null) { + return $body; + } + + if (null === $resourceClass && \array_key_exists($info->fieldName, $source ?? [])) { + return $body; + } + + // If authorization has failed for a relation field (e.g. via ApiProperty security), the field is not present in the source: null can be returned directly to ensure the collection isn't in the response. + if ($operation && (null === $resourceClass || null === $rootClass || (null !== $source && !\array_key_exists($info->fieldName, $source)))) { + return null; + } + + // Handles relay nodes + $operation ??= new Query(); + + $graphQlContext = []; + $context = ['source' => $source, 'args' => $args, 'info' => $info, 'root_class' => $rootClass, 'graphql_context' => &$graphQlContext]; + + if (null === $operation->canValidate()) { + $operation = $operation->withValidate($operation instanceof Mutation); + } + + $body = $this->provider->provide($operation, [], $context); + + if (null === $operation->canWrite()) { + $operation = $operation->withWrite($operation instanceof Mutation && null !== $body); + } + + return $this->processor->process($body, $operation, [], $context); + }; + } +} diff --git a/src/GraphQl/Resolver/Util/IdentifierTrait.php b/src/GraphQl/Resolver/Util/IdentifierTrait.php index 8ec7c0c6d8d..c49dd792df8 100644 --- a/src/GraphQl/Resolver/Util/IdentifierTrait.php +++ b/src/GraphQl/Resolver/Util/IdentifierTrait.php @@ -13,6 +13,10 @@ namespace ApiPlatform\GraphQl\Resolver\Util; +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Operation; +use ApiPlatform\Metadata\GraphQl\Subscription; + /** * Identifier helper methods. * @@ -32,4 +36,16 @@ private function getIdentifierFromContext(array $context): ?string return $args['id'] ?? null; } + + /** + * @param array $args + */ + private function getIdentifierFromOperation(Operation $operation, array $args): ?string + { + if ($operation instanceof Subscription || $operation instanceof Mutation) { + return $args['input']['id'] ?? null; + } + + return $args['id'] ?? null; + } } diff --git a/src/GraphQl/Serializer/SerializerContextBuilder.php b/src/GraphQl/Serializer/SerializerContextBuilder.php index e4a32779a25..7726167102c 100644 --- a/src/GraphQl/Serializer/SerializerContextBuilder.php +++ b/src/GraphQl/Serializer/SerializerContextBuilder.php @@ -13,7 +13,9 @@ namespace ApiPlatform\GraphQl\Serializer; +use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Operation; +use ApiPlatform\Metadata\GraphQl\Subscription; use GraphQL\Type\Definition\ResolveInfo; use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -68,7 +70,7 @@ private function fieldsToAttributes(?string $resourceClass, Operation $operation $attributes = $this->replaceIdKeys($fields['edges']['node'] ?? $fields['collection'] ?? $fields, $resourceClass, $context); - if ($resolverContext['is_mutation'] || $resolverContext['is_subscription']) { + if ($operation instanceof Subscription || $operation instanceof Mutation) { $wrapFieldName = lcfirst($operation->getShortName()); return $attributes[$wrapFieldName] ?? []; diff --git a/src/GraphQl/State/Processor/NormalizeProcessor.php b/src/GraphQl/State/Processor/NormalizeProcessor.php new file mode 100644 index 00000000000..58307b7b8af --- /dev/null +++ b/src/GraphQl/State/Processor/NormalizeProcessor.php @@ -0,0 +1,225 @@ + + * + * 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\GraphQl\State\Processor; + +use ApiPlatform\GraphQl\Resolver\Util\IdentifierTrait; +use ApiPlatform\GraphQl\Serializer\ItemNormalizer; +use ApiPlatform\GraphQl\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\DeleteOperationInterface; +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; +use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\Pagination; +use ApiPlatform\State\Pagination\PaginatorInterface; +use ApiPlatform\State\Pagination\PartialPaginatorInterface; +use ApiPlatform\State\ProcessorInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Transforms the data to a GraphQl json format. It uses the Symfony Normalizer then performs changes according to the type of operation. + */ +final class NormalizeProcessor implements ProcessorInterface +{ + use IdentifierTrait; + + public function __construct(private readonly NormalizerInterface $normalizer, private readonly SerializerContextBuilderInterface $serializerContextBuilder, private readonly Pagination $pagination) + { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): array|null + { + if (!$operation instanceof GraphQlOperation) { + return $data; + } + + return $this->getData($data, $operation, $uriVariables, $context); + } + + /** + * @param array $uriVariables + * @param array $context + * + * @return array + */ + private function getData(mixed $itemOrCollection, GraphQlOperation $operation, array $uriVariables = [], array $context = []): ?array + { + if (!($operation->canSerialize() ?? true)) { + if ($operation instanceof CollectionOperationInterface) { + if ($this->pagination->isGraphQlEnabled($operation, $context)) { + return 'cursor' === $this->pagination->getGraphQlPaginationType($operation) ? + $this->getDefaultCursorBasedPaginatedData() : + $this->getDefaultPageBasedPaginatedData(); + } + + return []; + } + + if ($operation instanceof Mutation) { + return $this->getDefaultMutationData($context); + } + + if ($operation instanceof Subscription) { + return $this->getDefaultSubscriptionData($context); + } + + return null; + } + + $normalizationContext = $this->serializerContextBuilder->create($operation->getClass(), $operation, $context, normalization: true); + + $data = null; + if (!$operation instanceof CollectionOperationInterface) { + if ($operation instanceof Mutation && $operation instanceof DeleteOperationInterface) { + $data = ['id' => $this->getIdentifierFromOperation($operation, $context['args'] ?? [])]; + } else { + $data = $this->normalizer->normalize($itemOrCollection, ItemNormalizer::FORMAT, $normalizationContext); + } + } + + if ($operation instanceof CollectionOperationInterface && is_iterable($itemOrCollection)) { + if (!$this->pagination->isGraphQlEnabled($operation, $context)) { + $data = []; + foreach ($itemOrCollection as $index => $object) { + $data[$index] = $this->normalizer->normalize($object, ItemNormalizer::FORMAT, $normalizationContext); + } + } else { + $data = 'cursor' === $this->pagination->getGraphQlPaginationType($operation) ? + $this->serializeCursorBasedPaginatedCollection($itemOrCollection, $normalizationContext, $context) : + $this->serializePageBasedPaginatedCollection($itemOrCollection, $normalizationContext); + } + } + + if (null !== $data && !\is_array($data)) { + throw new \UnexpectedValueException('Expected serialized data to be a nullable array.'); + } + + $isMutation = $operation instanceof Mutation; + $isSubscription = $operation instanceof Subscription; + if ($isMutation || $isSubscription) { + $wrapFieldName = lcfirst($operation->getShortName()); + + return [$wrapFieldName => $data] + ($isMutation ? $this->getDefaultMutationData($context) : $this->getDefaultSubscriptionData($context)); + } + + return $data; + } + + /** + * @throws \LogicException + * @throws \UnexpectedValueException + */ + private function serializeCursorBasedPaginatedCollection(iterable $collection, array $normalizationContext, array $context): array + { + $args = $context['args']; + + if (!($collection instanceof PartialPaginatorInterface)) { + throw new \LogicException(sprintf('Collection returned by the collection data provider must implement %s or %s.', PaginatorInterface::class, PartialPaginatorInterface::class)); + } + + $offset = 0; + $totalItems = 1; // For partial pagination, always consider there is at least one item. + $nbPageItems = $collection->count(); + if (isset($args['after'])) { + $after = base64_decode($args['after'], true); + if (false === $after || '' === $args['after']) { + throw new \UnexpectedValueException('' === $args['after'] ? 'Empty cursor is invalid' : sprintf('Cursor %s is invalid', $args['after'])); + } + $offset = 1 + (int) $after; + } + + if ($collection instanceof PaginatorInterface) { + $totalItems = $collection->getTotalItems(); + + if (isset($args['before'])) { + $before = base64_decode($args['before'], true); + if (false === $before || '' === $args['before']) { + throw new \UnexpectedValueException('' === $args['before'] ? 'Empty cursor is invalid' : sprintf('Cursor %s is invalid', $args['before'])); + } + $offset = (int) $before - $nbPageItems; + } + if (isset($args['last']) && !isset($args['before'])) { + $offset = $totalItems - $args['last']; + } + } + + $offset = 0 > $offset ? 0 : $offset; + + $data = $this->getDefaultCursorBasedPaginatedData(); + if ($totalItems > 0) { + $data['pageInfo']['startCursor'] = base64_encode((string) $offset); + $end = $offset + $nbPageItems - 1; + $data['pageInfo']['endCursor'] = base64_encode((string) ($end >= 0 ? $end : 0)); + $data['pageInfo']['hasPreviousPage'] = $offset > 0; + if ($collection instanceof PaginatorInterface) { + $data['totalCount'] = $totalItems; + $itemsPerPage = $collection->getItemsPerPage(); + $data['pageInfo']['hasNextPage'] = (float) ($itemsPerPage > 0 ? $offset % $itemsPerPage : $offset) + $itemsPerPage * $collection->getCurrentPage() < $totalItems; + } + } + + $index = 0; + foreach ($collection as $object) { + $data['edges'][$index] = [ + 'node' => $this->normalizer->normalize($object, ItemNormalizer::FORMAT, $normalizationContext), + 'cursor' => base64_encode((string) ($index + $offset)), + ]; + ++$index; + } + + return $data; + } + + /** + * @throws \LogicException + */ + private function serializePageBasedPaginatedCollection(iterable $collection, array $normalizationContext): array + { + if (!($collection instanceof PaginatorInterface)) { + throw new \LogicException(sprintf('Collection returned by the collection data provider must implement %s.', PaginatorInterface::class)); + } + + $data = $this->getDefaultPageBasedPaginatedData(); + $data['paginationInfo']['totalCount'] = $collection->getTotalItems(); + $data['paginationInfo']['lastPage'] = $collection->getLastPage(); + $data['paginationInfo']['itemsPerPage'] = $collection->getItemsPerPage(); + + foreach ($collection as $object) { + $data['collection'][] = $this->normalizer->normalize($object, ItemNormalizer::FORMAT, $normalizationContext); + } + + return $data; + } + + private function getDefaultCursorBasedPaginatedData(): array + { + return ['totalCount' => 0., 'edges' => [], 'pageInfo' => ['startCursor' => null, 'endCursor' => null, 'hasNextPage' => false, 'hasPreviousPage' => false]]; + } + + private function getDefaultPageBasedPaginatedData(): array + { + return ['collection' => [], 'paginationInfo' => ['itemsPerPage' => 0., 'totalCount' => 0., 'lastPage' => 0.]]; + } + + private function getDefaultMutationData(array $context): array + { + return ['clientMutationId' => $context['args']['input']['clientMutationId'] ?? null]; + } + + private function getDefaultSubscriptionData(array $context): array + { + return ['clientSubscriptionId' => $context['args']['input']['clientSubscriptionId'] ?? null]; + } +} diff --git a/src/GraphQl/State/Processor/SubscriptionProcessor.php b/src/GraphQl/State/Processor/SubscriptionProcessor.php new file mode 100644 index 00000000000..d4389499221 --- /dev/null +++ b/src/GraphQl/State/Processor/SubscriptionProcessor.php @@ -0,0 +1,56 @@ + + * + * 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\GraphQl\State\Processor; + +use ApiPlatform\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface; +use ApiPlatform\GraphQl\Subscription\OperationAwareSubscriptionManagerInterface; +use ApiPlatform\GraphQl\Subscription\SubscriptionManagerInterface; +use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; + +/** + * Adds the mercure subscription url if available. + */ +final class SubscriptionProcessor implements ProcessorInterface +{ + public function __construct(private readonly ProcessorInterface $decorated, private readonly SubscriptionManagerInterface $subscriptionManager, private readonly ?MercureSubscriptionIriGeneratorInterface $mercureSubscriptionIriGenerator) + { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + $data = $this->decorated->process($data, $operation, $uriVariables, $context); + if (!$operation instanceof GraphQlOperation || !($mercure = $operation->getMercure())) { + return $data; + } + + if ($this->subscriptionManager instanceof OperationAwareSubscriptionManagerInterface) { + $subscriptionId = $this->subscriptionManager->retrieveSubscriptionId($context, $data, $operation); + } else { + $subscriptionId = $this->subscriptionManager->retrieveSubscriptionId($context, $data); + } + + if ($subscriptionId) { + if (!$this->mercureSubscriptionIriGenerator) { + throw new \LogicException('Cannot use Mercure for subscriptions when MercureBundle is not installed. Try running "composer require mercure".'); + } + + $hub = \is_array($mercure) ? ($mercure['hub'] ?? null) : null; + $data['mercureUrl'] = $this->mercureSubscriptionIriGenerator->generateMercureUrl($subscriptionId, $hub); + } + + return $data; + } +} diff --git a/src/GraphQl/State/Provider/DenormalizeProvider.php b/src/GraphQl/State/Provider/DenormalizeProvider.php new file mode 100644 index 00000000000..481bb8bb6b0 --- /dev/null +++ b/src/GraphQl/State/Provider/DenormalizeProvider.php @@ -0,0 +1,58 @@ + + * + * 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\GraphQl\State\Provider; + +use ApiPlatform\GraphQl\Serializer\ItemNormalizer; +use ApiPlatform\GraphQl\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +/** + * A denormalization provider for GraphQl. + */ +final class DenormalizeProvider implements ProviderInterface +{ + /** + * @param ProviderInterface $decorated + */ + public function __construct(private readonly ProviderInterface $decorated, private readonly DenormalizerInterface $denormalizer, private readonly SerializerContextBuilderInterface $serializerContextBuilder) + { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $data = $this->decorated->provide($operation, $uriVariables, $context); + + if (!($operation->canDeserialize() ?? true) || (!$operation instanceof Mutation)) { + return $data; + } + + $denormalizationContext = $this->serializerContextBuilder->create($operation->getClass(), $operation, $context, normalization: false); + + if (null !== $data) { + $denormalizationContext[AbstractNormalizer::OBJECT_TO_POPULATE] = $data; + } + + $item = $this->denormalizer->denormalize($context['args']['input'], $operation->getClass(), ItemNormalizer::FORMAT, $denormalizationContext); + + if (!\is_object($item)) { + throw new \UnexpectedValueException('Expected item to be an object.'); + } + + return $item; + } +} diff --git a/src/GraphQl/State/Provider/ReadProvider.php b/src/GraphQl/State/Provider/ReadProvider.php new file mode 100644 index 00000000000..a96f6486d7d --- /dev/null +++ b/src/GraphQl/State/Provider/ReadProvider.php @@ -0,0 +1,147 @@ + + * + * 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\GraphQl\State\Provider; + +use ApiPlatform\Exception\ItemNotFoundException; +use ApiPlatform\GraphQl\Resolver\Util\IdentifierTrait; +use ApiPlatform\GraphQl\Serializer\ItemNormalizer; +use ApiPlatform\GraphQl\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\GraphQl\Util\ArrayTrait; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\State\ProviderInterface; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +/** + * Reads data of the provided args, it is also called when reading relations in the same query. + */ +final class ReadProvider implements ProviderInterface +{ + use ArrayTrait; + use ClassInfoTrait; + use IdentifierTrait; + + public function __construct(private readonly ProviderInterface $provider, private readonly IriConverterInterface $iriConverter, private readonly ?SerializerContextBuilderInterface $serializerContextBuilder, private readonly string $nestingSeparator) + { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + if (!$operation instanceof GraphQlOperation || !($operation->canRead() ?? true)) { + return $operation instanceof QueryCollection ? [] : null; + } + + $args = $context['args'] ?? []; + + if (!$operation instanceof CollectionOperationInterface) { + $identifier = $this->getIdentifierFromOperation($operation, $args); + + if (!$identifier) { + return null; + } + + try { + $item = $this->iriConverter->getResourceFromIri($identifier, $context); + } catch (ItemNotFoundException) { + $item = null; + } + + if ($operation instanceof Subscription || $operation instanceof Mutation) { + if (null === $item) { + throw new NotFoundHttpException(sprintf('Item "%s" not found.', $args['input']['id'])); + } + + if ($operation->getClass() !== $this->getObjectClass($item)) { + throw new \UnexpectedValueException(sprintf('Item "%s" did not match expected type "%s".', $args['input']['id'], $operation->getShortName())); + } + } + + if (!\is_object($item)) { + throw new \LogicException('Item from read provider should be a nullable object.'); + } + + if (isset($context['graphql_context']) && !enum_exists($item::class)) { + $context['graphql_context']['previous_object'] = clone $item; + } + + return $item; + } + + if (null === ($context['root_class'] ?? null)) { + return []; + } + + $uriVariables = []; + $context['filters'] = $this->getNormalizedFilters($args); + + // This is how we resolve graphql links see ApiPlatform\Doctrine\Common\State\LinksHandlerTrait, I'm wondering if we couldn't do that in an UriVariables + // resolver within our ApiPlatform\GraphQl\Resolver\Factory\ResolverFactory, this would mimic what's happening in the HTTP controller and simplify some code. + $source = $context['source']; + /** @var \GraphQL\Type\Definition\ResolveInfo $info */ + $info = $context['info']; + if (isset($source[$info->fieldName], $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY], $source[ItemNormalizer::ITEM_RESOURCE_CLASS_KEY])) { + $uriVariables = $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY]; + $context['linkClass'] = $source[ItemNormalizer::ITEM_RESOURCE_CLASS_KEY]; + $context['linkProperty'] = $info->fieldName; + } + + if ($this->serializerContextBuilder) { + // Builtin data providers are able to use the serialization context to automatically add join clauses + $context += $this->serializerContextBuilder->create($operation->getClass(), $operation, $context, true); + } + + return $this->provider->provide($operation, $uriVariables, $context); + } + + /** + * @param array $args + * + * @return array + */ + private function getNormalizedFilters(array $args): array + { + $filters = $args; + + foreach ($filters as $name => $value) { + if (\is_array($value)) { + if (strpos($name, '_list')) { + $name = substr($name, 0, \strlen($name) - \strlen('_list')); + } + + // If the value contains arrays, we need to merge them for the filters to understand this syntax, proper to GraphQL to preserve the order of the arguments. + if ($this->isSequentialArrayOfArrays($value)) { + $value = array_merge(...$value); + } + $filters[$name] = $this->getNormalizedFilters($value); + } + + if (\is_string($name) && strpos($name, $this->nestingSeparator)) { + // Gives a chance to relations/nested fields. + $index = array_search($name, array_keys($filters), true); + $filters = + \array_slice($filters, 0, $index + 1) + + [str_replace($this->nestingSeparator, '.', $name) => $value] + + \array_slice($filters, $index + 1); + } + } + + return $filters; + } +} diff --git a/src/GraphQl/State/Provider/ResolverProvider.php b/src/GraphQl/State/Provider/ResolverProvider.php new file mode 100644 index 00000000000..05dcb785c3e --- /dev/null +++ b/src/GraphQl/State/Provider/ResolverProvider.php @@ -0,0 +1,78 @@ + + * + * 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\GraphQl\State\Provider; + +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\State\ProviderInterface; +use Psr\Container\ContainerInterface; + +/** + * This provider calls a GraphQl resolver if defined. + */ +final class ResolverProvider implements ProviderInterface +{ + use ClassInfoTrait; + + public function __construct(private readonly ProviderInterface $decorated, private readonly ContainerInterface $queryResolverLocator) + { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $item = $this->decorated->provide($operation, $uriVariables, $context); + + if (!$operation instanceof GraphQlOperation || null === ($queryResolverId = $operation->getResolver())) { + return $item; + } + + /** @var \ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface $queryResolver */ + $queryResolver = $this->queryResolverLocator->get($queryResolverId); + $item = $queryResolver($item, $context); + if (!$operation instanceof CollectionOperationInterface) { + // The item retrieved can be of another type when using an identifier (see Relay Nodes at query.feature:23) + $this->getResourceClass($item, $operation->getOutput()['class'] ?? $operation->getClass(), sprintf('Custom query resolver "%s"', $queryResolverId).' has to return an item of class %s but returned an item of class %s.'); + } + + return $item; + } + + /** + * @throws \UnexpectedValueException + */ + private function getResourceClass(?object $item, ?string $resourceClass, string $errorMessage = 'Resolver only handles items of class %s but retrieved item is of class %s.'): string + { + if (null === $item) { + if (null === $resourceClass) { + throw new \UnexpectedValueException('Resource class cannot be determined.'); + } + + return $resourceClass; + } + + $itemClass = $this->getObjectClass($item); + + if (null === $resourceClass) { + return $itemClass; + } + + if ($resourceClass !== $itemClass) { + throw new \UnexpectedValueException(sprintf($errorMessage, (new \ReflectionClass($resourceClass))->getShortName(), (new \ReflectionClass($itemClass))->getShortName())); + } + + return $resourceClass; + } +} diff --git a/src/GraphQl/Subscription/OperationAwareSubscriptionManagerInterface.php b/src/GraphQl/Subscription/OperationAwareSubscriptionManagerInterface.php new file mode 100644 index 00000000000..5de5faedc77 --- /dev/null +++ b/src/GraphQl/Subscription/OperationAwareSubscriptionManagerInterface.php @@ -0,0 +1,26 @@ + + * + * 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\GraphQl\Subscription; + +use ApiPlatform\Metadata\GraphQl\Operation; + +/** + * Manages all the queried subscriptions and creates their ID. + * + * @author Alan Poulain + */ +interface OperationAwareSubscriptionManagerInterface extends SubscriptionManagerInterface +{ + public function retrieveSubscriptionId(array $context, ?array $result, Operation $operation = null): ?string; +} diff --git a/src/GraphQl/Subscription/SubscriptionManager.php b/src/GraphQl/Subscription/SubscriptionManager.php index 648f8424af4..126b188772b 100644 --- a/src/GraphQl/Subscription/SubscriptionManager.php +++ b/src/GraphQl/Subscription/SubscriptionManager.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; use ApiPlatform\Metadata\Util\SortTrait; +use ApiPlatform\State\ProcessorInterface; use GraphQL\Type\Definition\ResolveInfo; use Psr\Cache\CacheItemPoolInterface; @@ -30,23 +31,23 @@ * * @author Alan Poulain */ -final class SubscriptionManager implements SubscriptionManagerInterface +final class SubscriptionManager implements OperationAwareSubscriptionManagerInterface { use IdentifierTrait; use ResourceClassInfoTrait; use SortTrait; - public function __construct(private readonly CacheItemPoolInterface $subscriptionsCache, private readonly SubscriptionIdentifierGeneratorInterface $subscriptionIdentifierGenerator, private readonly SerializeStageInterface $serializeStage, private readonly IriConverterInterface $iriConverter, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory) + public function __construct(private readonly CacheItemPoolInterface $subscriptionsCache, private readonly SubscriptionIdentifierGeneratorInterface $subscriptionIdentifierGenerator, private readonly ?SerializeStageInterface $serializeStage, private readonly IriConverterInterface $iriConverter, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ?ProcessorInterface $normalizeProcessor = null) { } - public function retrieveSubscriptionId(array $context, ?array $result): ?string + public function retrieveSubscriptionId(array $context, ?array $result, Operation $operation = null): ?string { /** @var ResolveInfo $info */ $info = $context['info']; $fields = $info->getFieldSelection(\PHP_INT_MAX); $this->arrayRecursiveSort($fields, 'ksort'); - $iri = $this->getIdentifierFromContext($context); + $iri = $operation ? $this->getIdentifierFromOperation($operation, $context['args'] ?? []) : $this->getIdentifierFromContext($context); if (null === $iri) { return null; } @@ -84,7 +85,14 @@ public function getPushPayloads(object $object): array $resolverContext = ['fields' => $subscriptionFields, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; /** @var Operation */ $operation = (new Subscription())->withName('update_subscription')->withShortName($shortName); - $data = ($this->serializeStage)($object, $resourceClass, $operation, $resolverContext); + if ($this->normalizeProcessor) { + $data = $this->normalizeProcessor->process($object, $operation, [], $resolverContext); + } elseif ($this->serializeStage) { + $data = ($this->serializeStage)($object, $resourceClass, $operation, $resolverContext); + } else { + throw new \LogicException(); + } + unset($data['clientSubscriptionId']); if ($data !== $subscriptionResult) { diff --git a/src/GraphQl/Tests/Resolver/Factory/ResolverFactoryTest.php b/src/GraphQl/Tests/Resolver/Factory/ResolverFactoryTest.php new file mode 100644 index 00000000000..7abacb5dafa --- /dev/null +++ b/src/GraphQl/Tests/Resolver/Factory/ResolverFactoryTest.php @@ -0,0 +1,55 @@ + + * + * 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\GraphQl\Tests\Resolver\Factory; + +use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactory; +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Operation; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\ProviderInterface; +use GraphQL\Type\Definition\ResolveInfo; +use PHPUnit\Framework\TestCase; + +class ResolverFactoryTest extends TestCase +{ + /** + * @dataProvider graphQlQueries + */ + public function testGraphQlResolver(string $resourceClass = null, string $rootClass = null, Operation $operation = null, Operation $providedOperation = null, Operation $processedOperation = null): void + { + $returnValue = new \stdClass(); + $body = new \stdClass(); + $context = $this->logicalAnd( + $this->arrayHasKey('source'), $this->arrayHasKey('args'), $this->arrayHasKey('graphql_context'), $this->arrayHasKey('info'), $this->arrayHasKey('root_class') + ); + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once())->method('provide')->with($providedOperation ?: $operation, [], $context)->willReturn($body); + $processor = $this->createMock(ProcessorInterface::class); + $processor->expects($this->once())->method('process')->with($body, $processedOperation ?: $operation, [], $context)->willReturn($returnValue); + $resolveInfo = $this->createMock(ResolveInfo::class); + $resolveInfo->fieldName = 'test'; + + $resolverFactory = new ResolverFactory($provider, $processor); + $this->assertEquals($resolverFactory->__invoke($resourceClass, $rootClass, $operation)(['test' => null], [], [], $resolveInfo), $returnValue); + } + + public function graphQlQueries(): array + { + return [ + ['Dummy', 'Dummy', new Query()], + ['Dummy', 'Dummy', new Mutation(), (new Mutation())->withValidate(true), (new Mutation())->withValidate(true)->withWrite(true)], + ]; + } +} diff --git a/src/GraphQl/Tests/Resolver/Stage/SerializeStageTest.php b/src/GraphQl/Tests/Resolver/Stage/SerializeStageTest.php index af72a28d6d1..6f817d690a0 100644 --- a/src/GraphQl/Tests/Resolver/Stage/SerializeStageTest.php +++ b/src/GraphQl/Tests/Resolver/Stage/SerializeStageTest.php @@ -133,7 +133,7 @@ public function testApplyCollectionWithPagination(iterable|callable $collection, 'is_mutation' => false, 'is_subscription' => false, 'args' => $args, - 'info' => self::createStub(ResolveInfo::class), + 'info' => self::createMock(ResolveInfo::class), ]; /** @var Operation $operation */ diff --git a/src/GraphQl/Tests/Serializer/SerializerContextBuilderTest.php b/src/GraphQl/Tests/Serializer/SerializerContextBuilderTest.php index 73f7ba9d107..34b628a6208 100644 --- a/src/GraphQl/Tests/Serializer/SerializerContextBuilderTest.php +++ b/src/GraphQl/Tests/Serializer/SerializerContextBuilderTest.php @@ -87,10 +87,7 @@ private function buildOperationFromContext(bool $isMutation, bool $isSubscriptio */ public function testCreateNormalizationContext(?string $resourceClass, string $operationName, array $fields, bool $isMutation, bool $isSubscription, bool $noInfo, array $expectedContext, callable $advancedNameConverter = null, string $expectedExceptionClass = null, string $expectedExceptionMessage = null): void { - $resolverContext = [ - 'is_mutation' => $isMutation, - 'is_subscription' => $isSubscription, - ]; + $resolverContext = []; $operation = $this->buildOperationFromContext($isMutation, $isSubscription, $expectedContext, true, $resourceClass); if ($noInfo) { diff --git a/src/GraphQl/Tests/State/Processor/NormalizeProcessorTest.php b/src/GraphQl/Tests/State/Processor/NormalizeProcessorTest.php new file mode 100644 index 00000000000..34411efe66e --- /dev/null +++ b/src/GraphQl/Tests/State/Processor/NormalizeProcessorTest.php @@ -0,0 +1,77 @@ + + * + * 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\GraphQl\Tests\State\Processor; + +use ApiPlatform\GraphQl\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\GraphQl\State\Processor\NormalizeProcessor; +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\State\Pagination\ArrayPaginator; +use ApiPlatform\State\Pagination\Pagination; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +class NormalizeProcessorTest extends TestCase +{ + /** + * @dataProvider processItems + */ + public function testProcess($body, $operation): void + { + $context = ['args' => []]; + $serializerContext = ['resource_class' => $operation->getClass()]; + $normalizer = $this->createMock(NormalizerInterface::class); + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $serializerContextBuilder->expects($this->once())->method('create')->with($operation->getClass(), $operation, $context, normalization: true)->willReturn($serializerContext); + $normalizer->expects($this->once())->method('normalize')->with($body, 'graphql', $serializerContext); + $processor = new NormalizeProcessor($normalizer, $serializerContextBuilder, new Pagination()); + $processor->process($body, $operation, [], $context); + } + + public function processItems(): array + { + return [ + [new \stdClass(), new Query(class: 'foo')], + [new \stdClass(), new Mutation(class: 'foo', shortName: 'Foo')], + [new \stdClass(), new Subscription(class: 'foo', shortName: 'Foo')], + ]; + } + + /** + * @dataProvider processCollection + */ + public function testProcessCollection($body, $operation): void + { + $context = ['args' => []]; + $serializerContext = ['resource_class' => $operation->getClass()]; + $normalizer = $this->createMock(NormalizerInterface::class); + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $serializerContextBuilder->expects($this->once())->method('create')->with($operation->getClass(), $operation, $context, normalization: true)->willReturn($serializerContext); + foreach ($body as $v) { + $normalizer->expects($this->once())->method('normalize')->with($v, 'graphql', $serializerContext); + } + + $processor = new NormalizeProcessor($normalizer, $serializerContextBuilder, new Pagination()); + $processor->process($body, $operation, [], $context); + } + + public function processCollection(): array + { + return [ + [new ArrayPaginator([new \stdClass()], 0, 1), new QueryCollection(class: 'foo')], + ]; + } +} diff --git a/src/GraphQl/Tests/State/Processor/SubscriptionProcessorTest.php b/src/GraphQl/Tests/State/Processor/SubscriptionProcessorTest.php new file mode 100644 index 00000000000..f9ee045f8dc --- /dev/null +++ b/src/GraphQl/Tests/State/Processor/SubscriptionProcessorTest.php @@ -0,0 +1,66 @@ + + * + * 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\GraphQl\Tests\State\Processor; + +use ApiPlatform\GraphQl\State\Processor\SubscriptionProcessor; +use ApiPlatform\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface; +use ApiPlatform\GraphQl\Subscription\SubscriptionManagerInterface; +use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\State\ProcessorInterface; +use PHPUnit\Framework\TestCase; + +class SubscriptionProcessorTest extends TestCase +{ + public function testProcess(): void + { + $operation = new Subscription(mercure: ['hub' => 'mercure.rocks']); + $context = []; + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once())->method('process')->willReturn([]); + $subscriptionManager = $this->createMock(SubscriptionManagerInterface::class); + $subscriptionManager->expects($this->once())->method('retrieveSubscriptionId')->willReturn('/1'); + $mercureSubscriptionIriGenerator = $this->createMock(MercureSubscriptionIriGeneratorInterface::class); + $mercureSubscriptionIriGenerator->expects($this->once())->method('generateMercureUrl')->with('/1', $operation->getMercure()['hub']); + $processor = new SubscriptionProcessor($decorated, $subscriptionManager, $mercureSubscriptionIriGenerator); + $processor->process([], $operation, [], $context); + } + + public function testProcessWithoutId(): void + { + $operation = new Subscription(mercure: ['hub' => 'mercure.rocks']); + $context = []; + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once())->method('process')->willReturn([]); + $subscriptionManager = $this->createMock(SubscriptionManagerInterface::class); + $subscriptionManager->expects($this->once())->method('retrieveSubscriptionId')->willReturn(null); + $mercureSubscriptionIriGenerator = $this->createMock(MercureSubscriptionIriGeneratorInterface::class); + $mercureSubscriptionIriGenerator->expects($this->never())->method('generateMercureUrl')->with('/1', $operation->getMercure()['hub']); + $processor = new SubscriptionProcessor($decorated, $subscriptionManager, $mercureSubscriptionIriGenerator); + $processor->process([], $operation, [], $context); + } + + public function testProcessWithoutMercure(): void + { + $operation = new Subscription(); + $context = []; + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once())->method('process')->willReturn([]); + $subscriptionManager = $this->createMock(SubscriptionManagerInterface::class); + $subscriptionManager->expects($this->never())->method('retrieveSubscriptionId')->willReturn(null); + $mercureSubscriptionIriGenerator = $this->createMock(MercureSubscriptionIriGeneratorInterface::class); + $mercureSubscriptionIriGenerator->expects($this->never())->method('generateMercureUrl'); + $processor = new SubscriptionProcessor($decorated, $subscriptionManager, $mercureSubscriptionIriGenerator); + $processor->process([], $operation, [], $context); + } +} diff --git a/src/GraphQl/Tests/State/Provider/DenormalizeProviderTest.php b/src/GraphQl/Tests/State/Provider/DenormalizeProviderTest.php new file mode 100644 index 00000000000..d356b1f92e6 --- /dev/null +++ b/src/GraphQl/Tests/State/Provider/DenormalizeProviderTest.php @@ -0,0 +1,89 @@ + + * + * 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\GraphQl\Tests\State\Provider; + +use ApiPlatform\GraphQl\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\GraphQl\State\Provider\DenormalizeProvider; +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\State\ProviderInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +class DenormalizeProviderTest extends TestCase +{ + public function testProvide(): void + { + $objectToPopulate = null; + $context = ['args' => ['input' => ['test']]]; + $operation = new Mutation(class: 'dummy'); + $serializerContext = ['resource_class' => $operation->getClass()]; + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->willReturn($objectToPopulate); + $denormalizer = $this->createMock(DenormalizerInterface::class); + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $serializerContextBuilder->expects($this->once())->method('create')->with($operation->getClass(), $operation, $context, normalization: false)->willReturn($serializerContext); + $denormalizer->expects($this->once())->method('denormalize')->with(['test'], 'dummy', 'graphql', $serializerContext)->willReturn(new \stdClass()); + $provider = new DenormalizeProvider($decorated, $denormalizer, $serializerContextBuilder); + $provider->provide($operation, [], $context); + } + + public function testProvideWithObjectToPopulate(): void + { + $objectToPopulate = new \stdClass(); + $context = ['args' => ['input' => ['test']]]; + $operation = new Mutation(class: 'dummy'); + $serializerContext = ['resource_class' => $operation->getClass(), 'object_to_populate' => $objectToPopulate]; + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->willReturn($objectToPopulate); + $denormalizer = $this->createMock(DenormalizerInterface::class); + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $serializerContextBuilder->expects($this->once())->method('create')->with($operation->getClass(), $operation, $context, normalization: false)->willReturn($serializerContext); + $denormalizer->expects($this->once())->method('denormalize')->with(['test'], 'dummy', 'graphql', $serializerContext)->willReturn(new \stdClass()); + $provider = new DenormalizeProvider($decorated, $denormalizer, $serializerContextBuilder); + $provider->provide($operation, [], $context); + } + + public function testProvideNotCalledWithQuery(): void + { + $objectToPopulate = new \stdClass(); + $context = ['args' => ['input' => ['test']]]; + $operation = new Query(class: 'dummy'); + $serializerContext = ['resource_class' => $operation->getClass()]; + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->willReturn($objectToPopulate); + $denormalizer = $this->createMock(DenormalizerInterface::class); + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $serializerContextBuilder->expects($this->never())->method('create')->with($operation->getClass(), $operation, $context, normalization: false)->willReturn($serializerContext); + $denormalizer->expects($this->never())->method('denormalize')->with(['test'], 'dummy', 'graphql', $serializerContext)->willReturn(new \stdClass()); + $provider = new DenormalizeProvider($decorated, $denormalizer, $serializerContextBuilder); + $provider->provide($operation, [], $context); + } + + public function testProvideNotCalledWithoutDeserialize(): void + { + $objectToPopulate = new \stdClass(); + $context = ['args' => ['input' => ['test']]]; + $operation = new Query(class: 'dummy', deserialize: false); + $serializerContext = ['resource_class' => $operation->getClass()]; + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->willReturn($objectToPopulate); + $denormalizer = $this->createMock(DenormalizerInterface::class); + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $serializerContextBuilder->expects($this->never())->method('create')->with($operation->getClass(), $operation, $context, normalization: false)->willReturn($serializerContext); + $denormalizer->expects($this->never())->method('denormalize')->with(['test'], 'dummy', 'graphql', $serializerContext)->willReturn(new \stdClass()); + $provider = new DenormalizeProvider($decorated, $denormalizer, $serializerContextBuilder); + $provider->provide($operation, [], $context); + } +} diff --git a/src/GraphQl/Tests/State/Provider/ReadProviderTest.php b/src/GraphQl/Tests/State/Provider/ReadProviderTest.php new file mode 100644 index 00000000000..d1e3a141bb7 --- /dev/null +++ b/src/GraphQl/Tests/State/Provider/ReadProviderTest.php @@ -0,0 +1,53 @@ + + * + * 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\GraphQl\Tests\State\Provider; + +use ApiPlatform\GraphQl\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\GraphQl\State\Provider\ReadProvider; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\State\ProviderInterface; +use GraphQL\Type\Definition\ResolveInfo; +use PHPUnit\Framework\TestCase; + +class ReadProviderTest extends TestCase +{ + public function testProvide(): void + { + $context = ['args' => ['id' => '/dummy/1']]; + $operation = new Query(class: 'dummy'); + $decorated = $this->createMock(ProviderInterface::class); + $iriConverter = $this->createMock(IriConverterInterface::class); + $iriConverter->expects($this->once())->method('getResourceFromIri')->with('/dummy/1'); + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $provider = new ReadProvider($decorated, $iriConverter, $serializerContextBuilder, '.'); + $provider->provide($operation, [], $context); + } + + public function testProvideCollection(): void + { + $info = $this->createMock(ResolveInfo::class); + $info->fieldName = ''; + $context = ['root_class' => 'dummy', 'source' => [], 'info' => $info, 'filters' => []]; + $operation = new QueryCollection(class: 'dummy'); + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->with($operation, [], ['a'] + $context); + $iriConverter = $this->createMock(IriConverterInterface::class); + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $serializerContextBuilder->expects($this->once())->method('create')->willReturn(['a']); + $provider = new ReadProvider($decorated, $iriConverter, $serializerContextBuilder, '.'); + $provider->provide($operation, [], $context); + } +} diff --git a/src/GraphQl/Tests/State/Provider/ResolverProviderTest.php b/src/GraphQl/Tests/State/Provider/ResolverProviderTest.php new file mode 100644 index 00000000000..75d8f698ac9 --- /dev/null +++ b/src/GraphQl/Tests/State/Provider/ResolverProviderTest.php @@ -0,0 +1,38 @@ + + * + * 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\GraphQl\Tests\State\Provider; + +use ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface; +use ApiPlatform\GraphQl\State\Provider\ResolverProvider; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\State\ProviderInterface; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; + +class ResolverProviderTest extends TestCase +{ + public function testProvide(): void + { + $res = new \stdClass(); + $operation = new QueryCollection(class: 'dummy', resolver: 'foo'); + $context = []; + $decorated = $this->createMock(ProviderInterface::class); + $resolverMock = $this->createMock(QueryItemResolverInterface::class); + $resolverMock->expects($this->once())->method('__invoke')->willReturn($res); + $resolverLocator = $this->createMock(ContainerInterface::class); + $resolverLocator->expects($this->once())->method('get')->with('foo')->willReturn($resolverMock); + $provider = new ResolverProvider($decorated, $resolverLocator); + $this->assertEquals($res, $provider->provide($operation, [], $context)); + } +} diff --git a/tests/Util/ArrayTraitTest.php b/src/GraphQl/Tests/Util/ArrayTraitTest.php similarity index 97% rename from tests/Util/ArrayTraitTest.php rename to src/GraphQl/Tests/Util/ArrayTraitTest.php index 4d939817a99..92c3a50ff10 100644 --- a/tests/Util/ArrayTraitTest.php +++ b/src/GraphQl/Tests/Util/ArrayTraitTest.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Tests\Util; -use ApiPlatform\Util\ArrayTrait; +use ApiPlatform\GraphQl\Util\ArrayTrait; use PHPUnit\Framework\TestCase; class ArrayTraitTest extends TestCase diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index acee6e139eb..fa9ddb108a4 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -14,6 +14,7 @@ namespace ApiPlatform\GraphQl\Type; use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactory; use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactoryInterface; use ApiPlatform\GraphQl\Type\Definition\TypeInterface; use ApiPlatform\Metadata\GraphQl\Mutation; @@ -47,7 +48,7 @@ final class FieldsBuilder implements FieldsBuilderInterface, FieldsBuilderEnumIn { private readonly TypeBuilderEnumInterface|TypeBuilderInterface $typeBuilder; - public function __construct(private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ResourceClassResolverInterface $resourceClassResolver, private readonly TypesContainerInterface $typesContainer, TypeBuilderEnumInterface|TypeBuilderInterface $typeBuilder, private readonly TypeConverterInterface $typeConverter, private readonly ResolverFactoryInterface $itemResolverFactory, private readonly ResolverFactoryInterface $collectionResolverFactory, private readonly ResolverFactoryInterface $itemMutationResolverFactory, private readonly ResolverFactoryInterface $itemSubscriptionResolverFactory, private readonly ContainerInterface $filterLocator, private readonly Pagination $pagination, private readonly ?NameConverterInterface $nameConverter, private readonly string $nestingSeparator) + public function __construct(private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ResourceClassResolverInterface $resourceClassResolver, private readonly TypesContainerInterface $typesContainer, TypeBuilderEnumInterface|TypeBuilderInterface $typeBuilder, private readonly TypeConverterInterface $typeConverter, private readonly ResolverFactoryInterface $itemResolverFactory, private readonly ?ResolverFactoryInterface $collectionResolverFactory, private readonly ?ResolverFactoryInterface $itemMutationResolverFactory, private readonly ?ResolverFactoryInterface $itemSubscriptionResolverFactory, private readonly ContainerInterface $filterLocator, private readonly Pagination $pagination, private readonly ?NameConverterInterface $nameConverter, private readonly string $nestingSeparator) { if ($typeBuilder instanceof TypeBuilderInterface) { @trigger_error(sprintf('$typeBuilder argument of FieldsBuilder implementing "%s" is deprecated since API Platform 3.1. It has to implement "%s" instead.', TypeBuilderInterface::class, TypeBuilderEnumInterface::class), \E_USER_DEPRECATED); @@ -336,14 +337,22 @@ private function getResourceFieldConfiguration(?string $property, ?string $field $args = $this->getFilterArgs($args, $resourceClass, $rootResource, $resourceOperation, $rootOperation, $property, $depth); } - if ($isStandardGraphqlType || $input) { - $resolve = null; - } elseif (($rootOperation instanceof Mutation || $rootOperation instanceof Subscription) && $depth <= 0) { - $resolve = $rootOperation instanceof Mutation ? ($this->itemMutationResolverFactory)($resourceClass, $rootResource, $resourceOperation) : ($this->itemSubscriptionResolverFactory)($resourceClass, $rootResource, $resourceOperation); - } elseif ($this->typeBuilder->isCollection($type)) { - $resolve = ($this->collectionResolverFactory)($resourceClass, $rootResource, $resourceOperation); + if ($this->itemResolverFactory instanceof ResolverFactory) { + if ($isStandardGraphqlType || $input) { + $resolve = null; + } else { + $resolve = ($this->itemResolverFactory)($resourceClass, $rootResource, $resourceOperation); + } } else { - $resolve = ($this->itemResolverFactory)($resourceClass, $rootResource, $resourceOperation); + if ($isStandardGraphqlType || $input) { + $resolve = null; + } elseif (($rootOperation instanceof Mutation || $rootOperation instanceof Subscription) && $depth <= 0) { + $resolve = $rootOperation instanceof Mutation ? ($this->itemMutationResolverFactory)($resourceClass, $rootResource, $resourceOperation) : ($this->itemSubscriptionResolverFactory)($resourceClass, $rootResource, $resourceOperation); + } elseif ($this->typeBuilder->isCollection($type)) { + $resolve = ($this->collectionResolverFactory)($resourceClass, $rootResource, $resourceOperation); + } else { + $resolve = ($this->itemResolverFactory)($resourceClass, $rootResource, $resourceOperation); + } } return [ diff --git a/src/GraphQl/Util/ArrayTrait.php b/src/GraphQl/Util/ArrayTrait.php new file mode 100644 index 00000000000..710546beea6 --- /dev/null +++ b/src/GraphQl/Util/ArrayTrait.php @@ -0,0 +1,43 @@ + + * + * 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\GraphQl\Util; + +/** + * @internal + */ +trait ArrayTrait +{ + public function isSequentialArrayOfArrays(array $array): bool + { + if (!$this->isSequentialArray($array)) { + return false; + } + + return $this->arrayContainsOnly($array, 'array'); + } + + public function isSequentialArray(array $array): bool + { + if ([] === $array) { + return false; + } + + return array_is_list($array); + } + + public function arrayContainsOnly(array $array, string $type): bool + { + return $array === array_filter($array, static fn ($item): bool => $type === \gettype($item)); + } +} diff --git a/src/GraphQl/composer.json b/src/GraphQl/composer.json index f41f36ba3ad..15f1b8ee97e 100644 --- a/src/GraphQl/composer.json +++ b/src/GraphQl/composer.json @@ -26,7 +26,8 @@ "api-platform/state": "*@dev || ^3.1", "symfony/property-info": "^6.1", "symfony/serializer": "^6.1", - "webonyx/graphql-php": "^14.0 || ^15.0" + "webonyx/graphql-php": "^14.0 || ^15.0", + "willdurand/negotiation": "^3.1" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.0", diff --git a/src/HttpCache/EventListener/AddHeadersListener.php b/src/HttpCache/EventListener/AddHeadersListener.php index a963e103a2a..d2f513b3992 100644 --- a/src/HttpCache/EventListener/AddHeadersListener.php +++ b/src/HttpCache/EventListener/AddHeadersListener.php @@ -53,6 +53,9 @@ public function onKernelResponse(ResponseEvent $event): void } $operation = $this->initializeOperation($request); + if ('api_platform.symfony.main_controller' === $operation?->getController()) { + return; + } $resourceCacheHeaders = $attributes['cache_headers'] ?? $operation?->getCacheHeaders() ?? []; if ($this->etag && !$response->getEtag()) { diff --git a/src/HttpCache/EventListener/AddTagsListener.php b/src/HttpCache/EventListener/AddTagsListener.php index 92badc6128d..6e3ac863492 100644 --- a/src/HttpCache/EventListener/AddTagsListener.php +++ b/src/HttpCache/EventListener/AddTagsListener.php @@ -54,6 +54,9 @@ public function onKernelResponse(ResponseEvent $event): void { $request = $event->getRequest(); $operation = $this->initializeOperation($request); + if ('api_platform.symfony.main_controller' === $operation?->getController()) { + return; + } $response = $event->getResponse(); if ( diff --git a/src/HttpCache/State/AddHeadersProcessor.php b/src/HttpCache/State/AddHeadersProcessor.php new file mode 100644 index 00000000000..eb856313efd --- /dev/null +++ b/src/HttpCache/State/AddHeadersProcessor.php @@ -0,0 +1,83 @@ + + * + * 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\HttpCache\State; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use Symfony\Component\HttpFoundation\Response; + +final class AddHeadersProcessor implements ProcessorInterface +{ + /** + * @param ProcessorInterface|ProcessorInterface $decorated + */ + public function __construct(private readonly ProcessorInterface $decorated, private readonly bool $etag = false, private readonly ?int $maxAge = null, private readonly ?int $sharedMaxAge = null, private readonly ?array $vary = null, private readonly ?bool $public = null, private readonly ?int $staleWhileRevalidate = null, private readonly ?int $staleIfError = null) + { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + $response = $this->decorated->process($data, $operation, $uriVariables, $context); + + if ( + !($request = $context['request'] ?? null) + || !$request->isMethodCacheable() + || !$response instanceof Response + || !$operation instanceof HttpOperation + ) { + return $response; + } + + if (!($content = $response->getContent()) || !$response->isSuccessful()) { + return $response; + } + + $resourceCacheHeaders = $operation->getCacheHeaders() ?? []; + + if ($this->etag && !$response->getEtag()) { + $response->setEtag(md5((string) $content)); + } + + if (null !== ($maxAge = $resourceCacheHeaders['max_age'] ?? $this->maxAge) && !$response->headers->hasCacheControlDirective('max-age')) { + $response->setMaxAge($maxAge); + } + + $vary = $resourceCacheHeaders['vary'] ?? $this->vary; + if (null !== $vary) { + $response->setVary(array_diff($vary, $response->getVary()), false); + } + + // if the public-property is defined and not yet set; apply it to the response + $public = ($resourceCacheHeaders['public'] ?? $this->public); + if (null !== $public && !$response->headers->hasCacheControlDirective('public')) { + $public ? $response->setPublic() : $response->setPrivate(); + } + + // Cache-Control "s-maxage" is only relevant is resource is not marked as "private" + if (false !== $public && null !== ($sharedMaxAge = $resourceCacheHeaders['shared_max_age'] ?? $this->sharedMaxAge) && !$response->headers->hasCacheControlDirective('s-maxage')) { + $response->setSharedMaxAge($sharedMaxAge); + } + + if (null !== ($staleWhileRevalidate = $resourceCacheHeaders['stale_while_revalidate'] ?? $this->staleWhileRevalidate) && !$response->headers->hasCacheControlDirective('stale-while-revalidate')) { + $response->headers->addCacheControlDirective('stale-while-revalidate', (string) $staleWhileRevalidate); + } + + if (null !== ($staleIfError = $resourceCacheHeaders['stale_if_error'] ?? $this->staleIfError) && !$response->headers->hasCacheControlDirective('stale-if-error')) { + $response->headers->addCacheControlDirective('stale-if-error', (string) $staleIfError); + } + + return $response; + } +} diff --git a/src/HttpCache/State/AddTagsProcessor.php b/src/HttpCache/State/AddTagsProcessor.php new file mode 100644 index 00000000000..4ea3386381c --- /dev/null +++ b/src/HttpCache/State/AddTagsProcessor.php @@ -0,0 +1,90 @@ + + * + * 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\HttpCache\State; + +use ApiPlatform\HttpCache\PurgerInterface; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\UriVariablesResolverTrait; +use Symfony\Component\HttpFoundation\Response; + +/** + * Sets the list of resources' IRIs included in this response in the configured cache tag HTTP header and/or "xkey" HTTP headers. + * + * By default the "Cache-Tags" HTTP header is used because it is supported by CloudFlare. + * + * @see https://developers.cloudflare.com/cache/how-to/purge-cache#add-cache-tag-http-response-headers + * + * The "xkey" is used because it is supported by Varnish. + * @see https://docs.varnish-software.com/varnish-cache-plus/vmods/ykey/ + * + * @author Kévin Dunglas + */ +final class AddTagsProcessor implements ProcessorInterface +{ + use UriVariablesResolverTrait; + + public function __construct(private readonly ProcessorInterface $decorated, private readonly IriConverterInterface $iriConverter, private readonly ?PurgerInterface $purger = null) + { + } + + /** + * Adds the configured HTTP cache tag and "xkey" headers. + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + $response = $this->decorated->process($data, $operation, $uriVariables, $context); + + if ( + !($request = $context['request'] ?? null) + || !$request->isMethodCacheable() + || !$response instanceof Response + || !$operation instanceof HttpOperation + || !$response->isCacheable() + ) { + return $response; + } + + $resources = $request->attributes->get('_resources', []); + if ($operation instanceof CollectionOperationInterface) { + // Allows to purge collections + $uriVariables = $this->getOperationUriVariables($operation, $request->attributes->all(), $operation->getClass()); + $iri = $this->iriConverter->getIriFromResource($operation->getClass(), UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => $uriVariables]); + + $resources[$iri] = $iri; + } + + if (!$resources) { + return $response; + } + + if (!$this->purger) { + $response->headers->set('Cache-Tags', implode(',', $resources)); + + return $response; + } + + $headers = $this->purger->getResponseHeaders($resources); + + foreach ($headers as $key => $value) { + $response->headers->set($key, $value); + } + + return $response; + } +} diff --git a/src/HttpCache/Tests/State/AddHeadersProcessorTest.php b/src/HttpCache/Tests/State/AddHeadersProcessorTest.php new file mode 100644 index 00000000000..384ac105ee2 --- /dev/null +++ b/src/HttpCache/Tests/State/AddHeadersProcessorTest.php @@ -0,0 +1,56 @@ + + * + * 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\HttpCache\Tests\State; + +use ApiPlatform\HttpCache\State\AddHeadersProcessor; +use ApiPlatform\Metadata\Get; +use ApiPlatform\State\ProcessorInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\ResponseHeaderBag; + +class AddHeadersProcessorTest extends TestCase +{ + public function testAddHeaders(): void + { + $operation = new Get(); + $response = $this->createMock(Response::class); + $response->expects($this->once())->method('setEtag'); + $response->method('getContent')->willReturn('{}'); + $response->method('isSuccessful')->willReturn(true); + $response->headers = $this->createMock(ResponseHeaderBag::class); + $response->headers->method('hasCacheControlDirective')->with($this->logicalOr( + $this->identicalTo('public'), + $this->identicalTo('s-maxage'), + $this->identicalTo('max-age'), + $this->identicalTo('stale-while-revalidate'), + $this->identicalTo('stale-if-error'), + ))->willReturn(false); + $response->headers->expects($this->exactly(2))->method('addCacheControlDirective')->with($this->logicalOr( + $this->identicalTo('stale-while-revalidate'), + $this->identicalTo('stale-if-error'), + ), '10'); + $response->expects($this->once())->method('setPublic'); + $response->expects($this->once())->method('setMaxAge'); + $response->expects($this->once())->method('setSharedMaxAge'); + $request = $this->createMock(Request::class); + $request->method('isMethodCacheable')->willReturn(true); + $context = ['request' => $request]; + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->method('process')->willReturn($response); + $processor = new AddHeadersProcessor($decorated, etag: true, maxAge: 100, sharedMaxAge: 200, vary: ['Accept', 'Accept-Encoding'], public: true, staleWhileRevalidate: 10, staleIfError: 10); + $processor->process($response, $operation, [], $context); + } +} diff --git a/src/HttpCache/Tests/State/AddTagsProcessorTest.php b/src/HttpCache/Tests/State/AddTagsProcessorTest.php new file mode 100644 index 00000000000..4413bda2570 --- /dev/null +++ b/src/HttpCache/Tests/State/AddTagsProcessorTest.php @@ -0,0 +1,94 @@ + + * + * 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\HttpCache\Tests\State; + +use ApiPlatform\HttpCache\PurgerInterface; +use ApiPlatform\HttpCache\State\AddTagsProcessor; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\State\ProcessorInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\ParameterBag; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\ResponseHeaderBag; + +class AddTagsProcessorTest extends TestCase +{ + public function testAddTags(): void + { + $operation = new Get(); + $response = $this->createMock(Response::class); + $response->method('isCacheable')->willReturn(true); + $response->headers = $this->createMock(ResponseHeaderBag::class); + $response->headers->expects($this->once())->method('set')->with('Cache-Tags', 'a,b'); + $request = $this->createMock(Request::class); + $request->method('isMethodCacheable')->willReturn(true); + $request->attributes = $this->createMock(ParameterBag::class); + $request->attributes->method('get')->with('_resources', [])->willReturn(['a', 'b']); + $context = ['request' => $request]; + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->method('process')->willReturn($response); + $iriConverter = $this->createMock(IriConverterInterface::class); + $iriConverter->expects($this->never())->method('getIriFromResource'); + $processor = new AddTagsProcessor($decorated, $iriConverter); + $processor->process($response, $operation, [], $context); + } + + public function testAddTagsCollection(): void + { + $operation = new GetCollection(class: 'Foo', uriVariables: ['id' => new Link()]); + $response = $this->createMock(Response::class); + $response->method('isCacheable')->willReturn(true); + $response->headers = $this->createMock(ResponseHeaderBag::class); + $response->headers->expects($this->once())->method('set')->with('Cache-Tags', 'a,b,/foos/1/bars'); + $request = $this->createMock(Request::class); + $request->method('isMethodCacheable')->willReturn(true); + $request->attributes = $this->createMock(ParameterBag::class); + $request->attributes->method('get')->with('_resources', [])->willReturn(['a', 'b']); + $request->attributes->method('all')->willReturn(['id' => 1]); + $context = ['request' => $request]; + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->method('process')->willReturn($response); + $iriConverter = $this->createMock(IriConverterInterface::class); + $iriConverter->expects($this->once())->method('getIriFromResource')->with('Foo', UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => ['id' => 1]])->willReturn('/foos/1/bars'); + $processor = new AddTagsProcessor($decorated, $iriConverter); + $processor->process($response, $operation, [], $context); + } + + public function testAddTagsWithPurger(): void + { + $operation = new Get(); + $response = $this->createMock(Response::class); + $response->method('isCacheable')->willReturn(true); + $response->headers = $this->createMock(ResponseHeaderBag::class); + $response->headers->expects($this->once())->method('set')->with('Cache-Tags', 'a,b'); + $request = $this->createMock(Request::class); + $request->method('isMethodCacheable')->willReturn(true); + $request->attributes = $this->createMock(ParameterBag::class); + $request->attributes->method('get')->with('_resources', [])->willReturn(['a', 'b']); + $context = ['request' => $request]; + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->method('process')->willReturn($response); + $iriConverter = $this->createMock(IriConverterInterface::class); + $iriConverter->expects($this->never())->method('getIriFromResource'); + $purger = $this->createMock(PurgerInterface::class); + $purger->expects($this->once())->method('getResponseHeaders')->willReturn(['Cache-Tags' => 'a,b']); + $processor = new AddTagsProcessor($decorated, $iriConverter, $purger); + $processor->process($response, $operation, [], $context); + } +} diff --git a/src/HttpCache/composer.json b/src/HttpCache/composer.json index bedb11b765d..db3bf4b446b 100644 --- a/src/HttpCache/composer.json +++ b/src/HttpCache/composer.json @@ -22,6 +22,7 @@ "require": { "php": ">=8.1", "api-platform/metadata": "*@dev || ^3.1", + "api-platform/state": "*@dev || ^3.1", "symfony/http-foundation": "^6.1" }, "require-dev": { @@ -61,6 +62,10 @@ { "type": "path", "url": "../Metadata" + }, + { + "type": "path", + "url": "../State" } ] } diff --git a/src/Hydra/EventListener/AddLinkHeaderListener.php b/src/Hydra/EventListener/AddLinkHeaderListener.php index 00a6907f192..def1a994ad3 100644 --- a/src/Hydra/EventListener/AddLinkHeaderListener.php +++ b/src/Hydra/EventListener/AddLinkHeaderListener.php @@ -16,6 +16,7 @@ use ApiPlatform\Api\UrlGeneratorInterface; use ApiPlatform\JsonLd\ContextBuilder; use ApiPlatform\Util\CorsTrait; +use Psr\Link\EvolvableLinkProviderInterface; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\WebLink\GenericLinkProvider; use Symfony\Component\WebLink\Link; @@ -39,19 +40,29 @@ public function __construct(private readonly UrlGeneratorInterface $urlGenerator public function onKernelResponse(ResponseEvent $event): void { $request = $event->getRequest(); + if (($operation = $request->attributes->get('_api_operation')) && 'api_platform.symfony.main_controller' === $operation->getController()) { + return; + } + // Prevent issues with NelmioCorsBundle if ($this->isPreflightRequest($request)) { return; } $apiDocUrl = $this->urlGenerator->generate('api_doc', ['_format' => 'jsonld'], UrlGeneratorInterface::ABS_URL); - $link = new Link(ContextBuilder::HYDRA_NS.'apiDocumentation', $apiDocUrl); - - if (null === $linkProvider = $request->attributes->get('_links')) { - $request->attributes->set('_links', new GenericLinkProvider([$link])); + $apiDocLink = new Link(ContextBuilder::HYDRA_NS.'apiDocumentation', $apiDocUrl); + $linkProvider = $request->attributes->get('_links', new GenericLinkProvider()); + if (!$linkProvider instanceof EvolvableLinkProviderInterface) { return; } - $request->attributes->set('_links', $linkProvider->withLink($link)); + + foreach ($linkProvider->getLinks() as $link) { + if ($link->getHref() === $apiDocUrl) { + return; + } + } + + $request->attributes->set('_links', $linkProvider->withLink($apiDocLink)); } } diff --git a/src/Hydra/Serializer/CollectionFiltersNormalizer.php b/src/Hydra/Serializer/CollectionFiltersNormalizer.php index c947122a6ef..1b53a84dcdb 100644 --- a/src/Hydra/Serializer/CollectionFiltersNormalizer.php +++ b/src/Hydra/Serializer/CollectionFiltersNormalizer.php @@ -99,6 +99,7 @@ public function normalize(mixed $object, string $format = null, array $context = if (!$resourceFilters) { return $data; } + $requestParts = parse_url($context['request_uri'] ?? ''); if (!\is_array($requestParts)) { return $data; diff --git a/src/Hydra/Serializer/PartialCollectionViewNormalizer.php b/src/Hydra/Serializer/PartialCollectionViewNormalizer.php index ebfe7fb793e..ac64696db4f 100644 --- a/src/Hydra/Serializer/PartialCollectionViewNormalizer.php +++ b/src/Hydra/Serializer/PartialCollectionViewNormalizer.php @@ -69,6 +69,9 @@ public function normalize(mixed $object, string $format = null, array $context = $currentPage = $object->getCurrentPage(); } + // TODO: This needs to be changed as well as I wrote in the CollectionFiltersNormalizer + // We should not rely on the request_uri but instead rely on the UriTemplate + // This needs that we implement the RFC and that we do more parsing before calling the serialization (MainController) $parsed = IriHelper::parseIri($context['request_uri'] ?? '/', $this->pageParameterName); $appliedFilters = $parsed['parameters']; unset($appliedFilters[$this->enabledParameterName]); diff --git a/src/Hydra/State/HydraLinkProcessor.php b/src/Hydra/State/HydraLinkProcessor.php new file mode 100644 index 00000000000..4228bb62144 --- /dev/null +++ b/src/Hydra/State/HydraLinkProcessor.php @@ -0,0 +1,51 @@ + + * + * 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\Hydra\State; + +use ApiPlatform\JsonLd\ContextBuilder; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\State\ProcessorInterface; +use Symfony\Component\WebLink\GenericLinkProvider; +use Symfony\Component\WebLink\Link; + +final class HydraLinkProcessor implements ProcessorInterface +{ + /** + * @param ProcessorInterface $decorated + */ + public function __construct(private readonly ProcessorInterface $decorated, private readonly UrlGeneratorInterface $urlGenerator) + { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!($request = $context['request'] ?? null) || !$operation instanceof HttpOperation) { + return $this->decorated->process($data, $operation, $uriVariables, $context); + } + + $apiDocUrl = $this->urlGenerator->generate('api_doc', ['_format' => 'jsonld'], UrlGeneratorInterface::ABS_URL); + $linkProvider = $request->attributes->get('_links') ?? new GenericLinkProvider(); + + foreach ($operation->getLinks() ?? [] as $link) { + $linkProvider = $linkProvider->withLink($link); + } + + $link = new Link(ContextBuilder::HYDRA_NS.'apiDocumentation', $apiDocUrl); + $request->attributes->set('_links', $linkProvider->withLink($link)); + + return $this->decorated->process($data, $operation, $uriVariables, $context); + } +} diff --git a/src/JsonApi/State/DefaultErrorProvider.php b/src/JsonApi/State/DefaultErrorProvider.php deleted file mode 100644 index a441d05a718..00000000000 --- a/src/JsonApi/State/DefaultErrorProvider.php +++ /dev/null @@ -1,35 +0,0 @@ - - * - * 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\JsonApi\State; - -use ApiPlatform\Metadata\Operation; -use ApiPlatform\State\ProviderInterface; -use ApiPlatform\Symfony\Validator\Exception\ConstraintViolationListAwareExceptionInterface; - -/** - * @internal - */ -final class DefaultErrorProvider implements ProviderInterface -{ - public function provide(Operation $operation, array $uriVariables = [], array $context = []): object - { - $exception = $context['previous_data']; - - if ($exception instanceof ConstraintViolationListAwareExceptionInterface) { - return $exception->getConstraintViolationList(); - } - - return $exception; - } -} diff --git a/src/JsonApi/State/JsonApiProvider.php b/src/JsonApi/State/JsonApiProvider.php new file mode 100644 index 00000000000..bf86cb406de --- /dev/null +++ b/src/JsonApi/State/JsonApiProvider.php @@ -0,0 +1,117 @@ + + * + * 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\JsonApi\State; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; + +final class JsonApiProvider implements ProviderInterface +{ + public function __construct(private readonly ProviderInterface $decorated, private readonly string $orderParameterName = 'order') + { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $request = $context['request'] ?? null; + + if (!$request || 'jsonapi' !== $request->getRequestFormat()) { + return $this->decorated->provide($operation, $uriVariables, $context); + } + + $filters = $request->attributes->get('_api_filters', []); + $queryParameters = $request->query->all(); + $orderParameter = $queryParameters['sort'] ?? null; + + if ( + null !== $orderParameter + && !\is_array($orderParameter) + ) { + $orderParametersArray = explode(',', (string) $orderParameter); + $transformedOrderParametersArray = []; + + foreach ($orderParametersArray as $orderParameter) { + $sorting = 'asc'; + + if ('-' === ($orderParameter[0] ?? null)) { + $sorting = 'desc'; + $orderParameter = substr($orderParameter, 1); + } + + $transformedOrderParametersArray[$orderParameter] = $sorting; + } + + $filters[$this->orderParameterName] = $transformedOrderParametersArray; + } + + $filterParameter = $queryParameters['filter'] ?? null; + if ( + $filterParameter + && \is_array($filterParameter) + ) { + $filters = array_merge($filterParameter, $filters); + } + + $pageParameter = $queryParameters['page'] ?? null; + if ( + \is_array($pageParameter) + ) { + $filters = array_merge($pageParameter, $filters); + } + + [$included, $properties] = $this->transformFieldsetsParameters($queryParameters, $operation->getShortName() ?? ''); + + if ($properties) { + $request->attributes->set('_api_filter_property', $properties); + } + + if ($included) { + $request->attributes->set('_api_included', $included); + } + + if ($filters) { + $request->attributes->set('_api_filters', $filters); + } + + return $this->decorated->provide($operation, $uriVariables, $context); + } + + private function transformFieldsetsParameters(array $queryParameters, string $resourceShortName): array + { + $includeParameter = $queryParameters['include'] ?? null; + $fieldsParameter = $queryParameters['fields'] ?? null; + + $includeParameter = \is_string($includeParameter) ? explode(',', $includeParameter) : []; + if (!$fieldsParameter) { + return [$includeParameter, []]; + } + + $properties = []; + $included = []; + foreach ($fieldsParameter as $resourceType => $fields) { + $fields = explode(',', (string) $fields); + + if ($resourceShortName === $resourceType) { + $properties = array_merge($properties, $fields); + } elseif (\in_array($resourceType, $includeParameter, true)) { + $properties[$resourceType] = $fields; + $included[] = $resourceType; + } else { + $properties[$resourceType] = $fields; + } + } + + return [$included, $properties]; + } +} diff --git a/src/JsonLd/Action/ContextAction.php b/src/JsonLd/Action/ContextAction.php index 68527fb2ef6..40483482ecc 100644 --- a/src/JsonLd/Action/ContextAction.php +++ b/src/JsonLd/Action/ContextAction.php @@ -15,9 +15,15 @@ use ApiPlatform\Exception\OperationNotFoundException; use ApiPlatform\JsonLd\ContextBuilderInterface; +use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\ProviderInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Serializer\SerializerInterface; /** * Generates JSON-LD contexts. @@ -31,16 +37,49 @@ final class ContextAction 'Error' => true, ]; - public function __construct(private readonly ContextBuilderInterface $contextBuilder, private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory) - { + public function __construct( + private readonly ContextBuilderInterface $contextBuilder, + private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, + private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + private readonly ?ProviderInterface $provider = null, + private readonly ?ProcessorInterface $processor = null, + private readonly ?SerializerInterface $serializer = null + ) { } /** * Generates a context according to the type requested. * * @throws NotFoundHttpException + * + * @return array{'@context': array}|Response + */ + public function __invoke(string $shortName, Request $request = null): array|Response + { + if (null !== $request && $this->provider && $this->processor && $this->serializer) { + $operation = new Get( + outputFormats: ['jsonld' => ['application/ld+json']], + validate: false, + provider: fn () => $this->getContext($shortName), + serialize: false + ); + $context = ['request' => $request]; + $jsonLdContext = $this->provider->provide($operation, [], $context); + + return $this->processor->process($this->serializer->serialize($jsonLdContext, 'json'), $operation, [], $context); + } + + if (!$context = $this->getContext($shortName)) { + throw new NotFoundHttpException(); + } + + return $context; + } + + /** + * @return array{'@context': array}|null */ - public function __invoke(string $shortName): array + private function getContext(string $shortName): ?array { if ('Entrypoint' === $shortName) { return ['@context' => $this->contextBuilder->getEntrypointContext()]; @@ -65,6 +104,6 @@ public function __invoke(string $shortName): array } } - throw new NotFoundHttpException(); + return null; } } diff --git a/src/JsonLd/AnonymousContextBuilderInterface.php b/src/JsonLd/AnonymousContextBuilderInterface.php index 7d8bb4a4be1..cf9fae7e667 100644 --- a/src/JsonLd/AnonymousContextBuilderInterface.php +++ b/src/JsonLd/AnonymousContextBuilderInterface.php @@ -13,7 +13,7 @@ namespace ApiPlatform\JsonLd; -use ApiPlatform\Api\UrlGeneratorInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; /** * JSON-LD context builder with Input Output DTO support interface. diff --git a/src/JsonLd/ContextBuilder.php b/src/JsonLd/ContextBuilder.php index 57852352aa4..652c77f7044 100644 --- a/src/JsonLd/ContextBuilder.php +++ b/src/JsonLd/ContextBuilder.php @@ -13,14 +13,14 @@ namespace ApiPlatform\JsonLd; -use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Api\UrlGeneratorInterface; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; diff --git a/src/JsonLd/ContextBuilderInterface.php b/src/JsonLd/ContextBuilderInterface.php index 3e78e559694..63c34d66c42 100644 --- a/src/JsonLd/ContextBuilderInterface.php +++ b/src/JsonLd/ContextBuilderInterface.php @@ -13,8 +13,8 @@ namespace ApiPlatform\JsonLd; -use ApiPlatform\Api\UrlGeneratorInterface; -use ApiPlatform\Exception\ResourceClassNotFoundException; +use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; +use ApiPlatform\Metadata\UrlGeneratorInterface; /** * JSON-LD context builder interface. @@ -24,6 +24,7 @@ interface ContextBuilderInterface { public const HYDRA_NS = 'http://www.w3.org/ns/hydra/core#'; + public const JSONLD_NS = 'http://www.w3.org/ns/json-ld#'; public const RDF_NS = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; public const RDFS_NS = 'http://www.w3.org/2000/01/rdf-schema#'; public const XML_NS = 'http://www.w3.org/2001/XMLSchema#'; diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index ee8d7a3f8cc..df1bef15c19 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -925,6 +925,7 @@ public function __construct( protected ?bool $compositeIdentifier = null, protected ?array $exceptionToStatus = null, protected ?bool $queryParameterValidationEnabled = null, + protected ?array $links = null, protected ?array $graphQlOperations = null, $provider = null, $processor = null, @@ -1370,4 +1371,20 @@ public function withGraphQlOperations(array $graphQlOperations): self return $self; } + + public function getLinks(): ?array + { + return $this->links; + } + + /** + * @param Link[] $links + */ + public function withLinks(array $links): self + { + $self = clone $this; + $self->links = $links; + + return $self; + } } diff --git a/src/Metadata/Delete.php b/src/Metadata/Delete.php index 01d04eb7500..b381971746d 100644 --- a/src/Metadata/Delete.php +++ b/src/Metadata/Delete.php @@ -46,6 +46,7 @@ public function __construct( bool|OpenApiOperation $openapi = null, array $exceptionToStatus = null, bool $queryParameterValidationEnabled = null, + array $links = null, string $shortName = null, string $class = null, @@ -121,6 +122,7 @@ public function __construct( openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, + links: $links, shortName: $shortName, class: $class, paginationEnabled: $paginationEnabled, diff --git a/src/Metadata/Error.php b/src/Metadata/Error.php new file mode 100644 index 00000000000..988521523a3 --- /dev/null +++ b/src/Metadata/Error.php @@ -0,0 +1,172 @@ + + * + * 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\Metadata; + +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; +use ApiPlatform\State\OptionsInterface; + +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +final class Error extends HttpOperation +{ + public function __construct( + string $uriTemplate = null, + array $types = null, + $formats = null, + $inputFormats = null, + $outputFormats = null, + $uriVariables = null, + string $routePrefix = null, + string $routeName = null, + array $defaults = null, + array $requirements = null, + array $options = null, + bool $stateless = null, + string $sunset = null, + string $acceptPatch = null, + $status = null, + string $host = null, + array $schemes = null, + string $condition = null, + string $controller = null, + array $cacheHeaders = null, + array $paginationViaCursor = null, + array $hydraContext = null, + array $openapiContext = null, + bool|OpenApiOperation $openapi = null, + array $exceptionToStatus = null, + bool $queryParameterValidationEnabled = null, + array $links = null, + + string $shortName = null, + string $class = null, + bool $paginationEnabled = null, + string $paginationType = null, + int $paginationItemsPerPage = null, + int $paginationMaximumItemsPerPage = null, + bool $paginationPartial = null, + bool $paginationClientEnabled = null, + bool $paginationClientItemsPerPage = null, + bool $paginationClientPartial = null, + bool $paginationFetchJoinCollection = null, + bool $paginationUseOutputWalkers = null, + array $order = null, + string $description = null, + array $normalizationContext = null, + array $denormalizationContext = null, + bool $collectDenormalizationErrors = null, + string $security = null, + string $securityMessage = null, + string $securityPostDenormalize = null, + string $securityPostDenormalizeMessage = null, + string $securityPostValidation = null, + string $securityPostValidationMessage = null, + string $deprecationReason = null, + array $filters = null, + array $validationContext = null, + $input = null, + $output = null, + $mercure = null, + $messenger = null, + bool $elasticsearch = null, + int $urlGenerationStrategy = null, + bool $read = null, + bool $deserialize = null, + bool $validate = null, + bool $write = null, + bool $serialize = null, + bool $fetchPartial = null, + bool $forceEager = null, + int $priority = null, + string $name = null, + $provider = null, + $processor = null, + OptionsInterface $stateOptions = null, + array $extraProperties = [], + ) { + parent::__construct( + uriTemplate: $uriTemplate, + types: $types, + formats: $formats, + inputFormats: $inputFormats, + outputFormats: $outputFormats, + uriVariables: $uriVariables, + routePrefix: $routePrefix, + routeName: $routeName, + defaults: $defaults, + requirements: $requirements, + options: $options, + stateless: $stateless, + sunset: $sunset, + acceptPatch: $acceptPatch, + status: $status, + host: $host, + schemes: $schemes, + condition: $condition, + controller: $controller, + cacheHeaders: $cacheHeaders, + paginationViaCursor: $paginationViaCursor, + hydraContext: $hydraContext, + openapiContext: $openapiContext, + openapi: $openapi, + exceptionToStatus: $exceptionToStatus, + queryParameterValidationEnabled: $queryParameterValidationEnabled, + links: $links, + shortName: $shortName, + class: $class, + paginationEnabled: $paginationEnabled, + paginationType: $paginationType, + paginationItemsPerPage: $paginationItemsPerPage, + paginationMaximumItemsPerPage: $paginationMaximumItemsPerPage, + paginationPartial: $paginationPartial, + paginationClientEnabled: $paginationClientEnabled, + paginationClientItemsPerPage: $paginationClientItemsPerPage, + paginationClientPartial: $paginationClientPartial, + paginationFetchJoinCollection: $paginationFetchJoinCollection, + paginationUseOutputWalkers: $paginationUseOutputWalkers, + order: $order, + description: $description, + normalizationContext: $normalizationContext, + denormalizationContext: $denormalizationContext, + collectDenormalizationErrors: $collectDenormalizationErrors, + security: $security, + securityMessage: $securityMessage, + securityPostDenormalize: $securityPostDenormalize, + securityPostDenormalizeMessage: $securityPostDenormalizeMessage, + securityPostValidation: $securityPostValidation, + securityPostValidationMessage: $securityPostValidationMessage, + deprecationReason: $deprecationReason, + filters: $filters, + validationContext: $validationContext, + input: $input, + output: $output, + mercure: $mercure, + messenger: $messenger, + elasticsearch: $elasticsearch, + urlGenerationStrategy: $urlGenerationStrategy, + read: $read, + deserialize: $deserialize, + validate: $validate, + write: $write, + serialize: $serialize, + fetchPartial: $fetchPartial, + forceEager: $forceEager, + priority: $priority, + name: $name, + provider: $provider, + processor: $processor, + stateOptions: $stateOptions, + extraProperties: $extraProperties, + ); + } +} diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php index fcd2cdb5459..f9dfa443b77 100644 --- a/src/Metadata/Extractor/XmlResourceExtractor.php +++ b/src/Metadata/Extractor/XmlResourceExtractor.php @@ -23,6 +23,7 @@ use ApiPlatform\OpenApi\Model\RequestBody; use ApiPlatform\State\OptionsInterface; use Symfony\Component\Config\Util\XmlUtils; +use Symfony\Component\WebLink\Link; /** * Extracts an array of metadata from a list of XML files. @@ -94,6 +95,7 @@ private function buildExtendedBase(\SimpleXMLElement $resource): array 'exceptionToStatus' => $this->buildExceptionToStatus($resource), 'queryParameterValidationEnabled' => $this->phpize($resource, 'queryParameterValidationEnabled', 'bool'), 'stateOptions' => $this->buildStateOptions($resource), + 'links' => $this->buildLinks($resource), ]); } @@ -455,4 +457,22 @@ private function buildStateOptions(\SimpleXMLElement $resource): ?OptionsInterfa return null; } + + /** + * @return Link[] + */ + private function buildLinks(\SimpleXMLElement $resource): ?array + { + $links = $resource->links ?? null; + if (!$resource->links) { + return null; + } + + $links = []; + foreach ($resource->links as $link) { + $links[] = new Link(rel: (string) $link->link->attributes()->rel, href: (string) $link->link->attributes()->href); + } + + return $links; + } } diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php index d1a6f1159f8..841dbc3033f 100644 --- a/src/Metadata/Extractor/YamlResourceExtractor.php +++ b/src/Metadata/Extractor/YamlResourceExtractor.php @@ -22,6 +22,7 @@ use ApiPlatform\OpenApi\Model\Parameter; use ApiPlatform\OpenApi\Model\RequestBody; use ApiPlatform\State\OptionsInterface; +use Symfony\Component\WebLink\Link; use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml; @@ -121,6 +122,7 @@ private function buildExtendedBase(array $resource): array 'inputFormats' => $this->buildArrayValue($resource, 'inputFormats'), 'outputFormats' => $this->buildArrayValue($resource, 'outputFormats'), 'stateOptions' => $this->buildStateOptions($resource), + 'links' => $this->buildLinks($resource), ]); } @@ -411,4 +413,21 @@ private function buildStateOptions(array $resource): ?OptionsInterface return null; } + + /** + * @return Link[] + */ + private function buildLinks(array $resource): ?array + { + if (!isset($resource['links']) || !\is_array($resource['links'])) { + return null; + } + + $links = []; + foreach ($resource['links'] as $link) { + $links[] = new Link(rel: $link['rel'], href: $link['href']); + } + + return $links; + } } diff --git a/src/Metadata/Extractor/schema/resources.xsd b/src/Metadata/Extractor/schema/resources.xsd index 2f960f49c6d..86e478099bf 100644 --- a/src/Metadata/Extractor/schema/resources.xsd +++ b/src/Metadata/Extractor/schema/resources.xsd @@ -393,6 +393,19 @@ + + + + + + + + + + + + + @@ -424,6 +437,7 @@ + diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index 5042e994061..ad1e6d08408 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -46,6 +46,7 @@ public function __construct( bool|OpenApiOperation $openapi = null, array $exceptionToStatus = null, bool $queryParameterValidationEnabled = null, + array $links = null, string $shortName = null, string $class = null, @@ -120,6 +121,7 @@ public function __construct( openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, + links: $links, shortName: $shortName, class: $class, paginationEnabled: $paginationEnabled, diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index 129b37aa2ae..b9b2073dabc 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -46,6 +46,7 @@ public function __construct( bool|OpenApiOperation $openapi = null, array $exceptionToStatus = null, bool $queryParameterValidationEnabled = null, + array $links = null, string $shortName = null, string $class = null, @@ -121,6 +122,7 @@ public function __construct( openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, + links: $links, shortName: $shortName, class: $class, paginationEnabled: $paginationEnabled, diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index 7ccdbaa0424..ccd8ca94dc0 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -15,6 +15,7 @@ use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\State\OptionsInterface; +use Symfony\Component\WebLink\Link as WebLink; class HttpOperation extends Operation { @@ -74,6 +75,7 @@ class HttpOperation extends Operation * @param string|bool|null $messenger {@see https://api-platform.com/docs/core/messenger/#dispatching-a-resource-through-the-message-bus} * @param string|callable|null $provider {@see https://api-platform.com/docs/core/state-providers/#state-providers} * @param string|callable|null $processor {@see https://api-platform.com/docs/core/state-processors/#state-processors} + * @param WebLink[]|null $links */ public function __construct( protected string $method = 'GET', @@ -147,6 +149,7 @@ public function __construct( protected bool|OpenApiOperation|null $openapi = null, protected ?array $exceptionToStatus = null, protected ?bool $queryParameterValidationEnabled = null, + protected ?array $links = null, string $shortName = null, string $class = null, @@ -596,4 +599,20 @@ public function withQueryParameterValidationEnabled(bool $queryParameterValidati return $self; } + + public function getLinks(): ?array + { + return $this->links; + } + + /** + * @param WebLink[] $links + */ + public function withLinks(array $links): self + { + $self = clone $this; + $self->links = $links; + + return $self; + } } diff --git a/src/Metadata/NotExposed.php b/src/Metadata/NotExposed.php index 3577fb39b41..f7fa24af895 100644 --- a/src/Metadata/NotExposed.php +++ b/src/Metadata/NotExposed.php @@ -59,6 +59,7 @@ public function __construct( array $exceptionToStatus = null, bool $queryParameterValidationEnabled = null, + array $links = null, string $shortName = null, string $class = null, @@ -134,6 +135,7 @@ public function __construct( openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, + links: $links, shortName: $shortName, class: $class, paginationEnabled: $paginationEnabled, diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php index f13b72bbabf..ba6e61f196a 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -46,6 +46,7 @@ public function __construct( bool|OpenApiOperation $openapi = null, array $exceptionToStatus = null, bool $queryParameterValidationEnabled = null, + array $links = null, string $shortName = null, string $class = null, @@ -121,6 +122,7 @@ public function __construct( openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, + links: $links, shortName: $shortName, class: $class, paginationEnabled: $paginationEnabled, diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index ab1ac133052..feba797ab9e 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -46,6 +46,7 @@ public function __construct( bool|OpenApiOperation $openapi = null, array $exceptionToStatus = null, bool $queryParameterValidationEnabled = null, + array $links = null, string $shortName = null, string $class = null, @@ -122,6 +123,7 @@ public function __construct( openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, + links: $links, shortName: $shortName, class: $class, paginationEnabled: $paginationEnabled, diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php index aac7b295807..3157ddf1fea 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -46,6 +46,7 @@ public function __construct( bool|OpenApiOperation $openapi = null, array $exceptionToStatus = null, bool $queryParameterValidationEnabled = null, + array $links = null, string $shortName = null, string $class = null, @@ -122,6 +123,7 @@ public function __construct( openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, + links: $links, shortName: $shortName, class: $class, paginationEnabled: $paginationEnabled, diff --git a/src/Metadata/Resource/Factory/MainControllerResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/MainControllerResourceMetadataCollectionFactory.php new file mode 100644 index 00000000000..970f4ca1014 --- /dev/null +++ b/src/Metadata/Resource/Factory/MainControllerResourceMetadataCollectionFactory.php @@ -0,0 +1,61 @@ + + * + * 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\Metadata\Resource\Factory; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; + +/** + * @author Antoine Bluchet + */ +final class MainControllerResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface +{ + use OperationDefaultsTrait; + + public function __construct(private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, private ?bool $useSymfonyEvents = false) + { + } + + /** + * {@inheritdoc} + */ + public function create(string $resourceClass): ResourceMetadataCollection + { + $resourceMetadataCollection = new ResourceMetadataCollection($resourceClass); + if ($this->decorated) { + $resourceMetadataCollection = $this->decorated->create($resourceClass); + } + + foreach ($resourceMetadataCollection as $i => $resource) { + /** @var ApiResource $resource */ + $operations = $resource->getOperations() ?? new Operations(); + foreach ($resource->getOperations() as $key => $operation) { + if ($operation->getRouteName() || $operation->getController()) { + continue; + } + + if (false === $this->useSymfonyEvents) { + $operation = $operation->withController('api_platform.symfony.main_controller'); + $operations->add($key, $operation); + } + } + + $resource = $resource->withOperations($operations->sort()); + $resourceMetadataCollection[$i] = $resource; + } + + return $resourceMetadataCollection; + } +} diff --git a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php index 69f73065b3b..98432490002 100644 --- a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php +++ b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php @@ -64,6 +64,7 @@ final class XmlResourceAdapter implements ResourceAdapterInterface 'queryParameterValidationEnabled', 'stateOptions', 'collectDenormalizationErrors', + 'links', ]; /** @@ -494,6 +495,18 @@ private function buildValues(\SimpleXMLElement $resource, array $values): void } } + private function buildLinks(\SimpleXMLElement $resource, array $values = null): void + { + if (!$values) { + return; + } + + $node = $resource->addChild('links'); + $childNode = $node->addChild('link'); + $childNode->addAttribute('rel', $values[0]['rel']); + $childNode->addAttribute('href', $values[0]['href']); + } + private function parse($value): ?string { if (null === $value) { diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.xml b/src/Metadata/Tests/Extractor/Adapter/resources.xml index 59f4d62c37c..09fa161a791 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.xml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.xml @@ -1,3 +1,3 @@ -someirischemaanotheririschemaCommentapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetapplication/merge-patch+json+ldapplication/merge-patch+json+ld_foo\d+bazhttps60120AuthorizationAccept-LanguageAcceptcomment:read_collectioncomment:writebazbazbazbarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbarapplication/vnd.ms-excelapplication/merge-patch+jsonapplication/merge-patch+jsonpouet\d+barhttphttps60120AuthorizationAccept-Languagecomment:readcomment:writecomment:custombazbazbazbarcomment.custom_filterfoobarcustombazcomment:read_collectioncomment:writebarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbar/v1/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit ametLorem ipsum dolor sit ametDolor sit amet +someirischemaanotheririschemaCommentapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetapplication/merge-patch+json+ldapplication/merge-patch+json+ld_foo\d+bazhttps60120AuthorizationAccept-LanguageAcceptcomment:read_collectioncomment:writebazbazbazbarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbarapplication/vnd.ms-excelapplication/merge-patch+jsonapplication/merge-patch+jsonpouet\d+barhttphttps60120AuthorizationAccept-Languagecomment:readcomment:writecomment:custombazbazbazbarcomment.custom_filterfoobarcustombazcustomquxcomment:read_collectioncomment:writebarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbar/v1/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit ametLorem ipsum dolor sit ametDolor sit amet diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.yaml b/src/Metadata/Tests/Extractor/Adapter/resources.yaml index 8db4397b8ba..9de5f0e600a 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.yaml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.yaml @@ -113,6 +113,10 @@ resources: another_custom_property: 'Lorem ipsum': 'Dolor sit amet' foo: bar + links: + - + rel: 'http://www.w3.org/ns/json-ld#error' + href: 'http://www.w3.org/ns/hydra/error' - uriTemplate: '/users/{userId}/comments/{commentId}{._format}' class: ApiPlatform\Metadata\Get @@ -124,6 +128,10 @@ resources: commentId: - ApiPlatform\Metadata\Tests\Fixtures\ApiResource\Comment - id + links: + - + rel: 'http://www.w3.org/ns/json-ld#error' + href: 'http://www.w3.org/ns/hydra/error' formats: json: null jsonld: null @@ -214,12 +222,17 @@ resources: exceptionToStatus: Symfony\Component\Serializer\Exception\ExceptionInterface: 400 queryParameterValidationEnabled: true + links: null graphQlOperations: - args: foo: type: custom bar: baz + extraArgs: + bar: + type: custom + baz: qux shortName: Comment description: 'Creates a Comment.' class: ApiPlatform\Metadata\GraphQl\Mutation diff --git a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php index bcb20426525..0dce9014fa9 100644 --- a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php +++ b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php @@ -44,6 +44,7 @@ use ApiPlatform\State\OptionsInterface; use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\TestCase; +use Symfony\Component\WebLink\Link; /** * Ensures XML and YAML mappings are fully compatible with ApiResource. @@ -416,6 +417,9 @@ final class ResourceMetadataCompatibilityTest extends TestCase ], 'foo' => 'bar', ], + 'links' => [ + ['rel' => 'http://www.w3.org/ns/json-ld#error', 'href' => 'http://www.w3.org/ns/hydra/error'], + ], ], [ 'uriTemplate' => '/users/{userId}/comments/{commentId}{._format}', @@ -428,6 +432,9 @@ final class ResourceMetadataCompatibilityTest extends TestCase ], 'commentId' => [Comment::class, 'id'], ], + 'links' => [ + ['rel' => 'http://www.w3.org/ns/json-ld#error', 'href' => 'http://www.w3.org/ns/hydra/error'], + ], ], ], ], @@ -498,6 +505,7 @@ final class ResourceMetadataCompatibilityTest extends TestCase 'openapi', 'paginationViaCursor', 'stateOptions', + 'links', ]; /** @@ -518,6 +526,11 @@ public function testValidMetadata(string $extractorClass, ResourceAdapterInterfa throw new AssertionFailedError('Failed asserting that the schema is valid according to '.ApiResource::class, 0, $exception); } + $a = new ResourceMetadataCollection(self::RESOURCE_CLASS, $this->buildApiResources()); + $b = $collection; + + $this->assertEquals($a[0], $b[0]); + $this->assertEquals(new ResourceMetadataCollection(self::RESOURCE_CLASS, $this->buildApiResources()), $collection); } @@ -725,4 +738,13 @@ private function withStateOptions(array $values): ?OptionsInterface throw new \LogicException(sprintf('Unsupported "%s" state options.', key($values))); } + + private function withLinks(array $values): ?array + { + if (!$values) { + return null; + } + + return [new Link($values[0]['rel'] ?? null, $values[0]['href'] ?? null)]; + } } diff --git a/src/Metadata/Tests/Extractor/XmlExtractorTest.php b/src/Metadata/Tests/Extractor/XmlExtractorTest.php index d4c4ee2c1f9..8a2b59ab34f 100644 --- a/src/Metadata/Tests/Extractor/XmlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/XmlExtractorTest.php @@ -100,6 +100,7 @@ public function testValidXML(): void 'read' => null, 'write' => null, 'stateOptions' => null, + 'links' => null, ], [ 'uriTemplate' => '/users/{author}/comments{._format}', @@ -271,6 +272,7 @@ public function testValidXML(): void 'provider' => null, 'itemUriTemplate' => null, 'stateOptions' => null, + 'links' => null, ], [ 'name' => null, @@ -369,6 +371,7 @@ public function testValidXML(): void 'processor' => null, 'provider' => null, 'stateOptions' => null, + 'links' => null, ], ], 'graphQlOperations' => null, @@ -378,6 +381,7 @@ public function testValidXML(): void 'read' => null, 'write' => null, 'stateOptions' => null, + 'links' => null, ], ], ], $extractor->getResources()); diff --git a/src/Metadata/Tests/Extractor/YamlExtractorTest.php b/src/Metadata/Tests/Extractor/YamlExtractorTest.php index 080859c2e9d..dea75bd974a 100644 --- a/src/Metadata/Tests/Extractor/YamlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/YamlExtractorTest.php @@ -100,6 +100,7 @@ public function testValidYaml(): void 'read' => null, 'write' => null, 'stateOptions' => null, + 'links' => null, ], ], Program::class => [ @@ -170,6 +171,7 @@ public function testValidYaml(): void 'read' => null, 'write' => null, 'stateOptions' => null, + 'links' => null, ], [ 'uriTemplate' => '/users/{author}/programs{._format}', @@ -311,6 +313,7 @@ public function testValidYaml(): void 'provider' => null, 'itemUriTemplate' => null, 'stateOptions' => null, + 'links' => null, ], [ 'name' => null, @@ -390,6 +393,7 @@ public function testValidYaml(): void 'processor' => null, 'provider' => null, 'stateOptions' => null, + 'links' => null, ], ], 'graphQlOperations' => null, @@ -398,6 +402,7 @@ public function testValidYaml(): void 'read' => null, 'write' => null, 'stateOptions' => null, + 'links' => null, ], ], SingleFileConfigDummy::class => [ @@ -468,6 +473,7 @@ public function testValidYaml(): void 'read' => null, 'write' => null, 'stateOptions' => null, + 'links' => null, ], ], ], $extractor->getResources()); diff --git a/src/Metadata/Tests/Resource/Factory/MainControllerResourceMetadataCollectionFactoryTest.php b/src/Metadata/Tests/Resource/Factory/MainControllerResourceMetadataCollectionFactoryTest.php new file mode 100644 index 00000000000..0cf7226d083 --- /dev/null +++ b/src/Metadata/Tests/Resource/Factory/MainControllerResourceMetadataCollectionFactoryTest.php @@ -0,0 +1,44 @@ + + * + * 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\Metadata\Tests\Resource\Factory; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Resource\Factory\MainControllerResourceMetadataCollectionFactory; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use PHPUnit\Framework\TestCase; + +class MainControllerResourceMetadataCollectionFactoryTest extends TestCase +{ + public function testCreate(): void + { + $decorated = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $decorated->method('create')->willReturn(new ResourceMetadataCollection('Dummy', [ + new ApiResource( + shortName: 'AttributeResource', + class: 'Dummy', + operations: [ + 'get' => new Get(shortName: 'AttributeResource', class: 'Dummy'), + ] + ), + ])); + + $apiResource = (new MainControllerResourceMetadataCollectionFactory($decorated))->create('Dummy'); + $operation = $apiResource->getOperation(); + $this->assertInstanceOf(HttpOperation::class, $operation); + $this->assertEquals($operation->getController(), 'api_platform.symfony.main_controller'); + } +} diff --git a/src/Metadata/Util/ContentNegotiationTrait.php b/src/Metadata/Util/ContentNegotiationTrait.php new file mode 100644 index 00000000000..4a257d81fcb --- /dev/null +++ b/src/Metadata/Util/ContentNegotiationTrait.php @@ -0,0 +1,138 @@ + + * + * 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\Metadata\Util; + +use Negotiation\Negotiator; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\NotAcceptableHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +/** + * @internal + */ +trait ContentNegotiationTrait +{ + private Negotiator $negotiator; + + /** + * Gets the format associated with the mime type. + * + * Adapted from {@see \Symfony\Component\HttpFoundation\Request::getFormat}. + * + * @param array $formats + */ + private function getMimeTypeFormat(string $mimeType, array $formats): ?string + { + $canonicalMimeType = null; + $pos = strpos($mimeType, ';'); + if (false !== $pos) { + $canonicalMimeType = trim(substr($mimeType, 0, $pos)); + } + + foreach ($formats as $format => $mimeTypes) { + if (\in_array($mimeType, $mimeTypes, true)) { + return $format; + } + if (null !== $canonicalMimeType && \in_array($canonicalMimeType, $mimeTypes, true)) { + return $format; + } + } + + return null; + } + + /** + * Flattened the list of MIME types. + * + * @param array $formats + * + * @return array + */ + private function flattenMimeTypes(array $formats): array + { + $flattenedMimeTypes = []; + foreach ($formats as $format => $mimeTypes) { + foreach ($mimeTypes as $mimeType) { + $flattenedMimeTypes[$mimeType] = $format; + } + } + + return $flattenedMimeTypes; + } + + /** + * @param array $formats + */ + private function getRequestFormat(Request $request, array $formats, bool $throw = true): string + { + $mimeTypes = []; + $flattenedMimeTypes = []; + + if ($routeFormat = $request->attributes->get('_format') ?: null) { + if (isset($formats[$routeFormat])) { + $mimeTypes = Request::getMimeTypes($routeFormat); + $flattenedMimeTypes = $this->flattenMimeTypes([$routeFormat => $mimeTypes]); + } elseif ($throw) { + throw new NotFoundHttpException(sprintf('Format "%s" is not supported', $routeFormat)); + } + } + + if (!$mimeTypes) { + $flattenedMimeTypes = $this->flattenMimeTypes($formats); + $mimeTypes = array_keys($flattenedMimeTypes); + } + + // First, try to guess the format from the Accept header + /** @var string|null $accept */ + $accept = $request->headers->get('Accept'); + if (null !== $accept) { + if ($mediaType = $this->negotiator->getBest($accept, $mimeTypes)) { + return $this->getMimeTypeFormat($mediaType->getType(), $formats); + } + + if ($throw) { + throw $this->getNotAcceptableHttpException($accept, $flattenedMimeTypes); + } + } + + // Then use the Symfony request format if available and applicable + $requestFormat = $request->getRequestFormat('') ?: null; + if (null !== $requestFormat) { + $mimeType = $request->getMimeType($requestFormat); + + if (isset($flattenedMimeTypes[$mimeType])) { + return $requestFormat; + } + + if ($throw) { + throw $this->getNotAcceptableHttpException($mimeType, $flattenedMimeTypes); + } + } + + // Finally, if no Accept header nor Symfony request format is set, return the default format + return array_key_first($formats); + } + + /** + * Retrieves an instance of NotAcceptableHttpException. + */ + private function getNotAcceptableHttpException(string $accept, array $mimeTypes): NotAcceptableHttpException + { + return new NotAcceptableHttpException(sprintf( + 'Requested format "%s" is not supported. Supported MIME types are "%s".', + $accept, + implode('", "', array_keys($mimeTypes)) + )); + } +} diff --git a/src/Metadata/composer.json b/src/Metadata/composer.json index 05b3720efa3..fd7691370e1 100644 --- a/src/Metadata/composer.json +++ b/src/Metadata/composer.json @@ -35,15 +35,17 @@ "symfony/string": "^6.1" }, "require-dev": { - "phpstan/phpdoc-parser": "^1.16", + "api-platform/json-schema": "*@dev || ^3.1", + "api-platform/openapi": "*@dev || ^3.1", + "api-platform/state": "*@dev || ^3.1", "phpspec/prophecy-phpunit": "^2.0", + "phpstan/phpdoc-parser": "^1.16", + "symfony/config": "^6.1", "symfony/phpunit-bridge": "^6.1", "symfony/routing": "^6.1", - "symfony/yaml": "^6.1", - "symfony/config": "^6.1", - "api-platform/openapi": "*@dev || ^3.1", - "api-platform/json-schema": "*@dev || ^3.1", - "api-platform/state": "*@dev || ^3.1" + "symfony/var-dumper": "^6.3", + "symfony/web-link": "^6.3", + "symfony/yaml": "^6.1" }, "suggest": { "phpstan/phpdoc-parser": "For PHP documentation support.", diff --git a/src/OpenApi/Serializer/OpenApiNormalizer.php b/src/OpenApi/Serializer/OpenApiNormalizer.php index 83ebf63deca..8eb652304bc 100644 --- a/src/OpenApi/Serializer/OpenApiNormalizer.php +++ b/src/OpenApi/Serializer/OpenApiNormalizer.php @@ -37,7 +37,7 @@ public function __construct(private readonly NormalizerInterface $decorated) */ public function normalize(mixed $object, string $format = null, array $context = []): array { - $pathsCallback = static fn ($innerObject): array => $innerObject instanceof Paths ? $innerObject->getPaths() : []; + $pathsCallback = static fn ($decoratedObject): array => $decoratedObject instanceof Paths ? $decoratedObject->getPaths() : []; $context[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS] = true; $context[AbstractObjectNormalizer::SKIP_NULL_VALUES] = true; $context[AbstractNormalizer::CALLBACKS] = [ diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index 76ea83bd7a7..b77f40dec0a 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -13,7 +13,9 @@ namespace ApiPlatform\Serializer; +use ApiPlatform\Doctrine\Orm\State\Options; use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Error as ErrorOperation; use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Symfony\Util\RequestAttributesExtractor; @@ -29,7 +31,7 @@ */ final class SerializerContextBuilder implements SerializerContextBuilderInterface { - public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory) + public function __construct(private readonly ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private readonly bool $debug = false) { } @@ -42,7 +44,14 @@ public function createFromRequest(Request $request, bool $normalization, array $ throw new RuntimeException('Request attributes are not valid.'); } - $operation = $attributes['operation'] ?? $this->resourceMetadataFactory->create($attributes['resource_class'])->getOperation($attributes['operation_name']); + if (!($operation = $attributes['operation'] ?? null)) { + if (!$this->resourceMetadataFactory) { + throw new RuntimeException('No operation'); + } + + $operation = $this->resourceMetadataFactory->create($attributes['resource_class'])->getOperation($attributes['operation_name'] ?? null); + } + $context = $normalization ? ($operation->getNormalizationContext() ?? []) : ($operation->getDenormalizationContext() ?? []); $context['operation_name'] = $operation->getName(); $context['operation'] = $operation; @@ -53,16 +62,18 @@ public function createFromRequest(Request $request, bool $normalization, array $ $context['uri'] = $request->getUri(); $context['input'] = $operation->getInput(); $context['output'] = $operation->getOutput(); + $context['skip_deprecated_exception_normalizers'] = true; // Special case as this is usually handled by our OperationContextTrait, here we want to force the IRI in the response if (!$operation instanceof CollectionOperationInterface && method_exists($operation, 'getItemUriTemplate') && $operation->getItemUriTemplate()) { $context['item_uri_template'] = $operation->getItemUriTemplate(); } - if ($operation->getTypes()) { - $context['types'] = $operation->getTypes(); + if ($types = $operation->getTypes()) { + $context['types'] = $types; } + // TODO: remove this as uri variables are available in the SerializerProcessor but correctly parsed if ($operation->getUriVariables()) { $context['uri_variables'] = []; @@ -71,6 +82,18 @@ public function createFromRequest(Request $request, bool $normalization, array $ } } + if (($options = $operation?->getStateOptions()) && $options instanceof Options && $options->getEntityClass()) { + $context['force_resource_class'] = $operation->getClass(); + } + + if ($this->debug && isset($context['groups']) && $operation instanceof ErrorOperation) { + if (!\is_array($context['groups'])) { + $context['groups'] = (array) $context['groups']; + } + + $context['groups'][] = 'trace'; + } + if (!$normalization) { if (!isset($context['api_allow_update'])) { $context['api_allow_update'] = \in_array($method = $request->getMethod(), ['PUT', 'PATCH'], true); @@ -92,6 +115,11 @@ public function createFromRequest(Request $request, bool $normalization, array $ $context[AbstractObjectNormalizer::EXCLUDE_FROM_CACHE_KEY][] = 'root_operation'; $context[AbstractObjectNormalizer::EXCLUDE_FROM_CACHE_KEY][] = 'operation'; + // JSON API see JsonApiProvider + if ($included = $request->attributes->get('_api_included')) { + $context['api_included'] = $included; + } + return $context; } } diff --git a/src/Serializer/SerializerFilterContextBuilder.php b/src/Serializer/SerializerFilterContextBuilder.php index 452fe399984..cf80894b459 100644 --- a/src/Serializer/SerializerFilterContextBuilder.php +++ b/src/Serializer/SerializerFilterContextBuilder.php @@ -42,7 +42,11 @@ public function createFromRequest(Request $request, bool $normalization, array $ $context = $this->decorated->createFromRequest($request, $normalization, $attributes); - $resourceFilters = $this->resourceMetadataCollectionFactory->create($attributes['resource_class'])->getOperation($attributes['operation_name'] ?? null)->getFilters(); + if (!($operation = $context['operation'] ?? null)) { + $operation = $this->resourceMetadataCollectionFactory->create($attributes['resource_class'])->getOperation($attributes['operation_name'] ?? null); + } + + $resourceFilters = $operation->getFilters(); if (!$resourceFilters) { return $context; diff --git a/src/Serializer/Tests/SerializerContextBuilderTest.php b/src/Serializer/Tests/SerializerContextBuilderTest.php index dacc366a9fb..b95ed38bc8b 100644 --- a/src/Serializer/Tests/SerializerContextBuilderTest.php +++ b/src/Serializer/Tests/SerializerContextBuilderTest.php @@ -67,42 +67,42 @@ public function testCreateFromRequest(): void { $request = Request::create('/foos/1'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['foo' => 'bar', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation']]; + $expected = ['foo' => 'bar', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => true]; $this->assertEquals($expected, $this->builder->createFromRequest($request, true)); $request = Request::create('/foos'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'get_collection', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['foo' => 'bar', 'operation_name' => 'get_collection', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation->withName('get_collection'), 'exclude_from_cache_key' => ['root_operation', 'operation']]; + $expected = ['foo' => 'bar', 'operation_name' => 'get_collection', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation->withName('get_collection'), 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => true]; $this->assertEquals($expected, $this->builder->createFromRequest($request, true)); $request = Request::create('/foos/1'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation']]; + $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => true]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/foos', 'POST'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'post', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'operation_name' => 'post', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => false, 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation->withName('post'), 'exclude_from_cache_key' => ['root_operation', 'operation']]; + $expected = ['bar' => 'baz', 'operation_name' => 'post', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => false, 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation->withName('post'), 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => true]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/foos', 'PUT'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'put', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'operation_name' => 'put', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => true, 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => (new Put(name: 'put'))->withOperation($this->operation), 'exclude_from_cache_key' => ['root_operation', 'operation']]; + $expected = ['bar' => 'baz', 'operation_name' => 'put', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => true, 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => (new Put(name: 'put'))->withOperation($this->operation), 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => true]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/bars/1/foos'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation']]; + $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => true]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/foowithpatch/1', 'PATCH'); $request->attributes->replace(['_api_resource_class' => 'FooWithPatch', '_api_operation_name' => 'patch', '_api_format' => 'json', '_api_mime_type' => 'application/json']); - $expected = ['operation_name' => 'patch', 'resource_class' => 'FooWithPatch', 'request_uri' => '/foowithpatch/1', 'api_allow_update' => true, 'uri' => 'http://localhost/foowithpatch/1', 'output' => null, 'input' => null, 'deep_object_to_populate' => true, 'skip_null_values' => true, 'iri_only' => false, 'operation' => $this->patchOperation, 'exclude_from_cache_key' => ['root_operation', 'operation']]; + $expected = ['operation_name' => 'patch', 'resource_class' => 'FooWithPatch', 'request_uri' => '/foowithpatch/1', 'api_allow_update' => true, 'uri' => 'http://localhost/foowithpatch/1', 'output' => null, 'input' => null, 'deep_object_to_populate' => true, 'skip_null_values' => true, 'iri_only' => false, 'operation' => $this->patchOperation, 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => true]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/bars/1/foos'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml', 'id' => '1']); - $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'operation' => $this->operation, 'skip_null_values' => true, 'exclude_from_cache_key' => ['root_operation', 'operation']]; + $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'operation' => $this->operation, 'skip_null_values' => true, 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => true]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); } @@ -115,7 +115,7 @@ public function testThrowExceptionOnInvalidRequest(): void public function testReuseExistingAttributes(): void { - $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation']]; + $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => true]; $this->assertEquals($expected, $this->builder->createFromRequest(Request::create('/foos/1'), false, ['resource_class' => 'Foo', 'operation_name' => 'get'])); } diff --git a/src/State/CallableProcessor.php b/src/State/CallableProcessor.php index eff8f5633ef..64435b66ad1 100644 --- a/src/State/CallableProcessor.php +++ b/src/State/CallableProcessor.php @@ -29,7 +29,7 @@ public function __construct(private readonly ContainerInterface $locator) public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) { if (!($processor = $operation->getProcessor())) { - return null; + return $data; } if (\is_callable($processor)) { diff --git a/src/State/CreateProvider.php b/src/State/CreateProvider.php index cdd83dc3890..7b33ef05966 100644 --- a/src/State/CreateProvider.php +++ b/src/State/CreateProvider.php @@ -40,7 +40,7 @@ public function __construct(private ProviderInterface $decorated, private ?Prope public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object { - if (!$uriVariables || !$operation instanceof HttpOperation || null !== $operation->getController()) { + if (!$uriVariables || !$operation instanceof HttpOperation || (null !== $operation->getController() && 'api_platform.symfony.main_controller' !== $operation->getController())) { return $this->decorated->provide($operation, $uriVariables, $context); } diff --git a/src/State/DefaultErrorProvider.php b/src/State/DefaultErrorProvider.php deleted file mode 100644 index f7f08d8d1cc..00000000000 --- a/src/State/DefaultErrorProvider.php +++ /dev/null @@ -1,27 +0,0 @@ - - * - * 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\State; - -use ApiPlatform\Metadata\Operation; - -/** - * @internal - */ -final class DefaultErrorProvider implements ProviderInterface -{ - public function provide(Operation $operation, array $uriVariables = [], array $context = []): object - { - return $context['previous_data']; - } -} diff --git a/src/State/Processor/AddLinkHeaderProcessor.php b/src/State/Processor/AddLinkHeaderProcessor.php new file mode 100644 index 00000000000..59b6f859e9d --- /dev/null +++ b/src/State/Processor/AddLinkHeaderProcessor.php @@ -0,0 +1,48 @@ + + * + * 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\State\Processor; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\WebLink\HttpHeaderSerializer; + +final class AddLinkHeaderProcessor implements ProcessorInterface +{ + public function __construct(private readonly ProcessorInterface $decorated, private readonly ?HttpHeaderSerializer $serializer = new HttpHeaderSerializer()) + { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + $response = $this->decorated->process($data, $operation, $uriVariables, $context); + + if ( + !($request = $context['request'] ?? null) + || !$response instanceof Response + ) { + return $response; + } + + // We add our header here as Symfony does it only for the main Request and we want it to be done on errors (sub-request) as well + $linksProvider = $request->attributes->get('_links'); + if ($this->serializer && ($links = $linksProvider->getLinks())) { + $response->headers->set('Link', $this->serializer->serialize($links)); + // We don't want Symfony WebLink component do add links twice + $request->attributes->set('_links', []); + } + + return $response; + } +} diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php new file mode 100644 index 00000000000..6464f047498 --- /dev/null +++ b/src/State/Processor/RespondProcessor.php @@ -0,0 +1,105 @@ + + * + * 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\State\Processor; + +use ApiPlatform\Api\IriConverterInterface; +use ApiPlatform\Api\UrlGeneratorInterface; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Put; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\Util\CloneTrait; +use Symfony\Component\HttpFoundation\Response; + +/** + * Serializes data. + * + * @author Kévin Dunglas + */ +final class RespondProcessor implements ProcessorInterface +{ + use ClassInfoTrait; + use CloneTrait; + + public const METHOD_TO_CODE = [ + 'POST' => Response::HTTP_CREATED, + 'DELETE' => Response::HTTP_NO_CONTENT, + ]; + + public function __construct(private ?IriConverterInterface $iriConverter = null, private readonly ?ResourceClassResolverInterface $resourceClassResolver = null) + { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + if ($data instanceof Response || !$operation instanceof HttpOperation) { + return $data; + } + + if (!($request = $context['request'] ?? null)) { + return $data; + } + + $headers = [ + 'Content-Type' => sprintf('%s; charset=utf-8', $request->getMimeType($request->getRequestFormat())), + 'Vary' => 'Accept', + 'X-Content-Type-Options' => 'nosniff', + 'X-Frame-Options' => 'deny', + ]; + + $status = $operation->getStatus(); + + if ($sunset = $operation->getSunset()) { + $headers['Sunset'] = (new \DateTimeImmutable($sunset))->format(\DateTime::RFC1123); + } + + if ($acceptPatch = $operation->getAcceptPatch()) { + $headers['Accept-Patch'] = $acceptPatch; + } + + $method = $request->getMethod(); + $originalData = $context['original_data'] ?? null; + + if ($hasData = ($this->resourceClassResolver && $originalData && \is_object($originalData) && $this->resourceClassResolver->isResourceClass($this->getObjectClass($originalData))) && $this->iriConverter) { + if ( + ($operation->getExtraProperties()['is_alternate_resource_metadata'] ?? false) + && 301 === $operation->getStatus() + ) { + $status = 301; + $headers['Location'] = $this->iriConverter->getIriFromResource($originalData, UrlGeneratorInterface::ABS_PATH, $operation); + } elseif ('PUT' === $method && !$request->attributes->get('previous_data') && null === $status && ($operation instanceof Put && ($operation->getAllowCreate() ?? false))) { + $status = 201; + } + } + + $status ??= self::METHOD_TO_CODE[$method] ?? 200; + + if ($hasData && $this->iriConverter) { + $iri = $this->iriConverter->getIriFromResource($originalData); + $headers['Content-Location'] = $iri; + + if ((201 === $status || (300 <= $status && $status < 400)) && 'POST' === $method) { + $headers['Location'] = $iri; + } + } + + return new Response( + $data, + $status, + $headers + ); + } +} diff --git a/src/State/Processor/SerializeProcessor.php b/src/State/Processor/SerializeProcessor.php new file mode 100644 index 00000000000..0bc47398ece --- /dev/null +++ b/src/State/Processor/SerializeProcessor.php @@ -0,0 +1,77 @@ + + * + * 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\State\Processor; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Serializer\ResourceList; +use ApiPlatform\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\State\ProcessorInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\WebLink\GenericLinkProvider; +use Symfony\Component\WebLink\Link; + +/** + * Serializes data. + * + * @author Kévin Dunglas + */ +final class SerializeProcessor implements ProcessorInterface +{ + 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() ?? true) || !($request = $context['request'] ?? null)) { + return $this->processor->process($data, $operation, $uriVariables, $context); + } + + // @see ApiPlatform\State\Processor\RespondProcessor + $context['original_data'] = $data; + + $serializerContext = $this->serializerContextBuilder->createFromRequest($request, true, [ + 'resource_class' => $operation->getClass(), + 'operation' => $operation, + ]); + + $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); + } + + $resources = new ResourceList(); + $serializerContext['resources'] = &$resources; + $serializerContext[AbstractObjectNormalizer::EXCLUDE_FROM_CACHE_KEY][] = 'resources'; + + $resourcesToPush = new ResourceList(); + $serializerContext['resources_to_push'] = &$resourcesToPush; + $serializerContext[AbstractObjectNormalizer::EXCLUDE_FROM_CACHE_KEY][] = 'resources_to_push'; + + $serialized = $this->serializer->serialize($data, $request->getRequestFormat(), $serializerContext); + $request->attributes->set('_resources', $request->attributes->get('_resources', []) + (array) $resources); + if (\count($resourcesToPush)) { + $linkProvider = $request->attributes->get('_links', new GenericLinkProvider()); + foreach ($resourcesToPush as $resourceToPush) { + $linkProvider = $linkProvider->withLink((new Link('preload', $resourceToPush))->withAttribute('as', 'fetch')); + } + $request->attributes->set('_links', $linkProvider); + } + + return $this->processor->process($serialized, $operation, $uriVariables, $context); + } +} diff --git a/src/State/Processor/WriteProcessor.php b/src/State/Processor/WriteProcessor.php new file mode 100644 index 00000000000..eb611ac5ba3 --- /dev/null +++ b/src/State/Processor/WriteProcessor.php @@ -0,0 +1,47 @@ + + * + * 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\State\Processor; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\State\ProcessorInterface; +use Symfony\Component\HttpFoundation\Response; + +/** + * Bridges persistence and the API system. + * + * @author Kévin Dunglas + * @author Baptiste Meyer + */ +final class WriteProcessor implements ProcessorInterface +{ + use ClassInfoTrait; + + public function __construct(private readonly ProcessorInterface $processor, private readonly ProcessorInterface $callableProcessor) + { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + if ( + $data instanceof Response + || !($operation->canWrite() ?? true) + || !$operation->getProcessor() + ) { + return $this->processor->process($data, $operation, $uriVariables, $context); + } + + return $this->processor->process($this->callableProcessor->process($data, $operation, $uriVariables, $context), $operation, $uriVariables, $context); + } +} diff --git a/src/State/ProcessorInterface.php b/src/State/ProcessorInterface.php index 123f9705a7c..2dfbf869f67 100644 --- a/src/State/ProcessorInterface.php +++ b/src/State/ProcessorInterface.php @@ -25,10 +25,10 @@ interface ProcessorInterface { /** - * Processes the state. + * Handle the state. * - * @param array $uriVariables - * @param array $context + * @param array $uriVariables + * @param array&array{request?: \Symfony\Component\HttpFoundation\Request, previous_data?: mixed, resource_class?: string, original_data?: mixed} $context * * @return T */ diff --git a/src/State/Provider/ContentNegotiationProvider.php b/src/State/Provider/ContentNegotiationProvider.php new file mode 100644 index 00000000000..8b3cb533ff8 --- /dev/null +++ b/src/State/Provider/ContentNegotiationProvider.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\State\Provider; + +use ApiPlatform\Metadata\Error as ErrorOperation; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Util\ContentNegotiationTrait; +use ApiPlatform\State\ProviderInterface; +use Negotiation\Negotiator; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; + +final class ContentNegotiationProvider implements ProviderInterface +{ + use ContentNegotiationTrait; + + /** + * @param array $formats + * @param array $errorFormats + */ + public function __construct(private readonly ProviderInterface $decorated, Negotiator $negotiator = null, private readonly array $formats = [], private readonly array $errorFormats = []) + { + $this->negotiator = $negotiator ?? new Negotiator(); + } + + 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); + } + + $isErrorOperation = $operation instanceof ErrorOperation; + + $formats = $operation->getOutputFormats() ?? ($isErrorOperation ? $this->errorFormats : $this->formats); + $this->addRequestFormats($request, $formats); + $request->attributes->set('input_format', $this->getInputFormat($operation, $request)); + + if (!$isErrorOperation) { + $request->setRequestFormat($this->getRequestFormat($request, $formats)); + } else { + $request->setRequestFormat($this->getRequestFormat($request, $formats, false)); + } + + return $this->decorated->provide($operation, $uriVariables, $context); + } + + /** + * Adds the supported formats to the request. + * + * This is necessary for {@see Request::getMimeType} and {@see Request::getMimeTypes} to work. + * Note that this replaces default mime types configured at {@see Request::initializeFormats} + * + * @param array $formats + */ + private function addRequestFormats(Request $request, array $formats): void + { + foreach ($formats as $format => $mimeTypes) { + $request->setFormat($format, (array) $mimeTypes); + } + } + + /** + * Flattened the list of MIME types. + * + * @param array $formats + * + * @return array + */ + private function flattenMimeTypes(array $formats): array + { + $flattenedMimeTypes = []; + foreach ($formats as $format => $mimeTypes) { + foreach ($mimeTypes as $mimeType) { + $flattenedMimeTypes[$mimeType] = $format; + } + } + + return $flattenedMimeTypes; + } + + /** + * Extracts the format from the Content-Type header and check that it is supported. + * + * @throws UnsupportedMediaTypeHttpException + */ + private function getInputFormat(HttpOperation $operation, Request $request): ?string + { + if (null === ($contentType = $request->headers->get('CONTENT_TYPE'))) { + return null; + } + + /** @var string $contentType */ + $formats = $operation->getInputFormats() ?? []; + if ($format = $this->getMimeTypeFormat($contentType, $formats)) { + return $format; + } + + $supportedMimeTypes = []; + foreach ($formats as $mimeTypes) { + foreach ($mimeTypes as $mimeType) { + $supportedMimeTypes[] = $mimeType; + } + } + + if (!$request->isMethodSafe() && 'DELETE' !== $request->getMethod()) { + throw new UnsupportedMediaTypeHttpException(sprintf('The content-type "%s" is not supported. Supported MIME types are "%s".', $contentType, implode('", "', $supportedMimeTypes))); + } + + return null; + } +} diff --git a/src/State/Provider/DeserializeProvider.php b/src/State/Provider/DeserializeProvider.php new file mode 100644 index 00000000000..fd502aec0ca --- /dev/null +++ b/src/State/Provider/DeserializeProvider.php @@ -0,0 +1,110 @@ + + * + * 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\State\Provider; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Symfony\Validator\Exception\ValidationException; +use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Validator\Constraints\Type; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Contracts\Translation\LocaleAwareInterface; +use Symfony\Contracts\Translation\TranslatorInterface; +use Symfony\Contracts\Translation\TranslatorTrait; + +final class DeserializeProvider implements ProviderInterface +{ + 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 { + use TranslatorTrait; + }; + $this->translator->setLocale('en'); + } + } + + 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; + } + + if ( + !($operation->canDeserialize() ?? true) + || !\in_array($method = $operation->getMethod(), ['POST', 'PUT', 'PATCH'], true) + ) { + return $data; + } + + $contentType = $request->headers->get('CONTENT_TYPE'); + if (null === $contentType) { + throw new UnsupportedMediaTypeHttpException('The "Content-Type" header must exist.'); + } + + $serializerContext = $this->serializerContextBuilder->createFromRequest($request, false, [ + 'resource_class' => $operation->getClass(), + 'operation' => $operation, + ]); + + $serializerContext['uri_variables'] = $uriVariables; + + if (!$format = $request->attributes->get('input_format') ?? null) { + throw new UnsupportedMediaTypeHttpException('Format not supported.'); + } + + if ( + null !== $data + && ( + 'POST' === $method + || 'PATCH' === $method + || ('PUT' === $method && !($operation->getExtraProperties()['standard_put'] ?? false)) + ) + ) { + $serializerContext[AbstractNormalizer::OBJECT_TO_POPULATE] = $data; + } + + try { + return $this->serializer->deserialize((string) $request->getContent(), $operation->getClass(), $format, $serializerContext); + } catch (PartialDenormalizationException $e) { + $violations = new ConstraintViolationList(); + foreach ($e->getErrors() as $exception) { + if (!$exception instanceof NotNormalizableValueException) { + continue; + } + $message = (new Type($exception->getExpectedTypes() ?? []))->message; + $parameters = []; + if ($exception->canUseMessageForUser()) { + $parameters['hint'] = $exception->getMessage(); + } + $violations->add(new ConstraintViolation($this->translator->trans($message, ['{{ type }}' => implode('|', $exception->getExpectedTypes() ?? [])], 'validators'), $message, $parameters, null, $exception->getPath(), null, null, (string) $exception->getCode())); + } + if (0 !== \count($violations)) { + throw new ValidationException($violations); + } + } + + return $data; + } +} diff --git a/src/State/Provider/ReadProvider.php b/src/State/Provider/ReadProvider.php new file mode 100644 index 00000000000..bf33c875d14 --- /dev/null +++ b/src/State/Provider/ReadProvider.php @@ -0,0 +1,95 @@ + + * + * 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\State\Provider; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Put; +use ApiPlatform\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\State\Exception\ProviderNotFoundException; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\UriVariablesResolverTrait; +use ApiPlatform\Util\CloneTrait; +use ApiPlatform\Util\OperationRequestInitiatorTrait; +use ApiPlatform\Util\RequestParser; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +/** + * Retrieves data from the applicable data provider, based on the current IRI, and sets it as a request parameter called data. + * + * @author Kévin Dunglas + */ +final class ReadProvider implements ProviderInterface +{ + use CloneTrait; + use OperationRequestInitiatorTrait; + use UriVariablesResolverTrait; + + public function __construct( + private readonly ProviderInterface $provider, + private readonly ?SerializerContextBuilderInterface $serializerContextBuilder = null, + ) { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + if (!$operation instanceof HttpOperation) { + return null; + } + + $request = ($context['request'] ?? null); + if (!($operation->canRead() ?? true) || (!$operation->getUriVariables() && !$request?->isMethodSafe())) { + return null; + } + + if (null === $filters = $request->attributes->get('_api_filters')) { + $queryString = RequestParser::getQueryString($request); + $filters = $queryString ? RequestParser::parseRequestParams($queryString) : null; + } + + if ($filters) { + $context['filters'] = $filters; + } + + if ($this->serializerContextBuilder) { + // Builtin data providers are able to use the serialization context to automatically add join clauses + $context += $this->serializerContextBuilder->createFromRequest($request, true, [ + 'resource_class' => $operation->getClass(), + 'operation' => $operation, + ]); + } + + try { + $data = $this->provider->provide($operation, $uriVariables, $context); + } catch (ProviderNotFoundException $e) { + $data = null; + } + + if ( + null === $data + && 'POST' !== $operation->getMethod() + && ( + 'PUT' !== $operation->getMethod() + || ($operation instanceof Put && !($operation->getAllowCreate() ?? false)) + ) + ) { + throw new NotFoundHttpException('Not Found'); + } + + $request->attributes->set('data', $data); + $request->attributes->set('previous_data', $this->clone($data)); + + return $data; + } +} diff --git a/src/State/ProviderInterface.php b/src/State/ProviderInterface.php index 41f22418b04..7404f3685d4 100644 --- a/src/State/ProviderInterface.php +++ b/src/State/ProviderInterface.php @@ -27,8 +27,8 @@ interface ProviderInterface /** * Provides data. * - * @param array $uriVariables - * @param array $context + * @param array $uriVariables + * @param array|array{request?: \Symfony\Component\HttpFoundation\Request, resource_class?: string} $context * * @return T|Pagination\PartialPaginatorInterface|iterable|null */ diff --git a/src/Symfony/Bundle/ApiPlatformBundle.php b/src/Symfony/Bundle/ApiPlatformBundle.php index 69f6d065860..11b84a24d5f 100644 --- a/src/Symfony/Bundle/ApiPlatformBundle.php +++ b/src/Symfony/Bundle/ApiPlatformBundle.php @@ -20,6 +20,7 @@ use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\FilterPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlMutationResolverPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlQueryResolverPass; +use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlResolverPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlTypePass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\MetadataAwareNameConverterPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\TestClientPass; @@ -47,8 +48,11 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new FilterPass()); $container->addCompilerPass(new ElasticsearchClientPass()); $container->addCompilerPass(new GraphQlTypePass()); + // These two are deprecated $container->addCompilerPass(new GraphQlQueryResolverPass()); $container->addCompilerPass(new GraphQlMutationResolverPass()); + // We can use this one only in 4.0 + $container->addCompilerPass(new GraphQlResolverPass()); $container->addCompilerPass(new MetadataAwareNameConverterPass()); $container->addCompilerPass(new TestClientPass()); $container->addCompilerPass(new AuthenticatorManagerPass()); diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 4574fa97647..fbe2ac31844 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -13,8 +13,6 @@ namespace ApiPlatform\Symfony\Bundle\DependencyInjection; -use ApiPlatform\Api\FilterInterface; -use ApiPlatform\Api\UrlGeneratorInterface; use ApiPlatform\ApiResource\Error; use ApiPlatform\Doctrine\Odm\Extension\AggregationCollectionExtensionInterface; use ApiPlatform\Doctrine\Odm\Extension\AggregationItemExtensionInterface; @@ -31,6 +29,8 @@ use ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface; use ApiPlatform\GraphQl\Type\Definition\TypeInterface as GraphQlTypeInterface; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\FilterInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\Inflector; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProviderInterface; @@ -111,6 +111,10 @@ public function load(array $configs, ContainerBuilder $container): void $errorFormats['html'] = ['text/html']; } + if (!isset($errorFormats['json'])) { + $errorFormats['json'] = ['application/problem+json', 'application/json']; + } + // Backward Compatibility layer if (isset($formats['jsonapi']) && !isset($patchFormats['jsonapi'])) { $patchFormats['jsonapi'] = ['application/vnd.api+json']; @@ -121,7 +125,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->registerOAuthConfiguration($container, $config); $this->registerOpenApiConfiguration($container, $config, $loader); $this->registerSwaggerConfiguration($container, $config, $loader); - $this->registerJsonApiConfiguration($formats, $loader); + $this->registerJsonApiConfiguration($formats, $loader, $config); $this->registerJsonLdHydraConfiguration($container, $formats, $loader, $config); $this->registerJsonHalConfiguration($formats, $loader); $this->registerJsonProblemConfiguration($errorFormats, $loader); @@ -135,7 +139,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->registerMercureConfiguration($container, $config, $loader); $this->registerMessengerConfiguration($container, $config, $loader); $this->registerElasticsearchConfiguration($container, $config, $loader); - $this->registerSecurityConfiguration($container, $loader); + $this->registerSecurityConfiguration($container, $config, $loader); $this->registerMakerConfiguration($container, $config, $loader); $this->registerArgumentResolverConfiguration($loader); @@ -156,6 +160,7 @@ public function load(array $configs, ContainerBuilder $container): void private function registerCommonConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader, array $formats, array $patchFormats, array $errorFormats): void { $loader->load('symfony/events.xml'); + $loader->load('symfony/controller.xml'); $loader->load('api.xml'); $loader->load('state.xml'); $loader->load('filter.xml'); @@ -168,6 +173,10 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $loader->load('symfony/uid.xml'); } + // 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.enable_entrypoint', $config['enable_entrypoint']); $container->setParameter('api_platform.enable_docs', $config['enable_docs']); $container->setParameter('api_platform.keep_legacy_inflector', $config['keep_legacy_inflector']); @@ -440,6 +449,8 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array $loader->load('openapi.xml'); $loader->load('swagger_ui.xml'); + $loader->load('legacy/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 $container->removeDefinition('api_platform.swagger.listener.ui'); @@ -454,13 +465,14 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.swagger_ui.extra_configuration', $config['openapi']['swagger_ui_extra_configuration'] ?: $config['swagger']['swagger_ui_extra_configuration']); } - private function registerJsonApiConfiguration(array $formats, XmlFileLoader $loader): void + private function registerJsonApiConfiguration(array $formats, XmlFileLoader $loader, array $config): void { if (!isset($formats['jsonapi'])) { return; } $loader->load('jsonapi.xml'); + $loader->load('legacy/jsonapi.xml'); } private function registerJsonLdHydraConfiguration(ContainerBuilder $container, array $formats, XmlFileLoader $loader, array $config): void @@ -470,6 +482,7 @@ private function registerJsonLdHydraConfiguration(ContainerBuilder $container, a } $loader->load('jsonld.xml'); + $loader->load('legacy/hydra.xml'); $loader->load('hydra.xml'); if (!$container->has('api_platform.json_schema.schema_factory')) { @@ -536,11 +549,11 @@ private function registerGraphQlConfiguration(ContainerBuilder $container, array } $container->registerForAutoconfiguration(QueryItemResolverInterface::class) - ->addTag('api_platform.graphql.query_resolver'); + ->addTag('api_platform.graphql.resolver'); $container->registerForAutoconfiguration(QueryCollectionResolverInterface::class) - ->addTag('api_platform.graphql.query_resolver'); + ->addTag('api_platform.graphql.resolver'); $container->registerForAutoconfiguration(MutationResolverInterface::class) - ->addTag('api_platform.graphql.mutation_resolver'); + ->addTag('api_platform.graphql.resolver'); $container->registerForAutoconfiguration(GraphQlTypeInterface::class) ->addTag('api_platform.graphql.type'); $container->registerForAutoconfiguration(ErrorHandlerInterface::class) @@ -550,29 +563,33 @@ private function registerGraphQlConfiguration(ContainerBuilder $container, array return; } - $requestStack = new Reference('request_stack', ContainerInterface::NULL_ON_INVALID_REFERENCE); - $collectionDataCollectorResolverFactory = (new Definition(DataCollectorResolverFactory::class)) - ->setDecoratedService('api_platform.graphql.resolver.factory.collection') - ->setArguments([new Reference('api_platform.graphql.data_collector.resolver.factory.collection.inner'), $requestStack]); + /* 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) { + $loader->load('legacy/graphql.xml'); + $requestStack = new Reference('request_stack', ContainerInterface::NULL_ON_INVALID_REFERENCE); + $collectionDataCollectorResolverFactory = (new Definition(DataCollectorResolverFactory::class)) + ->setDecoratedService('api_platform.graphql.resolver.factory.collection') + ->setArguments([new Reference('api_platform.graphql.data_collector.resolver.factory.collection.inner'), $requestStack]); - $itemDataCollectorResolverFactory = (new Definition(DataCollectorResolverFactory::class)) - ->setDecoratedService('api_platform.graphql.resolver.factory.item') - ->setArguments([new Reference('api_platform.graphql.data_collector.resolver.factory.item.inner'), $requestStack]); + $itemDataCollectorResolverFactory = (new Definition(DataCollectorResolverFactory::class)) + ->setDecoratedService('api_platform.graphql.resolver.factory.item') + ->setArguments([new Reference('api_platform.graphql.data_collector.resolver.factory.item.inner'), $requestStack]); - $itemMutationDataCollectorResolverFactory = (new Definition(DataCollectorResolverFactory::class)) - ->setDecoratedService('api_platform.graphql.resolver.factory.item_mutation') - ->setArguments([new Reference('api_platform.graphql.data_collector.resolver.factory.item_mutation.inner'), $requestStack]); + $itemMutationDataCollectorResolverFactory = (new Definition(DataCollectorResolverFactory::class)) + ->setDecoratedService('api_platform.graphql.resolver.factory.item_mutation') + ->setArguments([new Reference('api_platform.graphql.data_collector.resolver.factory.item_mutation.inner'), $requestStack]); - $itemSubscriptionDataCollectorResolverFactory = (new Definition(DataCollectorResolverFactory::class)) - ->setDecoratedService('api_platform.graphql.resolver.factory.item_subscription') - ->setArguments([new Reference('api_platform.graphql.data_collector.resolver.factory.item_subscription.inner'), $requestStack]); + $itemSubscriptionDataCollectorResolverFactory = (new Definition(DataCollectorResolverFactory::class)) + ->setDecoratedService('api_platform.graphql.resolver.factory.item_subscription') + ->setArguments([new Reference('api_platform.graphql.data_collector.resolver.factory.item_subscription.inner'), $requestStack]); - $container->addDefinitions([ - 'api_platform.graphql.data_collector.resolver.factory.collection' => $collectionDataCollectorResolverFactory, - 'api_platform.graphql.data_collector.resolver.factory.item' => $itemDataCollectorResolverFactory, - 'api_platform.graphql.data_collector.resolver.factory.item_mutation' => $itemMutationDataCollectorResolverFactory, - 'api_platform.graphql.data_collector.resolver.factory.item_subscription' => $itemSubscriptionDataCollectorResolverFactory, - ]); + $container->addDefinitions([ + 'api_platform.graphql.data_collector.resolver.factory.collection' => $collectionDataCollectorResolverFactory, + 'api_platform.graphql.data_collector.resolver.factory.item' => $itemDataCollectorResolverFactory, + 'api_platform.graphql.data_collector.resolver.factory.item_mutation' => $itemMutationDataCollectorResolverFactory, + 'api_platform.graphql.data_collector.resolver.factory.item_subscription' => $itemSubscriptionDataCollectorResolverFactory, + ]); + } } private function registerCacheConfiguration(ContainerBuilder $container): void @@ -630,6 +647,7 @@ 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 (!$this->isConfigEnabled($container, $config['http_cache']['invalidation'])) { return; @@ -640,6 +658,7 @@ private function registerHttpCacheConfiguration(ContainerBuilder $container, arr } $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); @@ -689,6 +708,8 @@ private function registerValidatorConfiguration(ContainerBuilder $container, arr ->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']) { @@ -725,6 +746,7 @@ 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 ($this->isConfigEnabled($container, $config['doctrine'])) { @@ -767,7 +789,7 @@ private function registerElasticsearchConfiguration(ContainerBuilder $container, $container->setParameter('api_platform.elasticsearch.mapping', $config['elasticsearch']['mapping']); } - private function registerSecurityConfiguration(ContainerBuilder $container, XmlFileLoader $loader): void + private function registerSecurityConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader): void { /** @var string[] $bundles */ $bundles = $container->getParameter('kernel.bundles'); @@ -777,6 +799,7 @@ private function registerSecurityConfiguration(ContainerBuilder $container, XmlF } $loader->load('security.xml'); + $loader->load('legacy/security.xml'); } private function registerOpenApiConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader): void diff --git a/src/Symfony/Bundle/DependencyInjection/Compiler/GraphQlMutationResolverPass.php b/src/Symfony/Bundle/DependencyInjection/Compiler/GraphQlMutationResolverPass.php index 54e573fb706..f4a8f055b40 100644 --- a/src/Symfony/Bundle/DependencyInjection/Compiler/GraphQlMutationResolverPass.php +++ b/src/Symfony/Bundle/DependencyInjection/Compiler/GraphQlMutationResolverPass.php @@ -22,6 +22,8 @@ * * @internal * + * @deprecated prefer GraphQlResolverPass + * * @author Raoul Clais */ final class GraphQlMutationResolverPass implements CompilerPassInterface diff --git a/src/Symfony/Bundle/DependencyInjection/Compiler/GraphQlQueryResolverPass.php b/src/Symfony/Bundle/DependencyInjection/Compiler/GraphQlQueryResolverPass.php index 06a2b1531aa..94b5d01ca56 100644 --- a/src/Symfony/Bundle/DependencyInjection/Compiler/GraphQlQueryResolverPass.php +++ b/src/Symfony/Bundle/DependencyInjection/Compiler/GraphQlQueryResolverPass.php @@ -22,6 +22,8 @@ * * @internal * + * @deprecated prefer GraphQlResolverPass + * * @author Lukas Lücke */ final class GraphQlQueryResolverPass implements CompilerPassInterface diff --git a/src/Symfony/Bundle/DependencyInjection/Compiler/GraphQlResolverPass.php b/src/Symfony/Bundle/DependencyInjection/Compiler/GraphQlResolverPass.php new file mode 100644 index 00000000000..594acf2fe46 --- /dev/null +++ b/src/Symfony/Bundle/DependencyInjection/Compiler/GraphQlResolverPass.php @@ -0,0 +1,72 @@ + + * + * 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\Bundle\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; + +/** + * Injects GraphQL resolvers. + * + * @internal + * + * @author Lukas Lücke + */ +final class GraphQlResolverPass implements CompilerPassInterface +{ + /** + * {@inheritdoc} + */ + public function process(ContainerBuilder $container): void + { + if (!$container->getParameter('api_platform.graphql.enabled')) { + return; + } + + $resolvers = array_merge( + $this->getDeprecatedTaggedResolvers($container, 'api_platform.graphql.query_resolver'), + $this->getDeprecatedTaggedResolvers($container, 'api_platform.graphql.mutation_resolver'), + ); + + foreach ($container->findTaggedServiceIds('api_platform.graphql.resolver', true) as $serviceId => $tags) { + foreach ($tags as $tag) { + $resolvers[$tag['id'] ?? $serviceId] = new Reference($serviceId); + } + } + + $container->getDefinition('api_platform.graphql.resolver_locator')->addArgument($resolvers); + } + + /** + * @return array + */ + private function getDeprecatedTaggedResolvers(ContainerBuilder $container, string $tag): array + { + $resolvers = []; + $taggedResolvers = $container->findTaggedServiceIds($tag, true); + + if ($taggedResolvers) { + trigger_deprecation('api-platform/core', '3.2', 'The tag "%s" is deprecated use "api_platform.graphql.resolver" instead.', $tag); + } + + foreach ($taggedResolvers as $serviceId => $tags) { + foreach ($tags as $tag) { + $resolvers[$tag['id'] ?? $serviceId] = new Reference($serviceId); + } + } + + return $resolvers; + } +} diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 063d66a58d7..80e02c3d4de 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -84,6 +84,7 @@ 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 ->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/EventListener/SwaggerUiListener.php b/src/Symfony/Bundle/EventListener/SwaggerUiListener.php index 42885fc05a2..094d2ff6f23 100644 --- a/src/Symfony/Bundle/EventListener/SwaggerUiListener.php +++ b/src/Symfony/Bundle/EventListener/SwaggerUiListener.php @@ -30,6 +30,10 @@ public function onKernelRequest(RequestEvent $event): void return; } + if (($operation = $request->attributes->get('_api_operation')) && 'api_platform.symfony.main_controller' === $operation->getController()) { + return; + } + $request->attributes->set('_controller', 'api_platform.swagger_ui.action'); } } diff --git a/src/Symfony/Bundle/Resources/config/api.xml b/src/Symfony/Bundle/Resources/config/api.xml index 1e3f4ae7ef1..f46df5d5b62 100644 --- a/src/Symfony/Bundle/Resources/config/api.xml +++ b/src/Symfony/Bundle/Resources/config/api.xml @@ -14,8 +14,10 @@ + + @@ -26,6 +28,7 @@ + %kernel.debug% @@ -95,6 +98,8 @@ + + @@ -103,6 +108,8 @@ %api_platform.description% %api_platform.version% + + @@ -148,6 +155,7 @@ + @@ -166,7 +174,7 @@ - + @@ -179,7 +187,7 @@ - api_platform.action.placeholder + api_platform.symfony.main_controller %kernel.debug% diff --git a/src/Symfony/Bundle/Resources/config/graphql.xml b/src/Symfony/Bundle/Resources/config/graphql.xml index 440292ca597..a089eebb2bc 100644 --- a/src/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Symfony/Bundle/Resources/config/graphql.xml @@ -19,6 +19,9 @@ + + + @@ -94,17 +97,6 @@ - - - - - - - - - %api_platform.graphql.nesting_separator% - - @@ -127,10 +119,11 @@ + - - - + + + @@ -150,69 +143,57 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + %api_platform.graphql.nesting_separator% - + + + + + - - + + + + - - + + + + - - + + + - + - - - + + + - - + + + + + + + %api_platform.graphql.nesting_separator% @@ -250,9 +231,10 @@ - + + @@ -274,7 +256,5 @@ - - diff --git a/src/Symfony/Bundle/Resources/config/http_cache.xml b/src/Symfony/Bundle/Resources/config/http_cache.xml index 77b0225af96..5a9a4b494e8 100644 --- a/src/Symfony/Bundle/Resources/config/http_cache.xml +++ b/src/Symfony/Bundle/Resources/config/http_cache.xml @@ -5,15 +5,13 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> - + + %api_platform.http_cache.etag% %api_platform.http_cache.max_age% %api_platform.http_cache.shared_max_age% %api_platform.http_cache.vary% %api_platform.http_cache.public% - - - diff --git a/src/Symfony/Bundle/Resources/config/http_cache_purger.xml b/src/Symfony/Bundle/Resources/config/http_cache_purger.xml index 74d663d709f..5dc9c5068e2 100644 --- a/src/Symfony/Bundle/Resources/config/http_cache_purger.xml +++ b/src/Symfony/Bundle/Resources/config/http_cache_purger.xml @@ -19,11 +19,10 @@ %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 7b50428c6b8..8f2c3f9464b 100644 --- a/src/Symfony/Bundle/Resources/config/hydra.xml +++ b/src/Symfony/Bundle/Resources/config/hydra.xml @@ -20,12 +20,11 @@ - + - + + - - diff --git a/src/Symfony/Bundle/Resources/config/jsonapi.xml b/src/Symfony/Bundle/Resources/config/jsonapi.xml index 7e084dacc62..bb2796046ac 100644 --- a/src/Symfony/Bundle/Resources/config/jsonapi.xml +++ b/src/Symfony/Bundle/Resources/config/jsonapi.xml @@ -15,6 +15,11 @@ + + + %api_platform.collection.order_parameter_name% + + @@ -71,32 +76,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 8bb2327df38..fc2e00be8c4 100644 --- a/src/Symfony/Bundle/Resources/config/jsonld.xml +++ b/src/Symfony/Bundle/Resources/config/jsonld.xml @@ -56,6 +56,9 @@ + + + diff --git a/src/Symfony/Bundle/Resources/config/legacy/events.xml b/src/Symfony/Bundle/Resources/config/legacy/events.xml new file mode 100644 index 00000000000..df7d451758f --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/legacy/events.xml @@ -0,0 +1,68 @@ + + + + + + + + %api_platform.formats% + %api_platform.error_formats% + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + %api_platform.error_formats% + %api_platform.exception_to_status% + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/legacy/graphql.xml b/src/Symfony/Bundle/Resources/config/legacy/graphql.xml new file mode 100644 index 00000000000..2045331f75a --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/legacy/graphql.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + %api_platform.graphql.nesting_separator% + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/legacy/http_cache.xml b/src/Symfony/Bundle/Resources/config/legacy/http_cache.xml new file mode 100644 index 00000000000..c09fc896711 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/legacy/http_cache.xml @@ -0,0 +1,17 @@ + + + + + %api_platform.http_cache.etag% + %api_platform.http_cache.max_age% + %api_platform.http_cache.shared_max_age% + %api_platform.http_cache.vary% + %api_platform.http_cache.public% + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/legacy/http_cache_purger.xml b/src/Symfony/Bundle/Resources/config/legacy/http_cache_purger.xml new file mode 100644 index 00000000000..7cddb16affa --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/legacy/http_cache_purger.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/legacy/hydra.xml b/src/Symfony/Bundle/Resources/config/legacy/hydra.xml new file mode 100644 index 00000000000..5e7b592dc97 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/legacy/hydra.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/legacy/jsonapi.xml b/src/Symfony/Bundle/Resources/config/legacy/jsonapi.xml new file mode 100644 index 00000000000..c4803cf2ce4 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/legacy/jsonapi.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + %api_platform.collection.order_parameter_name% + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/legacy/mercure.xml b/src/Symfony/Bundle/Resources/config/legacy/mercure.xml new file mode 100644 index 00000000000..5dde0e384bb --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/legacy/mercure.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/legacy/security.xml b/src/Symfony/Bundle/Resources/config/legacy/security.xml new file mode 100644 index 00000000000..e36a91a9dd4 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/legacy/security.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/legacy/swagger_ui.xml b/src/Symfony/Bundle/Resources/config/legacy/swagger_ui.xml new file mode 100644 index 00000000000..20dc848a1d4 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/legacy/swagger_ui.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/legacy/validator.xml b/src/Symfony/Bundle/Resources/config/legacy/validator.xml new file mode 100644 index 00000000000..756bc46aa84 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/legacy/validator.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + %api_platform.validator.query_parameter_validation% + + + + + diff --git a/src/Symfony/Bundle/Resources/config/mercure.xml b/src/Symfony/Bundle/Resources/config/mercure.xml index 5dde0e384bb..4578a8725fa 100644 --- a/src/Symfony/Bundle/Resources/config/mercure.xml +++ b/src/Symfony/Bundle/Resources/config/mercure.xml @@ -7,11 +7,9 @@ - + + - - - diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource.xml b/src/Symfony/Bundle/Resources/config/metadata/resource.xml index 4e21c1d40c6..0228f9b09e0 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource.xml +++ b/src/Symfony/Bundle/Resources/config/metadata/resource.xml @@ -35,6 +35,12 @@ + + + %api_platform.event_listeners_backward_compatibility_layer% + + + diff --git a/src/Symfony/Bundle/Resources/config/security.xml b/src/Symfony/Bundle/Resources/config/security.xml index 6898635f5a0..dae07661482 100644 --- a/src/Symfony/Bundle/Resources/config/security.xml +++ b/src/Symfony/Bundle/Resources/config/security.xml @@ -16,16 +16,38 @@ - - + + + + + + + + post_denormalize + + + + + + post_validate + - - - - - - + + + + + + + + + post_denormalize + + + + + + post_validate diff --git a/src/Symfony/Bundle/Resources/config/state.xml b/src/Symfony/Bundle/Resources/config/state.xml index 1a87aab4573..31f501a2293 100644 --- a/src/Symfony/Bundle/Resources/config/state.xml +++ b/src/Symfony/Bundle/Resources/config/state.xml @@ -9,17 +9,56 @@ + + + + + + + + %api_platform.formats% + %api_platform.error_formats% + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + %api_platform.collection.pagination% %api_platform.graphql.collection.pagination% - - - - - @@ -53,11 +92,7 @@ - - - - - + diff --git a/src/Symfony/Bundle/Resources/config/swagger_ui.xml b/src/Symfony/Bundle/Resources/config/swagger_ui.xml index e60682f7fef..9dc70554293 100644 --- a/src/Symfony/Bundle/Resources/config/swagger_ui.xml +++ b/src/Symfony/Bundle/Resources/config/swagger_ui.xml @@ -3,11 +3,6 @@ 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.enable_swagger_ui% %api_platform.show_webby% @@ -19,6 +14,25 @@ %api_platform.swagger_ui.extra_configuration% + + + + + + + + + + + + %api_platform.formats% + %api_platform.oauth.clientId% + %api_platform.oauth.clientSecret% + %api_platform.oauth.pkce% + + + + diff --git a/src/Symfony/Bundle/Resources/config/symfony/controller.xml b/src/Symfony/Bundle/Resources/config/symfony/controller.xml new file mode 100644 index 00000000000..975b51a1b31 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/symfony/controller.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.xml b/src/Symfony/Bundle/Resources/config/symfony/events.xml index b6b58b8a174..41b8d29ac02 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.xml +++ b/src/Symfony/Bundle/Resources/config/symfony/events.xml @@ -3,70 +3,7 @@ - - - - - - - %api_platform.formats% - %api_platform.error_formats% - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - %api_platform.error_formats% - %kernel.debug% - - - - - - - - - - - - - %api_platform.error_formats% - %api_platform.exception_to_status% - - @@ -86,6 +23,5 @@ - diff --git a/src/Symfony/Bundle/Resources/config/symfony/validator.xml b/src/Symfony/Bundle/Resources/config/symfony/validator.xml index 912757c247d..32127c31edc 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/validator.xml +++ b/src/Symfony/Bundle/Resources/config/symfony/validator.xml @@ -11,24 +11,25 @@ - - - - - - - - + + - - %api_platform.validator.query_parameter_validation% + - + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/public/init-swagger-ui.js b/src/Symfony/Bundle/Resources/public/init-swagger-ui.js index 2d81e70f858..ec8b4a8f87c 100644 --- a/src/Symfony/Bundle/Resources/public/init-swagger-ui.js +++ b/src/Symfony/Bundle/Resources/public/init-swagger-ui.js @@ -9,7 +9,7 @@ window.onload = function() { self.disconnect(); - op.querySelector('.opblock-summary').click(); + op.querySelector('.opblock-summary-control').click(); const tryOutObserver = new MutationObserver(function (mutations, self) { const tryOut = op.querySelector('.try-out__btn'); if (!tryOut) return; diff --git a/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig b/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig index 4b216a87958..7879467f881 100644 --- a/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig +++ b/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig @@ -77,7 +77,7 @@
Available formats: {% for format in formats|keys %} - {{ format }} + {{ format }} {% endfor %}
Other API docs: diff --git a/src/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php b/src/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php index c262d788fd5..3f1c1324f02 100644 --- a/src/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php +++ b/src/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php @@ -27,6 +27,8 @@ /** * Displays the swaggerui interface. * + * @deprecated use ApiPlatform\Symfony\Bundle\SwaggerUi\Processor instead + * * @author Antoine Bluchet */ final class SwaggerUiAction @@ -59,6 +61,8 @@ public function __invoke(Request $request): Response 'graphiQlEnabled' => $this->swaggerUiContext->isGraphiQlEnabled(), 'graphQlPlaygroundEnabled' => $this->swaggerUiContext->isGraphQlPlaygroundEnabled(), 'assetPackage' => $this->swaggerUiContext->getAssetPackage(), + 'originalRoute' => $request->attributes->get('_api_original_route', $request->attributes->get('_route')), + 'originalRouteParams' => $request->attributes->get('_api_original_route_params', $request->attributes->get('_route_params', [])), ]; $swaggerData = [ @@ -78,11 +82,14 @@ public function __invoke(Request $request): Response 'extraConfiguration' => $this->swaggerUiContext->getExtraConfiguration(), ]; - if ($request->isMethodSafe() && null !== $resourceClass = $request->attributes->get('_api_resource_class')) { + $originalRouteParams = $request->attributes->get('_api_original_route_params') ?? []; + $resourceClass = $originalRouteParams['_api_resource_class'] ?? $request->attributes->get('_api_resource_class'); + + if ($request->isMethodSafe() && $resourceClass) { $swaggerData['id'] = $request->attributes->get('id'); $swaggerData['queryParameters'] = $request->query->all(); - $metadata = $this->resourceMetadataFactory->create($resourceClass)->getOperation($request->attributes->get('_api_operation_name')); + $metadata = $this->resourceMetadataFactory->create($resourceClass)->getOperation($originalRouteParams['_api_operation_name'] ?? $request->attributes->get('_api_operation_name')); $swaggerData['shortName'] = $metadata->getShortName(); $swaggerData['operationId'] = $this->normalizeOperationName($metadata->getName()); diff --git a/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php b/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php new file mode 100644 index 00000000000..394149b28a1 --- /dev/null +++ b/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php @@ -0,0 +1,111 @@ + + * + * 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\Bundle\SwaggerUi; + +use ApiPlatform\Exception\RuntimeException; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\OpenApi\OpenApi; +use ApiPlatform\OpenApi\Options; +use ApiPlatform\OpenApi\Serializer\NormalizeOperationNameTrait; +use ApiPlatform\State\ProcessorInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Twig\Environment as TwigEnvironment; + +/** + * @internal + */ +final class SwaggerUiProcessor implements ProcessorInterface +{ + use NormalizeOperationNameTrait; + + public function __construct(private readonly ?TwigEnvironment $twig, private readonly UrlGeneratorInterface $urlGenerator, private readonly NormalizerInterface $normalizer, private readonly Options $openApiOptions, private readonly SwaggerUiContext $swaggerUiContext, private readonly array $formats = [], private readonly ?string $oauthClientId = null, private readonly ?string $oauthClientSecret = null, private readonly bool $oauthPkce = false) + { + if (null === $this->twig) { + throw new \RuntimeException('The documentation cannot be displayed since the Twig bundle is not installed. Try running "composer require symfony/twig-bundle".'); + } + } + + /** + * @param OpenApi $openApi + */ + public function process(mixed $openApi, Operation $operation, array $uriVariables = [], array $context = []): Response + { + $request = $context['request'] ?? null; + + $swaggerContext = [ + 'formats' => $this->formats, + 'title' => $openApi->getInfo()->getTitle(), + 'description' => $openApi->getInfo()->getDescription(), + 'showWebby' => $this->swaggerUiContext->isWebbyShown(), + 'swaggerUiEnabled' => $this->swaggerUiContext->isSwaggerUiEnabled(), + 'reDocEnabled' => $this->swaggerUiContext->isRedocEnabled(), + 'graphQlEnabled' => $this->swaggerUiContext->isGraphQlEnabled(), + 'graphiQlEnabled' => $this->swaggerUiContext->isGraphiQlEnabled(), + 'graphQlPlaygroundEnabled' => $this->swaggerUiContext->isGraphQlPlaygroundEnabled(), + 'assetPackage' => $this->swaggerUiContext->getAssetPackage(), + 'originalRoute' => $request->attributes->get('_api_original_route', $request->attributes->get('_route')), + 'originalRouteParams' => $request->attributes->get('_api_original_route_params', $request->attributes->get('_route_params', [])), + ]; + + $swaggerData = [ + 'url' => $this->urlGenerator->generate('api_doc', ['format' => 'json']), + 'spec' => $this->normalizer->normalize($openApi, 'json', []), + 'oauth' => [ + 'enabled' => $this->openApiOptions->getOAuthEnabled(), + 'type' => $this->openApiOptions->getOAuthType(), + 'flow' => $this->openApiOptions->getOAuthFlow(), + 'tokenUrl' => $this->openApiOptions->getOAuthTokenUrl(), + 'authorizationUrl' => $this->openApiOptions->getOAuthAuthorizationUrl(), + 'scopes' => $this->openApiOptions->getOAuthScopes(), + 'clientId' => $this->oauthClientId, + 'clientSecret' => $this->oauthClientSecret, + 'pkce' => $this->oauthPkce, + ], + 'extraConfiguration' => $this->swaggerUiContext->getExtraConfiguration(), + ]; + + $status = 200; + $requestedOperation = $request?->attributes->get('_api_requested_operation') ?? null; + if ($request->isMethodSafe() && $requestedOperation && $requestedOperation->getName()) { + $swaggerData['id'] = $request->get('id'); + $swaggerData['queryParameters'] = $request->query->all(); + + $swaggerData['shortName'] = $requestedOperation->getShortName(); + $swaggerData['operationId'] = $this->normalizeOperationName($requestedOperation->getName()); + + [$swaggerData['path'], $swaggerData['method']] = $this->getPathAndMethod($swaggerData); + $status = $requestedOperation->getStatus() ?? $status; + } + + return new Response($this->twig->render('@ApiPlatform/SwaggerUi/index.html.twig', $swaggerContext + ['swagger_data' => $swaggerData]), $status); + } + + /** + * @param array $swaggerData + */ + private function getPathAndMethod(array $swaggerData): array + { + foreach ($swaggerData['spec']['paths'] as $path => $operations) { + foreach ($operations as $method => $operation) { + if (($operation['operationId'] ?? null) === $swaggerData['operationId']) { + return [$path, $method]; + } + } + } + + throw new RuntimeException(sprintf('The operation "%s" cannot be found in the Swagger specification.', $swaggerData['operationId'])); + } +} diff --git a/src/Symfony/Bundle/SwaggerUi/SwaggerUiProvider.php b/src/Symfony/Bundle/SwaggerUi/SwaggerUiProvider.php new file mode 100644 index 00000000000..e93914b3e8a --- /dev/null +++ b/src/Symfony/Bundle/SwaggerUi/SwaggerUiProvider.php @@ -0,0 +1,77 @@ + + * + * 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\Bundle\SwaggerUi; + +use ApiPlatform\Documentation\Documentation; +use ApiPlatform\Metadata\Error; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface; +use ApiPlatform\OpenApi\OpenApi; +use ApiPlatform\State\ProviderInterface; + +/** + * When an HTML request is sent we provide a swagger ui documentation. + * + * @internal + */ +final class SwaggerUiProvider implements ProviderInterface +{ + public function __construct(private readonly ProviderInterface $decorated, private readonly OpenApiFactoryInterface $openApiFactory) + { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + // We went through the DocumentationAction + if (OpenApi::class === $operation->getClass()) { + return $this->decorated->provide($operation, $uriVariables, $context); + } + + if ( + !($operation instanceof HttpOperation) + || !($request = $context['request'] ?? null) + || 'html' !== $request->getRequestFormat() + ) { + return $this->decorated->provide($operation, $uriVariables, $context); + } + + if (!$request->attributes->has('_api_requested_operation')) { + $request->attributes->set('_api_requested_operation', $operation); + } + + // We need to call our operation provider just in case it fails + // when it fails we'll get an Error and we'll fix the status accordingly + // @see features/main/content_negotiation.feature:119 + // DocumentationAction has no content negotation as well we want HTML so render swagger ui + if (!$operation instanceof Error && Documentation::class !== $operation->getClass()) { + $this->decorated->provide($operation, $uriVariables, $context); + } + + $swaggerUiOperation = new Get( + class: OpenApi::class, + processor: 'api_platform.swagger_ui.processor', + validate: false, + read: false, + write: true, // force write so that our processor gets called + status: $operation->getStatus() + ); + + // save our operation + $request->attributes->set('_api_operation', $swaggerUiOperation); + + return $this->openApiFactory->__invoke($context); + } +} diff --git a/src/Symfony/Controller/MainController.php b/src/Symfony/Controller/MainController.php new file mode 100644 index 00000000000..4658de417b0 --- /dev/null +++ b/src/Symfony/Controller/MainController.php @@ -0,0 +1,89 @@ + + * + * 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\Controller; + +use ApiPlatform\Api\UriVariablesConverterInterface; +use ApiPlatform\Exception\InvalidIdentifierException; +use ApiPlatform\Exception\InvalidUriVariableException; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\UriVariablesResolverTrait; +use ApiPlatform\Util\OperationRequestInitiatorTrait; +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +final class MainController +{ + use OperationRequestInitiatorTrait; + use UriVariablesResolverTrait; + + public function __construct( + ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + private readonly ProviderInterface $provider, + private readonly ProcessorInterface $processor, + UriVariablesConverterInterface $uriVariablesConverter = null, + private readonly ?LoggerInterface $logger = null + ) { + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; + $this->uriVariablesConverter = $uriVariablesConverter; + } + + public function __invoke(Request $request): Response + { + $operation = $this->initializeOperation($request); + $uriVariables = []; + try { + $uriVariables = $this->getOperationUriVariables($operation, $request->attributes->all(), $operation->getClass()); + } catch (InvalidIdentifierException|InvalidUriVariableException $e) { + throw new NotFoundHttpException('Invalid uri variables.', $e); + } + + $context = [ + 'request' => $request, + 'uri_variables' => $uriVariables, + 'resource_class' => $operation->getClass(), + ]; + + if (null === $operation->canValidate()) { + $operation = $operation->withValidate(!$request->isMethodSafe() && !$request->isMethod('DELETE')); + } + + $body = $this->provider->provide($operation, $uriVariables, $context); + + // The provider can change the Operation, extract it again from the Request attributes + if ($request->attributes->get('_api_operation') !== $operation) { + $operation = $this->initializeOperation($request); + try { + $uriVariables = $this->getOperationUriVariables($operation, $request->attributes->all(), $operation->getClass()); + } catch (InvalidIdentifierException|InvalidUriVariableException $e) { + // if this occurs with our base operation we throw above so log instead of throw here + if ($this->logger) { + $this->logger->error($e->getMessage(), ['operation' => $operation]); + } + } + } + + $context['previous_data'] = $request->attributes->get('previous_data'); + $context['data'] = $request->attributes->get('data'); + + if (null === $operation->canWrite()) { + $operation = $operation->withWrite(!$request->isMethodSafe()); + } + + return $this->processor->process($body, $operation, $uriVariables, $context); + } +} diff --git a/src/Symfony/EventListener/AddFormatListener.php b/src/Symfony/EventListener/AddFormatListener.php index 8e52135ea5a..71e798265ef 100644 --- a/src/Symfony/EventListener/AddFormatListener.php +++ b/src/Symfony/EventListener/AddFormatListener.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Symfony\EventListener; use ApiPlatform\Api\FormatMatcher; +use ApiPlatform\Metadata\Error as ErrorOperation; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Util\OperationRequestInitiatorTrait; @@ -49,6 +50,14 @@ public function onKernelRequest(RequestEvent $event): void $request = $event->getRequest(); $operation = $this->initializeOperation($request); + if ('api_platform.symfony.main_controller' === $operation?->getController()) { + return; + } + + if ($operation instanceof ErrorOperation) { + return; + } + if (!($request->attributes->has('_api_resource_class') || $request->attributes->getBoolean('_api_respond', false) || $request->attributes->getBoolean('_graphql', false) diff --git a/src/Symfony/EventListener/AddLinkHeaderListener.php b/src/Symfony/EventListener/AddLinkHeaderListener.php index b33fef48987..1693712daac 100644 --- a/src/Symfony/EventListener/AddLinkHeaderListener.php +++ b/src/Symfony/EventListener/AddLinkHeaderListener.php @@ -44,6 +44,11 @@ public function onKernelResponse(ResponseEvent $event): void $request = $event->getRequest(); $operation = $this->initializeOperation($request); + // API Platform 3.2 has a MainController where everything is handled by processors/providers + if ('api_platform.symfony.main_controller' === $operation?->getController()) { + return; + } + if ( null === $request->attributes->get('_api_resource_class') || !($attributes = RequestAttributesExtractor::extractAttributes($request)) diff --git a/src/Symfony/EventListener/DenyAccessListener.php b/src/Symfony/EventListener/DenyAccessListener.php index e72ef69afda..89bf9ca41bb 100644 --- a/src/Symfony/EventListener/DenyAccessListener.php +++ b/src/Symfony/EventListener/DenyAccessListener.php @@ -67,6 +67,10 @@ private function checkSecurity(Request $request, string $attribute, array $extra } $operation = $this->initializeOperation($request); + if ('api_platform.symfony.main_controller' === $operation?->getController()) { + return; + } + if (!$operation) { return; } diff --git a/src/Symfony/EventListener/DeserializeListener.php b/src/Symfony/EventListener/DeserializeListener.php index 3cc47640277..e481e1dee4a 100644 --- a/src/Symfony/EventListener/DeserializeListener.php +++ b/src/Symfony/EventListener/DeserializeListener.php @@ -76,6 +76,10 @@ public function onKernelRequest(RequestEvent $event): void $operation = $this->initializeOperation($request); + if ('api_platform.symfony.main_controller' === $operation?->getController()) { + return; + } + if (!($operation?->canDeserialize() ?? true)) { return; } diff --git a/src/Symfony/EventListener/ErrorListener.php b/src/Symfony/EventListener/ErrorListener.php index 84b67338bf2..7a77b6cd754 100644 --- a/src/Symfony/EventListener/ErrorListener.php +++ b/src/Symfony/EventListener/ErrorListener.php @@ -15,15 +15,16 @@ use ApiPlatform\Api\IdentifiersExtractorInterface; use ApiPlatform\ApiResource\Error; -use ApiPlatform\Metadata\ApiResource; -use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Error as ErrorOperation; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; -use ApiPlatform\Util\ErrorFormatGuesser; +use ApiPlatform\Metadata\Util\ContentNegotiationTrait; +use ApiPlatform\Symfony\Util\RequestAttributesExtractor; +use ApiPlatform\Symfony\Validator\Exception\ConstraintViolationListAwareExceptionInterface; use ApiPlatform\Util\OperationRequestInitiatorTrait; -use ApiPlatform\Util\RequestAttributesExtractor; use ApiPlatform\Validator\Exception\ValidationException; +use Negotiation\Negotiator; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Exception\RequestExceptionInterface; use Symfony\Component\HttpFoundation\Request; @@ -33,10 +34,11 @@ /** * This error listener extends the Symfony one in order to add * the `_api_operation` attribute when the request is duplicated. - * It will later be used to retrieve the exceptionToStatus from the operation ({@see ExceptionAction}). + * It will later be used to retrieve the exceptionToStatus from the operation ({@see ApiPlatform\Action\ExceptionAction}). */ final class ErrorListener extends SymfonyErrorListener { + use ContentNegotiationTrait; use OperationRequestInitiatorTrait; public function __construct( @@ -48,20 +50,19 @@ public function __construct( private readonly array $errorFormats = [], private readonly array $exceptionToStatus = [], private readonly ?IdentifiersExtractorInterface $identifiersExtractor = null, - private readonly ?ResourceClassResolverInterface $resourceClassResolver = null + private readonly ?ResourceClassResolverInterface $resourceClassResolver = null, + Negotiator $negotiator = null ) { parent::__construct($controller, $logger, $debug, $exceptionsMapping); $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; + $this->negotiator = $negotiator ?? new Negotiator(); } protected function duplicateRequest(\Throwable $exception, Request $request): Request { $dup = parent::duplicateRequest($exception, $request); - $apiOperation = $this->initializeOperation($request); - - $resourceClass = $exception::class; - $format = ErrorFormatGuesser::guessErrorFormat($request, $this->errorFormats); + $format = $this->getRequestFormat($request, $this->errorFormats, false); if ($this->resourceClassResolver?->isResourceClass($exception::class)) { $resourceCollection = $this->resourceMetadataCollectionFactory->create($exception::class); @@ -70,7 +71,7 @@ protected function duplicateRequest(\Throwable $exception, Request $request): Re foreach ($resourceCollection as $resource) { foreach ($resource->getOperations() as $op) { foreach ($op->getOutputFormats() as $key => $value) { - if ($key === $format['key']) { + if ($key === $format) { $operation = $op; break 3; } @@ -86,33 +87,49 @@ protected function duplicateRequest(\Throwable $exception, Request $request): Re } elseif ($this->resourceMetadataCollectionFactory) { // Create a generic, rfc7807 compatible error according to the wanted format /** @var HttpOperation $operation */ - $operation = $this->resourceMetadataCollectionFactory->create(Error::class)->getOperation($this->getFormatOperation($format['key'] ?? null)); + $operation = $this->resourceMetadataCollectionFactory->create(Error::class)->getOperation($this->getFormatOperation($format)); $operation = $operation->withStatus($this->getStatusCode($apiOperation, $request, $operation, $exception)); $errorResource = Error::createFromException($exception, $operation->getStatus()); - $resourceClass = Error::class; } else { - $operation = new Get(name: '_api_errors_problem', class: Error::class, outputFormats: ['jsonld' => ['application/ld+json']], normalizationContext: ['groups' => ['jsonld'], 'skip_null_values' => true]); + /** @var HttpOperation $operation */ + $operation = new ErrorOperation(name: '_api_errors_problem', class: Error::class, outputFormats: ['jsonld' => ['application/ld+json']], normalizationContext: ['groups' => ['jsonld'], 'skip_null_values' => true]); $operation = $operation->withStatus($this->getStatusCode($apiOperation, $request, $operation, $exception)); $errorResource = Error::createFromException($exception, $operation->getStatus()); - $resourceClass = Error::class; } - $identifiers = $this->identifiersExtractor?->getIdentifiersFromItem($errorResource, $operation) ?? []; + if (!$operation->getProvider()) { + $operation = $operation->withProvider(provider: fn () => 'jsonapi' === $format && $errorResource instanceof ConstraintViolationListAwareExceptionInterface ? $errorResource->getConstraintViolationList() : $errorResource); + } + + // For our swagger Ui errors + if ('html' === $format) { + $operation = $operation->withOutputFormats(['html' => ['text/html']]); + } + + $identifiers = []; + try { + $identifiers = $this->identifiersExtractor?->getIdentifiersFromItem($errorResource, $operation) ?? []; + } catch (\Exception $e) { + } + + if ($exception instanceof ValidationException) { + if (!($apiOperation?->getExtraProperties()['rfc_7807_compliant_errors'] ?? false)) { + $operation = $operation->withNormalizationContext([ + 'groups' => ['legacy_'.$format], + 'force_iri_generation' => false, + ]); + } + } - $dup->attributes->set('_api_error', true); - $dup->attributes->set('_api_resource_class', $resourceClass); + $dup->attributes->set('_api_resource_class', $operation->getClass()); $dup->attributes->set('_api_previous_operation', $apiOperation); $dup->attributes->set('_api_operation', $operation); $dup->attributes->set('_api_operation_name', $operation->getName()); $dup->attributes->remove('exception'); - $dup->attributes->set('data', $errorResource); - // Once we get rid of the SwaggerUiAction we'll be able to do this properly - $dup->attributes->set('_api_exception_swagger_data', [ - '_route' => $request->attributes->get('_route'), - '_route_params' => $request->attributes->get('_route_params'), - '_api_resource_class' => $request->attributes->get('_api_resource_class'), - '_api_operation_name' => $request->attributes->get('_api_operation_name'), - ]); + // These are for swagger + $dup->attributes->set('_api_original_route', $request->attributes->get('_route')); + $dup->attributes->set('_api_original_route_params', $request->attributes->get('_route_params')); + $dup->attributes->set('_api_requested_operation', $request->attributes->get('_api_requested_operation')); foreach ($identifiers as $name => $value) { $dup->attributes->set($name, $value); @@ -175,13 +192,15 @@ private function getStatusCode(?HttpOperation $apiOperation, Request $request, ? return 500; } - private function getFormatOperation(string $format): ?string + private function getFormatOperation(?string $format): string { return match ($format) { + 'json' => '_api_errors_problem', 'jsonproblem' => '_api_errors_problem', 'jsonld' => '_api_errors_hydra', 'jsonapi' => '_api_errors_jsonapi', - default => null + 'html' => '_api_errors_problem', // This will be intercepted by the SwaggerUiProvider + default => '_api_errors_problem' }; } } diff --git a/src/Symfony/EventListener/ExceptionListener.php b/src/Symfony/EventListener/ExceptionListener.php index 6c4d6ee6069..4a64d9761d3 100644 --- a/src/Symfony/EventListener/ExceptionListener.php +++ b/src/Symfony/EventListener/ExceptionListener.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Symfony\EventListener; +use ApiPlatform\Metadata\Error; use ApiPlatform\Util\RequestAttributesExtractor; use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\EventListener\ErrorListener; @@ -34,12 +35,16 @@ public function onKernelException(ExceptionEvent $event): void $request = $event->getRequest(); // Normalize exceptions only for routes managed by API Platform if ( - 'html' === $request->getRequestFormat('') - || !((RequestAttributesExtractor::extractAttributes($request)['respond'] ?? $request->attributes->getBoolean('_api_respond', false)) || $request->attributes->getBoolean('_graphql', false)) + !((RequestAttributesExtractor::extractAttributes($request)['respond'] ?? $request->attributes->getBoolean('_api_respond', false)) || $request->attributes->getBoolean('_graphql', false)) ) { return; } + // Don't loop on errors leave it to Symfony as we could not handle this properly + if (($operation = $request->attributes->get('_api_operation')) && $operation instanceof Error) { + return; + } + $this->errorListener->onKernelException($event); } } diff --git a/src/Symfony/EventListener/JsonApi/TransformFieldsetsParametersListener.php b/src/Symfony/EventListener/JsonApi/TransformFieldsetsParametersListener.php index 59fb0026844..ecf35cda668 100644 --- a/src/Symfony/EventListener/JsonApi/TransformFieldsetsParametersListener.php +++ b/src/Symfony/EventListener/JsonApi/TransformFieldsetsParametersListener.php @@ -31,6 +31,9 @@ public function __construct(private readonly ResourceMetadataCollectionFactoryIn public function onKernelRequest(RequestEvent $event): void { $request = $event->getRequest(); + if (($operation = $request->attributes->get('_api_operation')) && 'api_platform.symfony.main_controller' === $operation->getController()) { + return; + } $queryParameters = $request->query->all(); $includeParameter = $queryParameters['include'] ?? null; diff --git a/src/Symfony/EventListener/JsonApi/TransformFilteringParametersListener.php b/src/Symfony/EventListener/JsonApi/TransformFilteringParametersListener.php index 94ad6bd3924..6119f27845b 100644 --- a/src/Symfony/EventListener/JsonApi/TransformFilteringParametersListener.php +++ b/src/Symfony/EventListener/JsonApi/TransformFilteringParametersListener.php @@ -27,6 +27,10 @@ final class TransformFilteringParametersListener public function onKernelRequest(RequestEvent $event): void { $request = $event->getRequest(); + if (($operation = $request->attributes->get('_api_operation')) && 'api_platform.symfony.main_controller' === $operation->getController()) { + return; + } + $filterParameter = $request->query->all()['filter'] ?? null; if ( diff --git a/src/Symfony/EventListener/JsonApi/TransformPaginationParametersListener.php b/src/Symfony/EventListener/JsonApi/TransformPaginationParametersListener.php index dd95673eb17..bf0e26be7a4 100644 --- a/src/Symfony/EventListener/JsonApi/TransformPaginationParametersListener.php +++ b/src/Symfony/EventListener/JsonApi/TransformPaginationParametersListener.php @@ -27,6 +27,10 @@ final class TransformPaginationParametersListener public function onKernelRequest(RequestEvent $event): void { $request = $event->getRequest(); + if (($operation = $request->attributes->get('_api_operation')) && 'api_platform.symfony.main_controller' === $operation->getController()) { + return; + } + $pageParameter = $request->query->all()['page'] ?? null; if ( diff --git a/src/Symfony/EventListener/JsonApi/TransformSortingParametersListener.php b/src/Symfony/EventListener/JsonApi/TransformSortingParametersListener.php index 1c17a49abe5..ef6905eb112 100644 --- a/src/Symfony/EventListener/JsonApi/TransformSortingParametersListener.php +++ b/src/Symfony/EventListener/JsonApi/TransformSortingParametersListener.php @@ -31,6 +31,10 @@ public function __construct(private readonly string $orderParameterName = 'order public function onKernelRequest(RequestEvent $event): void { $request = $event->getRequest(); + if (($operation = $request->attributes->get('_api_operation')) && 'api_platform.symfony.main_controller' === $operation->getController()) { + return; + } + $orderParameter = $request->query->all()['sort'] ?? null; if ( diff --git a/src/Symfony/EventListener/QueryParameterValidateListener.php b/src/Symfony/EventListener/QueryParameterValidateListener.php index 550af8468e2..d1fda4cd1e9 100644 --- a/src/Symfony/EventListener/QueryParameterValidateListener.php +++ b/src/Symfony/EventListener/QueryParameterValidateListener.php @@ -51,6 +51,9 @@ public function onKernelRequest(RequestEvent $event): void } $operation = $this->initializeOperation($request); + if ('api_platform.symfony.main_controller' === $operation?->getController()) { + return; + } if (!($operation?->getQueryParameterValidationEnabled() ?? true) || !$operation instanceof CollectionOperationInterface) { return; diff --git a/src/Symfony/EventListener/ReadListener.php b/src/Symfony/EventListener/ReadListener.php index ec7b1f0cd2d..8c641e8e9aa 100644 --- a/src/Symfony/EventListener/ReadListener.php +++ b/src/Symfony/EventListener/ReadListener.php @@ -60,6 +60,10 @@ public function onKernelRequest(RequestEvent $event): void $request = $event->getRequest(); $operation = $this->initializeOperation($request); + if ('api_platform.symfony.main_controller' === $operation?->getController()) { + return; + } + if (!($attributes = RequestAttributesExtractor::extractAttributes($request))) { return; } diff --git a/src/Symfony/EventListener/RespondListener.php b/src/Symfony/EventListener/RespondListener.php index b8b94b187cb..4196956d60d 100644 --- a/src/Symfony/EventListener/RespondListener.php +++ b/src/Symfony/EventListener/RespondListener.php @@ -49,10 +49,14 @@ public function __construct( */ public function onKernelView(ViewEvent $event): void { - $controllerResult = $event->getControllerResult(); $request = $event->getRequest(); + $controllerResult = $event->getControllerResult(); $operation = $this->initializeOperation($request); + if ('api_platform.symfony.main_controller' === $operation?->getController()) { + return; + } + $attributes = RequestAttributesExtractor::extractAttributes($request); if ($controllerResult instanceof Response && ($attributes['respond'] ?? false)) { diff --git a/src/Symfony/EventListener/SerializeListener.php b/src/Symfony/EventListener/SerializeListener.php index 7bf4bf04527..540910cbe77 100644 --- a/src/Symfony/EventListener/SerializeListener.php +++ b/src/Symfony/EventListener/SerializeListener.php @@ -71,6 +71,11 @@ public function onKernelView(ViewEvent $event): void } $operation = $this->initializeOperation($request); + + if ('api_platform.symfony.main_controller' === $operation?->getController()) { + return; + } + if (!($operation?->canSerialize() ?? true)) { return; } diff --git a/src/Symfony/EventListener/ValidateListener.php b/src/Symfony/EventListener/ValidateListener.php index 980f822f464..bc244ed85be 100644 --- a/src/Symfony/EventListener/ValidateListener.php +++ b/src/Symfony/EventListener/ValidateListener.php @@ -46,6 +46,9 @@ public function onKernelView(ViewEvent $event): void $controllerResult = $event->getControllerResult(); $request = $event->getRequest(); $operation = $this->initializeOperation($request); + if ('api_platform.symfony.main_controller' === $operation?->getController()) { + return; + } if ( $controllerResult instanceof Response diff --git a/src/Symfony/EventListener/WriteListener.php b/src/Symfony/EventListener/WriteListener.php index 3fbf0be0d97..45af5e52da8 100644 --- a/src/Symfony/EventListener/WriteListener.php +++ b/src/Symfony/EventListener/WriteListener.php @@ -59,6 +59,11 @@ public function onKernelView(ViewEvent $event): void $request = $event->getRequest(); $operation = $this->initializeOperation($request); + // API Platform 3.2 has a MainController where everything is handled by processors/providers + if ('api_platform.symfony.main_controller' === $operation?->getController()) { + return; + } + if ( $controllerResult instanceof Response || $request->isMethodSafe() diff --git a/src/Symfony/Security/Exception/AccessDeniedException.php b/src/Symfony/Security/Exception/AccessDeniedException.php new file mode 100644 index 00000000000..1d6682ada40 --- /dev/null +++ b/src/Symfony/Security/Exception/AccessDeniedException.php @@ -0,0 +1,30 @@ + + * + * 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\Security\Exception; + +use ApiPlatform\Metadata\Exception\HttpExceptionInterface; +use Symfony\Component\Security\Core\Exception\AccessDeniedException as ExceptionAccessDeniedException; + +final class AccessDeniedException extends ExceptionAccessDeniedException implements HttpExceptionInterface +{ + public function getStatusCode(): int + { + return 403; + } + + public function getHeaders(): array + { + return []; + } +} diff --git a/src/Symfony/Security/ResourceAccessCheckerInterface.php b/src/Symfony/Security/ResourceAccessCheckerInterface.php index f699da6b8a6..450d228c4cf 100644 --- a/src/Symfony/Security/ResourceAccessCheckerInterface.php +++ b/src/Symfony/Security/ResourceAccessCheckerInterface.php @@ -22,6 +22,8 @@ interface ResourceAccessCheckerInterface { /** * Checks if the given item can be accessed by the current user. + * + * @param array{object?: mixed, previous_object?: mixed, request?: \Symfony\Component\HttpFoundation\Request} $extraVariables */ public function isGranted(string $resourceClass, string $expression, array $extraVariables = []): bool; } diff --git a/src/Symfony/Security/State/AccessCheckerProvider.php b/src/Symfony/Security/State/AccessCheckerProvider.php new file mode 100644 index 00000000000..af372971adb --- /dev/null +++ b/src/Symfony/Security/State/AccessCheckerProvider.php @@ -0,0 +1,83 @@ + + * + * 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\Security\State; + +use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Symfony\Security\Exception\AccessDeniedException; +use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; + +/** + * Allows access to content the resourceAccessChecker. + * + * @see ResourceAccessCheckerInterface + */ +final class AccessCheckerProvider implements ProviderInterface +{ + public function __construct(private readonly ProviderInterface $decorated, private readonly ResourceAccessCheckerInterface $resourceAccessChecker, private readonly ?string $event = null) + { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + switch ($this->event) { + case 'post_denormalize': + $isGranted = $operation->getSecurityPostDenormalize(); + $message = $operation->getSecurityPostDenormalizeMessage(); + break; + case 'post_validate': + $isGranted = $operation->getSecurityPostValidation(); + $message = $operation->getSecurityPostValidationMessage(); + break; + default: + $isGranted = $operation->getSecurity(); + $message = $operation->getSecurityMessage(); + } + + $body = $this->decorated->provide($operation, $uriVariables, $context); + if (null === $isGranted) { + return $body; + } + + // On a GraphQl QueryCollection we want to perform security stage only on the top-level query + if ($operation instanceof QueryCollection && null !== ($context['source'] ?? null)) { + return $body; + } + + if ($operation instanceof HttpOperation) { + $request = $context['request'] ?? null; + + $resourceAccessCheckerContext = [ + 'object' => $body, + 'previous_object' => $request?->attributes->get('previous_data'), + 'request' => $request, + ]; + } else { + $resourceAccessCheckerContext = [ + 'object' => $body, + 'previous_object' => $context['graphql_context']['previous_object'] ?? null, + ]; + } + + if (!$this->resourceAccessChecker->isGranted($operation->getClass(), $isGranted, $resourceAccessCheckerContext)) { + $operation instanceof GraphQlOperation ? throw new AccessDeniedHttpException($message ?? 'Access Denied.') : throw new AccessDeniedException($message ?? 'Access Denied.'); + } + + return $body; + } +} diff --git a/src/Symfony/State/MercureLinkProcessor.php b/src/Symfony/State/MercureLinkProcessor.php new file mode 100644 index 00000000000..9f1d31eb94e --- /dev/null +++ b/src/Symfony/State/MercureLinkProcessor.php @@ -0,0 +1,40 @@ + + * + * 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\State; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use Symfony\Component\Mercure\Discovery; + +final class MercureLinkProcessor implements ProcessorInterface +{ + /** + * @param ProcessorInterface $decorated + */ + public function __construct(private readonly ProcessorInterface $decorated, private readonly Discovery $discovery) + { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!($request = $context['request'] ?? null) || !$mercure = $operation->getMercure()) { + return $this->decorated->process($data, $operation, $uriVariables, $context); + } + + $hub = \is_array($mercure) ? ($mercure['hub'] ?? null) : null; + $this->discovery->addLink($request, $hub); + + return $this->decorated->process($data, $operation, $uriVariables, $context); + } +} diff --git a/src/Symfony/Validator/EventListener/ValidationExceptionListener.php b/src/Symfony/Validator/EventListener/ValidationExceptionListener.php index 99d913fddff..10249427edd 100644 --- a/src/Symfony/Validator/EventListener/ValidationExceptionListener.php +++ b/src/Symfony/Validator/EventListener/ValidationExceptionListener.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Symfony\Validator\EventListener; use ApiPlatform\Exception\FilterValidationException; +use ApiPlatform\Symfony\EventListener\ExceptionListener; use ApiPlatform\Symfony\Validator\Exception\ConstraintViolationListAwareExceptionInterface; use ApiPlatform\Util\ErrorFormatGuesser; use ApiPlatform\Validator\Exception\ValidationException; @@ -29,7 +30,7 @@ */ final class ValidationExceptionListener { - public function __construct(private readonly SerializerInterface $serializer, private readonly array $errorFormats, private readonly array $exceptionToStatus = []) + public function __construct(private readonly SerializerInterface $serializer, private readonly array $errorFormats, private readonly array $exceptionToStatus = [], private readonly ?ExceptionListener $exceptionListener = null) { } @@ -38,7 +39,14 @@ public function __construct(private readonly SerializerInterface $serializer, pr */ public function onKernelException(ExceptionEvent $event): void { + if ($this->exceptionListener) { + $this->exceptionListener->onKernelException($event); + + return; + } + trigger_deprecation('api-platform', '3.2', sprintf('The class "%s" is deprecated and will be removed in 4.x.', __CLASS__)); + $exception = $event->getThrowable(); if (!$exception instanceof ConstraintViolationListAwareExceptionInterface && !$exception instanceof FilterValidationException) { return; diff --git a/src/Symfony/Validator/Exception/ValidationException.php b/src/Symfony/Validator/Exception/ValidationException.php index 5f582d4e31a..759045ab50c 100644 --- a/src/Symfony/Validator/Exception/ValidationException.php +++ b/src/Symfony/Validator/Exception/ValidationException.php @@ -13,9 +13,9 @@ namespace ApiPlatform\Symfony\Validator\Exception; +use ApiPlatform\Metadata\Error as ErrorOperation; use ApiPlatform\Metadata\ErrorResource; use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; -use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Util\CompositeIdentifierParser; use ApiPlatform\Validator\Exception\ValidationException as BaseValidationException; use Symfony\Component\Serializer\Annotation\Groups; @@ -27,14 +27,15 @@ * * @author Kévin Dunglas */ -#[ErrorResource(uriTemplate: '/validation_errors/{id}', provider: 'api_platform.state_provider.default_error', +#[ErrorResource( + uriTemplate: '/validation_errors/{id}', status: 422, uriVariables: ['id'], shortName: 'ConstraintViolationList', operations: [ - new Get(name: '_api_validation_errors_hydra', outputFormats: ['jsonld' => ['application/ld+json']], normalizationContext: ['groups' => 'jsonld', 'skip_null_values' => true]), - new Get(name: '_api_validation_errors_problem', outputFormats: ['jsonproblem' => ['application/problem+json']], normalizationContext: ['groups' => 'json', 'skip_null_values' => true]), - new Get(name: '_api_validation_errors_jsonapi', outputFormats: ['jsonapi' => ['application/vnd.api+json']], normalizationContext: ['groups' => 'jsonapi', 'skip_null_values' => true], provider: 'api_platform.json_api.state_provider.default_error'), + new ErrorOperation(name: '_api_validation_errors_hydra', outputFormats: ['jsonld' => ['application/ld+json']], normalizationContext: ['groups' => ['jsonld'], 'skip_null_values' => true]), + new ErrorOperation(name: '_api_validation_errors_problem', outputFormats: ['jsonproblem' => ['application/problem+json'], 'json' => ['application/problem+json']], normalizationContext: ['groups' => ['json'], 'skip_null_values' => true]), + new ErrorOperation(name: '_api_validation_errors_jsonapi', outputFormats: ['jsonapi' => ['application/vnd.api+json']], normalizationContext: ['groups' => ['jsonapi'], 'skip_null_values' => true]), ] )] final class ValidationException extends BaseValidationException implements ConstraintViolationListAwareExceptionInterface, \Stringable, ProblemExceptionInterface @@ -96,25 +97,25 @@ public function getHydraDescription(): string return $this->__toString(); } - #[Groups(['jsonld', 'json', 'legacy_jsonproblem'])] + #[Groups(['jsonld', 'json', 'legacy_jsonproblem', 'legacy_json'])] public function getType(): string { return '/validation_errors/'.$this->getId(); } - #[Groups(['jsonld', 'json', 'legacy_jsonproblem'])] + #[Groups(['jsonld', 'json', 'legacy_jsonproblem', 'legacy_json'])] public function getTitle(): ?string { return $this->errorTitle ?? 'An error occurred'; } - #[Groups(['jsonld', 'json', 'legacy_jsonproblem'])] + #[Groups(['jsonld', 'json', 'legacy_jsonproblem', 'legacy_json'])] public function getDetail(): ?string { return $this->__toString(); } - #[Groups(['jsonld', 'json', 'legacy_jsonproblem'])] + #[Groups(['jsonld', 'json', 'legacy_jsonproblem', 'legacy_json'])] public function getStatus(): ?int { return 422; @@ -127,7 +128,7 @@ public function getInstance(): ?string } #[SerializedName('violations')] - #[Groups(['json', 'jsonld', 'legacy_jsonld', 'legacy_jsonproblem'])] + #[Groups(['json', 'jsonld', 'legacy_jsonld', 'legacy_jsonproblem', 'legacy_json'])] public function getViolations(): iterable { foreach ($this->getConstraintViolationList() as $violation) { diff --git a/src/Symfony/Validator/State/QueryParameterValidateProvider.php b/src/Symfony/Validator/State/QueryParameterValidateProvider.php new file mode 100644 index 00000000000..392fb081a07 --- /dev/null +++ b/src/Symfony/Validator/State/QueryParameterValidateProvider.php @@ -0,0 +1,56 @@ + + * + * 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\Validator\State; + +use ApiPlatform\Api\QueryParameterValidator\QueryParameterValidator; +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Util\RequestParser; + +final class QueryParameterValidateProvider implements ProviderInterface +{ + public function __construct(private readonly ProviderInterface $decorated, private readonly QueryParameterValidator $queryParameterValidator) + { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + if ( + !$operation instanceof HttpOperation + || !($request = $context['request'] ?? null) + || !$request->isMethodSafe() + || 'GET' !== $request->getMethod() + ) { + return $this->decorated->provide($operation, $uriVariables, $context); + } + + if (!($operation->getQueryParameterValidationEnabled() ?? true) || !$operation instanceof CollectionOperationInterface) { + return $this->decorated->provide($operation, $uriVariables, $context); + } + + $queryString = RequestParser::getQueryString($request); + $queryParameters = $queryString ? RequestParser::parseRequestParams($queryString) : []; + $class = $operation->getClass(); + if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getEntityClass()) { + $class = $options->getEntityClass(); + } + + $this->queryParameterValidator->validateFilters($class, $operation->getFilters() ?? [], $queryParameters); + + return $this->decorated->provide($operation, $uriVariables, $context); + } +} diff --git a/src/Symfony/Validator/State/ValidateProvider.php b/src/Symfony/Validator/State/ValidateProvider.php new file mode 100644 index 00000000000..ec40eff9d81 --- /dev/null +++ b/src/Symfony/Validator/State/ValidateProvider.php @@ -0,0 +1,46 @@ + + * + * 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\Validator\State; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Validator\ValidatorInterface; +use Symfony\Component\HttpFoundation\Response; + +/** + * Validates data on an HTTP or GraphQl operation. + */ +final class ValidateProvider implements ProviderInterface +{ + 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); + + if ($body instanceof Response || !$body) { + return $body; + } + + if (!($operation->canValidate() ?? true)) { + return $body; + } + + $this->validator->validate($body, $operation->getValidationContext() ?? []); + + return $body; + } +} diff --git a/src/Util/ArrayTrait.php b/src/Util/ArrayTrait.php index 38156f94c87..bd69c4b2448 100644 --- a/src/Util/ArrayTrait.php +++ b/src/Util/ArrayTrait.php @@ -13,6 +13,9 @@ namespace ApiPlatform\Util; +/** + * @deprecated use ApiPlatform\GraphQl\ArrayTrait + */ trait ArrayTrait { public function isSequentialArrayOfArrays(array $array): bool diff --git a/src/Util/AttributesExtractor.php b/src/Util/AttributesExtractor.php index 89470fad7b2..0a2b4bd5dfd 100644 --- a/src/Util/AttributesExtractor.php +++ b/src/Util/AttributesExtractor.php @@ -32,6 +32,10 @@ private function __construct() */ public static function extractAttributes(array $attributes): array { + if (($attributes['_api_operation'] ?? null) && !isset($attributes['_api_resource_class'])) { + $attributes['_api_resource_class'] = $attributes['_api_operation']->getClass(); + } + $result = ['resource_class' => $attributes['_api_resource_class'] ?? null, 'has_composite_identifier' => $attributes['_api_has_composite_identifier'] ?? false]; if (null === $result['resource_class']) { @@ -44,6 +48,7 @@ public static function extractAttributes(array $attributes): array $result['operation_name'] = $attributes['_api_operation_name']; } if (isset($attributes['_api_operation'])) { + $hasRequestAttributeKey = true; $result['operation'] = $attributes['_api_operation']; } diff --git a/tests/Action/EntrypointActionTest.php b/tests/Action/EntrypointActionTest.php index dea49a23f6e..def1278bb54 100644 --- a/tests/Action/EntrypointActionTest.php +++ b/tests/Action/EntrypointActionTest.php @@ -17,6 +17,8 @@ use ApiPlatform\Api\Entrypoint; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\ProviderInterface; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; @@ -34,4 +36,17 @@ public function testGetEntrypoint(): void $entrypoint = new EntrypointAction($resourceNameCollectionFactoryProphecy->reveal()); $this->assertEquals(new Entrypoint(new ResourceNameCollection(['dummies'])), $entrypoint()); } + + public function testGetEntrypointWithProviderProcessor(): void + { + $expected = new Entrypoint(new ResourceNameCollection(['dummies'])); + $resourceNameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactory->method('create')->willReturn(new ResourceNameCollection(['dummies'])); + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once())->method('provide')->willReturn($expected); + $processor = $this->createMock(ProcessorInterface::class); + $processor->expects($this->once())->method('process')->willReturnArgument(0); + $entrypoint = new EntrypointAction($resourceNameCollectionFactory, $provider, $processor); + $this->assertEquals($expected, $entrypoint()); + } } diff --git a/tests/Documentation/Action/DocumentationActionTest.php b/tests/Documentation/Action/DocumentationActionTest.php index 7c0417e223e..260e200d677 100644 --- a/tests/Documentation/Action/DocumentationActionTest.php +++ b/tests/Documentation/Action/DocumentationActionTest.php @@ -21,6 +21,8 @@ use ApiPlatform\OpenApi\Model\Info; use ApiPlatform\OpenApi\Model\Paths; use ApiPlatform\OpenApi\OpenApi; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\ProviderInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -41,14 +43,17 @@ public function testDocumentationAction(): void $openApiFactoryProphecy = $this->prophesize(OpenApiFactoryInterface::class); $openApiFactoryProphecy->__invoke(Argument::any())->shouldBeCalled()->willReturn($openApi); $requestProphecy = $this->prophesize(Request::class); - $requestProphecy->getRequestFormat()->willReturn('json'); + $requestProphecy->getMimeType('json')->willReturn('application/json'); + $requestProphecy->getRequestFormat('')->willReturn('json'); $attributesProphecy = $this->prophesize(ParameterBagInterface::class); $queryProphecy = $this->prophesize(ParameterBag::class); $requestProphecy->attributes = $attributesProphecy->reveal(); $requestProphecy->query = $queryProphecy->reveal(); + $requestProphecy->headers = $this->prophesize(ParameterBagInterface::class)->reveal(); $requestProphecy->getBaseUrl()->willReturn('/api')->shouldBeCalledTimes(1); $queryProphecy->getBoolean('api_gateway')->willReturn(true)->shouldBeCalledTimes(1); $attributesProphecy->get('_api_normalization_context', [])->willReturn(['foo' => 'bar'])->shouldBeCalledTimes(1); + $attributesProphecy->get('_format')->willReturn(null)->shouldBeCalledTimes(1); $attributesProphecy->set('_api_normalization_context', ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true])->shouldBeCalledTimes(1); $documentation = new DocumentationAction($this->prophesize(ResourceNameCollectionFactoryInterface::class)->reveal(), 'my api', '', '1.0.0', $openApiFactoryProphecy->reveal()); @@ -60,8 +65,11 @@ public function testDocumentationActionWithoutOpenApiFactory(): void $openApiFactoryProphecy = $this->prophesize(OpenApiFactoryInterface::class); $openApiFactoryProphecy->__invoke(Argument::any())->shouldNotBeCalled(); $requestProphecy = $this->prophesize(Request::class); - $requestProphecy->getRequestFormat()->willReturn('json'); + $requestProphecy->getRequestFormat('')->willReturn('json'); + $requestProphecy->headers = $this->prophesize(ParameterBagInterface::class)->reveal(); + $requestProphecy->getMimeType('json')->willReturn('application/json'); $attributesProphecy = $this->prophesize(ParameterBagInterface::class); + $attributesProphecy->get('_format')->willReturn(null)->shouldBeCalledTimes(1); $queryProphecy = $this->prophesize(ParameterBag::class); $requestProphecy->attributes = $attributesProphecy->reveal(); $requestProphecy->query = $queryProphecy->reveal(); @@ -75,4 +83,39 @@ public function testDocumentationActionWithoutOpenApiFactory(): void $documentation = new DocumentationAction($resourceNameCollectionFactoryProphecy->reveal(), 'my api', '', '1.0.0'); $this->assertInstanceOf(Documentation::class, $documentation($requestProphecy->reveal())); } + + public static function getOpenApiContentTypes(): array + { + return [['application/json'], ['application/html']]; + } + + /** + * @dataProvider getOpenApiContentTypes + */ + public function testGetOpenApi($contentType): void + { + $request = new Request(server: ['CONTENT_TYPE' => $contentType]); + $openApiFactory = $this->createMock(OpenApiFactoryInterface::class); + $openApiFactory->expects($this->once())->method('__invoke')->willReturn(new OpenApi(new Info('a', 'v'), [], new Paths())); + $resourceNameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once())->method('provide')->willReturnCallback(fn ($operation, $uriVariables, $context) => $operation->getProvider()(...\func_get_args())); + $processor = $this->createMock(ProcessorInterface::class); + $processor->expects($this->once())->method('process')->willReturnArgument(0); + $entrypoint = new DocumentationAction($resourceNameCollectionFactory, provider: $provider, processor: $processor, openApiFactory: $openApiFactory); + $entrypoint($request); + } + + public function testGetHydraDocumentation(): void + { + $request = new Request(); + $resourceNameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactory->expects($this->once())->method('create')->willReturn(new ResourceNameCollection([])); + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once())->method('provide')->willReturnCallback(fn ($operation, $uriVariables, $context) => $operation->getProvider()(...\func_get_args())); + $processor = $this->createMock(ProcessorInterface::class); + $processor->expects($this->once())->method('process')->willReturnArgument(0); + $entrypoint = new DocumentationAction($resourceNameCollectionFactory, provider: $provider, processor: $processor); + $entrypoint($request); + } } diff --git a/tests/Fixtures/TestBundle/Controller/MongoDbOdm/DummyValidationController.php b/tests/Fixtures/TestBundle/Controller/MongoDbOdm/DummyValidationController.php deleted file mode 100644 index 964a4f88890..00000000000 --- a/tests/Fixtures/TestBundle/Controller/MongoDbOdm/DummyValidationController.php +++ /dev/null @@ -1,32 +0,0 @@ - - * - * 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\Tests\Fixtures\TestBundle\Controller\MongoDbOdm; - -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyValidation; -use Symfony\Component\Routing\Annotation\Route; - -class DummyValidationController -{ - #[Route(methods: ['POST'], name: 'post_validation_groups', path: '/dummy_validation/validation_groups', defaults: ['_api_resource_class' => DummyValidation::class, '_api_operation_name' => 'post_validation_groups'])] - public function postValidationGroups($data) - { - return $data; - } - - #[Route(methods: ['POST'], name: 'post_validation_sequence', path: '/dummy_validation/validation_sequence', defaults: ['_api_resource_class' => DummyValidation::class, '_api_operation_name' => 'post_validation_sequence'])] - public function postValidationSequence($data) - { - return $data; - } -} diff --git a/tests/Fixtures/TestBundle/Controller/Orm/DummyValidationController.php b/tests/Fixtures/TestBundle/Controller/Orm/DummyValidationController.php deleted file mode 100644 index b00d224459d..00000000000 --- a/tests/Fixtures/TestBundle/Controller/Orm/DummyValidationController.php +++ /dev/null @@ -1,32 +0,0 @@ - - * - * 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\Tests\Fixtures\TestBundle\Controller\Orm; - -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyValidation; -use Symfony\Component\Routing\Annotation\Route; - -class DummyValidationController -{ - #[Route(methods: ['POST'], name: 'post_validation_groups', path: '/dummy_validation/validation_groups', defaults: ['_api_resource_class' => DummyValidation::class, '_api_operation_name' => 'post_validation_groups'])] - public function postValidationGroups($data) - { - return $data; - } - - #[Route(methods: ['POST'], name: 'post_validation_sequence', path: '/dummy_validation/validation_sequence', defaults: ['_api_resource_class' => DummyValidation::class, '_api_operation_name' => 'post_validation_sequence'])] - public function postValidationSequence($data) - { - return $data; - } -} diff --git a/tests/Fixtures/TestBundle/Document/DummyValidation.php b/tests/Fixtures/TestBundle/Document/DummyValidation.php index be3f362e42d..95f762484ea 100644 --- a/tests/Fixtures/TestBundle/Document/DummyValidation.php +++ b/tests/Fixtures/TestBundle/Document/DummyValidation.php @@ -22,8 +22,8 @@ #[ApiResource(operations: [ new GetCollection(), new Post(uriTemplate: 'dummy_validation{._format}'), - new Post(routeName: 'post_validation_groups', validationContext: ['groups' => ['a']], extraProperties: ['rfc_7807_compliant_errors' => false]), - new Post(routeName: 'post_validation_sequence', validationContext: ['groups' => 'app.dummy_validation.group_generator'], extraProperties: ['rfc_7807_compliant_errors' => false]), + new Post(uriTemplate: '/dummy_validation/validation_groups', validationContext: ['groups' => ['a']], extraProperties: ['rfc_7807_compliant_errors' => false]), + new Post(uriTemplate: '/dummy_validation/validation_sequence', validationContext: ['groups' => 'app.dummy_validation.group_generator'], extraProperties: ['rfc_7807_compliant_errors' => false]), ] )] #[ODM\Document] diff --git a/tests/Fixtures/TestBundle/Entity/DummyValidation.php b/tests/Fixtures/TestBundle/Entity/DummyValidation.php index ba8e9cabfa0..b62fbf385d6 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyValidation.php +++ b/tests/Fixtures/TestBundle/Entity/DummyValidation.php @@ -22,8 +22,8 @@ #[ApiResource(operations: [ new GetCollection(), new Post(uriTemplate: 'dummy_validation{._format}'), - new Post(routeName: 'post_validation_groups', validationContext: ['groups' => ['a']], extraProperties: ['rfc_7807_compliant_errors' => false]), - new Post(routeName: 'post_validation_sequence', validationContext: ['groups' => 'app.dummy_validation.group_generator'], extraProperties: ['rfc_7807_compliant_errors' => false]), + new Post(uriTemplate: '/dummy_validation/validation_groups', validationContext: ['groups' => ['a']], extraProperties: ['rfc_7807_compliant_errors' => false]), + new Post(uriTemplate: '/dummy_validation/validation_sequence', validationContext: ['groups' => 'app.dummy_validation.group_generator'], extraProperties: ['rfc_7807_compliant_errors' => false]), ] )] #[ORM\Entity] diff --git a/tests/Fixtures/TestBundle/State/AttributeResourceProcessor.php b/tests/Fixtures/TestBundle/State/AttributeResourceProcessor.php index 5e697fbf8a7..9eb10eaa4fa 100644 --- a/tests/Fixtures/TestBundle/State/AttributeResourceProcessor.php +++ b/tests/Fixtures/TestBundle/State/AttributeResourceProcessor.php @@ -14,10 +14,18 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\State; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AttributeResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; class AttributeResourceProcessor { - public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void + public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): AttributeResource { + $dummy = new Dummy(); + $dummy->setId(1); + $a = new AttributeResource(2, 'Patched'); + $a->dummy = $dummy; + + return $a; } } diff --git a/tests/Fixtures/TestBundle/State/AttributeResourceProvider.php b/tests/Fixtures/TestBundle/State/AttributeResourceProvider.php index 5c76fb972a7..c133157e1d0 100644 --- a/tests/Fixtures/TestBundle/State/AttributeResourceProvider.php +++ b/tests/Fixtures/TestBundle/State/AttributeResourceProvider.php @@ -24,11 +24,11 @@ class AttributeResourceProvider implements ProviderInterface public function provide(Operation $operation, array $uriVariables = [], array $context = []): AttributeResources|AttributeResource { if (isset($uriVariables['identifier'])) { - $resource = new AttributeResource($uriVariables['identifier'], 'Foo'); + $resource = new AttributeResource((int) $uriVariables['identifier'], 'Foo'); if ($uriVariables['dummyId'] ?? false) { $resource->dummy = new Dummy(); - $resource->dummy->setId($uriVariables['dummyId']); + $resource->dummy->setId((int) $uriVariables['dummyId']); } return $resource; diff --git a/tests/Fixtures/TestBundle/State/CustomInputDtoProcessor.php b/tests/Fixtures/TestBundle/State/CustomInputDtoProcessor.php index 135f904730a..ff1f4469ab7 100644 --- a/tests/Fixtures/TestBundle/State/CustomInputDtoProcessor.php +++ b/tests/Fixtures/TestBundle/State/CustomInputDtoProcessor.php @@ -14,7 +14,6 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\State; use ApiPlatform\Metadata\Operation; -use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\Tests\Fixtures\TestBundle\Dto\CustomInputDto; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoCustom; @@ -37,7 +36,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = /** * @var DummyDtoCustom|\ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoCustom */ - $resourceObject = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] ?? new $context['resource_class'](); + $resourceObject = $context['previous_data'] ?? new $context['resource_class'](); $resourceObject->lorem = $data->foo; $resourceObject->ipsum = (string) $data->bar; diff --git a/tests/Fixtures/TestBundle/State/DummyDtoInputOutputProcessor.php b/tests/Fixtures/TestBundle/State/DummyDtoInputOutputProcessor.php index 7466b36c0a1..3fb2498adbd 100644 --- a/tests/Fixtures/TestBundle/State/DummyDtoInputOutputProcessor.php +++ b/tests/Fixtures/TestBundle/State/DummyDtoInputOutputProcessor.php @@ -16,6 +16,7 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoInputOutput as DummyDtoInputOutputDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Dto\Document\InputDto as InputDtoDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Dto\Document\OutputDto as OutputDtoDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Dto\InputDto; use ApiPlatform\Tests\Fixtures\TestBundle\Dto\OutputDto; @@ -32,10 +33,14 @@ public function __construct(private readonly ManagerRegistry $registry) /** * {@inheritDoc} * - * @param InputDto $data + * @param InputDto|InputDtoDocument|mixed $data */ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) { + if (!($data instanceof InputDto || $data instanceof InputDtoDocument)) { + throw new \RuntimeException('Data is not an InputDto'); + } + /** @var EntityManager */ $manager = $this->registry->getManagerForClass($operation->getClass()); $entity = new ($operation->getClass())(); diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index 8b16b6e0e24..bd6311de2e6 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -229,6 +229,9 @@ class_exists(NativePasswordHasher::class) ? 'password_hashers' : 'encoders' => [ $loader->load(__DIR__.'/config/config_swagger.php'); + $metadataBackwardCompatibilityLayer = (bool) ($_SERVER['EVENT_LISTENERS_BACKWARD_COMPATIBILITY_LAYER'] ?? false); + $c->prependExtensionConfig('api_platform', ['event_listeners_backward_compatibility_layer' => $metadataBackwardCompatibilityLayer]); + if ('mongodb' === $this->environment) { $c->prependExtensionConfig('api_platform', [ 'mapping' => [ diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index e745cdb0f7b..2c0ce7d31c8 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -40,6 +40,7 @@ api_platform: jsonproblem: ['application/problem+json'] jsonld: ['application/ld+json'] jsonapi: ['application/vnd.api+json'] + html: ['text/html'] graphql: enabled: true nesting_separator: __ @@ -66,6 +67,7 @@ api_platform: extra_properties: standard_put: true rfc_7807_compliant_errors: true + legacy_api_platform_controller: true normalization_context: skip_null_values: false pagination_client_enabled: true @@ -292,42 +294,42 @@ services: app.graphql.query_resolver.dummy_custom_item: class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver\DummyCustomQueryItemResolver' tags: - - { name: 'api_platform.graphql.query_resolver' } + - { name: 'api_platform.graphql.resolver' } app.graphql.query_resolver.dummy_custom_collection: class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver\DummyCustomQueryCollectionResolver' tags: - - { name: 'api_platform.graphql.query_resolver' } + - { name: 'api_platform.graphql.resolver' } app.graphql.query_resolver.dummy_custom_collection_no_read_and_serialize: class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver\DummyCustomQueryNoReadAndSerializeCollectionResolver' tags: - - { name: 'api_platform.graphql.query_resolver' } + - { name: 'api_platform.graphql.resolver' } app.graphql.mutation_resolver.dummy_custom: class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver\SumMutationResolver' tags: - - { name: 'api_platform.graphql.mutation_resolver' } + - { name: 'api_platform.graphql.resolver' } app.graphql.mutation_resolver.dummy_custom_not_persisted: class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver\SumNotPersistedMutationResolver' tags: - - { name: 'api_platform.graphql.mutation_resolver' } + - { name: 'api_platform.graphql.resolver' } app.graphql.mutation_resolver.dummy_custom_no_write_custom_result: class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver\SumNoWriteCustomResultMutationResolver' tags: - - { name: 'api_platform.graphql.mutation_resolver' } + - { name: 'api_platform.graphql.resolver' } app.graphql.mutation_resolver.upload_media_object: class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver\UploadMediaObjectResolver' tags: - - { name: 'api_platform.graphql.mutation_resolver' } + - { name: 'api_platform.graphql.resolver' } app.graphql.mutation_resolver.upload_multiple_media_object: class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver\UploadMultipleMediaObjectResolver' tags: - - { name: 'api_platform.graphql.mutation_resolver' } + - { name: 'api_platform.graphql.resolver' } app.graphql.date_time_type: class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Type\Definition\DateTimeType' @@ -353,17 +355,17 @@ services: app.graphql.query_resolver.dummy_custom_not_retrieved_item: class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver\DummyCustomQueryNotRetrievedItemResolver' tags: - - { name: 'api_platform.graphql.query_resolver' } + - { name: 'api_platform.graphql.resolver' } app.graphql.query_resolver.dummy_custom_item_no_read_and_serialize: class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver\DummyCustomQueryNoReadAndSerializeItemResolver' tags: - - { name: 'api_platform.graphql.query_resolver' } + - { name: 'api_platform.graphql.resolver' } app.graphql.mutation_resolver.dummy_custom_only_persist: class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver\SumOnlyPersistMutationResolver' tags: - - { name: 'api_platform.graphql.mutation_resolver' } + - { name: 'api_platform.graphql.resolver' } app.messenger_handler.messenger_with_inputs: class: 'ApiPlatform\Tests\Fixtures\TestBundle\MessengerHandler\Entity\MessengerWithInputHandler' diff --git a/tests/Fixtures/app/config/config_mongodb.yml b/tests/Fixtures/app/config/config_mongodb.yml index 2e29e433aa2..ed65db4eb9f 100644 --- a/tests/Fixtures/app/config/config_mongodb.yml +++ b/tests/Fixtures/app/config/config_mongodb.yml @@ -102,19 +102,19 @@ services: class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver\DummyCustomQueryNotRetrievedItemDocumentResolver' public: false tags: - - { name: 'api_platform.graphql.query_resolver' } + - { name: 'api_platform.graphql.resolver' } app.graphql.query_resolver.dummy_custom_item_no_read_and_serialize_document: class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver\DummyCustomQueryNoReadAndSerializeItemDocumentResolver' public: false tags: - - { name: 'api_platform.graphql.query_resolver' } + - { name: 'api_platform.graphql.resolver' } app.graphql.mutation_resolver.dummy_custom_only_persist_document: class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver\SumOnlyPersistDocumentMutationResolver' public: false tags: - - { name: 'api_platform.graphql.mutation_resolver' } + - { name: 'api_platform.graphql.resolver' } app.messenger_handler.messenger_with_inputs: class: 'ApiPlatform\Tests\Fixtures\TestBundle\MessengerHandler\Document\MessengerWithInputHandler' diff --git a/tests/Hydra/State/HydraLinkProcessorTest.php b/tests/Hydra/State/HydraLinkProcessorTest.php new file mode 100644 index 00000000000..658cd93d853 --- /dev/null +++ b/tests/Hydra/State/HydraLinkProcessorTest.php @@ -0,0 +1,48 @@ + + * + * 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\Tests\Hydra\State; + +use ApiPlatform\Hydra\State\HydraLinkProcessor; +use ApiPlatform\JsonLd\ContextBuilder; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\State\ProcessorInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\ParameterBag; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\WebLink\GenericLinkProvider; +use Symfony\Component\WebLink\Link; + +class HydraLinkProcessorTest extends TestCase +{ + public function testProcess(): void + { + $data = new \stdClass(); + $operation = new Get(links: [new Link('a', 'b')]); + $request = $this->createMock(Request::class); + $request->attributes = $this->createMock(ParameterBag::class); + + $request->attributes->expects($this->once())->method('set')->with('_links', $this->callback(function ($linkProvider) { + $this->assertInstanceOf(GenericLinkProvider::class, $linkProvider); + $this->assertEquals($linkProvider->getLinks(), [new Link('a', 'b'), new Link(ContextBuilder::HYDRA_NS.'apiDocumentation', '/docs')]); + + return true; + })); + $context = ['request' => $request]; + $decorated = $this->createMock(ProcessorInterface::class); + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->expects($this->once())->method('generate')->with('api_doc', ['_format' => 'jsonld'], UrlGeneratorInterface::ABS_URL)->willReturn('/docs'); + (new HydraLinkProcessor($decorated, $urlGenerator))->process($data, $operation, [], $context); + } +} diff --git a/tests/JsonApi/State/JsonApiProviderTest.php b/tests/JsonApi/State/JsonApiProviderTest.php new file mode 100644 index 00000000000..30e9106e584 --- /dev/null +++ b/tests/JsonApi/State/JsonApiProviderTest.php @@ -0,0 +1,40 @@ + + * + * 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\Tests\JsonApi\State; + +use ApiPlatform\JsonApi\State\JsonApiProvider; +use ApiPlatform\Metadata\Get; +use ApiPlatform\State\ProviderInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\ParameterBag; +use Symfony\Component\HttpFoundation\Request; + +class JsonApiProviderTest extends TestCase +{ + public function testProvide(): void + { + $request = $this->createMock(Request::class); + $request->expects($this->once())->method('getRequestFormat')->willReturn('jsonapi'); + $request->attributes = $this->createMock(ParameterBag::class); + $request->attributes->expects($this->once())->method('get')->with('_api_filters', [])->willReturn([]); + $request->attributes->method('set')->with($this->logicalOr('_api_filter_property', '_api_included', '_api_filters'), $this->logicalOr(['id', 'name', 'dummyFloat', 'relatedDummy' => ['id', 'name']], ['relatedDummy'], [])); + $request->query = $this->createMock(ParameterBag::class); // @phpstan-ignore-line + $request->query->method('all')->willReturn(['fields' => ['dummy' => 'id,name,dummyFloat', 'relatedDummy' => 'id,name'], 'include' => 'relatedDummy,foo']); + $operation = new Get(class: 'dummy', shortName: 'dummy'); + $context = ['request' => $request]; + $decorated = $this->createMock(ProviderInterface::class); + $provider = new JsonApiProvider($decorated); + $provider->provide($operation, [], $context); + } +} diff --git a/tests/JsonLd/Action/ContextActionTest.php b/tests/JsonLd/Action/ContextActionTest.php index 69d5d486eca..52683ad81b2 100644 --- a/tests/JsonLd/Action/ContextActionTest.php +++ b/tests/JsonLd/Action/ContextActionTest.php @@ -25,9 +25,14 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\ProviderInterface; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Serializer\SerializerInterface; /** * @author Amrouche Hamza @@ -70,17 +75,17 @@ public function testContextActionWithResourceClass(): void $resourceMetadataCollectionFactoryProphecy->create('dummy')->shouldBeCalled()->willReturn( new ResourceMetadataCollection('dummy', [ (new ApiResource()) - ->withShortName('dummy') - ->withDescription('dummy') - ->withTypes(['#dummy']) - ->withOperations(new Operations([ - 'get' => (new Get())->withShortName('dummy'), - 'put' => (new Put())->withShortName('dummy'), - 'get_collection' => (new GetCollection())->withShortName('dummy'), - 'post' => (new Post())->withShortName('dummy'), - 'custom' => (new Get())->withUriTemplate('/foo')->withShortName('dummy'), - 'custom2' => (new Post())->withUriTemplate('/foo')->withShortName('dummy'), - ])), + ->withShortName('dummy') + ->withDescription('dummy') + ->withTypes(['#dummy']) + ->withOperations(new Operations([ + 'get' => (new Get())->withShortName('dummy'), + 'put' => (new Put())->withShortName('dummy'), + 'get_collection' => (new GetCollection())->withShortName('dummy'), + 'post' => (new Post())->withShortName('dummy'), + 'custom' => (new Get())->withUriTemplate('/foo')->withShortName('dummy'), + 'custom2' => (new Post())->withUriTemplate('/foo')->withShortName('dummy'), + ])), ]) ); $this->assertEquals(['@context' => ['/dummies']], $contextAction('dummy')); @@ -114,4 +119,20 @@ public function testContextActionWithThrown(): void ); $contextAction('dummy'); } + + public function testWithProvider(): void + { + $c = ['@context' => []]; + $contextBuilder = $this->createMock(ContextBuilderInterface::class); + $resourceNameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $serializer = $this->createMock(SerializerInterface::class); + $serializer->expects($this->once())->method('serialize')->with($c, 'json')->willReturn('@'); + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->once())->method('provide')->willReturn($c); + $processor = $this->createMock(ProcessorInterface::class); + $processor->expects($this->once())->method('process')->willReturn(new JsonResponse('@')); + $contextAction = new ContextAction($contextBuilder, $resourceNameCollectionFactory, $resourceMetadataCollectionFactory, $provider, $processor, $serializer); + $contextAction->__invoke('dummy', $this->createMock(Request::class)); + } } diff --git a/tests/Symfony/Bundle/ApiPlatformBundleTest.php b/tests/Symfony/Bundle/ApiPlatformBundleTest.php index 1bbb909de5a..a2faa337fa9 100644 --- a/tests/Symfony/Bundle/ApiPlatformBundleTest.php +++ b/tests/Symfony/Bundle/ApiPlatformBundleTest.php @@ -21,6 +21,7 @@ use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\FilterPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlMutationResolverPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlQueryResolverPass; +use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlResolverPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlTypePass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\MetadataAwareNameConverterPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\TestClientPass; @@ -47,6 +48,7 @@ public function testBuild(): void $containerProphecy->addCompilerPass(Argument::type(GraphQlTypePass::class))->willReturn($containerProphecy->reveal())->shouldBeCalled(); $containerProphecy->addCompilerPass(Argument::type(GraphQlQueryResolverPass::class))->willReturn($containerProphecy->reveal())->shouldBeCalled(); $containerProphecy->addCompilerPass(Argument::type(GraphQlMutationResolverPass::class))->willReturn($containerProphecy->reveal())->shouldBeCalled(); + $containerProphecy->addCompilerPass(Argument::type(GraphQlResolverPass::class))->willReturn($containerProphecy->reveal())->shouldBeCalled(); $containerProphecy->addCompilerPass(Argument::type(MetadataAwareNameConverterPass::class))->willReturn($containerProphecy->reveal())->shouldBeCalled(); $containerProphecy->addCompilerPass(Argument::type(TestClientPass::class))->willReturn($containerProphecy->reveal())->shouldBeCalled(); $containerProphecy->addCompilerPass(Argument::type(AuthenticatorManagerPass::class))->willReturn($containerProphecy->reveal())->shouldBeCalled(); diff --git a/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index d7005213297..f1825f20000 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -14,11 +14,6 @@ namespace ApiPlatform\Tests\Symfony\Bundle\DependencyInjection; use ApiPlatform\Action\NotFoundAction; -use ApiPlatform\Api\FilterInterface; -use ApiPlatform\Api\IdentifiersExtractorInterface; -use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Api\ResourceClassResolverInterface; -use ApiPlatform\Api\UrlGeneratorInterface; use ApiPlatform\Doctrine\Common\State\PersistProcessor; use ApiPlatform\Doctrine\Common\State\RemoveProcessor; use ApiPlatform\Doctrine\Odm\Extension\AggregationCollectionExtensionInterface; @@ -44,9 +39,14 @@ use ApiPlatform\GraphQl\Type\Definition\TypeInterface as GraphQlTypeInterface; use ApiPlatform\JsonSchema\SchemaFactoryInterface; use ApiPlatform\JsonSchema\TypeFactoryInterface; +use ApiPlatform\Metadata\FilterInterface; +use ApiPlatform\Metadata\IdentifiersExtractorInterface; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface; use ApiPlatform\OpenApi\Options; use ApiPlatform\OpenApi\Serializer\OpenApiNormalizer; @@ -247,6 +247,12 @@ public function testCommonConfiguration(): void '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 = [ @@ -631,8 +637,7 @@ public function testGraphQlConfiguration(): void $services = [ // graphql.xml 'api_platform.graphql.executor', - 'api_platform.graphql.query_resolver_locator', - 'api_platform.graphql.mutation_resolver_locator', + 'api_platform.graphql.resolver_locator', 'api_platform.graphql.iterable_type', 'api_platform.graphql.upload_type', 'api_platform.graphql.type_locator', @@ -685,8 +690,7 @@ public function testGraphQlConfiguration(): void $this->assertContainerHas($services, $aliases); // graphql.xml - $this->assertServiceHasTags('api_platform.graphql.query_resolver_locator', ['container.service_locator']); - $this->assertServiceHasTags('api_platform.graphql.mutation_resolver_locator', ['container.service_locator']); + $this->assertServiceHasTags('api_platform.graphql.resolver_locator', ['container.service_locator']); $this->assertServiceHasTags('api_platform.graphql.iterable_type', ['api_platform.graphql.type']); $this->assertServiceHasTags('api_platform.graphql.upload_type', ['api_platform.graphql.type']); $this->assertServiceHasTags('api_platform.graphql.type_locator', ['container.service_locator']); @@ -1157,9 +1161,9 @@ public function testAutoConfigurableInterfaces(): void FilterInterface::class => 'api_platform.filter', ValidationGroupsGeneratorInterface::class => 'api_platform.validation_groups_generator', PropertySchemaRestrictionMetadataInterface::class => 'api_platform.metadata.property_schema_restriction', - QueryItemResolverInterface::class => 'api_platform.graphql.query_resolver', - QueryCollectionResolverInterface::class => 'api_platform.graphql.query_resolver', - MutationResolverInterface::class => 'api_platform.graphql.mutation_resolver', + QueryItemResolverInterface::class => 'api_platform.graphql.resolver', + QueryCollectionResolverInterface::class => 'api_platform.graphql.resolver', + MutationResolverInterface::class => 'api_platform.graphql.resolver', GraphQlTypeInterface::class => 'api_platform.graphql.type', ErrorHandlerInterface::class => 'api_platform.graphql.error_handler', QueryItemExtensionInterface::class => 'api_platform.doctrine.orm.query_extension.item', @@ -1248,9 +1252,6 @@ public function testHttpCacheBanConfiguration(): void $this->assertEquals('api_platform.http_cache.http_client', $service->getArgument(0)->getTag()); } - /** - * @doesNotPerformAssertions - */ public function testLegacyOpenApiApiKeysConfiguration(): void { $this->expectException(InvalidConfigurationException::class); diff --git a/tests/Symfony/Bundle/DependencyInjection/Compiler/GraphQlResolverPassTest.php b/tests/Symfony/Bundle/DependencyInjection/Compiler/GraphQlResolverPassTest.php new file mode 100644 index 00000000000..e98d12adb83 --- /dev/null +++ b/tests/Symfony/Bundle/DependencyInjection/Compiler/GraphQlResolverPassTest.php @@ -0,0 +1,80 @@ + + * + * 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\Tests\Symfony\Bundle\DependencyInjection\Compiler; + +use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\GraphQlResolverPass; +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; + +/** + * @author Alan Poulain + */ +class GraphQlResolverPassTest extends TestCase +{ + use ExpectDeprecationTrait; + + public function testProcess(): void + { + $filterPass = new GraphQlResolverPass(); + + $this->assertInstanceOf(CompilerPassInterface::class, $filterPass); + + $typeLocatorDefinition = $this->createMock(Definition::class); + $typeLocatorDefinition->expects($this->once())->method('addArgument')->with($this->callback(function () { + return true; + })); + + $containerBuilder = $this->createMock(ContainerBuilder::class); + $containerBuilder->expects($this->once())->method('getParameter')->with('api_platform.graphql.enabled')->willReturn(true); + $containerBuilder->method('findTaggedServiceIds')->willReturnOnConsecutiveCalls( + [], + [], + ['foo' => [], 'bar' => [['id' => 'bar']]] + ); + $containerBuilder->method('getDefinition')->with('api_platform.graphql.resolver_locator')->willReturn($typeLocatorDefinition); + + $filterPass->process($containerBuilder); + } + + /** + * @group legacy + */ + public function testProcessDeprecated(): void + { + $this->expectDeprecation('Since api-platform/core 3.2: The tag "api_platform.graphql.query_resolver" is deprecated use "api_platform.graphql.resolver" instead.'); + $this->expectDeprecation('Since api-platform/core 3.2: The tag "api_platform.graphql.mutation_resolver" is deprecated use "api_platform.graphql.resolver" instead.'); + $filterPass = new GraphQlResolverPass(); + + $this->assertInstanceOf(CompilerPassInterface::class, $filterPass); + + $typeLocatorDefinition = $this->createMock(Definition::class); + $typeLocatorDefinition->expects($this->once())->method('addArgument')->with($this->callback(function () { + return true; + })); + + $containerBuilder = $this->createMock(ContainerBuilder::class); + $containerBuilder->expects($this->once())->method('getParameter')->with('api_platform.graphql.enabled')->willReturn(true); + $containerBuilder->method('findTaggedServiceIds')->willReturnOnConsecutiveCalls( + ['a' => []], + ['b' => []], + [] + ); + $containerBuilder->method('getDefinition')->with('api_platform.graphql.resolver_locator')->willReturn($typeLocatorDefinition); + + $filterPass->process($containerBuilder); + } +} diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index fdddafac6d4..ab63523e693 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -221,6 +221,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'enabled' => true, ], 'keep_legacy_inflector' => true, + 'event_listeners_backward_compatibility_layer' => true, ], $config); } diff --git a/tests/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php b/tests/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php index b7d3751f87c..781e440dddb 100644 --- a/tests/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php +++ b/tests/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php @@ -97,6 +97,8 @@ public static function getInvokeParameters(): iterable 'graphiQlEnabled' => false, 'graphQlPlaygroundEnabled' => false, 'assetPackage' => null, + 'originalRoute' => null, + 'originalRouteParams' => [], 'swagger_data' => [ 'url' => '/url', 'spec' => self::SPEC, @@ -137,6 +139,8 @@ public static function getInvokeParameters(): iterable 'graphiQlEnabled' => false, 'graphQlPlaygroundEnabled' => false, 'assetPackage' => null, + 'originalRoute' => null, + 'originalRouteParams' => [], 'swagger_data' => [ 'url' => '/url', 'spec' => self::SPEC, @@ -196,6 +200,8 @@ public function testDoNotRunCurrentRequest(Request $request): void 'graphiQlEnabled' => false, 'graphQlPlaygroundEnabled' => false, 'assetPackage' => null, + 'originalRoute' => null, + 'originalRouteParams' => [], 'swagger_data' => [ 'url' => '/url', 'spec' => self::SPEC, diff --git a/tests/Symfony/EventListener/ErrorListenerTest.php b/tests/Symfony/EventListener/ErrorListenerTest.php index 30b2d03a331..d11b9cd8367 100644 --- a/tests/Symfony/EventListener/ErrorListenerTest.php +++ b/tests/Symfony/EventListener/ErrorListenerTest.php @@ -44,13 +44,12 @@ public function testDuplicateException(): void $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolver->isResourceClass($exception::class)->willReturn(false); $kernel = $this->prophesize(KernelInterface::class); - $kernel->handle(Argument::that(function ($request) use ($operation) { - $this->assertTrue($request->attributes->has('_api_exception_swagger_data')); + $kernel->handle(Argument::that(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')); - $this->assertTrue($request->attributes->get('_api_error')); - $this->assertEquals($request->attributes->get('_api_operation'), $operation); - $this->assertEquals($request->attributes->get('_api_resource_class'), Error::class); - $this->assertInstanceOf(Error::class, $request->attributes->get('data')); return true; }), HttpKernelInterface::SUB_REQUEST, false)->willReturn(new Response()); @@ -69,13 +68,12 @@ public function testDuplicateExceptionWithHydra(): void $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolver->isResourceClass($exception::class)->willReturn(false); $kernel = $this->prophesize(KernelInterface::class); - $kernel->handle(Argument::that(function ($request) use ($operation) { - $this->assertTrue($request->attributes->has('_api_exception_swagger_data')); + $kernel->handle(Argument::that(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')); - $this->assertTrue($request->attributes->get('_api_error')); - $this->assertEquals($request->attributes->get('_api_operation'), $operation); - $this->assertEquals($request->attributes->get('_api_resource_class'), Error::class); - $this->assertInstanceOf(Error::class, $request->attributes->get('data')); return true; }), HttpKernelInterface::SUB_REQUEST, false)->willReturn(new Response()); @@ -94,20 +92,19 @@ public function testDuplicateExceptionWithErrorResource(): void $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolver->isResourceClass(Error::class)->willReturn(true); $kernel = $this->prophesize(KernelInterface::class); - $kernel->handle(Argument::that(function ($request) use ($operation) { - $this->assertTrue($request->attributes->has('_api_exception_swagger_data')); + $kernel->handle(Argument::that(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')); - $this->assertTrue($request->attributes->get('_api_error')); - $this->assertEquals($request->attributes->get('_api_operation'), $operation); - $this->assertEquals($request->attributes->get('_api_resource_class'), Error::class); $this->assertEquals($request->attributes->get('id'), 1); - $this->assertInstanceOf(Error::class, $request->attributes->get('data')); 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, $operation)->willReturn(['id' => 1]); + $identifiersExtractor->getIdentifiersFromItem($exception, Argument::any())->willReturn(['id' => 1]); $errorListener = new ErrorListener('action', null, true, [], $resourceMetadataCollectionFactory->reveal(), ['jsonld' => ['application/ld+json']], [], $identifiersExtractor->reveal(), $resourceClassResolver->reveal()); $errorListener->onKernelException($exceptionEvent); } diff --git a/tests/Symfony/Security/State/AccessCheckerProviderTest.php b/tests/Symfony/Security/State/AccessCheckerProviderTest.php new file mode 100644 index 00000000000..29983767679 --- /dev/null +++ b/tests/Symfony/Security/State/AccessCheckerProviderTest.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\Tests\Symfony\Security\State; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Symfony\Security\Exception\AccessDeniedException; +use ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface; +use ApiPlatform\Symfony\Security\State\AccessCheckerProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; + +class AccessCheckerProviderTest extends TestCase +{ + public function testCheckAccess(): void + { + $obj = new \stdClass(); + $operation = new Get(class: 'foo', security: 'hi'); + $decorated = $this->createMock(ProviderInterface::class); + $decorated->method('provide')->willReturn($obj); + $resourceAccessChecker = $this->createMock(ResourceAccessCheckerInterface::class); + $resourceAccessChecker->expects($this->once())->method('isGranted')->with('foo', 'hi', ['object' => $obj, 'previous_object' => null, 'request' => null])->willReturn(true); + $accessChecker = new AccessCheckerProvider($decorated, $resourceAccessChecker); + $accessChecker->provide($operation, [], []); + } + + public function testCheckAccessWithEvent(): void + { + $obj = new \stdClass(); + $operation = new Get(class: 'foo', securityPostDenormalize: 'hi'); + $decorated = $this->createMock(ProviderInterface::class); + $decorated->method('provide')->willReturn($obj); + $resourceAccessChecker = $this->createMock(ResourceAccessCheckerInterface::class); + $resourceAccessChecker->expects($this->once())->method('isGranted')->with('foo', 'hi', ['object' => $obj, 'previous_object' => null, 'request' => null])->willReturn(true); + $accessChecker = new AccessCheckerProvider($decorated, $resourceAccessChecker, 'post_denormalize'); + $accessChecker->provide($operation, [], []); + } + + public function testCheckAccessWithEventPostValidate(): void + { + $obj = new \stdClass(); + $operation = new Get(class: 'foo', securityPostValidation: 'hi'); + $decorated = $this->createMock(ProviderInterface::class); + $decorated->method('provide')->willReturn($obj); + $resourceAccessChecker = $this->createMock(ResourceAccessCheckerInterface::class); + $resourceAccessChecker->expects($this->once())->method('isGranted')->with('foo', 'hi', ['object' => $obj, 'previous_object' => null, 'request' => null])->willReturn(true); + $accessChecker = new AccessCheckerProvider($decorated, $resourceAccessChecker, 'post_validate'); + $accessChecker->provide($operation, [], []); + } + + public function testCheckAccessDenied(): void + { + $this->expectException(AccessDeniedException::class); + $this->expectExceptionMessage('hello'); + + $obj = new \stdClass(); + $operation = new Get(class: 'foo', security: 'hi', securityMessage: 'hello'); + $decorated = $this->createMock(ProviderInterface::class); + $decorated->method('provide')->willReturn($obj); + $resourceAccessChecker = $this->createMock(ResourceAccessCheckerInterface::class); + $resourceAccessChecker->expects($this->once())->method('isGranted')->with('foo', 'hi', ['object' => $obj, 'previous_object' => null, 'request' => null])->willReturn(false); + $accessChecker = new AccessCheckerProvider($decorated, $resourceAccessChecker); + $accessChecker->provide($operation, [], []); + } + + public function testCheckAccessDeniedWithGraphQl(): void + { + $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('hello'); + + $obj = new \stdClass(); + $operation = new Query(class: 'foo', security: 'hi', securityMessage: 'hello'); + $decorated = $this->createMock(ProviderInterface::class); + $decorated->method('provide')->willReturn($obj); + $resourceAccessChecker = $this->createMock(ResourceAccessCheckerInterface::class); + $resourceAccessChecker->expects($this->once())->method('isGranted')->with('foo', 'hi', ['object' => $obj, 'previous_object' => null])->willReturn(false); + $accessChecker = new AccessCheckerProvider($decorated, $resourceAccessChecker); + $accessChecker->provide($operation, [], []); + } +} diff --git a/tests/Symfony/State/MercureLinkProcessorTest.php b/tests/Symfony/State/MercureLinkProcessorTest.php new file mode 100644 index 00000000000..3f4cdfde33a --- /dev/null +++ b/tests/Symfony/State/MercureLinkProcessorTest.php @@ -0,0 +1,40 @@ + + * + * 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\Tests\Symfony\State; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\Symfony\State\MercureLinkProcessor; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\ParameterBag; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Mercure\Discovery; +use Symfony\Component\Mercure\HubInterface; +use Symfony\Component\Mercure\HubRegistry; + +class MercureLinkProcessorTest extends TestCase +{ + public function testProcess(): void + { + $obj = new \stdClass(); + $request = $this->createMock(Request::class); + $request->attributes = $this->createMock(ParameterBag::class); + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once())->method('process'); + $discovery = new Discovery(new HubRegistry($this->createMock(HubInterface::class), ['example.com' => $this->createMock(HubInterface::class)])); + $operation = new Get(mercure: ['hub' => 'example.com']); + $processor = new MercureLinkProcessor($decorated, $discovery); + $processor->process($obj, $operation, [], ['request' => $request]); + } +} diff --git a/tests/Symfony/Validator/State/QueryParameterValidateProviderTest.php b/tests/Symfony/Validator/State/QueryParameterValidateProviderTest.php new file mode 100644 index 00000000000..b86fee95e83 --- /dev/null +++ b/tests/Symfony/Validator/State/QueryParameterValidateProviderTest.php @@ -0,0 +1,44 @@ + + * + * 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\Tests\Symfony\Validator\State; + +use ApiPlatform\Api\QueryParameterValidator\QueryParameterValidator; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Symfony\Validator\State\QueryParameterValidateProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\ServerBag; + +class QueryParameterValidateProviderTest extends TestCase +{ + public function testValidate(): void + { + $filters = ['test']; + $operation = new GetCollection(filters: $filters, class: 'foo'); + $request = $this->createMock(Request::class); + $request->server = $this->createMock(ServerBag::class); + $request->server->method('get')->with('QUERY_STRING')->willReturn('foo=bar'); + $request->method('isMethodSafe')->willReturn(true); + $request->method('getMethod')->willReturn('GET'); + $context = ['request' => $request]; + $obj = new \stdClass(); + $decorated = $this->createMock(ProviderInterface::class); + $decorated->method('provide')->willReturn($obj); + $validator = $this->createMock(QueryParameterValidator::class); + $validator->expects($this->once())->method('validateFilters')->with('foo', $filters, ['foo' => 'bar']); + $provider = new QueryParameterValidateProvider($decorated, $validator); + $provider->provide($operation, [], $context); + } +} diff --git a/tests/Symfony/Validator/State/ValidateProviderTest.php b/tests/Symfony/Validator/State/ValidateProviderTest.php new file mode 100644 index 00000000000..64004323766 --- /dev/null +++ b/tests/Symfony/Validator/State/ValidateProviderTest.php @@ -0,0 +1,46 @@ + + * + * 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\Tests\Symfony\Validator\State; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Symfony\Validator\State\ValidateProvider; +use ApiPlatform\Validator\ValidatorInterface; +use PHPUnit\Framework\TestCase; + +class ValidateProviderTest extends TestCase +{ + public function testValidate(): void + { + $obj = new \stdClass(); + $decorated = $this->createMock(ProviderInterface::class); + $decorated->method('provide')->willReturn($obj); + $validationContext = ['test']; + $validator = $this->createMock(ValidatorInterface::class); + $validator->expects($this->once())->method('validate')->with($obj, $validationContext); + $provider = new ValidateProvider($decorated, $validator); + $provider->provide(new Get(validationContext: $validationContext)); + } + + public function testNoValidate(): void + { + $obj = new \stdClass(); + $decorated = $this->createMock(ProviderInterface::class); + $decorated->method('provide')->willReturn($obj); + $validator = $this->createMock(ValidatorInterface::class); + $validator->expects($this->never())->method('validate'); + $provider = new ValidateProvider($decorated, $validator); + $provider->provide(new Get(validate: false)); + } +}