diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3653ca79ab1..44489755742 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,8 @@ jobs: key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} restore-keys: ${{ runner.os }}-composer- - name: Update project dependencies - run: composer update --no-interaction --no-progress --ansi + run: | + composer update --no-interaction --no-progress --ansi - name: Install PHPUnit run: vendor/bin/simple-phpunit --version - name: Cache PHPStan results @@ -456,6 +457,7 @@ jobs: - name: Update project dependencies run: | composer update --no-interaction --no-progress --ansi + composer require --dev doctrine/mongodb-odm-bundle - name: Install PHPUnit run: vendor/bin/simple-phpunit --version - name: Clear test app cache diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a52f3385b7..b4c293727c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## v3.2.7 + +Symfony 7 support. + +### Bug fixes + +* [183b4d637](https://github.com/api-platform/core/commit/183b4d6374a66ffaf33b3341b757a832d5a39799) fix(symfony): named arguments dependency injection +* [3d32d5e12](https://github.com/api-platform/core/commit/3d32d5e12b1d93be72064e12979402487aa3e49a) fix(openapi): entrypoint access vnd+openapi (#6012) +* [58f4a3dda](https://github.com/api-platform/core/commit/58f4a3dda820a0b61c7361f76a789f1560d8f8ab) fix: no boolean types for exclusive minimum and exclusive maximum open api (#5993) +* [5e8f5eb99](https://github.com/api-platform/core/commit/5e8f5eb99152a8914b725ffe3f4beea72ce6e5b6) fix(graphql): consider writable flag also for nested input types (#5954) +* [9848bd4d4](https://github.com/api-platform/core/commit/9848bd4d4917a97000119ee98a09916af469acd8) fix: missing eager joins on to-one relationships (#5992) +* [aa44dd726](https://github.com/api-platform/core/commit/aa44dd7264e6264ec3ec569f9f4be081927a67cb) fix(openapi): max cardinality +* [c2be40994](https://github.com/api-platform/core/commit/c2be40994ec08b51bf23b4b807eb3d4f984379ff) fix(symfony): error in provider without uri variables (#6005) +* [d2f281eed](https://github.com/api-platform/core/commit/d2f281eedbd87a3c1a3377bb23a229e1b17a0f45) fix(jsonschema): fix recursive documentation when using a dto entity wrapper (#5973) +* [e7bc2ab57](https://github.com/api-platform/core/commit/e7bc2ab5770fe673093596bc217516be61d582fc) fix(jsonschema): indirect resource input schema (#6001) + +## v3.2.6 + +### Bug fixes + +To have errors backward compatible with 3.1, use: + +```yaml +api_platform: + defaults: + extra_properties: + rfc_7807_compliant_errors: false +``` + +New extension points are available using [Errors](https://api-platform.com/docs/v3.2/core/errors/) with `rfc_7807_compliant_errors: true` such as [Error provider](https://api-platform.com/docs/v3.2/guides/error-provider/) and [Error Resource](https://api-platform.com/docs/v3.2/guides/error-resource/) + +* [1b4289412](https://github.com/api-platform/core/commit/1b42894128545ad72b19b6be1c31ad25351c9138) fix: errors bc with rfc_7807_compliant_errors false (#5974) +* [ce297e6f7](https://github.com/api-platform/core/commit/ce297e6f73e1797ede21312aa31af2b110e9e583) fix(jsonschema): child entity property schema generation (#5988) (#5989) + ## v3.2.5 ### Bug fixes @@ -1911,4 +1945,4 @@ Please read #2825 if you have issues with the behavior of Readable/Writable Link ## 1.0.0 beta 2 * Preserve indexes when normalizing and denormalizing associative arrays -* Allow setting default order for property when registering a `Doctrine\Orm\Filter\OrderFilter` instance \ No newline at end of file +* Allow setting default order for property when registering a `Doctrine\Orm\Filter\OrderFilter` instance diff --git a/composer.json b/composer.json index 3f2b79f49c2..074eee2bf1c 100644 --- a/composer.json +++ b/composer.json @@ -18,13 +18,13 @@ "psr/cache": "^1.0 || ^2.0 || ^3.0", "psr/container": "^1.0 || ^2.0", "symfony/deprecation-contracts": "^3.1", - "symfony/http-foundation": "^6.1", - "symfony/http-kernel": "^6.1", - "symfony/property-access": "^6.1", - "symfony/property-info": "^6.1", - "symfony/serializer": "^6.1", + "symfony/http-foundation": "^6.1 || ^7.0", + "symfony/http-kernel": "^6.1 || ^7.0", + "symfony/property-access": "^6.1 || ^7.0", + "symfony/property-info": "^6.1 || ^7.0", + "symfony/serializer": "^6.1 || ^7.0", "symfony/translation-contracts": "^3.3", - "symfony/web-link": "^6.1", + "symfony/web-link": "^6.1 || ^7.0", "willdurand/negotiation": "^3.0" }, "require-dev": { @@ -35,7 +35,6 @@ "doctrine/dbal": "^3.4.0", "doctrine/doctrine-bundle": "^1.12 || ^2.0", "doctrine/mongodb-odm": "^2.2", - "doctrine/mongodb-odm-bundle": "^4.0", "doctrine/orm": "^2.14", "elasticsearch/elasticsearch": "^7.11 || ^8.4", "friends-of-behat/mink-browserkit-driver": "^1.3.1", @@ -56,36 +55,36 @@ "ramsey/uuid-doctrine": "^1.4 || ^2.0", "soyuka/contexts": "v3.3.9", "soyuka/stubs-mongodb": "^1.0", - "symfony/asset": "^6.1", - "symfony/browser-kit": "^6.1", - "symfony/cache": "^6.1", - "symfony/config": "^6.1", - "symfony/console": "^6.1", - "symfony/css-selector": "^6.1", - "symfony/dependency-injection": "^6.1.12", - "symfony/doctrine-bridge": "^6.1", - "symfony/dom-crawler": "^6.1", - "symfony/error-handler": "^6.1", - "symfony/event-dispatcher": "^6.1", - "symfony/expression-language": "^6.1", - "symfony/finder": "^6.1", - "symfony/form": "^6.1", - "symfony/framework-bundle": "^6.1", - "symfony/http-client": "^6.1", - "symfony/intl": "^6.1", + "symfony/asset": "^6.1 || ^7.0", + "symfony/browser-kit": "^6.1 || ^7.0", + "symfony/cache": "^6.1 || ^7.0", + "symfony/config": "^6.1 || ^7.0", + "symfony/console": "^6.1 || ^7.0", + "symfony/css-selector": "^6.1 || ^7.0", + "symfony/dependency-injection": "^6.1 || ^7.0.12", + "symfony/doctrine-bridge": "^6.1 || ^7.0", + "symfony/dom-crawler": "^6.1 || ^7.0", + "symfony/error-handler": "^6.1 || ^7.0", + "symfony/event-dispatcher": "^6.1 || ^7.0", + "symfony/expression-language": "^6.1 || ^7.0", + "symfony/finder": "^6.1 || ^7.0", + "symfony/form": "^6.1 || ^7.0", + "symfony/framework-bundle": "^6.1 || ^7.0", + "symfony/http-client": "^6.1 || ^7.0", + "symfony/intl": "^6.1 || ^7.0", "symfony/maker-bundle": "^1.24", "symfony/mercure-bundle": "*", - "symfony/messenger": "^6.1", - "symfony/phpunit-bridge": "^6.1", - "symfony/routing": "^6.1", - "symfony/security-bundle": "^6.1", - "symfony/security-core": "^6.1", - "symfony/stopwatch": "^6.1", - "symfony/twig-bundle": "^6.1", - "symfony/uid": "^6.1", - "symfony/validator": "^6.1", - "symfony/web-profiler-bundle": "^6.1", - "symfony/yaml": "^6.1", + "symfony/messenger": "^6.1 || ^7.0", + "symfony/phpunit-bridge": "^6.1 || ^7.0", + "symfony/routing": "^6.1 || ^7.0", + "symfony/security-bundle": "^6.1 || ^7.0", + "symfony/security-core": "^6.1 || ^7.0", + "symfony/stopwatch": "^6.1 || ^7.0", + "symfony/twig-bundle": "^6.1 || ^7.0", + "symfony/uid": "^6.1 || ^7.0", + "symfony/validator": "^6.1 || ^7.0", + "symfony/web-profiler-bundle": "^6.1 || ^7.0", + "symfony/yaml": "^6.1 || ^7.0", "twig/twig": "^1.42.3 || ^2.12 || ^3.0", "webonyx/graphql-php": "^14.0 || ^15.0" }, @@ -145,7 +144,7 @@ "dev-main": "3.3.x-dev" }, "symfony": { - "require": "^6.1" + "require": "^6.1 || ^7.0" } } } diff --git a/docs/guides/declare-a-resource.php b/docs/guides/declare-a-resource.php index c741ea06bae..e562a22759c 100644 --- a/docs/guides/declare-a-resource.php +++ b/docs/guides/declare-a-resource.php @@ -7,7 +7,6 @@ // tags: design // --- -// # Declare a Resource // This class represents an API resource namespace App\ApiResource { diff --git a/docs/guides/doctrine-entity-as-resource.php b/docs/guides/doctrine-entity-as-resource.php index 343b57f922c..b6d2a3b88e4 100644 --- a/docs/guides/doctrine-entity-as-resource.php +++ b/docs/guides/doctrine-entity-as-resource.php @@ -6,8 +6,6 @@ // tags: doctrine // executable: true // --- - -// # API Resource on a Doctrine Entity. // // API Platform is compatible with [Doctrine ORM](https://www.doctrine-project.org), all we need is to declare an diff --git a/docs/guides/extend-openapi-documentation.php b/docs/guides/extend-openapi-documentation.php index cd1ddbd4b9a..0f7c0a62d68 100644 --- a/docs/guides/extend-openapi-documentation.php +++ b/docs/guides/extend-openapi-documentation.php @@ -7,7 +7,6 @@ // tags: openapi, expert // --- -// # Extend OpenAPI Documentation namespace App\ApiResource { use ApiPlatform\Metadata\Post; use ApiPlatform\OpenApi\Model\Operation; diff --git a/docs/guides/handle-links.php b/docs/guides/handle-links.php index a034fc63b2b..abd2491f0d1 100644 --- a/docs/guides/handle-links.php +++ b/docs/guides/handle-links.php @@ -7,8 +7,6 @@ // executable: true // --- -// # Handle links -// // When using subresources with doctrine, API Platform tries to handle your links, // and the algorithm sometimes overcomplicates SQL queries. diff --git a/docs/guides/hook-a-persistence-layer-with-a-processor.php b/docs/guides/hook-a-persistence-layer-with-a-processor.php index 0c689b56188..28d2ecde733 100644 --- a/docs/guides/hook-a-persistence-layer-with-a-processor.php +++ b/docs/guides/hook-a-persistence-layer-with-a-processor.php @@ -7,8 +7,6 @@ // tags: design // --- -// # Hook a Persistence Layer with a Processor - namespace App\ApiResource { use ApiPlatform\Metadata\ApiResource; use App\State\BookProcessor; diff --git a/docs/guides/provide-the-resource-state.php b/docs/guides/provide-the-resource-state.php index 675fb3c5c7e..5b0d21df27b 100644 --- a/docs/guides/provide-the-resource-state.php +++ b/docs/guides/provide-the-resource-state.php @@ -7,7 +7,6 @@ // tags: design, state // --- -// # Provide the Resource State // Our model is the same then in the previous guide ([Declare a Resource](/playground/declare-a-resource). API Platform will declare // CRUD operations if we don't declare them. diff --git a/docs/guides/subresource.php b/docs/guides/subresource.php index 0bf80024b6c..608207d5165 100644 --- a/docs/guides/subresource.php +++ b/docs/guides/subresource.php @@ -7,8 +7,6 @@ // executable: true // --- -// # Subresource -// // In API Platform, a subresource is an alternate way to reach a Resource. namespace App\Entity { diff --git a/docs/guides/validate-incoming-data.php b/docs/guides/validate-incoming-data.php index ac1786445c4..2b8bbed03b2 100644 --- a/docs/guides/validate-incoming-data.php +++ b/docs/guides/validate-incoming-data.php @@ -7,7 +7,6 @@ // tags: validation // --- -// # Validing incoming data // When processing the incoming request, when creating or updating content, API-Platform will validate the // incoming content. It will use the [Symfony's validator](https://symfony.com/doc/current/validation.html). // diff --git a/features/graphql/mutation.feature b/features/graphql/mutation.feature index d537784e9f8..63f46bb7096 100644 --- a/features/graphql/mutation.feature +++ b/features/graphql/mutation.feature @@ -662,6 +662,79 @@ Feature: GraphQL mutation support And the JSON node "data.updateDummy.dummy.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy11" And the JSON node "data.updateDummy.clientMutationId" should be equal to "myId" + @createSchema + @!mongodb + Scenario: Modify an item with embedded object through a mutation + Given there is a fooDummy objects with fake names and embeddable + When I send the following GraphQL request: + """ + mutation { + updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", embeddedFoo: {dummyName: "Embedded name"}, clientMutationId: "myId"}) { + fooDummy { + id + name + embeddedFoo { + dummyName + } + } + clientMutationId + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.updateFooDummy.fooDummy.name" should be equal to "modifiedName" + And the JSON node "data.updateFooDummy.fooDummy.embeddedFoo.dummyName" should be equal to "Embedded name" + And the JSON node "data.updateFooDummy.clientMutationId" should be equal to "myId" + + @createSchema + Scenario: Try to modify a non writable property through a mutation + Given there is a fooDummy objects with fake names and embeddable + When I send the following GraphQL request: + """ + mutation { + updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", nonWritableProp: "written", embeddedFoo: {dummyName: "Embedded name"}, clientMutationId: "myId"}) { + fooDummy { + id + name + embeddedFoo { + dummyName + } + } + clientMutationId + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "errors[0].message" should match '/^Field "nonWritableProp" is not defined by type "?updateFooDummyInput"?\.$/' + + @createSchema + @!mongodb + Scenario: Try to modify a non writable embedded property through a mutation + Given there is a fooDummy objects with fake names and embeddable + When I send the following GraphQL request: + """ + mutation { + updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", embeddedFoo: {dummyName: "Embedded name", nonWritableProp: "written"}, clientMutationId: "myId"}) { + fooDummy { + id + name + embeddedFoo { + dummyName + } + } + clientMutationId + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "errors[0].message" should match '/^Field "nonWritableProp" is not defined by type "?FooEmbeddableNestedInput"?\.$/' + @!mongodb Scenario: Modify an item with composite identifiers through a mutation Given there are Composite identifier objects diff --git a/features/json/relation.feature b/features/json/relation.feature index 0991407a8d1..3455d9d4123 100644 --- a/features/json/relation.feature +++ b/features/json/relation.feature @@ -25,7 +25,8 @@ Feature: JSON relations support "badFourthLevel": null, "id": 1, "level": 3, - "test": true + "test": true, + "relatedDummies": [] } """ diff --git a/features/jsonapi/related-resouces-inclusion.feature b/features/jsonapi/related-resouces-inclusion.feature index a7d07dbf3b5..17f89f2f496 100644 --- a/features/jsonapi/related-resouces-inclusion.feature +++ b/features/jsonapi/related-resouces-inclusion.feature @@ -483,6 +483,18 @@ Feature: JSON API Inclusion of Related Resources "type": "FourthLevel", "id": "/fourth_levels/1" } + }, + "relatedDummies": { + "data": [ + { + "type": "RelatedDummy", + "id": "/related_dummies/1" + }, + { + "type": "RelatedDummy", + "id": "/related_dummies/2" + } + ] } } }, @@ -581,6 +593,16 @@ Feature: JSON API Inclusion of Related Resources "_id": 1, "level": 3, "test": true + }, + "relationships": { + "relatedDummies": { + "data": [ + { + "type": "RelatedDummy", + "id": "/related_dummies/1" + } + ] + } } }, { @@ -618,6 +640,16 @@ Feature: JSON API Inclusion of Related Resources "_id": 2, "level": 3, "test": true + }, + "relationships": { + "relatedDummies": { + "data": [ + { + "type": "RelatedDummy", + "id": "/related_dummies/2" + } + ] + } } }, { @@ -655,6 +687,16 @@ Feature: JSON API Inclusion of Related Resources "_id": 3, "level": 3, "test": true + }, + "relationships": { + "relatedDummies": { + "data": [ + { + "type": "RelatedDummy", + "id": "/related_dummies/3" + } + ] + } } } ] @@ -802,6 +844,24 @@ Feature: JSON API Inclusion of Related Resources "_id": 1, "level": 3, "test": true + }, + "relationships": { + "relatedDummies": { + "data": [ + { + "type": "RelatedDummy", + "id": "/related_dummies/1" + }, + { + "type": "RelatedDummy", + "id": "/related_dummies/2" + }, + { + "type": "RelatedDummy", + "id": "/related_dummies/3" + } + ] + } } }, { @@ -1286,6 +1346,16 @@ Feature: JSON API Inclusion of Related Resources "_id": 1, "level": 3, "test": true + }, + "relationships": { + "relatedDummies": { + "data": [ + { + "type": "RelatedDummy", + "id": "/related_dummies/1" + } + ] + } } }, { @@ -1323,6 +1393,16 @@ Feature: JSON API Inclusion of Related Resources "_id": 2, "level": 3, "test": true + }, + "relationships": { + "relatedDummies": { + "data": [ + { + "type": "RelatedDummy", + "id": "/related_dummies/2" + } + ] + } } }, { @@ -1360,6 +1440,16 @@ Feature: JSON API Inclusion of Related Resources "_id": 3, "level": 3, "test": true + }, + "relationships": { + "relatedDummies": { + "data": [ + { + "type": "RelatedDummy", + "id": "/related_dummies/3" + } + ] + } } } ] diff --git a/features/main/default_order.feature b/features/main/default_order.feature index 7458c2456e9..ad05e08a835 100644 --- a/features/main/default_order.feature +++ b/features/main/default_order.feature @@ -79,6 +79,8 @@ Feature: Default order "@type": "FooDummy", "id": 5, "name": "Balbo", + "nonWritableProp": "readonly", + "embeddedFoo": null, "dummy": "/dummies/5", "soManies": [ "/so_manies/13", @@ -92,6 +94,8 @@ Feature: Default order "@type": "FooDummy", "id": 3, "name": "Sthenelus", + "nonWritableProp": "readonly", + "embeddedFoo": null, "dummy": "/dummies/3", "soManies": [ "/so_manies/7", @@ -104,6 +108,8 @@ Feature: Default order "@type": "FooDummy", "id": 2, "name": "Ephesian", + "nonWritableProp": "readonly", + "embeddedFoo": null, "dummy": "/dummies/2", "soManies": [ "/so_manies/4", @@ -116,6 +122,8 @@ Feature: Default order "@type": "FooDummy", "id": 1, "name": "Hawsepipe", + "nonWritableProp": "readonly", + "embeddedFoo": null, "dummy": "/dummies/1", "soManies": [ "/so_manies/1", @@ -128,6 +136,8 @@ Feature: Default order "@type": "FooDummy", "id": 4, "name": "Separativeness", + "nonWritableProp": "readonly", + "embeddedFoo": null, "dummy": "/dummies/4", "soManies": [ "/so_manies/10", diff --git a/features/main/relation.feature b/features/main/relation.feature index d406f1d3788..07d5050cda6 100644 --- a/features/main/relation.feature +++ b/features/main/relation.feature @@ -23,7 +23,8 @@ Feature: Relations support "badFourthLevel": null, "id": 1, "level": 3, - "test": true + "test": true, + "relatedDummies": [] } """ diff --git a/features/main/sub_resource.feature b/features/main/sub_resource.feature index 597bbb03214..1a8b9e14ad1 100644 --- a/features/main/sub_resource.feature +++ b/features/main/sub_resource.feature @@ -376,7 +376,11 @@ Feature: Sub-resource support "badFourthLevel": null, "id": 1, "level": 3, - "test": true + "test": true, + "relatedDummies": [ + "/related_dummies/1", + "/related_dummies/2" + ] } """ diff --git a/features/openapi/docs.feature b/features/openapi/docs.feature index 18f09998c8b..25407127dfc 100644 --- a/features/openapi/docs.feature +++ b/features/openapi/docs.feature @@ -368,3 +368,27 @@ Feature: Documentation support And I send a "GET" request to "/" Then the response status code should be 200 And the header "Content-Type" should be equal to "text/html; charset=utf-8" + + @!mongodb + Scenario: Retrieve the OpenAPI documentation for Entity Dto Wrappers + Given I send a "GET" request to "/docs.json" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json; charset=utf-8" + And the OpenAPI class "WrappedResponseEntity-read" exists + And the "id" property exists for the OpenAPI class "WrappedResponseEntity-read" + And the "id" property for the OpenAPI class "WrappedResponseEntity-read" should be equal to: + """ + { + "type": "string" + } + """ + And the OpenAPI class "WrappedResponseEntity.CustomOutputEntityWrapperDto-read" exists + And the "data" property exists for the OpenAPI class "WrappedResponseEntity.CustomOutputEntityWrapperDto-read" + And the "data" property for the OpenAPI class "WrappedResponseEntity.CustomOutputEntityWrapperDto-read" should be equal to: + """ + { + "owl:maxCardinality": 1, + "$ref": "#\/components\/schemas\/WrappedResponseEntity-read" + } + """ diff --git a/features/openapi/entrypoint.feature b/features/openapi/entrypoint.feature new file mode 100644 index 00000000000..da1e1ae46ae --- /dev/null +++ b/features/openapi/entrypoint.feature @@ -0,0 +1,19 @@ +Feature: Entrypoint support + In order to build an auto-discoverable API + As a client software developer + I need to access to an entrypoint listing top-level resources + + Scenario: Retrieve the Entrypoint + When I add "Accept" header equal to "application/vnd.openapi+json" + When I send a "GET" request to "/" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/vnd.openapi+json; charset=utf-8" + And the JSON should be sorted + + Scenario: Retrieve the Entrypoint with url format + When I send a "GET" request to "/index.jsonopenapi" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/vnd.openapi+json; charset=utf-8" + And the JSON should be sorted diff --git a/phpstan.neon.dist b/phpstan.neon.dist index d6dee67d939..ae81d178fb6 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -26,7 +26,13 @@ parameters: - src/Symfony/Bundle/DependencyInjection/Configuration.php # Templates for Maker - src/Symfony/Maker/Resources/skeleton + # subtree split - **vendor** + # Symfony 6 support + - src/OpenApi/Serializer/CacheableSupportsMethodInterface.php + - src/Serializer/CacheableSupportsMethodInterface.php + - tests/Hal/Serializer/ItemNormalizerTest.php + - tests/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php earlyTerminatingMethodCalls: PHPUnit\Framework\Constraint\Constraint: - fail @@ -72,7 +78,6 @@ parameters: # Expected, due to optional interfaces - '#Method Symfony\\Component\\Serializer\\NameConverter\\NameConverterInterface::denormalize\(\) invoked with (2|3|4) parameters, 1 required\.#' - '#Method Symfony\\Component\\Serializer\\NameConverter\\NameConverterInterface::normalize\(\) invoked with (2|3|4) parameters, 1 required\.#' - - '#Method Symfony\\Component\\Serializer\\Normalizer\\NormalizerInterface::supportsNormalization\(\) invoked with 3 parameters, 1-2 required\.#' # Expected, due to backward compatibility - @@ -90,3 +95,6 @@ parameters: - message: '#^Class .+ not found.$#' path: src/Elasticsearch/Tests + # Backward compatibility + - '#Call to method hasCacheableSupportsMethod\(\) on an unknown class Symfony\\Component\\Serializer\\Normalizer\\CacheableSupportsMethodInterface\.#' + - '#Class Symfony\\Component\\Serializer\\Normalizer\\CacheableSupportsMethodInterface not found\.#' diff --git a/src/Doctrine/Common/Filter/SearchFilterTrait.php b/src/Doctrine/Common/Filter/SearchFilterTrait.php index 35860a846ae..e85bbf8a056 100644 --- a/src/Doctrine/Common/Filter/SearchFilterTrait.php +++ b/src/Doctrine/Common/Filter/SearchFilterTrait.php @@ -111,7 +111,7 @@ abstract protected function getProperties(): ?array; abstract protected function getLogger(): LoggerInterface; - abstract protected function getIriConverter(): IriConverterInterface; + abstract protected function getIriConverter(): LegacyIriConverterInterface|IriConverterInterface; abstract protected function getPropertyAccessor(): PropertyAccessorInterface; diff --git a/src/Doctrine/Orm/Extension/EagerLoadingExtension.php b/src/Doctrine/Orm/Extension/EagerLoadingExtension.php index 4644283f1f3..dc6de300cb0 100644 --- a/src/Doctrine/Orm/Extension/EagerLoadingExtension.php +++ b/src/Doctrine/Orm/Extension/EagerLoadingExtension.php @@ -169,6 +169,7 @@ private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInt if ( null !== $parentAssociation && isset($mapping['inversedBy']) + && $mapping['sourceEntity'] === $mapping['targetEntity'] && $mapping['inversedBy'] === $parentAssociation && $mapping['type'] & ClassMetadata::TO_ONE ) { diff --git a/src/Elasticsearch/Serializer/DocumentNormalizer.php b/src/Elasticsearch/Serializer/DocumentNormalizer.php index 245db12e6fb..02aa74c3739 100644 --- a/src/Elasticsearch/Serializer/DocumentNormalizer.php +++ b/src/Elasticsearch/Serializer/DocumentNormalizer.php @@ -58,7 +58,7 @@ public function __construct( */ public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool { - return self::FORMAT === $format && $this->decoratedNormalizer->supportsDenormalization($data, $type, $format, $context); // @phpstan-ignore-line symfony bc-layer + return self::FORMAT === $format && $this->decoratedNormalizer->supportsDenormalization($data, $type, $format, $context); } /** diff --git a/src/Elasticsearch/Serializer/ItemNormalizer.php b/src/Elasticsearch/Serializer/ItemNormalizer.php index 7fe763ae4e5..efc8cfe4f78 100644 --- a/src/Elasticsearch/Serializer/ItemNormalizer.php +++ b/src/Elasticsearch/Serializer/ItemNormalizer.php @@ -82,7 +82,7 @@ public function supportsDenormalization(mixed $data, string $type, string $forma throw new LogicException(sprintf('The decorated normalizer must be an instance of "%s".', DenormalizerInterface::class)); } - return DocumentNormalizer::FORMAT !== $format && $this->decorated->supportsDenormalization($data, $type, $format, $context); // @phpstan-ignore-line symfony bc-layer + return DocumentNormalizer::FORMAT !== $format && $this->decorated->supportsDenormalization($data, $type, $format, $context); } /** diff --git a/src/Elasticsearch/Tests/Serializer/ItemNormalizerTest.php b/src/Elasticsearch/Tests/Serializer/ItemNormalizerTest.php index 5923c8d7954..7a59ea6dda8 100644 --- a/src/Elasticsearch/Tests/Serializer/ItemNormalizerTest.php +++ b/src/Elasticsearch/Tests/Serializer/ItemNormalizerTest.php @@ -150,12 +150,12 @@ public function testGetSupportedTypes(): void // TODO: use prophecy when getSupportedTypes() will be added to the interface $this->itemNormalizer = new ItemNormalizer(new class() implements NormalizerInterface { - public function normalize(mixed $object, string $format = null, array $context = []) + public function normalize(mixed $object, string $format = null, array $context = []): \ArrayObject|array|string|int|float|bool|null { return null; } - public function supportsNormalization(mixed $data, string $format = null): bool + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool { return true; } diff --git a/src/GraphQl/Tests/Resolver/Stage/SerializeStageTest.php b/src/GraphQl/Tests/Resolver/Stage/SerializeStageTest.php index 6f817d690a0..5f383ad3494 100644 --- a/src/GraphQl/Tests/Resolver/Stage/SerializeStageTest.php +++ b/src/GraphQl/Tests/Resolver/Stage/SerializeStageTest.php @@ -190,7 +190,7 @@ public function testApplyBadNormalizedData(): void $normalizationContext = ['normalization' => true]; $this->serializerContextBuilderProphecy->create($resourceClass, $operation, $context, true)->shouldBeCalled()->willReturn($normalizationContext); - $this->normalizerProphecy->normalize(Argument::type(\stdClass::class), ItemNormalizer::FORMAT, $normalizationContext)->willReturn(new \stdClass()); + $this->normalizerProphecy->normalize(Argument::type(\stdClass::class), ItemNormalizer::FORMAT, $normalizationContext)->willReturn(0); $this->expectException(\UnexpectedValueException::class); $this->expectExceptionMessage('Expected serialized data to be a nullable array.'); diff --git a/src/GraphQl/Tests/Type/FieldsBuilderTest.php b/src/GraphQl/Tests/Type/FieldsBuilderTest.php index 7cdade600a4..af610ea05c4 100644 --- a/src/GraphQl/Tests/Type/FieldsBuilderTest.php +++ b/src/GraphQl/Tests/Type/FieldsBuilderTest.php @@ -582,7 +582,8 @@ public static function resourceObjectTypeFieldsProvider(): iterable yield 'query input' => ['resourceClass', (new Query())->withClass('resourceClass'), [ 'property' => new ApiProperty(), - 'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(false), + 'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(true), + 'nonWritableProperty' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withReadable(false)->withWritable(false), ], true, 0, null, [ @@ -671,6 +672,7 @@ public static function resourceObjectTypeFieldsProvider(): iterable 'property' => new ApiProperty(), 'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withDescription('propertyBool description')->withReadable(false)->withWritable(true)->withDeprecationReason('not useful'), 'propertySubresource' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(true), + 'nonWritableProperty' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withReadable(false)->withWritable(false), 'id' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)])->withReadable(false)->withWritable(true), ], true, 0, null, diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 441bffd4597..c2a629820be 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -220,7 +220,7 @@ public function getResourceObjectTypeFields(?string $resourceClass, Operation $o if ( !$propertyTypes || (!$input && false === $propertyMetadata->isReadable()) - || ($input && $operation instanceof Mutation && false === $propertyMetadata->isWritable()) + || ($input && false === $propertyMetadata->isWritable()) ) { continue; } @@ -256,8 +256,14 @@ public function getEnumFields(string $enumClass): array $rEnum = new \ReflectionEnum($enumClass); $enumCases = []; + /* @var \ReflectionEnumUnitCase|\ReflectionEnumBackedCase */ foreach ($rEnum->getCases() as $rCase) { - $enumCase = ['value' => $rCase->getBackingValue()]; + if ($rCase instanceof \ReflectionEnumBackedCase) { + $enumCase = ['value' => $rCase->getBackingValue()]; + } else { + $enumCase = ['value' => $rCase->getValue()]; + } + $propertyMetadata = $this->propertyMetadataFactory->create($enumClass, $rCase->getName()); if ($enumCaseDescription = $propertyMetadata->getDescription()) { $enumCase['description'] = $enumCaseDescription; diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index bc29cc991a7..6d366ab63f4 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -83,7 +83,8 @@ public function buildSchema(string $className, string $format = 'json', string $ $method = Schema::TYPE_INPUT === $type ? 'POST' : 'GET'; } - if (Schema::TYPE_OUTPUT !== $type && !\in_array($method, ['POST', 'PATCH', 'PUT'], true)) { + // In case of FORCE_SUBSCHEMA an object can be writable through another class eventhough it has no POST operation + if (!($serializerContext[self::FORCE_SUBSCHEMA] ?? false) && Schema::TYPE_OUTPUT !== $type && !\in_array($method, ['POST', 'PATCH', 'PUT'], true)) { return $schema; } @@ -217,6 +218,10 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str } $subSchema = $this->buildSchema($className, $format, $parentType, null, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false); + if (!isset($subSchema['$ref'])) { + continue; + } + if ($isCollection) { $propertySchema['items']['$ref'] = $subSchema['$ref']; unset($propertySchema['items']['type']); @@ -247,8 +252,7 @@ private function buildDefinitionName(string $className, string $format = 'json', } if (null !== $inputOrOutputClass && $className !== $inputOrOutputClass) { - $parts = explode('\\', $inputOrOutputClass); - $shortName = end($parts); + $shortName = $this->getShortClassName($inputOrOutputClass); $prefix .= '.'.$shortName; } @@ -284,6 +288,7 @@ private function getMetadata(string $className, string $type = Schema::TYPE_OUTP ]; } + $forceSubschema = $serializerContext[self::FORCE_SUBSCHEMA] ?? false; if (null === $operation) { $resourceMetadataCollection = $this->resourceMetadataFactory->create($className); try { @@ -291,6 +296,9 @@ private function getMetadata(string $className, string $type = Schema::TYPE_OUTP } catch (OperationNotFoundException $e) { $operation = new HttpOperation(); } + if ($operation->getShortName() === $this->getShortClassName($className) && $forceSubschema) { + $operation = new HttpOperation(); + } $operation = $this->findOperationForType($resourceMetadataCollection, $type, $operation); } else { @@ -308,7 +316,7 @@ private function getMetadata(string $className, string $type = Schema::TYPE_OUTP $inputOrOutput = ['class' => $className]; $inputOrOutput = Schema::TYPE_OUTPUT === $type ? ($operation->getOutput() ?? $inputOrOutput) : ($operation->getInput() ?? $inputOrOutput); - $outputClass = ($serializerContext[self::FORCE_SUBSCHEMA] ?? false) ? ($inputOrOutput['class'] ?? $inputOrOutput->class ?? $operation->getClass()) : ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null); + $outputClass = $forceSubschema ? ($inputOrOutput['class'] ?? $inputOrOutput->class ?? $operation->getClass()) : ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null); if (null === $outputClass) { // input or output disabled @@ -384,4 +392,11 @@ private function getFactoryOptions(array $serializerContext, array $validationGr return $options; } + + private function getShortClassName(string $fullyQualifiedName): string + { + $parts = explode('\\', $fullyQualifiedName); + + return end($parts); + } } diff --git a/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php index 623354e821e..1857d4f952a 100644 --- a/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/NotExposedOperationResourceMetadataCollectionFactory.php @@ -60,7 +60,7 @@ public function create(string $resourceClass): ResourceMetadataCollection foreach ($resourceMetadataCollection as $resource) { $operations = $resource->getOperations(); - /** @var ApiPlatform\Metadata\HttpOperation $operation */ + /** @var \ApiPlatform\Metadata\HttpOperation $operation */ foreach ($operations as $operation) { // An item operation has been found, nothing to do anymore in this factory if (('GET' === $operation->getMethod() && !$operation instanceof CollectionOperationInterface) || ($operation->getExtraProperties()['is_legacy_resource_metadata'] ?? false)) { diff --git a/src/Metadata/Tests/Property/PropertyInfoPropertyNameCollectionFactoryTest.php b/src/Metadata/Tests/Property/PropertyInfoPropertyNameCollectionFactoryTest.php index e77a581a657..e6b3dc305b2 100644 --- a/src/Metadata/Tests/Property/PropertyInfoPropertyNameCollectionFactoryTest.php +++ b/src/Metadata/Tests/Property/PropertyInfoPropertyNameCollectionFactoryTest.php @@ -24,7 +24,7 @@ use Symfony\Component\PropertyInfo\Extractor\SerializerExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; -use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; /** * @author Oskar Stark @@ -81,7 +81,7 @@ public function testCreateMethodReturnsProperPropertyNameCollectionForObjectWith new PropertyInfoExtractor([ new SerializerExtractor( new ClassMetadataFactory( - new AnnotationLoader( + new AttributeLoader( ) ) ), diff --git a/src/Symfony/Bundle/CacheWarmer/CachePoolClearerCacheWarmer.php b/src/Symfony/Bundle/CacheWarmer/CachePoolClearerCacheWarmer.php index ca2f96b2490..4dba3c714f5 100644 --- a/src/Symfony/Bundle/CacheWarmer/CachePoolClearerCacheWarmer.php +++ b/src/Symfony/Bundle/CacheWarmer/CachePoolClearerCacheWarmer.php @@ -34,7 +34,7 @@ public function __construct(private readonly Psr6CacheClearer $poolClearer, priv * * @return string[] */ - public function warmUp(string $cacheDir): array + public function warmUp(string $cacheDir, string $buildDir = null): array { foreach ($this->pools as $pool) { if ($this->poolClearer->hasPool($pool)) { diff --git a/src/Symfony/Bundle/Resources/config/api.xml b/src/Symfony/Bundle/Resources/config/api.xml index f43022a5ee3..8a0d37e82fc 100644 --- a/src/Symfony/Bundle/Resources/config/api.xml +++ b/src/Symfony/Bundle/Resources/config/api.xml @@ -193,11 +193,13 @@ api_platform.symfony.main_controller %kernel.debug% + %api_platform.error_formats% %api_platform.exception_to_status% null + null %api_platform.rfc_7807_compliant_errors% diff --git a/src/Symfony/Bundle/Resources/config/legacy/events.xml b/src/Symfony/Bundle/Resources/config/legacy/events.xml index 09cd273d3be..9169d043490 100644 --- a/src/Symfony/Bundle/Resources/config/legacy/events.xml +++ b/src/Symfony/Bundle/Resources/config/legacy/events.xml @@ -10,7 +10,7 @@ %api_platform.formats% %api_platform.error_formats% %api_platform.docs_formats% - %api_platform.event_listeners_backward_compatibility_layer% + null diff --git a/src/Symfony/Controller/MainController.php b/src/Symfony/Controller/MainController.php index 541e8c2b14c..d9363848476 100644 --- a/src/Symfony/Controller/MainController.php +++ b/src/Symfony/Controller/MainController.php @@ -17,6 +17,7 @@ use ApiPlatform\Exception\InvalidIdentifierException; use ApiPlatform\Exception\InvalidUriVariableException; use ApiPlatform\Metadata\Error; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\State\ProcessorInterface; @@ -47,8 +48,11 @@ public function __construct( public function __invoke(Request $request): Response { $operation = $this->initializeOperation($request); - $uriVariables = []; + if (!$operation) { + throw new RuntimeException('Not an API operation.'); + } + $uriVariables = []; if (!$operation instanceof Error) { try { $uriVariables = $this->getOperationUriVariables($operation, $request->attributes->all(), $operation->getClass()); @@ -80,12 +84,15 @@ public function __invoke(Request $request): Response // 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]); + + if (!$operation instanceof Error) { + 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]); + } } } } diff --git a/src/Symfony/EventListener/AddFormatListener.php b/src/Symfony/EventListener/AddFormatListener.php index fa4c3990582..8ec2041e244 100644 --- a/src/Symfony/EventListener/AddFormatListener.php +++ b/src/Symfony/EventListener/AddFormatListener.php @@ -34,7 +34,7 @@ final class AddFormatListener { use OperationRequestInitiatorTrait; - public function __construct(private readonly Negotiator $negotiator, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, private readonly array $formats = [], private readonly array $errorFormats = [], private readonly array $docsFormats = [], private readonly bool $eventsBackwardCompatibility = true) + public function __construct(private readonly Negotiator $negotiator, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, private readonly array $formats = [], private readonly array $errorFormats = [], private readonly array $docsFormats = [], private readonly ?bool $eventsBackwardCompatibility = null) // @phpstan-ignore-line { $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; } @@ -50,7 +50,11 @@ public function onKernelRequest(RequestEvent $event): void $request = $event->getRequest(); $operation = $this->initializeOperation($request); - if ('api_platform.symfony.main_controller' === $operation?->getController() || ($this->eventsBackwardCompatibility && 'api_platform.action.entrypoint' === $request->attributes->get('_controller')) || $request->attributes->get('_api_platform_disable_listeners')) { + if ('api_platform.action.entrypoint' === $request->attributes->get('_controller')) { + return; + } + + if ('api_platform.symfony.main_controller' === $operation?->getController() || $request->attributes->get('_api_platform_disable_listeners')) { return; } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php index 4fc357d390a..6482cbfda8a 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php @@ -31,8 +31,7 @@ final class PropertySchemaGreaterThanRestriction implements PropertySchemaRestri public function create(Constraint $constraint, ApiProperty $propertyMetadata): array { return [ - 'minimum' => $constraint->value, - 'exclusiveMinimum' => true, + 'exclusiveMinimum' => $constraint->value, ]; } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php index c7354f9af80..0693aeee3f7 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php @@ -31,8 +31,7 @@ final class PropertySchemaLessThanRestriction implements PropertySchemaRestricti public function create(Constraint $constraint, ApiProperty $propertyMetadata): array { return [ - 'maximum' => $constraint->value, - 'exclusiveMaximum' => true, + 'exclusiveMaximum' => $constraint->value, ]; } diff --git a/src/Test/DoctrineMongoDbOdmFilterTestCase.php b/src/Test/DoctrineMongoDbOdmFilterTestCase.php index a87cf891c4f..c3218614306 100644 --- a/src/Test/DoctrineMongoDbOdmFilterTestCase.php +++ b/src/Test/DoctrineMongoDbOdmFilterTestCase.php @@ -41,7 +41,7 @@ protected function setUp(): void self::bootKernel(); $this->manager = DoctrineMongoDbOdmTestCase::createTestDocumentManager(); - $this->managerRegistry = self::$kernel->getContainer()->get('doctrine_mongodb'); + $this->managerRegistry = self::$kernel->getContainer()->get('doctrine_mongodb'); // @phpstan-ignore-line $this->repository = $this->manager->getRepository($this->resourceClass); } diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index 89d4d9bfdd9..503eaf747e6 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -58,6 +58,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Document\FileConfigDummy as FileConfigDummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\Foo as FooDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\FooDummy as FooDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FooEmbeddable as FooEmbeddableDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\FourthLevel as FourthLevelDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\Greeting as GreetingDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Document\InitializeInput as InitializeInputDocument; @@ -144,6 +145,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FileConfigDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FooDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FooEmbeddable; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FourthLevel; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Greeting; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\InitializeInput; @@ -354,7 +356,7 @@ public function thereAreFooObjectsWithFakeNames(int $nb): void /** * @Given there are :nb fooDummy objects with fake names */ - public function thereAreFooDummyObjectsWithFakeNames($nb): void + public function thereAreFooDummyObjectsWithFakeNames(int $nb, $embedd = false): void { $names = ['Hawsepipe', 'Ephesian', 'Sthenelus', 'Separativeness', 'Balbo']; $dummies = ['Lorem', 'Ipsum', 'Dolor', 'Sit', 'Amet']; @@ -365,6 +367,11 @@ public function thereAreFooDummyObjectsWithFakeNames($nb): void $foo = $this->buildFooDummy(); $foo->setName($names[$i]); + if ($embedd) { + $embeddedFoo = $this->buildFooEmbeddable(); + $embeddedFoo->setDummyName('embedded'.$names[$i]); + $foo->setEmbeddedFoo($embeddedFoo); + } $foo->setDummy($dummy); for ($j = 0; $j < 3; ++$j) { $soMany = $this->buildSoMany(); @@ -379,6 +386,14 @@ public function thereAreFooDummyObjectsWithFakeNames($nb): void $this->manager->flush(); } + /** + * @Given there is a fooDummy objects with fake names and embeddable + */ + public function thereAreFooDummyObjectsWithFakeNamesAndEmbeddable(): void + { + $this->thereAreFooDummyObjectsWithFakeNames(1, true); + } + /** * @Given there are :nb dummy group objects */ @@ -2399,6 +2414,11 @@ private function buildFooDummy(): FooDummy|FooDummyDocument return $this->isOrm() ? new FooDummy() : new FooDummyDocument(); } + private function buildFooEmbeddable(): FooEmbeddable|FooEmbeddableDocument + { + return $this->isOrm() ? new FooEmbeddable() : new FooEmbeddableDocument(); + } + private function buildFourthLevel(): FourthLevel|FourthLevelDocument { return $this->isOrm() ? new FourthLevel() : new FourthLevelDocument(); diff --git a/tests/Doctrine/Odm/Extension/OrderExtensionTest.php b/tests/Doctrine/Odm/Extension/OrderExtensionTest.php index 62181535282..654955acc40 100644 --- a/tests/Doctrine/Odm/Extension/OrderExtensionTest.php +++ b/tests/Doctrine/Odm/Extension/OrderExtensionTest.php @@ -127,10 +127,10 @@ public function testApplyToCollectionWithOrderOverriddenWithAssociation(): void $lookupProphecy = $this->prophesize(Lookup::class); $lookupProphecy->localField('author')->shouldBeCalled()->willReturn($lookupProphecy); $lookupProphecy->foreignField('_id')->shouldBeCalled()->willReturn($lookupProphecy); - $lookupProphecy->alias('author_lkup')->shouldBeCalled(); + $lookupProphecy->alias('author_lkup')->shouldBeCalled()->willReturn($lookupProphecy); $aggregationBuilderProphecy->lookup(Dummy::class)->shouldBeCalled()->willReturn($lookupProphecy->reveal()); $unwindProphecy = $this->prophesize(Unwind::class); - $unwindProphecy->preserveNullAndEmptyArrays(true)->shouldBeCalled(); + $unwindProphecy->preserveNullAndEmptyArrays(true)->shouldBeCalled()->willReturn($unwindProphecy->reveal()); $aggregationBuilderProphecy->unwind('$author_lkup')->shouldBeCalled()->willReturn($unwindProphecy->reveal()); $aggregationBuilderProphecy->getStage(0)->willThrow(new \OutOfRangeException('message')); $aggregationBuilderProphecy->sort(['author_lkup.name' => 'ASC'])->shouldBeCalled(); diff --git a/tests/Doctrine/Odm/Extension/PaginationExtensionTest.php b/tests/Doctrine/Odm/Extension/PaginationExtensionTest.php index 285b8144702..4f28ef88f9c 100644 --- a/tests/Doctrine/Odm/Extension/PaginationExtensionTest.php +++ b/tests/Doctrine/Odm/Extension/PaginationExtensionTest.php @@ -414,8 +414,8 @@ private function mockAggregationBuilder(int $expectedOffset, int $expectedLimit) $skipProphecy->limit($expectedLimit)->shouldBeCalled(); } else { $matchProphecy = $this->prophesize(AggregationMatch::class); - $matchProphecy->field(Paginator::LIMIT_ZERO_MARKER_FIELD)->shouldBeCalled()->willReturn($matchProphecy); - $matchProphecy->equals(Paginator::LIMIT_ZERO_MARKER)->shouldBeCalled(); + $matchProphecy->field(Paginator::LIMIT_ZERO_MARKER_FIELD)->shouldBeCalled()->willReturn($matchProphecy->reveal()); + $matchProphecy->equals(Paginator::LIMIT_ZERO_MARKER)->shouldBeCalled()->willReturn($matchProphecy->reveal()); $skipProphecy->match()->shouldBeCalled()->willReturn($matchProphecy->reveal()); } diff --git a/tests/Doctrine/Odm/State/ItemProviderTest.php b/tests/Doctrine/Odm/State/ItemProviderTest.php index 52481d9ce7d..89bdc3ecd71 100644 --- a/tests/Doctrine/Odm/State/ItemProviderTest.php +++ b/tests/Doctrine/Odm/State/ItemProviderTest.php @@ -49,7 +49,7 @@ public function testGetItemSingleIdentifier(): void $matchProphecy = $this->prophesize(AggregationMatch::class); $matchProphecy->field('id')->willReturn($matchProphecy)->shouldBeCalled(); - $matchProphecy->equals(1)->shouldBeCalled(); + $matchProphecy->equals(1)->shouldBeCalled()->willReturn($matchProphecy); $iterator = $this->prophesize(Iterator::class); $result = new \stdClass(); @@ -81,8 +81,8 @@ public function testGetItemWithExecuteOptions(): void $context = ['foo' => 'bar', 'fetch_data' => true]; $matchProphecy = $this->prophesize(AggregationMatch::class); - $matchProphecy->field('id')->willReturn($matchProphecy)->shouldBeCalled(); - $matchProphecy->equals(1)->shouldBeCalled(); + $matchProphecy->field('id')->willReturn($matchProphecy->reveal())->shouldBeCalled(); + $matchProphecy->equals(1)->shouldBeCalled()->willReturn($matchProphecy->reveal()); $iterator = $this->prophesize(Iterator::class); $result = new \stdClass(); @@ -115,8 +115,8 @@ public function testGetItemDoubleIdentifier(): void $matchProphecy = $this->prophesize(AggregationMatch::class); $matchProphecy->field('ida')->willReturn($matchProphecy)->shouldBeCalled(); $matchProphecy->field('idb')->willReturn($matchProphecy)->shouldBeCalled(); - $matchProphecy->equals(1)->shouldBeCalled(); - $matchProphecy->equals(2)->shouldBeCalled(); + $matchProphecy->equals(1)->shouldBeCalled()->willReturn($matchProphecy); + $matchProphecy->equals(2)->shouldBeCalled()->willReturn($matchProphecy); $iterator = $this->prophesize(Iterator::class); $result = new \stdClass(); @@ -150,7 +150,7 @@ public function testAggregationResultExtension(): void $matchProphecy = $this->prophesize(AggregationMatch::class); $matchProphecy->field('id')->willReturn($matchProphecy)->shouldBeCalled(); - $matchProphecy->equals(1)->shouldBeCalled(); + $matchProphecy->equals(1)->shouldBeCalled()->willReturn($matchProphecy); $aggregationBuilderProphecy = $this->prophesize(Builder::class); $aggregationBuilderProphecy->match()->willReturn($matchProphecy->reveal())->shouldBeCalled(); diff --git a/tests/Doctrine/Orm/Extension/EagerLoadingExtensionTest.php b/tests/Doctrine/Orm/Extension/EagerLoadingExtensionTest.php index a4e8bbbaeba..922880847a8 100644 --- a/tests/Doctrine/Orm/Extension/EagerLoadingExtensionTest.php +++ b/tests/Doctrine/Orm/Extension/EagerLoadingExtensionTest.php @@ -31,6 +31,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnly; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelation; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UnknownDummy; use Doctrine\ORM\EntityManager; use Doctrine\ORM\Mapping\ClassMetadata; @@ -142,6 +143,7 @@ public function testApplyToItem(): void $propertyNameCollectionFactoryProphecy->create(RelatedDummy::class)->willReturn($relatedNameCollection)->shouldBeCalled(); $propertyNameCollectionFactoryProphecy->create(EmbeddableDummy::class)->willReturn($relatedEmbedableCollection)->shouldBeCalled(); $propertyNameCollectionFactoryProphecy->create(UnknownDummy::class)->willReturn(new PropertyNameCollection(['id']))->shouldBeCalled(); + $propertyNameCollectionFactoryProphecy->create(ThirdLevel::class)->willReturn(new PropertyNameCollection(['id']))->shouldBeCalled(); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $relationPropertyMetadata = new ApiProperty(); @@ -153,6 +155,7 @@ public function testApplyToItem(): void $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy4', $callContext)->willReturn($relationPropertyMetadata)->shouldBeCalled(); $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy5', $callContext)->willReturn($relationPropertyMetadata)->shouldBeCalled(); $propertyMetadataFactoryProphecy->create(Dummy::class, 'singleInheritanceRelation', $callContext)->willReturn($relationPropertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', $callContext)->willReturn($relationPropertyMetadata)->shouldBeCalled(); $idPropertyMetadata = new ApiProperty(); $idPropertyMetadata = $idPropertyMetadata->withIdentifier(true); @@ -171,7 +174,9 @@ public function testApplyToItem(): void $propertyMetadataFactoryProphecy->create(RelatedDummy::class, 'notindatabase', $callContext)->willReturn($notInDatabasePropertyMetadata)->shouldBeCalled(); $propertyMetadataFactoryProphecy->create(RelatedDummy::class, 'notreadable', $callContext)->willReturn($notReadablePropertyMetadata)->shouldBeCalled(); $propertyMetadataFactoryProphecy->create(RelatedDummy::class, 'relation', $callContext)->willReturn($relationPropertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(RelatedDummy::class, 'thirdLevel', $callContext)->willReturn($relationPropertyMetadata)->shouldBeCalled(); $propertyMetadataFactoryProphecy->create(UnknownDummy::class, 'id', $callContext)->willReturn($idPropertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(ThirdLevel::class, 'id', $callContext)->willReturn($idPropertyMetadata)->shouldBeCalled(); $queryBuilderProphecy = $this->prophesize(QueryBuilder::class); @@ -183,6 +188,7 @@ public function testApplyToItem(): void 'relatedDummy4' => ['fetch' => ClassMetadataInfo::FETCH_EAGER, 'targetEntity' => UnknownDummy::class], 'relatedDummy5' => ['fetch' => ClassMetadataInfo::FETCH_LAZY, 'targetEntity' => UnknownDummy::class], 'singleInheritanceRelation' => ['fetch' => ClassMetadataInfo::FETCH_EAGER, 'targetEntity' => AbstractDummy::class], + 'relatedDummies' => ['fetch' => ClassMetadataInfo::FETCH_EAGER, 'targetEntity' => RelatedDummy::class], ]; $relatedClassMetadataProphecy = $this->prophesize(ClassMetadata::class); @@ -196,6 +202,7 @@ public function testApplyToItem(): void $relatedClassMetadataProphecy->associationMappings = [ 'relation' => ['fetch' => ClassMetadataInfo::FETCH_EAGER, 'joinColumns' => [['nullable' => false]], 'targetEntity' => UnknownDummy::class], + 'thirdLevel' => ['fetch' => ClassMetadataInfo::FETCH_EAGER, 'targetEntity' => ThirdLevel::class, 'sourceEntity' => RelatedDummy::class, 'inversedBy' => 'relatedDummies', 'type' => ClassMetadata::TO_ONE], ]; $relatedClassMetadataProphecy->embeddedClasses = ['embeddedDummy' => ['class' => EmbeddableDummy::class]]; @@ -206,26 +213,38 @@ public function testApplyToItem(): void $unknownClassMetadataProphecy = $this->prophesize(ClassMetadata::class); $unknownClassMetadataProphecy->associationMappings = []; + $thirdLevelMetadataProphecy = $this->prophesize(ClassMetadata::class); + $thirdLevelMetadataProphecy->associationMappings = []; + $emProphecy = $this->prophesize(EntityManager::class); $emProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); $emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($relatedClassMetadataProphecy->reveal()); $emProphecy->getClassMetadata(AbstractDummy::class)->shouldBeCalled()->willReturn($singleInheritanceClassMetadataProphecy->reveal()); $emProphecy->getClassMetadata(UnknownDummy::class)->shouldBeCalled()->willReturn($unknownClassMetadataProphecy->reveal()); + $emProphecy->getClassMetadata(ThirdLevel::class)->shouldBeCalled()->willReturn($thirdLevelMetadataProphecy->reveal()); $queryBuilderProphecy->getRootAliases()->willReturn(['o']); $queryBuilderProphecy->getEntityManager()->willReturn($emProphecy); $queryBuilderProphecy->leftJoin('o.relatedDummy', 'relatedDummy_a1')->shouldBeCalledTimes(1); $queryBuilderProphecy->leftJoin('relatedDummy_a1.relation', 'relation_a2')->shouldBeCalledTimes(1); - $queryBuilderProphecy->innerJoin('o.relatedDummy2', 'relatedDummy2_a3')->shouldBeCalledTimes(1); - $queryBuilderProphecy->leftJoin('o.relatedDummy3', 'relatedDummy3_a4')->shouldBeCalledTimes(1); - $queryBuilderProphecy->leftJoin('o.relatedDummy4', 'relatedDummy4_a5')->shouldBeCalledTimes(1); - $queryBuilderProphecy->leftJoin('o.singleInheritanceRelation', 'singleInheritanceRelation_a6')->shouldBeCalledTimes(1); + $queryBuilderProphecy->leftJoin('relatedDummy_a1.thirdLevel', 'thirdLevel_a3')->shouldBeCalledTimes(1); + $queryBuilderProphecy->innerJoin('o.relatedDummy2', 'relatedDummy2_a4')->shouldBeCalledTimes(1); + $queryBuilderProphecy->leftJoin('o.relatedDummy3', 'relatedDummy3_a5')->shouldBeCalledTimes(1); + $queryBuilderProphecy->leftJoin('o.relatedDummy4', 'relatedDummy4_a6')->shouldBeCalledTimes(1); + $queryBuilderProphecy->leftJoin('o.singleInheritanceRelation', 'singleInheritanceRelation_a7')->shouldBeCalledTimes(1); + $queryBuilderProphecy->leftJoin('o.relatedDummies', 'relatedDummies_a8')->shouldBeCalledTimes(1); + $queryBuilderProphecy->leftJoin('relatedDummies_a8.relation', 'relation_a9')->shouldBeCalledTimes(1); + $queryBuilderProphecy->leftJoin('relatedDummies_a8.thirdLevel', 'thirdLevel_a10')->shouldBeCalledTimes(1); $queryBuilderProphecy->addSelect('partial relatedDummy_a1.{id,name,embeddedDummy.name}')->shouldBeCalledTimes(1); + $queryBuilderProphecy->addSelect('partial thirdLevel_a3.{id}')->shouldBeCalledTimes(1); $queryBuilderProphecy->addSelect('partial relation_a2.{id}')->shouldBeCalledTimes(1); - $queryBuilderProphecy->addSelect('partial relatedDummy2_a3.{id}')->shouldBeCalledTimes(1); - $queryBuilderProphecy->addSelect('partial relatedDummy3_a4.{id}')->shouldBeCalledTimes(1); - $queryBuilderProphecy->addSelect('partial relatedDummy4_a5.{id}')->shouldBeCalledTimes(1); - $queryBuilderProphecy->addSelect('singleInheritanceRelation_a6')->shouldBeCalledTimes(1); + $queryBuilderProphecy->addSelect('partial relatedDummy2_a4.{id}')->shouldBeCalledTimes(1); + $queryBuilderProphecy->addSelect('partial relatedDummy3_a5.{id}')->shouldBeCalledTimes(1); + $queryBuilderProphecy->addSelect('partial relatedDummy4_a6.{id}')->shouldBeCalledTimes(1); + $queryBuilderProphecy->addSelect('singleInheritanceRelation_a7')->shouldBeCalledTimes(1); + $queryBuilderProphecy->addSelect('partial relatedDummies_a8.{id,name,embeddedDummy.name}')->shouldBeCalledTimes(1); + $queryBuilderProphecy->addSelect('partial relation_a9.{id}')->shouldBeCalledTimes(1); + $queryBuilderProphecy->addSelect('partial thirdLevel_a10.{id}')->shouldBeCalledTimes(1); $queryBuilderProphecy->getDQLPart('join')->willReturn([]); $queryBuilderProphecy->getDQLPart('select')->willReturn([]); diff --git a/tests/Fixtures/DummySequentiallyValidatedEntity.php b/tests/Fixtures/DummySequentiallyValidatedEntity.php index 8093d1260e3..1bae1861e77 100644 --- a/tests/Fixtures/DummySequentiallyValidatedEntity.php +++ b/tests/Fixtures/DummySequentiallyValidatedEntity.php @@ -19,13 +19,10 @@ class DummySequentiallyValidatedEntity { /** * @var string - * - * @Assert\Sequentially({ - * - * @Assert\Length(min=1, max=32), - * - * @Assert\Regex(pattern="/^[a-z]$/") - * }) */ + #[Assert\Sequentially([ + new Assert\Length(min: 1, max: 32), + new Assert\Regex(pattern: '/^[a-z]$/'), + ])] public $dummy; } diff --git a/tests/Fixtures/TestBundle/Document/FooDummy.php b/tests/Fixtures/TestBundle/Document/FooDummy.php index 8736509e855..c29cab1a41c 100644 --- a/tests/Fixtures/TestBundle/Document/FooDummy.php +++ b/tests/Fixtures/TestBundle/Document/FooDummy.php @@ -13,7 +13,9 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; +use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\QueryCollection; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; @@ -24,7 +26,7 @@ * * @author Vincent Chalamon */ -#[ApiResource(graphQlOperations: [new QueryCollection(name: 'collection_query', paginationType: 'page')], order: ['dummy.name'])] +#[ApiResource(graphQlOperations: [new QueryCollection(name: 'collection_query', paginationType: 'page'), new Mutation(name: 'update')], order: ['dummy.name'])] #[ODM\Document] class FooDummy { @@ -33,17 +35,26 @@ class FooDummy */ #[ODM\Id(strategy: 'INCREMENT', type: 'int')] private ?int $id = null; + /** * @var string The foo name */ #[ODM\Field] private $name; + + #[ODM\Field(nullable: true)] + private $nonWritableProp; + /** * @var Dummy The foo dummy */ #[ODM\ReferenceOne(targetDocument: Dummy::class, cascade: ['persist'], storeAs: 'id')] private ?Dummy $dummy = null; + #[ApiProperty(readableLink: true, writableLink: true)] + #[ODM\EmbedOne(targetDocument: FooEmbeddable::class)] + private ?FooEmbeddable $embeddedFoo = null; + /** * @var Collection */ @@ -52,6 +63,7 @@ class FooDummy public function __construct() { + $this->nonWritableProp = 'readonly'; $this->soManies = new ArrayCollection(); } @@ -70,6 +82,11 @@ public function getName() return $this->name; } + public function getNonWritableProp() + { + return $this->nonWritableProp; + } + public function getDummy(): ?Dummy { return $this->dummy; @@ -79,4 +96,16 @@ public function setDummy(Dummy $dummy): void { $this->dummy = $dummy; } + + public function getEmbeddedFoo(): ?FooEmbeddable + { + return $this->embeddedFoo && !$this->embeddedFoo->getDummyName() && !$this->embeddedFoo->getNonWritableProp() ? null : $this->embeddedFoo; + } + + public function setEmbeddedFoo(?FooEmbeddable $embeddedFoo): self + { + $this->embeddedFoo = $embeddedFoo; + + return $this; + } } diff --git a/tests/Fixtures/TestBundle/Document/FooEmbeddable.php b/tests/Fixtures/TestBundle/Document/FooEmbeddable.php new file mode 100644 index 00000000000..bf12471cfa9 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/FooEmbeddable.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\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\ApiProperty; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Embeddable Foo. + */ +#[ODM\EmbeddedDocument] +class FooEmbeddable +{ + /** + * @var string|null The dummy name + */ + #[ApiProperty(identifier: true)] + #[ODM\Field(type: 'string')] + private ?string $dummyName = null; + + #[ODM\Field(nullable: true)] + private $nonWritableProp; + + public function __construct() + { + } + + public function getDummyName(): ?string + { + return $this->dummyName; + } + + public function setDummyName(string $dummyName): void + { + $this->dummyName = $dummyName; + } + + public function getNonWritableProp() + { + return $this->nonWritableProp; + } +} diff --git a/tests/Fixtures/TestBundle/Document/RelatedDummy.php b/tests/Fixtures/TestBundle/Document/RelatedDummy.php index a400aa06450..6f8998ab1a4 100644 --- a/tests/Fixtures/TestBundle/Document/RelatedDummy.php +++ b/tests/Fixtures/TestBundle/Document/RelatedDummy.php @@ -72,7 +72,7 @@ class RelatedDummy extends ParentDummy implements \Stringable #[ApiFilter(filterClass: DateFilter::class)] public $dummyDate; #[Groups(['barcelona', 'chicago', 'friends'])] - #[ODM\ReferenceOne(targetDocument: ThirdLevel::class, cascade: ['persist'], nullable: true, storeAs: 'id')] + #[ODM\ReferenceOne(targetDocument: ThirdLevel::class, cascade: ['persist'], nullable: true, storeAs: 'id', inversedBy: 'relatedDummies')] public ?ThirdLevel $thirdLevel = null; #[Groups(['fakemanytomany', 'friends'])] #[ODM\ReferenceMany(targetDocument: RelatedToDummyFriend::class, cascade: ['persist'], mappedBy: 'relatedDummy', storeAs: 'id')] diff --git a/tests/Fixtures/TestBundle/Document/ThirdLevel.php b/tests/Fixtures/TestBundle/Document/ThirdLevel.php index 046b89afb16..7a79df0032a 100644 --- a/tests/Fixtures/TestBundle/Document/ThirdLevel.php +++ b/tests/Fixtures/TestBundle/Document/ThirdLevel.php @@ -16,6 +16,8 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Link; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; use Symfony\Component\Serializer\Annotation\Groups; @@ -49,6 +51,13 @@ class ThirdLevel public ?FourthLevel $fourthLevel = null; #[ODM\ReferenceOne(targetDocument: FourthLevel::class, cascade: ['persist'])] public $badFourthLevel; + #[ODM\ReferenceMany(mappedBy: 'thirdLevel', targetDocument: RelatedDummy::class)] + public Collection|iterable $relatedDummies; + + public function __construct() + { + $this->relatedDummies = new ArrayCollection(); + } public function getId(): ?int { diff --git a/tests/Fixtures/TestBundle/Dto/CustomOutputEntityWrapperDto.php b/tests/Fixtures/TestBundle/Dto/CustomOutputEntityWrapperDto.php new file mode 100644 index 00000000000..700e5fdc5b4 --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/CustomOutputEntityWrapperDto.php @@ -0,0 +1,24 @@ + + * + * 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\Dto; + +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\WrappedResponseEntity; +use Symfony\Component\Serializer\Annotation\Groups; + +class CustomOutputEntityWrapperDto +{ + /** @var WrappedResponseEntity */ + #[Groups(['read'])] + public $data; +} diff --git a/tests/Fixtures/TestBundle/Entity/EmbeddableDummy.php b/tests/Fixtures/TestBundle/Entity/EmbeddableDummy.php index 8e6a22f590d..e3bc042b3c9 100644 --- a/tests/Fixtures/TestBundle/Entity/EmbeddableDummy.php +++ b/tests/Fixtures/TestBundle/Entity/EmbeddableDummy.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; +use ApiPlatform\Metadata\ApiProperty; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; @@ -28,6 +29,7 @@ class EmbeddableDummy /** * @var string The dummy name */ + #[ApiProperty(identifier: true)] #[ORM\Column(nullable: true)] #[Groups(['embed'])] private ?string $dummyName = null; diff --git a/tests/Fixtures/TestBundle/Entity/FooDummy.php b/tests/Fixtures/TestBundle/Entity/FooDummy.php index 92596dad682..ae6e98e8a1c 100644 --- a/tests/Fixtures/TestBundle/Entity/FooDummy.php +++ b/tests/Fixtures/TestBundle/Entity/FooDummy.php @@ -13,7 +13,9 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; +use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\QueryCollection; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; @@ -24,7 +26,7 @@ * * @author Vincent Chalamon */ -#[ApiResource(graphQlOperations: [new QueryCollection(name: 'collection_query', paginationType: 'page')], order: ['dummy.name'])] +#[ApiResource(graphQlOperations: [new QueryCollection(name: 'collection_query', paginationType: 'page'), new Mutation(name: 'update')], order: ['dummy.name'])] #[ORM\Entity] class FooDummy { @@ -35,11 +37,20 @@ class FooDummy #[ORM\Id] #[ORM\GeneratedValue(strategy: 'AUTO')] private ?int $id = null; + /** * @var string The foo name */ #[ORM\Column] private $name; + + #[ORM\Column(nullable: true)] + private $nonWritableProp; + + #[ApiProperty(readableLink: true, writableLink: true)] + #[ORM\Embedded(class: FooEmbeddable::class)] + private ?FooEmbeddable $embeddedFoo = null; + /** * @var Dummy|null The foo dummy */ @@ -54,6 +65,7 @@ class FooDummy public function __construct() { + $this->nonWritableProp = 'readonly'; $this->soManies = new ArrayCollection(); } @@ -72,11 +84,28 @@ public function getName() return $this->name; } + public function getNonWritableProp() + { + return $this->nonWritableProp; + } + public function getDummy(): ?Dummy { return $this->dummy; } + public function getEmbeddedFoo(): ?FooEmbeddable + { + return $this->embeddedFoo && !$this->embeddedFoo->getDummyName() && !$this->embeddedFoo->getNonWritableProp() ? null : $this->embeddedFoo; + } + + public function setEmbeddedFoo(?FooEmbeddable $embeddedFoo): self + { + $this->embeddedFoo = $embeddedFoo; + + return $this; + } + public function setDummy(Dummy $dummy): void { $this->dummy = $dummy; diff --git a/tests/Fixtures/TestBundle/Entity/FooEmbeddable.php b/tests/Fixtures/TestBundle/Entity/FooEmbeddable.php new file mode 100644 index 00000000000..5ae58ed07f0 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/FooEmbeddable.php @@ -0,0 +1,57 @@ + + * + * 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\Entity; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +/** + * Embeddable Foo. + * + * @author Jordan Samouh + */ +#[ApiResource(operations: [], graphQlOperations: [])] +#[ORM\Embeddable] +class FooEmbeddable +{ + /** + * @var string The dummy name + */ + #[ApiProperty(identifier: true)] + #[ORM\Column(nullable: true)] + private ?string $dummyName = null; + + #[ORM\Column(nullable: true)] + private $nonWritableProp; // @phpstan-ignore-line + + public function __construct() + { + } + + public function getDummyName(): ?string + { + return $this->dummyName; + } + + public function setDummyName(string $dummyName): void + { + $this->dummyName = $dummyName; + } + + public function getNonWritableProp() + { + return $this->nonWritableProp; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Issue5998/Issue5998Product.php b/tests/Fixtures/TestBundle/Entity/Issue5998/Issue5998Product.php new file mode 100644 index 00000000000..14b26faaa9f --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Issue5998/Issue5998Product.php @@ -0,0 +1,76 @@ + + * + * 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\Entity\Issue5998; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[ApiResource] +#[Post( + denormalizationContext: ['groups' => ['product:write']], + input: SaveProduct::class, +)] +class Issue5998Product +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'product', targetEntity: ProductCode::class, cascade: ['persist'], orphanRemoval: true)] + private Collection $codes; + + public function __construct() + { + $this->codes = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + /** + * @return Collection + */ + public function getCodes(): Collection + { + return $this->codes; + } + + public function addCode(ProductCode $code): void + { + if (!$this->codes->contains($code)) { + $this->codes->add($code); + $code->setProduct($this); + } + } + + public function removeCode(ProductCode $code): void + { + if ($this->codes->removeElement($code)) { + // set the owning side to null (unless already changed) + if ($code->getProduct() === $this) { + $code->setProduct(null); + } + } + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Issue5998/ProductCode.php b/tests/Fixtures/TestBundle/Entity/Issue5998/ProductCode.php new file mode 100644 index 00000000000..fe828a8bb19 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Issue5998/ProductCode.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\Tests\Fixtures\TestBundle\Entity\Issue5998; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ApiResource] +#[Get] +#[ORM\Entity] +class ProductCode +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + #[ORM\Column(length: 180)] + #[Groups(['product:write'])] + private ?string $type = null; + + #[ORM\Column(length: 180)] + #[Groups(['product:write'])] + private ?string $value = null; + + #[ORM\ManyToOne(inversedBy: 'codes')] + #[ORM\JoinColumn(nullable: false)] + private ?Issue5998Product $product = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getType(): ?string + { + return $this->type; + } + + public function setType(string $type): void + { + $this->type = $type; + } + + public function getValue(): ?string + { + return $this->value; + } + + public function setValue(?string $value): void + { + $this->value = $value; + } + + public function getProduct(): ?Issue5998Product + { + return $this->product; + } + + public function setProduct(?Issue5998Product $product): void + { + $this->product = $product; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Issue5998/SaveProduct.php b/tests/Fixtures/TestBundle/Entity/Issue5998/SaveProduct.php new file mode 100644 index 00000000000..d75b243ce7b --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Issue5998/SaveProduct.php @@ -0,0 +1,54 @@ + + * + * 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\Entity\Issue5998; + +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Symfony\Component\Serializer\Annotation\Groups; + +class SaveProduct +{ + /** + * @var Collection + */ + #[Groups(['product:write'])] + private Collection $codes; + + public function __construct() + { + $this->codes = new ArrayCollection(); + } + + /** + * @return Collection + */ + public function getCodes(): Collection + { + return $this->codes; + } + + public function addCode(ProductCode $code): void + { + if (!$this->codes->contains($code)) { + $this->codes->add($code); + } + } + + public function removeCode(ProductCode $code): void + { + if ($this->codes->contains($code)) { + $this->codes->removeElement($code); + } + } +} diff --git a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php index a6e60f79169..79fef63b979 100644 --- a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php +++ b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php @@ -86,7 +86,7 @@ class RelatedDummy extends ParentDummy implements \Stringable #[ApiFilter(filterClass: DateFilter::class)] public $dummyDate; - #[ORM\ManyToOne(targetEntity: ThirdLevel::class, cascade: ['persist'])] + #[ORM\ManyToOne(targetEntity: ThirdLevel::class, cascade: ['persist'], inversedBy: 'relatedDummies')] #[Groups(['barcelona', 'chicago', 'friends'])] public ?ThirdLevel $thirdLevel = null; diff --git a/tests/Fixtures/TestBundle/Entity/ThirdLevel.php b/tests/Fixtures/TestBundle/Entity/ThirdLevel.php index 1ca2e4c6f43..ba099ce257e 100644 --- a/tests/Fixtures/TestBundle/Entity/ThirdLevel.php +++ b/tests/Fixtures/TestBundle/Entity/ThirdLevel.php @@ -16,6 +16,8 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Link; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation\Groups; @@ -51,6 +53,14 @@ class ThirdLevel #[ORM\ManyToOne(targetEntity: FourthLevel::class, cascade: ['persist'])] public $badFourthLevel; + #[ORM\OneToMany(mappedBy: 'thirdLevel', targetEntity: RelatedDummy::class)] + public Collection|iterable $relatedDummies; + + public function __construct() + { + $this->relatedDummies = new ArrayCollection(); + } + public function getId(): ?int { return $this->id; diff --git a/tests/Fixtures/TestBundle/Entity/WrappedResponseEntity.php b/tests/Fixtures/TestBundle/Entity/WrappedResponseEntity.php new file mode 100644 index 00000000000..18807eb008f --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/WrappedResponseEntity.php @@ -0,0 +1,31 @@ + + * + * 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\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Tests\Fixtures\TestBundle\Dto\CustomOutputEntityWrapperDto; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ApiResource(operations: [new Get(normalizationContext: ['groups' => ['read']], output: CustomOutputEntityWrapperDto::class +)])] +#[ORM\Entity] +class WrappedResponseEntity +{ + #[ORM\Id] + #[ORM\Column(type: 'guid')] + #[Groups(['read'])] + public $id; +} diff --git a/tests/Fixtures/TestBundle/Serializer/Normalizer/OverrideDocumentationNormalizer.php b/tests/Fixtures/TestBundle/Serializer/Normalizer/OverrideDocumentationNormalizer.php index 792a91149c0..542171905dd 100644 --- a/tests/Fixtures/TestBundle/Serializer/Normalizer/OverrideDocumentationNormalizer.php +++ b/tests/Fixtures/TestBundle/Serializer/Normalizer/OverrideDocumentationNormalizer.php @@ -31,7 +31,7 @@ public function __construct(private readonly NormalizerInterface $documentationN * * @throws ExceptionInterface */ - public function normalize($object, $format = null, array $context = []) + public function normalize($object, $format = null, array $context = []): \ArrayObject|array|string|int|float|bool|null { $data = $this->documentationNormalizer->normalize($object, $format, $context); if (!\is_array($data)) { @@ -50,8 +50,13 @@ public function normalize($object, $format = null, array $context = []) /** * @param mixed|null $format */ - public function supportsNormalization($data, $format = null): bool + public function supportsNormalization($data, $format = null, array $context = []): bool { - return $this->documentationNormalizer->supportsNormalization($data, $format); + return $this->documentationNormalizer->supportsNormalization($data, $format, $context); + } + + public function getSupportedTypes(?string $format): array + { + return []; } } diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index a7917cfaa6f..c4e3184104b 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -113,6 +113,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load ], ]; + $cookie = ['cookie_secure' => true, 'cookie_samesite' => 'lax', 'handler_id' => 'session.handler.native_file']; // This class is introduced in Symfony 6.4 just using it to use the new configuration and to avoid unnecessary deprecations if (class_exists(PingWebhookMessageHandler::class)) { $config = [ @@ -120,7 +121,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load 'validation' => ['enable_attributes' => true, 'email_validation_mode' => 'html5'], 'serializer' => ['enable_attributes' => true], 'test' => null, - 'session' => ['cookie_secure' => true, 'cookie_samesite' => 'lax', 'handler_id' => 'session.handler.native_file'], + 'session' => class_exists(SessionFactory::class) ? ['storage_factory_id' => 'session.storage.factory.mock_file'] + $cookie : ['storage_id' => 'session.storage.mock_file'] + $cookie, 'profiler' => [ 'enabled' => true, 'collect' => false, @@ -301,8 +302,10 @@ protected function build(ContainerBuilder $container): void $container->addCompilerPass(new class() implements CompilerPassInterface { public function process(ContainerBuilder $container): void { - // Deprecated command triggering a Symfony depreciation - $container->removeDefinition(TailCursorDoctrineODMCommand::class); + if ($container->hasDefinition(TailCursorDoctrineODMCommand::class)) { // @phpstan-ignore-line + // Deprecated command triggering a Symfony depreciation + $container->removeDefinition(TailCursorDoctrineODMCommand::class); // @phpstan-ignore-line + } } }); } diff --git a/tests/Fixtures/app/config/routing_common.yml b/tests/Fixtures/app/config/routing_common.yml index 328ff79b751..8e1d043fe81 100644 --- a/tests/Fixtures/app/config/routing_common.yml +++ b/tests/Fixtures/app/config/routing_common.yml @@ -3,7 +3,7 @@ _main: controller: resource: '@TestBundle/Controller/Common' - type: annotation + type: attribute relation_embedded.custom_get: path: '/relation_embedders/{id}/custom' diff --git a/tests/Fixtures/app/config/routing_mongodb.yml b/tests/Fixtures/app/config/routing_mongodb.yml index 85c7cccf021..1ac9a8ad499 100644 --- a/tests/Fixtures/app/config/routing_mongodb.yml +++ b/tests/Fixtures/app/config/routing_mongodb.yml @@ -3,7 +3,7 @@ _main: controller: resource: '@TestBundle/Controller/MongoDbOdm' - type: annotation + type: attribute web_profiler_wdt: resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' diff --git a/tests/Fixtures/app/config/routing_test.yml b/tests/Fixtures/app/config/routing_test.yml index 8f1996d8a75..094ea139e08 100644 --- a/tests/Fixtures/app/config/routing_test.yml +++ b/tests/Fixtures/app/config/routing_test.yml @@ -3,7 +3,7 @@ _main: controller: resource: '@TestBundle/Controller/Orm' - type: annotation + type: attribute web_profiler_wdt: resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' diff --git a/tests/Hal/Serializer/ItemNormalizerTest.php b/tests/Hal/Serializer/ItemNormalizerTest.php index c3d5266d39e..071481201fd 100644 --- a/tests/Hal/Serializer/ItemNormalizerTest.php +++ b/tests/Hal/Serializer/ItemNormalizerTest.php @@ -35,6 +35,7 @@ use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; @@ -359,7 +360,7 @@ public function testMaxDepth(): void $resourceClassResolverProphecy->reveal(), null, null, - new ClassMetadataFactory(new AnnotationLoader()) + new ClassMetadataFactory(class_exists(AttributeLoader::class) ? new AttributeLoader() : new AnnotationLoader()) ); $serializer = new Serializer([$normalizer]); $normalizer->setSerializer($serializer); diff --git a/tests/Hydra/Serializer/CollectionFiltersNormalizerTest.php b/tests/Hydra/Serializer/CollectionFiltersNormalizerTest.php index f1d9b487f9f..b82c8bb4d84 100644 --- a/tests/Hydra/Serializer/CollectionFiltersNormalizerTest.php +++ b/tests/Hydra/Serializer/CollectionFiltersNormalizerTest.php @@ -30,7 +30,6 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Container\ContainerInterface; -use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Serializer; @@ -47,10 +46,6 @@ class CollectionFiltersNormalizerTest extends TestCase public function testSupportsNormalization(): void { $decoratedProphecy = $this->prophesize(NormalizerInterface::class); - if (!method_exists(Serializer::class, 'getSupportedTypes')) { - $decoratedProphecy->willImplement(CacheableSupportsMethodInterface::class); - $decoratedProphecy->hasCacheableSupportsMethod()->willReturn(true)->shouldBeCalled(); - } $decoratedProphecy->supportsNormalization('foo', 'abc', Argument::type('array'))->willReturn(true)->shouldBeCalled(); $normalizer = new CollectionFiltersNormalizer( @@ -61,10 +56,6 @@ public function testSupportsNormalization(): void ); $this->assertTrue($normalizer->supportsNormalization('foo', 'abc')); - - if (!method_exists(Serializer::class, 'getSupportedTypes')) { - $this->assertTrue($normalizer->hasCacheableSupportsMethod()); - } } public function testNormalizeNonResourceCollection(): void @@ -344,12 +335,12 @@ public function testGetSupportedTypes(): void // TODO: use prophecy when getSupportedTypes() will be added to the interface $normalizer = new CollectionFiltersNormalizer( new class() implements NormalizerInterface { - public function normalize(mixed $object, string $format = null, array $context = []) + public function normalize(mixed $object, string $format = null, array $context = []): \ArrayObject|array|string|int|float|bool|null { return null; } - public function supportsNormalization(mixed $data, string $format = null): bool + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool { return true; } diff --git a/tests/Hydra/Serializer/PartialCollectionViewNormalizerTest.php b/tests/Hydra/Serializer/PartialCollectionViewNormalizerTest.php index 5882078a905..8311de25783 100644 --- a/tests/Hydra/Serializer/PartialCollectionViewNormalizerTest.php +++ b/tests/Hydra/Serializer/PartialCollectionViewNormalizerTest.php @@ -25,7 +25,6 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Serializer; @@ -165,20 +164,12 @@ private function normalizePaginator(bool $partial = false, bool $cursor = false) public function testSupportsNormalization(): void { $decoratedNormalizerProphecy = $this->prophesize(NormalizerInterface::class); - if (!method_exists(Serializer::class, 'getSupportedTypes')) { - $decoratedNormalizerProphecy->willImplement(CacheableSupportsMethodInterface::class); - $decoratedNormalizerProphecy->hasCacheableSupportsMethod()->willReturn(true)->shouldBeCalled(); - } $decoratedNormalizerProphecy->supportsNormalization(Argument::any(), null, Argument::type('array'))->willReturn(true)->shouldBeCalled(); $resourceMetadataFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $normalizer = new PartialCollectionViewNormalizer($decoratedNormalizerProphecy->reveal(), 'page', 'pagination', $resourceMetadataFactory->reveal()); $this->assertTrue($normalizer->supportsNormalization(new \stdClass())); - - if (!method_exists(Serializer::class, 'getSupportedTypes')) { - $this->assertTrue($normalizer->hasCacheableSupportsMethod()); - } } public function testSetNormalizer(): void @@ -202,12 +193,12 @@ public function testGetSupportedTypes(): void // TODO: use prophecy when getSupportedTypes() will be added to the interface $normalizer = new PartialCollectionViewNormalizer(new class() implements NormalizerInterface { - public function normalize(mixed $object, string $format = null, array $context = []) + public function normalize(mixed $object, string $format = null, array $context = []): \ArrayObject|array|string|int|float|bool|null { return null; } - public function supportsNormalization(mixed $data, string $format = null): bool + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool { return true; } diff --git a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php index 75ed827f8fb..27632d900cf 100644 --- a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php +++ b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php @@ -138,4 +138,16 @@ public function testArraySchemaWithTypeFactory(): void $this->assertEquals($json['definitions']['Foo.jsonld']['properties']['expiration'], ['type' => 'string', 'format' => 'date']); } + + /** + * Test issue #5998. + */ + public function testWritableNonResourceRef(): void + { + $this->tester->run(['command' => 'api:json-schema:generate', 'resource' => 'ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5998\SaveProduct', '--type' => 'input']); + $result = $this->tester->getDisplay(); + $json = json_decode($result, associative: true); + + $this->assertEquals($json['definitions']['SaveProduct.jsonld']['properties']['codes']['items']['$ref'], '#/definitions/ProductCode.jsonld'); + } } diff --git a/tests/Symfony/Bundle/Twig/ApiPlatformProfilerPanelTest.php b/tests/Symfony/Bundle/Twig/ApiPlatformProfilerPanelTest.php index 81a8989181f..f8e65642012 100644 --- a/tests/Symfony/Bundle/Twig/ApiPlatformProfilerPanelTest.php +++ b/tests/Symfony/Bundle/Twig/ApiPlatformProfilerPanelTest.php @@ -19,6 +19,7 @@ use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Tools\SchemaTool; use Doctrine\Persistence\ManagerRegistry; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; /** @@ -26,6 +27,7 @@ */ class ApiPlatformProfilerPanelTest extends WebTestCase { + use ExpectDeprecationTrait; private EntityManagerInterface $manager; private SchemaTool $schemaTool; private string $env; @@ -81,6 +83,9 @@ public function testDebugBarContentNotResourceClass(): void $this->assertSame('Not an API Platform resource', $block->filterXPath('//div[@class="sf-toolbar-info-piece"][./b[contains(., "Resource Class")]]/span')->html()); } + /** + * @group legacy + */ public function testDebugBarContent(): void { $client = static::createClient(); diff --git a/tests/Symfony/Controller/MainControllerTest.php b/tests/Symfony/Controller/MainControllerTest.php new file mode 100644 index 00000000000..64209b0bc0a --- /dev/null +++ b/tests/Symfony/Controller/MainControllerTest.php @@ -0,0 +1,157 @@ + + * + * 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\Controller; + +use ApiPlatform\Metadata\Error; +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Symfony\Controller\MainController; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +class MainControllerTest extends TestCase +{ + public function testControllerNotSupported(): void + { + $this->expectException(RuntimeException::class); + $resourceMetadataFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $provider = $this->createMock(ProviderInterface::class); + $processor = $this->createMock(ProcessorInterface::class); + $controller = new MainController($resourceMetadataFactory, $provider, $processor); + $controller->__invoke(new Request()); + } + + public function testController(): void + { + $resourceMetadataFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $provider = $this->createMock(ProviderInterface::class); + $processor = $this->createMock(ProcessorInterface::class); + $controller = new MainController($resourceMetadataFactory, $provider, $processor); + + $body = new \stdClass(); + $response = new Response(); + $request = new Request(); + $request->attributes->set('_api_operation', new Get()); + + $provider->expects($this->once()) + ->method('provide') + ->willReturn($body); + + $processor->expects($this->once()) + ->method('process') + ->willReturn($response); + + $this->assertEquals($response, $controller->__invoke($request)); + } + + public function testControllerWithNonExistentUriVariables(): void + { + $this->expectException(NotFoundHttpException::class); + $resourceMetadataFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $provider = $this->createMock(ProviderInterface::class); + $processor = $this->createMock(ProcessorInterface::class); + $controller = new MainController($resourceMetadataFactory, $provider, $processor); + + $body = new \stdClass(); + $response = new Response(); + $request = new Request(); + $request->attributes->set('_api_operation', new Get(uriVariables: ['id' => new Link()])); + + $controller->__invoke($request); + } + + public function testControllerWithUriVariables(): void + { + $resourceMetadataFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $provider = $this->createMock(ProviderInterface::class); + $processor = $this->createMock(ProcessorInterface::class); + $controller = new MainController($resourceMetadataFactory, $provider, $processor); + + $body = new \stdClass(); + $response = new Response(); + $request = new Request(); + $request->attributes->set('_api_operation', new Get(uriVariables: ['id' => new Link()])); + $request->attributes->set('id', 0); + + $provider->expects($this->once()) + ->method('provide') + ->willReturn($body); + + $processor->expects($this->once()) + ->method('process') + ->willReturn($response); + + $this->assertEquals($response, $controller->__invoke($request)); + } + + public function testControllerErrorWithUriVariables(): void + { + $resourceMetadataFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $provider = $this->createMock(ProviderInterface::class); + $processor = $this->createMock(ProcessorInterface::class); + $controller = new MainController($resourceMetadataFactory, $provider, $processor); + + $body = new \stdClass(); + $response = new Response(); + $request = new Request(); + $request->attributes->set('_api_operation', new Error(uriVariables: ['id' => new Link()])); + + $provider->expects($this->once()) + ->method('provide') + ->willReturn($body); + + $processor->expects($this->once()) + ->method('process') + ->willReturn($response); + + $this->assertEquals($response, $controller->__invoke($request)); + } + + public function testControllerErrorWithUriVariablesDuringProvider(): void + { + $resourceMetadataFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $provider = $this->createMock(ProviderInterface::class); + $logger = $this->createMock(LoggerInterface::class); + $processor = $this->createMock(ProcessorInterface::class); + $controller = new MainController($resourceMetadataFactory, $provider, $processor, logger: $logger); + + $response = new Response(); + $request = new Request(); + $request->attributes->set('_api_operation', new Get(uriVariables: ['id' => new Link()])); + $request->attributes->set('id', '1'); + + $provider->expects($this->once()) + ->method('provide') + ->willReturnCallback(function () use ($request) { + $request->attributes->set('_api_operation', new Error(uriVariables: ['status' => new Link()])); + $request->attributes->remove('id'); + + return new \stdClass(); + }); + + $logger->expects($this->never())->method('error'); + $processor->expects($this->once()) + ->method('process') + ->willReturn($response); + + $this->assertEquals($response, $controller->__invoke($request)); + } +} diff --git a/tests/Symfony/Routing/RouterTest.php b/tests/Symfony/Routing/RouterTest.php index 0ca90f5d784..3ddfb0f8bae 100644 --- a/tests/Symfony/Routing/RouterTest.php +++ b/tests/Symfony/Routing/RouterTest.php @@ -115,7 +115,7 @@ public function testMatchDuplicatedBaseUrl(): void $mockedRouter = $this->prophesize(RouterInterface::class); $mockedRouter->getContext()->willReturn($context); - $mockedRouter->setContext(Argument::type(RequestContext::class))->willReturn(); + $mockedRouter->setContext(Argument::type(RequestContext::class))->shouldBeCalled(); $mockedRouter->match('/api/app_crm/resource')->willReturn(['bar']); $router = new Router($mockedRouter->reveal()); @@ -129,7 +129,7 @@ public function testMatchEmptyBaseUrl(): void $mockedRouter = $this->prophesize(RouterInterface::class); $mockedRouter->getContext()->willReturn($context); - $mockedRouter->setContext(Argument::type(RequestContext::class))->willReturn(); + $mockedRouter->setContext(Argument::type(RequestContext::class))->shouldBeCalled(); $mockedRouter->match('/foo')->willReturn(['bar']); $router = new Router($mockedRouter->reveal()); diff --git a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php index bbf45247146..73901abef57 100644 --- a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php +++ b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php @@ -58,8 +58,7 @@ public static function supportsProvider(): \Generator public function testCreate(): void { self::assertEquals([ - 'minimum' => 10, - 'exclusiveMinimum' => true, + 'exclusiveMinimum' => 10, ], $this->propertySchemaGreaterThanRestriction->create(new GreaterThan(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]))); } } diff --git a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php index 84e2c1daa64..80409cd3a5f 100644 --- a/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php +++ b/tests/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php @@ -58,8 +58,7 @@ public static function supportsProvider(): \Generator public function testCreate(): void { self::assertEquals([ - 'maximum' => 10, - 'exclusiveMaximum' => true, + 'exclusiveMaximum' => 10, ], $this->propertySchemaLessThanRestriction->create(new LessThan(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]))); } } diff --git a/tests/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php b/tests/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php index 1a343e47054..e4027607afb 100644 --- a/tests/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php +++ b/tests/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php @@ -42,7 +42,6 @@ use ApiPlatform\Tests\Fixtures\DummyValidatedEntity; use ApiPlatform\Tests\Fixtures\DummyValidatedHostnameEntity; use ApiPlatform\Tests\Fixtures\DummyValidatedUlidEntity; -use Doctrine\Common\Annotations\AnnotationReader; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\PropertyInfo\Type; @@ -51,6 +50,7 @@ use Symfony\Component\Validator\Mapping\ClassMetadata; use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface; use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; /** * @author Baptiste Meyer @@ -64,7 +64,7 @@ class ValidatorPropertyMetadataFactoryTest extends TestCase protected function setUp(): void { $this->validatorClassMetadata = new ClassMetadata(DummyValidatedEntity::class); - (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($this->validatorClassMetadata); + (class_exists(AttributeLoader::class) ? new AttributeLoader() : new AnnotationLoader())->loadClassMetadata($this->validatorClassMetadata); } public function testCreateWithPropertyWithRequiredConstraints(): void @@ -218,7 +218,7 @@ public function testCreateWithRequiredByDecorated(): void public function testCreateWithPropertyWithValidationConstraints(): void { $validatorClassMetadata = new ClassMetadata(DummyIriWithValidationEntity::class); - (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + (class_exists(AttributeLoader::class) ? new AttributeLoader() : new AnnotationLoader())->loadClassMetadata($validatorClassMetadata); $types = [ 'dummyUrl' => 'https://schema.org/url', @@ -260,7 +260,7 @@ public function testCreateWithPropertyWithValidationConstraints(): void public function testCreateWithPropertyLengthRestriction(): void { $validatorClassMetadata = new ClassMetadata(DummyValidatedEntity::class); - (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + (class_exists(AttributeLoader::class) ? new AttributeLoader() : new AnnotationLoader())->loadClassMetadata($validatorClassMetadata); $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class) @@ -287,7 +287,7 @@ public function testCreateWithPropertyLengthRestriction(): void public function testCreateWithPropertyRegexRestriction(): void { $validatorClassMetadata = new ClassMetadata(DummyValidatedEntity::class); - (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + (class_exists(AttributeLoader::class) ? new AttributeLoader() : new AnnotationLoader())->loadClassMetadata($validatorClassMetadata); $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class) @@ -316,7 +316,7 @@ public function testCreateWithPropertyRegexRestriction(): void public function testCreateWithPropertyFormatRestriction(string $property, string $class, array $expectedSchema): void { $validatorClassMetadata = new ClassMetadata($class); - (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + (class_exists(AttributeLoader::class) ? new AttributeLoader() : new AnnotationLoader())->loadClassMetadata($validatorClassMetadata); $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor($class) @@ -355,7 +355,7 @@ public static function providePropertySchemaFormatCases(): \Generator public function testCreateWithSequentiallyConstraint(): void { $validatorClassMetadata = new ClassMetadata(DummySequentiallyValidatedEntity::class); - (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + (class_exists(AttributeLoader::class) ? new AttributeLoader() : new AnnotationLoader())->loadClassMetadata($validatorClassMetadata); $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummySequentiallyValidatedEntity::class) @@ -382,7 +382,7 @@ public function testCreateWithSequentiallyConstraint(): void public function testCreateWithCompoundConstraint(): void { $validatorClassMetadata = new ClassMetadata(DummyCompoundValidatedEntity::class); - (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + (class_exists(AttributeLoader::class) ? new AttributeLoader() : new AnnotationLoader())->loadClassMetadata($validatorClassMetadata); $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyCompoundValidatedEntity::class) @@ -409,7 +409,7 @@ public function testCreateWithCompoundConstraint(): void public function testCreateWithAtLeastOneOfConstraint(): void { $validatorClassMetadata = new ClassMetadata(DummyAtLeastOneOfValidatedEntity::class); - (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + (class_exists(AttributeLoader::class) ? new AttributeLoader() : new AnnotationLoader())->loadClassMetadata($validatorClassMetadata); $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyAtLeastOneOfValidatedEntity::class) @@ -440,7 +440,7 @@ public function testCreateWithAtLeastOneOfConstraint(): void public function testCreateWithPropertyUniqueRestriction(): void { $validatorClassMetadata = new ClassMetadata(DummyUniqueValidatedEntity::class); - (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + (class_exists(AttributeLoader::class) ? new AttributeLoader() : new AnnotationLoader())->loadClassMetadata($validatorClassMetadata); $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyUniqueValidatedEntity::class) @@ -469,7 +469,7 @@ public function testCreateWithPropertyUniqueRestriction(): void public function testCreateWithRangeConstraint(Type $type, string $property, array $expectedSchema): void { $validatorClassMetadata = new ClassMetadata(DummyRangeValidatedEntity::class); - (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + (class_exists(AttributeLoader::class) ? new AttributeLoader() : new AnnotationLoader())->loadClassMetadata($validatorClassMetadata); $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyRangeValidatedEntity::class) @@ -506,7 +506,7 @@ public static function provideRangeConstraintCases(): \Generator public function testCreateWithPropertyChoiceRestriction(ApiProperty $propertyMetadata, string $property, array $expectedSchema): void { $validatorClassMetadata = new ClassMetadata(DummyValidatedChoiceEntity::class); - (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + (class_exists(AttributeLoader::class) ? new AttributeLoader() : new AnnotationLoader())->loadClassMetadata($validatorClassMetadata); $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyValidatedChoiceEntity::class) @@ -545,7 +545,7 @@ public static function provideChoiceConstraintCases(): \Generator public function testCreateWithPropertyCountRestriction(string $property, array $expectedSchema): void { $validatorClassMetadata = new ClassMetadata(DummyCountValidatedEntity::class); - (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + (class_exists(AttributeLoader::class) ? new AttributeLoader() : new AnnotationLoader())->loadClassMetadata($validatorClassMetadata); $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyCountValidatedEntity::class) @@ -577,7 +577,7 @@ public static function provideCountConstraintCases(): \Generator public function testCreateWithPropertyCollectionRestriction(): void { $validatorClassMetadata = new ClassMetadata(DummyCollectionValidatedEntity::class); - (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + (class_exists(AttributeLoader::class) ? new AttributeLoader() : new AnnotationLoader())->loadClassMetadata($validatorClassMetadata); $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyCollectionValidatedEntity::class) @@ -643,7 +643,7 @@ public function testCreateWithPropertyCollectionRestriction(): void public function testCreateWithPropertyNumericRestriction(ApiProperty $propertyMetadata, string $property, array $expectedSchema): void { $validatorClassMetadata = new ClassMetadata(DummyNumericValidatedEntity::class); - (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + (class_exists(AttributeLoader::class) ? new AttributeLoader() : new AnnotationLoader())->loadClassMetadata($validatorClassMetadata); $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyNumericValidatedEntity::class) @@ -675,7 +675,7 @@ public static function provideNumericConstraintCases(): \Generator yield [ 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), 'property' => 'greaterThanMe', - 'expectedSchema' => ['minimum' => 10, 'exclusiveMinimum' => true], + 'expectedSchema' => ['exclusiveMinimum' => 10], ]; yield [ @@ -687,7 +687,7 @@ public static function provideNumericConstraintCases(): \Generator yield [ 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), 'property' => 'lessThanMe', - 'expectedSchema' => ['maximum' => 99, 'exclusiveMaximum' => true], + 'expectedSchema' => ['exclusiveMaximum' => 99], ]; yield [ @@ -699,7 +699,7 @@ public static function provideNumericConstraintCases(): \Generator yield [ 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), 'property' => 'positive', - 'expectedSchema' => ['minimum' => 0, 'exclusiveMinimum' => true], + 'expectedSchema' => ['exclusiveMinimum' => 0], ]; yield [ @@ -711,7 +711,7 @@ public static function provideNumericConstraintCases(): \Generator yield [ 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]), 'property' => 'negative', - 'expectedSchema' => ['maximum' => 0, 'exclusiveMaximum' => true], + 'expectedSchema' => ['exclusiveMaximum' => 0], ]; yield [