diff --git a/CHANGELOG.md b/CHANGELOG.md index f3dfccfed4d..5dc3d174e07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,28 +13,38 @@ * OpenAPI: Add PHP default values to the documentation (#2386) * Deprecate using a validation groups generator service not implementing `ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface` (#3346) +## 2.5.6 + +* Add support for Mercure 0.10 (#3584) +* Allow objects without properties (#3544) +* Fix Ramsey uuid denormalization (#3473) +* Revert #3331 as it breaks backwards compatibility +* Handle deprecations from Doctrine Inflector (#3564) +* JSON Schema: Missing JSON-LD context from Data Transformers (#3479) +* GraphQL: Resource with no operations should be available through relations (#3532) + ## 2.5.5 -* Filter: Improve the RangeFilter query in case the values are equals using the between operator #3488 -* Pagination: Fix bug with large values #3451 -* Doctrine: use the correct type within `setParameter` of the SearchFilter #3331 -* Allow `\Traversable` resources #3463 -* Hydra: `hydra:writable` => `hydra:writeable` #3481 -* Hydra: Show `hydra:next` only when it's available #3457 -* Swagger UI: Missing default context argument #3443 -* Swagger UI: Fix API docs path in swagger ui #3475 -* OpenAPI: Export with unescaped slashes #3368 -* OpenAPI: OAuth flows fix #3333 -* JSON Schema: Fix metadata options #3425 -* JSON Schema: Allow decoration #3417 -* JSON Schema: Add DateInterval type #3351 -* JSON Schema: Correct schema generation for many types #3402 -* Validation: Use API Platform's `ValidationException` instead of Symfony's #3414 -* Validation: Fix a bug preventing to serialize validator's payload #3375 -* Subresources: Improve queries when there's only one level #3396 -* HTTP: Location header is only set on POST with a 201 or between 300 and 400 #3497 -* GraphQL: Do not allow empty cursor values on `before` or `after` #3360 -* Bump versions of Swagger UI, GraphiQL and GraphQL Playground #3510 +* Filter: Improve the RangeFilter query in case the values are equals using the between operator (#3488) +* Pagination: Fix bug with large values (#3451) +* Doctrine: use the correct type within `setParameter` of the SearchFilter (#3331) +* Allow `\Traversable` resources (#3463) +* Hydra: `hydra:writable` => `hydra:writeable` (#3481) +* Hydra: Show `hydra:next` only when it's available (#3457) +* Swagger UI: Missing default context argument (#3443) +* Swagger UI: Fix API docs path in swagger ui (#3475) +* OpenAPI: Export with unescaped slashes (#3368) +* OpenAPI: OAuth flows fix (#3333) +* JSON Schema: Fix metadata options (#3425) +* JSON Schema: Allow decoration (#3417) +* JSON Schema: Add DateInterval type (#3351) +* JSON Schema: Correct schema generation for many types (#3402) +* Validation: Use API Platform's `ValidationException` instead of Symfony's (#3414) +* Validation: Fix a bug preventing to serialize validator's payload (#3375) +* Subresources: Improve queries when there's only one level (#3396) +* HTTP: Location header is only set on POST with a 201 or between 300 and 400 (#3497) +* GraphQL: Do not allow empty cursor values on `before` or `after` (#3360) +* Bump versions of Swagger UI, GraphiQL and GraphQL Playground (#3510) ## 2.5.4 @@ -60,7 +70,7 @@ * Compatibility with Symfony 5 beta * Fix a notice in `SerializerContextBuilder` * Fix dashed path segment generation -* Fix support for custom filters without constructor in the `@ApiFilter` annotation +* Fix support for custom filters without constructors in the `@ApiFilter` annotation * Fix a bug that was preventing to disable Swagger/OpenAPI * Return a `404` HTTP status code instead of `500` whe the identifier is invalid (e.g.: invalid UUID) * Add links to the documentation in `@ApiResource` annotation's attributes to improve DX @@ -88,9 +98,9 @@ * Allow to not declare GET item operation * Add support for the Accept-Patch header -* Make the the `maximum_items_per_page` attribute consistent with other attributes controlling pagination +* Make the `maximum_items_per_page` attribute consistent with other attributes controlling pagination * Allow to use a string instead of an array for serializer groups -* Test: Add an helper method to find the IRI of a resource +* Test: Add a helper method to find the IRI of a resource * Test: Add assertions for testing response against JSON Schema from API resource * GraphQL: Add support for multipart request so user can create custom file upload mutations (#3041) * GraphQL: Add support for name converter (#2765) @@ -103,7 +113,7 @@ * Add infrastructure to generate a JSON Schema from a Resource `ApiPlatform\Core\JsonSchema\SchemaFactoryInterface` (#2983) * Replaces `access_control` by `security` and adds a `security_post_denormalize` attribute (#2992) * Add basic infrastructure for cursor-based pagination (#2532) -* Change ExistsFilter syntax to `exists[property]`, old syntax still supported see #2243, fixes it's behavior on GraphQL (also related #2640). +* Change ExistsFilter syntax to `exists[property]`, old syntax still supported see #2243, fixes its behavior on GraphQL (also related #2640). * Pagination with subresources (#2698) * Improve search filter id's management (#1844) * Add support of name converter in filters (#2751, #2897), filter signature in abstract methods has changed see b42dfd198b1644904fd6a684ab2cedaf530254e3 @@ -147,10 +157,10 @@ Please read #2825 if you have issues with the behavior of Readable/Writable Link * Varnish: Prevent cache miss by generating IRI for child related resources * Messenger: Unwrap exception thrown in handler for Symfony Messenger 4.3 * Fix remaining Symfony 4.3 deprecation notices -* Prevent cloning non clonable objects in `previous_data` +* Prevent cloning non cloneable objects in `previous_data` * Return a 415 HTTP status code instead of a 406 one when a faulty `Content-Type` is sent * Fix `WriteListener` trying to generate IRI for non-resources -* Allow to extract blank values from composite identifier +* Allow extracting blank values from composite identifier ## 2.4.5 @@ -318,18 +328,18 @@ Please read #2825 if you have issues with the behavior of Readable/Writable Link * OpenAPI: support generating documentation using [ReDoc](https://github.com/Rebilly/ReDoc) * OpenAPI: basic hypermedia hints using OpenAPI v3 links * OpenAPI: expose the pagination controls -* Allow to use custom classes for input and output (DTO) with the `input_class` and `output_class` attributes -* Allow to disable the input or the output by setting `input_class` and `output_class` to false +* Allow using custom classes for input and output (DTO) with the `input_class` and `output_class` attributes +* Allow disabling the input or the output by setting `input_class` and `output_class` to false * Guess and automatically set the appropriate Schema.org IRIs for common validation constraints -* Allow to set custom cache HTTP headers using the `cache_headers` attribute -* Allow to set the HTTP status code to send to the client through the `status` attribute +* Allow setting custom cache HTTP headers using the `cache_headers` attribute +* Allow setting the HTTP status code to send to the client through the `status` attribute * Add support for the `Sunset` HTTP header using the `sunset` attribute * Set the `Content-Location` and `Location` headers when appropriate for better RFC7231 conformance * Display the matching data provider and data persister in the debug panel * GraphQL: improve performance by lazy loading types * Add the `api_persist` request attribute to enable or disable the `WriteListener` -* Allow to set a default context in all normalizers -* Permit to use a string instead of an array when there is only one serialization group +* Allow setting a default context in all normalizers +* Permit using a string instead of an array when there is only one serialization group * Add support for setting relations using the constructor of the resource classes * Automatically set a [409 Conflict](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/409) HTTP status code when an `OptimisticLockException` is thrown * Resolve Dependency Injection Container parameters in the XML and YAML files for the resource class configuration @@ -354,7 +364,7 @@ Please read #2825 if you have issues with the behavior of Readable/Writable Link * OpenAPI/Swagger: add a description for the `properties[]` filter * OpenAPI/Swagger: Leverage advanced name converters * JSON-LD: Prevent an error in `ItemNormalizer` when `$context['resource_class']` is not defined -* Allow to pass a the serialization group to use a string instead of as an array of one element +* Allow to pass the serialization group to use a string instead of as an array of one element * Modernize the code base to use PHP 7.1 features when possible * Bump minimal dependencies of the used Symfony components * Improve the Packagist description @@ -373,7 +383,7 @@ Please read #2825 if you have issues with the behavior of Readable/Writable Link * Throw an `InvalidArgumentException` when trying to get an item from a collection route * Improve the debug bar panel visibility * Take into account the `route_prefix` attribute in subresources -* Allow to use multiple values with `NumericFilter` +* Allow using multiple values with `NumericFilter` * Improve exception handling in `ReadListener` by adding the previous exception ## 2.3.3 @@ -412,7 +422,7 @@ Please read #2825 if you have issues with the behavior of Readable/Writable Link * Add support for deprecating resources, operations and fields in GraphQL, Hydra and Swagger * Add API Platform panels in the Symfony profiler and in the web debug toolbar * Make resource class's constructor parameters writable -* Add support for interface as a resource +* Add support for interfaces as resources * Add a shortcut syntax to define attributes at the root of `@ApiResource` and `@ApiProperty` annotations * Throw an exception if a required filter isn't set * Allow to specify the message when access is denied using the `access_control_message` attribute @@ -487,7 +497,7 @@ Please read #2825 if you have issues with the behavior of Readable/Writable Link ## 2.2.5 -* Fix a various issues preventing the metadata cache to work properly (performance fix) +* Fix various issues preventing the metadata cache to work properly (performance fix) * Fix a cache corruption issue when using subresources * Fix non-standard outputs when using the HAL format * Persist data in Doctrine DataPersister only if needed @@ -505,7 +515,7 @@ Please read #2825 if you have issues with the behavior of Readable/Writable Link ## 2.2.3 * Fix object state inconsistency after persistence -* Allow to use multiple `@ApiFilter` annotations on the same class +* Allow using multiple `@ApiFilter` annotations on the same class * Fix a BC break when the serialization context builder depends of the retrieved data * Fix a bug regarding collections handling in the GraphQL endpoint @@ -540,8 +550,8 @@ Please read #2825 if you have issues with the behavior of Readable/Writable Link * Deprecate the `ApiPlatform\Core\Bridge\Doctrine\EventListener\WriteListener` class in favor of the new `ApiPlatform\Core\EventListener\WriteListener` class. * Remove the `api_platform.doctrine.listener.view.write` event listener service. * Add a data persistence layer with a new `ApiPlatform\Core\DataPersister\DataPersisterInterface` interface. -* Add the a new configuration to disable the API entrypoint and the documentation -* Allow to set maximum items per page at operation/resource level +* Add a new configuration to disable the API entrypoint and the documentation +* Allow setting maximum items per page at operation/resource level * Add the ability to customize the message when configuring an access control rule trough the `access_control_message` attribute * Allow empty operations in XML configs @@ -560,9 +570,9 @@ Please read #2825 if you have issues with the behavior of Readable/Writable Link * Add support for the immutable date and time types introduced in Doctrine * Fix the Doctrine query generated to retrieve nested subresources * Fix several bugs in the automatic eager loading support -* Fix a bug occurring when passing neither an IRI nor an array in an embedded relation -* Allow to request `0` items per page in collections -* Also copy the `Host` from the Symfony Router +* Fix a bug occurring when passing neither an IRI, nor an array in an embedded relation +* Allow requesting `0` items per page in collections +* Copy the `Host` from the Symfony Router * `Paginator::getLastPage()` now always returns a `float` * Minor performance improvements * Minor quality fixes @@ -572,7 +582,7 @@ Please read #2825 if you have issues with the behavior of Readable/Writable Link * Symfony 3.4 and 4.0 compatibility * Autowiring strict mode compatibility * Fix a bug preventing to create resource classes in the global namespace -* Fix Doctrine type conversion in filter's WHERE clauses +* Fix Doctrine type conversion in filters WHERE clauses * Fix filters when using eager loading and non-association composite identifier * Fix Doctrine type resolution for identifiers (for custom DBALType) * Add missing Symfony Routing options to operations configuration @@ -627,9 +637,9 @@ Please read #2825 if you have issues with the behavior of Readable/Writable Link * Add a flag to disable all request listeners * Add a default order option in the configuration * Allow to disable all operations using the XML configuration format and deprecate the previous format -* Allow upper cased property names +* Allow upper-cased property names * Improve the overall performance by optimizing `RequestAttributesExtractor` -* Improve the performance of the filters subsystem by using a PSR-11 service locator and deprecate the `FilterCollection` class +* Improve the performance of the filter subsystem by using a PSR-11 service locator and deprecate the `FilterCollection` class * Add compatibility with Symfony Flex and Symfony 4 * Allow the Symfony Dependency Injection component to autoconfigure data providers and query extensions * Allow to use service for dynamic validation groups @@ -727,7 +737,7 @@ Please read #2825 if you have issues with the behavior of Readable/Writable Link * Fix the support of the Symfony's serializer @MaxDepth annotation * Fix property range of relations in the Hydra doc when an IRI is used * Fix an error "api:swagger:export" command when decorating the Swagger normalizer -* Fix an an error in the Swagger documentation generator when a property has several serialization groups +* Fix an error in the Swagger documentation generator when a property has several serialization groups ## 2.0.1 @@ -765,7 +775,7 @@ Please read #2825 if you have issues with the behavior of Readable/Writable Link * Add a range filter * Search filter: add a case sensitivity setting * Search filter: fix the behavior of the search filter when 0 is provided as value -* Search filter: allow to use identifiers different than id +* Search filter: allow using identifiers different from id * Exclude tests from classmap * Fix some deprecations and tests @@ -811,4 +821,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 to set default order for property when registering a `Doctrine\Orm\Filter\OrderFilter` instance +* Allow setting default order for property when registering a `Doctrine\Orm\Filter\OrderFilter` instance diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a58587c9e6f..54d8d51964e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,11 +90,13 @@ Coverage will be available in `coverage/index.html`. The command to launch Behat tests is: - ./vendor/bin/behat --suite=default --stop-on-failure -vvv + php -d memory_limit=-1 ./vendor/bin/behat --suite=default --stop-on-failure --format=progress If you want to launch Behat tests for MongoDB, the command is: - APP_ENV=mongodb ./vendor/bin/behat --suite=mongodb --stop-on-failure -vvv + APP_ENV=mongodb php -d memory_limit=-1 ./vendor/bin/behat --suite=mongodb --stop-on-failure --format=progress + +To get more details about an error, replace `--format=progress` by `-vvv`. ## Squash your Commits diff --git a/features/bootstrap/DoctrineContext.php b/features/bootstrap/DoctrineContext.php index 6ae697eeb29..f1adf28cc70 100644 --- a/features/bootstrap/DoctrineContext.php +++ b/features/bootstrap/DoctrineContext.php @@ -35,6 +35,8 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoCustom as DummyDtoCustomDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoNoInput as DummyDtoNoInputDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoNoOutput as DummyDtoNoOutputDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoOutputFallbackToSameClass as DummyDtoOutputFallbackToSameClassDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoOutputSameClass as DummyDtoOutputSameClassDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyFriend as DummyFriendDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyGroup as DummyGroupDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyMercure as DummyMercureDocument; @@ -72,7 +74,6 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositeLabel; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositePrimitiveItem; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositeRelation; -use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Container; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\ConvertedBoolean; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\ConvertedDate; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\ConvertedInteger; @@ -91,6 +92,8 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoCustom; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoNoInput; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoNoOutput; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoOutputFallbackToSameClass; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoOutputSameClass; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyFriend; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyGroup; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyImmutableDate; @@ -109,7 +112,6 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Greeting; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\InternalUser; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\MaxDepthDummy; -use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Node; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Order; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Person; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\PersonToPet; @@ -1053,25 +1055,6 @@ public function thereIsAnAnswerToTheQuestion(string $a, string $q) $this->manager->clear(); } - /** - * @Given there are :nb nodes in a container :uuid - */ - public function thereAreNodesInAContainer(int $nb, string $uuid) - { - $container = new Container(); - $container->setId($uuid); - $this->manager->persist($container); - - for ($i = 0; $i < $nb; ++$i) { - $node = new Node(); - $node->setContainer($container); - $node->setSerial($i); - $this->manager->persist($node); - } - - $this->manager->flush(); - } - /** * @Then the password :password for user :user should be hashed */ @@ -1381,6 +1364,32 @@ public function thereAreNbDummyDtoCustom($nb) $this->manager->clear(); } + /** + * @Given there is a DummyDtoOutputSameClass + */ + public function thereIsADummyDtoOutputSameClass() + { + $dto = $this->isOrm() ? new DummyDtoOutputSameClass() : new DummyDtoOutputSameClassDocument(); + $dto->lorem = 'test'; + $dto->ipsum = '1'; + $this->manager->persist($dto); + $this->manager->flush(); + $this->manager->clear(); + } + + /** + * @Given there is a DummyDtoOutputFallbackToSameClass + */ + public function thereIsADummyDtoOutputFallbackToSameClass() + { + $dto = $this->isOrm() ? new DummyDtoOutputFallbackToSameClass() : new DummyDtoOutputFallbackToSameClassDocument(); + $dto->lorem = 'test'; + $dto->ipsum = '1'; + $this->manager->persist($dto); + $this->manager->flush(); + $this->manager->clear(); + } + /** * @Given there is an order with same customer and recipient */ @@ -1474,7 +1483,7 @@ public function thereIsTheFollowingProduct(PyStringNode $dataNode): void $product = $this->isOrm() ? new Product() : new ProductDocument(); $product->setCode($data['code']); if (isset($data['mainTaxon'])) { - $mainTaxonCode = str_replace('/taxons/', '', $data['mainTaxon']); + $mainTaxonCode = str_replace('/taxa/', '', $data['mainTaxon']); $mainTaxon = $this->manager->getRepository($this->isOrm() ? Taxon::class : TaxonDocument::class)->findOneBy([ 'code' => $mainTaxonCode, ]); diff --git a/features/graphql/introspection.feature b/features/graphql/introspection.feature index 62c34ae86e2..1867b588c7c 100644 --- a/features/graphql/introspection.feature +++ b/features/graphql/introspection.feature @@ -428,7 +428,7 @@ Feature: GraphQL introspection support And the JSON node "data.typeCreatePayloadData.fields[3].type.name" should be equal to "createDummyGroupNestedPayload" And the JSON node "data.typeCreateNestedPayload.fields[0].name" should be equal to "id" - Scenario: Retrieve an item through a GraphQL query + Scenario: Retrieve a type name through a GraphQL query Given there are 4 dummy objects with relatedDummy When I send the following GraphQL request: """ @@ -449,3 +449,28 @@ Feature: GraphQL introspection support And the JSON node "data.dummy.name" should be equal to "Dummy #3" And the JSON node "data.dummy.relatedDummy.name" should be equal to "RelatedDummy #3" And the JSON node "data.dummy.relatedDummy.__typename" should be equal to "RelatedDummy" + + Scenario: Introspect a type available only through relations + When I send the following GraphQL request: + """ + { + typeNotAvailable: __type(name: "VoDummyInspectionConnection") { + description + } + typeOwner: __type(name: "VoDummyCar") { + description, + fields { + name + type { + name + } + } + } + } + """ + 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].debugMessage" should be equal to 'Type with id "VoDummyInspectionConnection" is not present in the types container' + And the JSON node "data.typeNotAvailable" should be null + And the JSON node "data.typeOwner.fields[3].type.name" should be equal to "VoDummyInspectionConnection" diff --git a/features/jsonld/input_output.feature b/features/jsonld/input_output.feature index 12f1492f870..256eb610a22 100644 --- a/features/jsonld/input_output.feature +++ b/features/jsonld/input_output.feature @@ -83,6 +83,44 @@ Feature: JSON-LD DTO input and output } """ + @createSchema + Scenario: Get an item with same class as custom output + Given there is a DummyDtoOutputSameClass + When I send a "GET" request to "/dummy_dto_output_same_classes/1" + 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/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/DummyDtoOutputSameClass", + "@id": "/dummy_dto_output_same_classes/1", + "@type": "DummyDtoOutputSameClass", + "lorem": "test", + "ipsum": "modified", + "id": 1 + } + """ + + @createSchema + Scenario: Get an item with a data transformer that will return the original class as a fallback + Given there is a DummyDtoOutputFallbackToSameClass + When I send a "GET" request to "/dummy_dto_output_fallback_to_same_classes/1" + 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/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/DummyDtoOutputFallbackToSameClass", + "@id": "/dummy_dto_output_fallback_to_same_classes/1", + "@type": "DummyDtoOutputFallbackToSameClass", + "lorem": "test", + "ipsum": "modified", + "id": 1 + } + """ + @createSchema Scenario: Create a DummyDtoCustom object without output When I send a "POST" request to "/dummy_dto_custom_post_without_output" with body: diff --git a/features/jsonld/interface_as_resource.feature b/features/jsonld/interface_as_resource.feature index 29ce56cce70..7236ace7ae7 100644 --- a/features/jsonld/interface_as_resource.feature +++ b/features/jsonld/interface_as_resource.feature @@ -15,7 +15,7 @@ Feature: JSON-LD using interface as resource "code": "WONDERFUL_TAXON" } """ - When I send a "GET" request to "/taxons/WONDERFUL_TAXON" + When I send a "GET" request to "/taxa/WONDERFUL_TAXON" 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/ld+json; charset=utf-8" @@ -23,7 +23,7 @@ Feature: JSON-LD using interface as resource """ { "@context": "/contexts/Taxon", - "@id": "/taxons/WONDERFUL_TAXON", + "@id": "/taxa/WONDERFUL_TAXON", "@type": "Taxon", "code": "WONDERFUL_TAXON" } @@ -34,7 +34,7 @@ Feature: JSON-LD using interface as resource """ { "code": "GREAT_PRODUCT", - "mainTaxon": "/taxons/WONDERFUL_TAXON" + "mainTaxon": "/taxa/WONDERFUL_TAXON" } """ When I send a "GET" request to "/products/GREAT_PRODUCT" @@ -49,7 +49,7 @@ Feature: JSON-LD using interface as resource "@type": "Product", "code": "GREAT_PRODUCT", "mainTaxon": { - "@id": "/taxons/WONDERFUL_TAXON", + "@id": "/taxa/WONDERFUL_TAXON", "@type": "Taxon", "code": "WONDERFUL_TAXON" } diff --git a/features/main/uuid.feature b/features/main/uuid.feature index ca5bc9ec9fc..9278cb15ee5 100644 --- a/features/main/uuid.feature +++ b/features/main/uuid.feature @@ -130,3 +130,17 @@ Feature: Using uuid identifier on resource Then the response status code should be 404 And the response should be in JSON And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + + @!mongodb + @createSchema + Scenario: Create a resource identified by Ramsey\Uuid\Uuid + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/ramsey_uuid_dummies" with body: + """ + { + "id": "41b29566-144b-11e6-a148-3e1d05defe78" + } + """ + Then the response status code should be 201 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" diff --git a/src/Annotation/AttributesHydratorTrait.php b/src/Annotation/AttributesHydratorTrait.php index aba28a5fedb..1211f73b6a1 100644 --- a/src/Annotation/AttributesHydratorTrait.php +++ b/src/Annotation/AttributesHydratorTrait.php @@ -14,7 +14,7 @@ namespace ApiPlatform\Core\Annotation; use ApiPlatform\Core\Exception\InvalidArgumentException; -use Doctrine\Common\Inflector\Inflector; +use ApiPlatform\Core\Util\Inflector; /** * Hydrates attributes from annotation's parameters. diff --git a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php index 572722d5a70..a2ae6c593af 100644 --- a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php +++ b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -41,6 +41,15 @@ */ final class PublishMercureUpdatesListener { + private const ALLOWED_KEYS = [ + 'topics' => true, + 'data' => true, + 'private' => true, + 'id' => true, + 'type' => true, + 'retry' => true, + ]; + use DispatchTrait; use ResourceClassInfoTrait; @@ -144,60 +153,75 @@ private function storeObjectToPublish($object, string $property): void return; } - $value = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('mercure', false); - if (false === $value) { + $options = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('mercure', false); + if (false === $options) { return; } - if (\is_string($value)) { + if (\is_string($options)) { if (null === $this->expressionLanguage) { throw new RuntimeException('The Expression Language component is not installed. Try running "composer require symfony/expression-language".'); } - $value = $this->expressionLanguage->evaluate($value, ['object' => $object]); + $options = $this->expressionLanguage->evaluate($options, ['object' => $object]); + } + + if (true === $options) { + $options = []; } - if (true === $value) { - $value = []; + if (!\is_array($options)) { + throw new InvalidArgumentException(sprintf('The value of the "mercure" attribute of the "%s" resource class must be a boolean, an array of options or an expression returning this array, "%s" given.', $resourceClass, \gettype($options))); } - if (!\is_array($value)) { - throw new InvalidArgumentException(sprintf('The value of the "mercure" attribute of the "%s" resource class must be a boolean, an array of targets or a valid expression, "%s" given.', $resourceClass, \gettype($value))); + foreach ($options as $key => $value) { + if (0 === $key) { + if (method_exists(Update::class, 'isPrivate')) { + throw new \InvalidArgumentException('Targets do not exist anymore since Mercure 0.10. Mark the update as private instead or downgrade the Mercure Component to version 0.3'); + } + + @trigger_error('Targets do not exist anymore since Mercure 0.10. Mark the update as private instead.', E_USER_DEPRECATED); + break; + } + + if (!isset(self::ALLOWED_KEYS[$key])) { + throw new InvalidArgumentException(sprintf('The option "%s" set in the "mercure" attribute of the "%s" resource does not exist. Existing options: "%s"', $key, $resourceClass, implode('", "', self::ALLOWED_KEYS))); + } } if ('deletedObjects' === $property) { $this->deletedObjects[(object) [ 'id' => $this->iriConverter->getIriFromItem($object), 'iri' => $this->iriConverter->getIriFromItem($object, UrlGeneratorInterface::ABS_URL), - ]] = $value; + ]] = $options; return; } - $this->{$property}[$object] = $value; + $this->{$property}[$object] = $options; } /** * @param object $object */ - private function publishUpdate($object, array $targets, string $type): void + private function publishUpdate($object, array $options, string $type): void { if ($object instanceof \stdClass) { // By convention, if the object has been deleted, we send only its IRI. // This may change in the feature, because it's not JSON Merge Patch compliant, // and I'm not a fond of this approach. - $iri = $object->iri; + $iri = $options['topics'] ?? $object->iri; /** @var string $data */ - $data = json_encode(['@id' => $object->id]); + $data = $options['data'] ?? json_encode(['@id' => $object->id]); } else { $resourceClass = $this->getObjectClass($object); $context = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('normalization_context', []); - $iri = $this->iriConverter->getIriFromItem($object, UrlGeneratorInterface::ABS_URL); - $data = $this->serializer->serialize($object, key($this->formats), $context); + $iri = $options['topics'] ?? $this->iriConverter->getIriFromItem($object, UrlGeneratorInterface::ABS_URL); + $data = $options['data'] ?? $this->serializer->serialize($object, key($this->formats), $context); } - $updates = array_merge([new Update($iri, $data, $targets)], $this->getGraphQlSubscriptionUpdates($object, $targets, $type)); + $updates = array_merge([$this->buildUpdate($iri, $data, $options)], $this->getGraphQlSubscriptionUpdates($object, $options, $type)); foreach ($updates as $update) { $this->messageBus ? $this->dispatch($update) : ($this->publisher)($update); @@ -209,7 +233,7 @@ private function publishUpdate($object, array $targets, string $type): void * * @return Update[] */ - private function getGraphQlSubscriptionUpdates($object, array $targets, string $type): array + private function getGraphQlSubscriptionUpdates($object, array $options, string $type): array { if ('update' !== $type || !$this->graphQlSubscriptionManager || !$this->graphQlMercureSubscriptionIriGenerator) { return []; @@ -219,13 +243,24 @@ private function getGraphQlSubscriptionUpdates($object, array $targets, string $ $updates = []; foreach ($payloads as [$subscriptionId, $data]) { - $updates[] = new Update( + $updates[] = $this->buildUpdate( $this->graphQlMercureSubscriptionIriGenerator->generateTopicIri($subscriptionId), (string) (new JsonResponse($data))->getContent(), - $targets + $options ); } return $updates; } + + private function buildUpdate(string $iri, string $data, array $options): Update + { + if (method_exists(Update::class, 'isPrivate')) { + return new Update($iri, $data, $options['private'] ?? false, $options['id'] ?? null, $options['type'] ?? null, $options['retry'] ?? null); + } + + // Mercure Component < 0.4. + /* @phpstan-ignore-next-line */ + return new Update($iri, $data, $options); + } } diff --git a/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php b/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php index 6b539999645..da0b8f3b505 100644 --- a/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php @@ -92,11 +92,12 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB $caseSensitive = true; $metadata = $this->getNestedMetadata($resourceClass, $associations); - $doctrineTypeField = $this->getDoctrineFieldType($property, $resourceClass); - $values = array_map([$this, 'getIdFromValue'], $values); - if ($metadata->hasField($field)) { - if (!$this->hasValidValues($values, $doctrineTypeField)) { + if ('id' === $field) { + $values = array_map([$this, 'getIdFromValue'], $values); + } + + if (!$this->hasValidValues($values, $this->getDoctrineFieldType($property, $resourceClass))) { $this->logger->notice('Invalid filter ignored', [ 'exception' => new InvalidArgumentException(sprintf('Values for field "%s" are not valid according to the doctrine type.', $field)), ]); @@ -113,7 +114,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB } if (1 === \count($values)) { - $this->addWhereByStrategy($strategy, $queryBuilder, $queryNameGenerator, $alias, $field, $doctrineTypeField, $values[0], $caseSensitive); + $this->addWhereByStrategy($strategy, $queryBuilder, $queryNameGenerator, $alias, $field, $values[0], $caseSensitive); return; } @@ -139,7 +140,9 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB return; } + $values = array_map([$this, 'getIdFromValue'], $values); $associationFieldIdentifier = 'id'; + $doctrineTypeField = $this->getDoctrineFieldType($property, $resourceClass); if (null !== $this->identifiersExtractor) { $associationResourceClass = $metadata->getAssociationTargetClass($field); @@ -168,11 +171,11 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB if (1 === \count($values)) { $queryBuilder ->andWhere(sprintf('%s.%s = :%s', $associationAlias, $associationField, $valueParameter)) - ->setParameter($valueParameter, $values[0], $doctrineTypeField); + ->setParameter($valueParameter, $values[0]); } else { $queryBuilder ->andWhere(sprintf('%s.%s IN (:%s)', $associationAlias, $associationField, $valueParameter)) - ->setParameter($valueParameter, $values, $doctrineTypeField); + ->setParameter($valueParameter, $values); } } @@ -181,7 +184,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB * * @throws InvalidArgumentException If strategy does not exist */ - protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, $fieldType, $value, bool $caseSensitive) + protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, $value, bool $caseSensitive) { $wrapCase = $this->createWrapCase($caseSensitive); $valueParameter = $queryNameGenerator->generateParameterName($field); @@ -191,27 +194,27 @@ protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuild case self::STRATEGY_EXACT: $queryBuilder ->andWhere(sprintf($wrapCase('%s.%s').' = '.$wrapCase(':%s'), $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value, $fieldType); + ->setParameter($valueParameter, $value); break; case self::STRATEGY_PARTIAL: $queryBuilder ->andWhere(sprintf($wrapCase('%s.%s').' LIKE '.$wrapCase('CONCAT(\'%%\', :%s, \'%%\')'), $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value, $fieldType); + ->setParameter($valueParameter, $value); break; case self::STRATEGY_START: $queryBuilder ->andWhere(sprintf($wrapCase('%s.%s').' LIKE '.$wrapCase('CONCAT(:%s, \'%%\')'), $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value, $fieldType); + ->setParameter($valueParameter, $value); break; case self::STRATEGY_END: $queryBuilder ->andWhere(sprintf($wrapCase('%s.%s').' LIKE '.$wrapCase('CONCAT(\'%%\', :%s)'), $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value, $fieldType); + ->setParameter($valueParameter, $value); break; case self::STRATEGY_WORD_START: $queryBuilder ->andWhere(sprintf($wrapCase('%1$s.%2$s').' LIKE '.$wrapCase('CONCAT(:%3$s, \'%%\')').' OR '.$wrapCase('%1$s.%2$s').' LIKE '.$wrapCase('CONCAT(\'%% \', :%3$s, \'%%\')'), $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value, $fieldType); + ->setParameter($valueParameter, $value); break; default: throw new InvalidArgumentException(sprintf('strategy %s does not exist.', $strategy)); diff --git a/src/Bridge/Elasticsearch/Metadata/Document/Factory/CatDocumentMetadataFactory.php b/src/Bridge/Elasticsearch/Metadata/Document/Factory/CatDocumentMetadataFactory.php index 0fb5c5260fc..c7d42878e36 100644 --- a/src/Bridge/Elasticsearch/Metadata/Document/Factory/CatDocumentMetadataFactory.php +++ b/src/Bridge/Elasticsearch/Metadata/Document/Factory/CatDocumentMetadataFactory.php @@ -16,7 +16,7 @@ use ApiPlatform\Core\Bridge\Elasticsearch\Exception\IndexNotFoundException; use ApiPlatform\Core\Bridge\Elasticsearch\Metadata\Document\DocumentMetadata; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; -use Doctrine\Common\Inflector\Inflector; +use ApiPlatform\Core\Util\Inflector; use Elasticsearch\Client; use Elasticsearch\Common\Exceptions\Missing404Exception; diff --git a/src/Bridge/RamseyUuid/Identifier/Normalizer/UuidNormalizer.php b/src/Bridge/RamseyUuid/Identifier/Normalizer/UuidNormalizer.php index 8bac4e27f52..8a3ce082486 100644 --- a/src/Bridge/RamseyUuid/Identifier/Normalizer/UuidNormalizer.php +++ b/src/Bridge/RamseyUuid/Identifier/Normalizer/UuidNormalizer.php @@ -43,6 +43,6 @@ public function denormalize($data, $class, $format = null, array $context = []) */ public function supportsDenormalization($data, $type, $format = null) { - return is_a($type, UuidInterface::class, true); + return \is_string($data) && is_a($type, UuidInterface::class, true); } } diff --git a/src/Bridge/RamseyUuid/Serializer/UuidDenormalizer.php b/src/Bridge/RamseyUuid/Serializer/UuidDenormalizer.php new file mode 100644 index 00000000000..87090190a85 --- /dev/null +++ b/src/Bridge/RamseyUuid/Serializer/UuidDenormalizer.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\Core\Bridge\RamseyUuid\Serializer; + +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +final class UuidDenormalizer implements DenormalizerInterface +{ + public function denormalize($data, $type, $format = null, array $context = []) + { + return Uuid::fromString($data); + } + + public function supportsDenormalization($data, $type, $format = null) + { + return \is_string($data) && is_a($type, UuidInterface::class, true); + } +} diff --git a/src/Bridge/Symfony/Bundle/Resources/config/ramsey_uuid.xml b/src/Bridge/Symfony/Bundle/Resources/config/ramsey_uuid.xml index a5d0626ab06..bbf81f232fa 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/ramsey_uuid.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/ramsey_uuid.xml @@ -8,5 +8,9 @@ + + + + diff --git a/src/Bridge/Symfony/PropertyInfo/Metadata/Property/PropertyInfoPropertyNameCollectionFactory.php b/src/Bridge/Symfony/PropertyInfo/Metadata/Property/PropertyInfoPropertyNameCollectionFactory.php index f988850c080..f603e08f3a1 100644 --- a/src/Bridge/Symfony/PropertyInfo/Metadata/Property/PropertyInfoPropertyNameCollectionFactory.php +++ b/src/Bridge/Symfony/PropertyInfo/Metadata/Property/PropertyInfoPropertyNameCollectionFactory.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Core\Bridge\Symfony\PropertyInfo\Metadata\Property; -use ApiPlatform\Core\Exception\RuntimeException; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; @@ -36,16 +35,11 @@ public function __construct(PropertyInfoExtractorInterface $propertyInfo) /** * {@inheritdoc} - * - * @throws RuntimeException */ public function create(string $resourceClass, array $options = []): PropertyNameCollection { $properties = $this->propertyInfo->getProperties($resourceClass, $options); - if (null === $properties) { - throw new RuntimeException(sprintf('There is no PropertyInfo extractor supporting the class "%s".', $resourceClass)); - } - return new PropertyNameCollection($properties); + return new PropertyNameCollection($properties ?? []); } } diff --git a/src/Bridge/Symfony/Routing/RouteNameGenerator.php b/src/Bridge/Symfony/Routing/RouteNameGenerator.php index d765dc9ef0e..89b3ca3ac5f 100644 --- a/src/Bridge/Symfony/Routing/RouteNameGenerator.php +++ b/src/Bridge/Symfony/Routing/RouteNameGenerator.php @@ -16,7 +16,7 @@ use ApiPlatform\Core\Api\OperationType; use ApiPlatform\Core\Api\OperationTypeDeprecationHelper; use ApiPlatform\Core\Exception\InvalidArgumentException; -use Doctrine\Common\Inflector\Inflector; +use ApiPlatform\Core\Util\Inflector; /** * Generates the Symfony route name associated with an operation name and a resource short name. diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index c073edaeebe..826f193760e 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -21,7 +21,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; -use Doctrine\Common\Inflector\Inflector; +use ApiPlatform\Core\Util\Inflector; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\NullableType; use GraphQL\Type\Definition\Type as GraphQLType; diff --git a/src/GraphQl/Type/TypeConverter.php b/src/GraphQl/Type/TypeConverter.php index db7cec25557..9cc2da4d29c 100644 --- a/src/GraphQl/Type/TypeConverter.php +++ b/src/GraphQl/Type/TypeConverter.php @@ -105,9 +105,12 @@ private function getResourceType(Type $type, bool $input, ?string $queryName, ?s try { $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); - if ([] === ($resourceMetadata->getGraphql() ?? [])) { + if (null === $resourceMetadata->getGraphql()) { return null; } + if ('Node' === $resourceMetadata->getShortName()) { + throw new \UnexpectedValueException('A "Node" resource cannot be used with GraphQL because the type is already used by the Relay specification.'); + } } catch (ResourceClassNotFoundException $e) { // Skip objects that are not resources for now return null; diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index bece5e16396..60d61499a67 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -93,13 +93,11 @@ public function normalize($object, $format = null, array $context = []) $data['hydra:member'][] = $iriOnly ? ['@id' => $this->iriConverter->getIriFromItem($obj)] : $this->normalizer->normalize($obj, $format, $context); } - $paginated = null; - if ( - \is_array($object) || - ($paginated = $object instanceof PaginatorInterface) || - $object instanceof \Countable && !$object instanceof PartialPaginatorInterface - ) { - $data['hydra:totalItems'] = $paginated ? $object->getTotalItems() : \count($object); + if ($object instanceof PaginatorInterface) { + $data['hydra:totalItems'] = $object->getTotalItems(); + } + if (\is_array($object) || ($object instanceof \Countable && !$object instanceof PartialPaginatorInterface)) { + $data['hydra:totalItems'] = \count($object); } return $data; diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 197050fdd55..793eba67639 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -66,7 +66,9 @@ public function supportsNormalization($data, $format = null): bool */ public function normalize($object, $format = null, array $context = []) { - if (null !== $this->getOutputClass($this->getObjectClass($object), $context)) { + $objectClass = $this->getObjectClass($object); + $outputClass = $this->getOutputClass($objectClass, $context); + if (null !== $outputClass && !isset($context[self::IS_TRANSFORMED_TO_SAME_CLASS])) { return parent::normalize($object, $format, $context); } diff --git a/src/Operation/DashPathSegmentNameGenerator.php b/src/Operation/DashPathSegmentNameGenerator.php index 07acc591ecc..1a31f1fa7fc 100644 --- a/src/Operation/DashPathSegmentNameGenerator.php +++ b/src/Operation/DashPathSegmentNameGenerator.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\Operation; -use Doctrine\Common\Inflector\Inflector; +use ApiPlatform\Core\Util\Inflector; /** * Generate a path name with a dash separator according to a string and whether it's a collection or not. diff --git a/src/Operation/UnderscorePathSegmentNameGenerator.php b/src/Operation/UnderscorePathSegmentNameGenerator.php index a48f051fa75..c0e5694ac53 100644 --- a/src/Operation/UnderscorePathSegmentNameGenerator.php +++ b/src/Operation/UnderscorePathSegmentNameGenerator.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\Operation; -use Doctrine\Common\Inflector\Inflector; +use ApiPlatform\Core\Util\Inflector; /** * Generate a path name with an underscore separator according to a string and whether it's a collection or not. diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 3b4d4446bbe..6a75419b13f 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -52,6 +52,8 @@ abstract class AbstractItemNormalizer extends AbstractObjectNormalizer use ContextTrait; use InputOutputMetadataTrait; + public const IS_TRANSFORMED_TO_SAME_CLASS = 'is_transformed_to_same_class'; + protected $propertyNameCollectionFactory; protected $propertyMetadataFactory; protected $iriConverter; @@ -115,18 +117,24 @@ public function hasCacheableSupportsMethod(): bool */ public function normalize($object, $format = null, array $context = []) { - if ($object !== $transformed = $this->transformOutput($object, $context)) { + if (!($isTransformed = isset($context[self::IS_TRANSFORMED_TO_SAME_CLASS])) && $outputClass = $this->getOutputClass($this->getObjectClass($object), $context)) { if (!$this->serializer instanceof NormalizerInterface) { throw new LogicException('Cannot normalize the output because the injected serializer is not a normalizer'); } - $context['api_normalize'] = true; - $context['api_resource'] = $object; - unset($context['output']); - unset($context['resource_class']); + if ($object !== $transformed = $this->transformOutput($object, $context, $outputClass)) { + $context['api_normalize'] = true; + $context['api_resource'] = $object; + unset($context['output'], $context['resource_class']); + } else { + $context[self::IS_TRANSFORMED_TO_SAME_CLASS] = true; + } return $this->serializer->normalize($transformed, $format, $context); } + if ($isTransformed) { + unset($context[self::IS_TRANSFORMED_TO_SAME_CLASS]); + } $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null); $context = $this->initContext($resourceClass, $context); @@ -669,9 +677,12 @@ protected function getDataTransformer($data, string $to, array $context = []): ? * For a given resource, it returns an output representation if any * If not, the resource is returned. */ - protected function transformOutput($object, array $context = []) + protected function transformOutput($object, array $context = [], string $outputClass = null) { - $outputClass = $this->getOutputClass($this->getObjectClass($object), $context); + if (null === $outputClass) { + $outputClass = $this->getOutputClass($this->getObjectClass($object), $context); + } + if (null !== $outputClass && null !== $dataTransformer = $this->getDataTransformer($object, $outputClass, $context)) { return $dataTransformer->transform($object, $outputClass, $context); } diff --git a/src/Util/AnnotationFilterExtractorTrait.php b/src/Util/AnnotationFilterExtractorTrait.php index 756dab0d376..33be708471a 100644 --- a/src/Util/AnnotationFilterExtractorTrait.php +++ b/src/Util/AnnotationFilterExtractorTrait.php @@ -15,7 +15,6 @@ use ApiPlatform\Core\Annotation\ApiFilter; use Doctrine\Common\Annotations\Reader; -use Doctrine\Common\Inflector\Inflector; /** * Generates a service id for a generic filter. diff --git a/src/Util/Inflector.php b/src/Util/Inflector.php new file mode 100644 index 00000000000..0c9cf904316 --- /dev/null +++ b/src/Util/Inflector.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Util; + +use Doctrine\Common\Inflector\Inflector as LegacyInflector; +use Doctrine\Inflector\Inflector as InflectorObject; +use Doctrine\Inflector\InflectorFactory; + +/** + * Facade for Doctrine Inflector. + * + * This class allows us to maintain compatibility with Doctrine Inflector 1.3 and 2.0 at the same time. + * + * @internal + */ +final class Inflector +{ + /** + * @var InflectorObject + */ + private static $instance; + + private static function getInstance(): InflectorObject + { + return self::$instance + ?? self::$instance = InflectorFactory::create()->build(); + } + + /** + * @see LegacyInflector::tableize() + */ + public static function tableize(string $word): string + { + return class_exists(InflectorFactory::class) ? self::getInstance()->tableize($word) : LegacyInflector::tableize($word); + } + + /** + * @see LegacyInflector::pluralize() + */ + public static function pluralize(string $word): string + { + return class_exists(InflectorFactory::class) ? self::getInstance()->pluralize($word) : LegacyInflector::pluralize($word); + } +} diff --git a/tests/Annotation/ApiResourceTest.php b/tests/Annotation/ApiResourceTest.php index 1fdc1fefe2c..5f24c6aacca 100644 --- a/tests/Annotation/ApiResourceTest.php +++ b/tests/Annotation/ApiResourceTest.php @@ -43,7 +43,7 @@ public function testConstruct() 'input' => 'Foo', 'iri' => 'http://example.com/res', 'itemOperations' => ['foo' => ['bar']], - 'mercure' => '[\'foo\', object.owner]', + 'mercure' => ['private' => true], 'messenger' => true, 'normalizationContext' => ['groups' => ['bar']], 'order' => ['foo', 'bar' => 'ASC'], @@ -85,7 +85,7 @@ public function testConstruct() 'formats' => ['foo', 'bar' => ['application/bar']], 'filters' => ['foo', 'bar'], 'input' => 'Foo', - 'mercure' => '[\'foo\', object.owner]', + 'mercure' => ['private' => true], 'messenger' => true, 'normalization_context' => ['groups' => ['bar']], 'order' => ['foo', 'bar' => 'ASC'], diff --git a/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php b/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php index 26d7954ce86..3780d3d1e65 100644 --- a/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php +++ b/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php @@ -39,8 +39,12 @@ */ class PublishMercureUpdatesListenerTest extends TestCase { - public function testPublishUpdate(): void + public function testLegacyPublishUpdate(): void { + if (method_exists(Update::class, 'isPrivate')) { + $this->markTestSkipped(); + } + $toInsert = new Dummy(); $toInsert->setId(1); $toInsertNotResource = new NotAResource('foo', 'bar'); @@ -86,7 +90,7 @@ public function testPublishUpdate(): void $targets = []; $publisher = function (Update $update) use (&$topics, &$targets): string { $topics = array_merge($topics, $update->getTopics()); - $targets[] = $update->getTargets(); + $targets[] = $update->getTargets(); // @phpstan-ignore-line return 'id'; }; @@ -117,8 +121,97 @@ public function testPublishUpdate(): void $this->assertSame([[], [], [], ['foo', 'bar']], $targets); } + public function testPublishUpdate(): void + { + if (!method_exists(Update::class, 'isPrivate')) { + $this->markTestSkipped(); + } + + $toInsert = new Dummy(); + $toInsert->setId(1); + $toInsertNotResource = new NotAResource('foo', 'bar'); + + $toUpdate = new Dummy(); + $toUpdate->setId(2); + $toUpdateNoMercureAttribute = new DummyCar(); + + $toDelete = new Dummy(); + $toDelete->setId(3); + $toDeleteExpressionLanguage = new DummyFriend(); + $toDeleteExpressionLanguage->setId(4); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(DummyCar::class))->willReturn(DummyCar::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(DummyFriend::class))->willReturn(DummyFriend::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); + $resourceClassResolverProphecy->isResourceClass(DummyCar::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(DummyFriend::class)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($toInsert, UrlGeneratorInterface::ABS_URL)->willReturn('http://example.com/dummies/1')->shouldBeCalled(); + $iriConverterProphecy->getIriFromItem($toUpdate, UrlGeneratorInterface::ABS_URL)->willReturn('http://example.com/dummies/2')->shouldBeCalled(); + $iriConverterProphecy->getIriFromItem($toDelete, UrlGeneratorInterface::ABS_URL)->willReturn('http://example.com/dummies/3')->shouldBeCalled(); + $iriConverterProphecy->getIriFromItem($toDelete)->willReturn('/dummies/3')->shouldBeCalled(); + $iriConverterProphecy->getIriFromItem($toDeleteExpressionLanguage)->willReturn('/dummy_friends/4')->shouldBeCalled(); + $iriConverterProphecy->getIriFromItem($toDeleteExpressionLanguage, UrlGeneratorInterface::ABS_URL)->willReturn('http://example.com/dummy_friends/4')->shouldBeCalled(); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['mercure' => true, 'normalization_context' => ['groups' => ['foo', 'bar']]])); + $resourceMetadataFactoryProphecy->create(DummyCar::class)->willReturn(new ResourceMetadata()); + $resourceMetadataFactoryProphecy->create(DummyFriend::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['mercure' => ['private' => true, 'retry' => 10]])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->serialize($toInsert, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('1'); + $serializerProphecy->serialize($toUpdate, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('2'); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $publisher = function (Update $update) use (&$topics, &$private, &$retry): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + + return 'id'; + }; + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + $publisher + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([$toInsert, $toInsertNotResource])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([$toUpdate, $toUpdateNoMercureAttribute])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([$toDelete, $toDeleteExpressionLanguage])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertSame(['http://example.com/dummies/1', 'http://example.com/dummies/2', 'http://example.com/dummies/3', 'http://example.com/dummy_friends/4'], $topics); + $this->assertSame([false, false, false, true], $private); + $this->assertSame([null, null, null, 10], $retry); + } + public function testPublishGraphQlUpdates(): void { + if (!method_exists(Update::class, 'isPrivate')) { + $this->markTestSkipped(); + } + $toUpdate = new Dummy(); $toUpdate->setId(2); @@ -138,11 +231,13 @@ public function testPublishGraphQlUpdates(): void $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; $topics = []; - $targets = []; + $private = []; + $retry = []; $data = []; - $publisher = function (Update $update) use (&$topics, &$targets, &$data): string { + $publisher = function (Update $update) use (&$topics, &$private, &$retry, &$data): string { $topics = array_merge($topics, $update->getTopics()); - $targets[] = $update->getTargets(); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); $data[] = $update->getData(); return 'id'; @@ -181,7 +276,8 @@ public function testPublishGraphQlUpdates(): void $listener->postFlush(); $this->assertSame(['http://example.com/dummies/2', 'subscription-topic-iri'], $topics); - $this->assertSame([[], []], $targets); + $this->assertSame([false, false], $private); + $this->assertSame([null, null], $retry); $this->assertSame(['2', '["data"]'], $data); } @@ -204,7 +300,7 @@ public function testNoPublisher(): void public function testInvalidMercureAttribute(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The value of the "mercure" attribute of the "ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy" resource class must be a boolean, an array of targets or a valid expression, "integer" given.'); + $this->expectExceptionMessage('The value of the "mercure" attribute of the "ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy" resource class must be a boolean, an array of options or an expression returning this array, "integer" given.'); $toInsert = new Dummy(); diff --git a/tests/Bridge/Doctrine/MongoDbOdm/PropertyInfo/DoctrineExtractorTest.php b/tests/Bridge/Doctrine/MongoDbOdm/PropertyInfo/DoctrineExtractorTest.php index 2bf5c361644..36505c4dc9c 100644 --- a/tests/Bridge/Doctrine/MongoDbOdm/PropertyInfo/DoctrineExtractorTest.php +++ b/tests/Bridge/Doctrine/MongoDbOdm/PropertyInfo/DoctrineExtractorTest.php @@ -60,7 +60,6 @@ public function testGetProperties(): void 'integer', 'string', 'key', - 'file', 'hash', 'collection', 'objectId', @@ -149,7 +148,6 @@ public function typesProvider(): array ['integer', [new Type(Type::BUILTIN_TYPE_INT)]], ['string', [new Type(Type::BUILTIN_TYPE_STRING)]], ['key', [new Type(Type::BUILTIN_TYPE_INT)]], - ['file', null], ['hash', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true)]], ['collection', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(TYPE::BUILTIN_TYPE_INT))]], ['objectId', [new Type(Type::BUILTIN_TYPE_STRING)]], @@ -208,8 +206,8 @@ private function createExtractor(): DoctrineExtractor $config = DoctrineMongoDbOdmSetup::createAnnotationMetadataConfiguration([__DIR__.\DIRECTORY_SEPARATOR.'Fixtures'], true); $documentManager = DocumentManager::create(null, $config); - if (!MongoDbType::hasType('foo')) { - MongoDbType::addType('foo', DoctrineFooType::class); + if (!MongoDbType::hasType('custom_foo')) { + MongoDbType::addType('custom_foo', DoctrineFooType::class); } return new DoctrineExtractor($documentManager); diff --git a/tests/Bridge/Doctrine/MongoDbOdm/PropertyInfo/Fixtures/DoctrineDummy.php b/tests/Bridge/Doctrine/MongoDbOdm/PropertyInfo/Fixtures/DoctrineDummy.php index 4e9e2a86194..64a34ed4f65 100644 --- a/tests/Bridge/Doctrine/MongoDbOdm/PropertyInfo/Fixtures/DoctrineDummy.php +++ b/tests/Bridge/Doctrine/MongoDbOdm/PropertyInfo/Fixtures/DoctrineDummy.php @@ -131,11 +131,6 @@ class DoctrineDummy */ private $key; - /** - * @Field(type="file") - */ - private $file; - /** * @Field(type="hash") */ diff --git a/tests/Bridge/RamseyUuid/Normalizer/UuidNormalizerTest.php b/tests/Bridge/RamseyUuid/Normalizer/UuidNormalizerTest.php index 3a74e6cfb55..cbf158da679 100644 --- a/tests/Bridge/RamseyUuid/Normalizer/UuidNormalizerTest.php +++ b/tests/Bridge/RamseyUuid/Normalizer/UuidNormalizerTest.php @@ -44,4 +44,11 @@ public function testFailDenormalizeUuid() $this->assertTrue($normalizer->supportsDenormalization($uuid, Uuid::class)); $normalizer->denormalize($uuid, Uuid::class); } + + public function testDoNotSupportNotString() + { + $uuid = Uuid::uuid4(); + $normalizer = new UuidNormalizer(); + $this->assertFalse($normalizer->supportsDenormalization($uuid, Uuid::class)); + } } diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 823ca459ecd..9daf7bdd61d 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -967,6 +967,7 @@ private function getPartialContainerBuilderProphecy($configuration = null) 'api_platform.serializer.group_filter', 'api_platform.serializer.normalizer.item', 'api_platform.serializer.property_filter', + 'api_platform.serializer.uuid_denormalizer', 'api_platform.serializer_locator', 'api_platform.subresource_data_provider', 'api_platform.subresource_operation_factory', diff --git a/tests/Bridge/Symfony/PropertyInfo/Metadata/Property/PropertyInfoPropertyNameCollectionFactoryTest.php b/tests/Bridge/Symfony/PropertyInfo/Metadata/Property/PropertyInfoPropertyNameCollectionFactoryTest.php new file mode 100644 index 00000000000..27384cccb64 --- /dev/null +++ b/tests/Bridge/Symfony/PropertyInfo/Metadata/Property/PropertyInfoPropertyNameCollectionFactoryTest.php @@ -0,0 +1,73 @@ + + * + * 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\Core\Tests\Bridge\Symfony\PropertyInfo\Metadata\Property; + +use ApiPlatform\Core\Bridge\Symfony\PropertyInfo\Metadata\Property\PropertyInfoPropertyNameCollectionFactory; +use ApiPlatform\Core\Tests\Fixtures\DummyObjectWithOnlyPrivateProperty; +use ApiPlatform\Core\Tests\Fixtures\DummyObjectWithOnlyPublicProperty; +use ApiPlatform\Core\Tests\Fixtures\DummyObjectWithoutProperty; +use ApiPlatform\Core\Tests\Fixtures\DummyObjectWithPublicAndPrivateProperty; +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; + +/** + * @author Oskar Stark + */ +class PropertyInfoPropertyNameCollectionFactoryTest extends TestCase +{ + public function testCreateMethodReturnsEmptyPropertyNameCollectionForObjectWithOnlyPrivateProperty() + { + $factory = new PropertyInfoPropertyNameCollectionFactory(new PropertyInfoExtractor([ + new ReflectionExtractor(), + ])); + + $collection = $factory->create(DummyObjectWithOnlyPrivateProperty::class); + + self::assertCount(0, $collection->getIterator()); + } + + public function testCreateMethodReturnsEmptyPropertyNameCollectionForObjectWithoutProperties() + { + $factory = new PropertyInfoPropertyNameCollectionFactory(new PropertyInfoExtractor([ + new ReflectionExtractor(), + ])); + + $collection = $factory->create(DummyObjectWithoutProperty::class); + + self::assertCount(0, $collection->getIterator()); + } + + public function testCreateMethodReturnsProperPropertyNameCollectionForObjectWithPublicAndPrivateProperty() + { + $factory = new PropertyInfoPropertyNameCollectionFactory(new PropertyInfoExtractor([ + new ReflectionExtractor(), + ])); + + $collection = $factory->create(DummyObjectWithPublicAndPrivateProperty::class); + + self::assertCount(1, $collection->getIterator()); + } + + public function testCreateMethodReturnsProperPropertyNameCollectionForObjectWithPublicProperty() + { + $factory = new PropertyInfoPropertyNameCollectionFactory(new PropertyInfoExtractor([ + new ReflectionExtractor(), + ])); + + $collection = $factory->create(DummyObjectWithOnlyPublicProperty::class); + + self::assertCount(1, $collection->getIterator()); + } +} diff --git a/tests/Fixtures/DummyObjectWithOnlyPrivateProperty.php b/tests/Fixtures/DummyObjectWithOnlyPrivateProperty.php new file mode 100644 index 00000000000..1cc3c6dfaf9 --- /dev/null +++ b/tests/Fixtures/DummyObjectWithOnlyPrivateProperty.php @@ -0,0 +1,19 @@ + + * + * 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\Core\Tests\Fixtures; + +class DummyObjectWithOnlyPrivateProperty +{ + private $foo; +} diff --git a/tests/Fixtures/DummyObjectWithOnlyPublicProperty.php b/tests/Fixtures/DummyObjectWithOnlyPublicProperty.php new file mode 100644 index 00000000000..4a4ad2a22ed --- /dev/null +++ b/tests/Fixtures/DummyObjectWithOnlyPublicProperty.php @@ -0,0 +1,19 @@ + + * + * 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\Core\Tests\Fixtures; + +class DummyObjectWithOnlyPublicProperty +{ + public $foo; +} diff --git a/tests/Fixtures/DummyObjectWithPublicAndPrivateProperty.php b/tests/Fixtures/DummyObjectWithPublicAndPrivateProperty.php new file mode 100644 index 00000000000..2c146a0048e --- /dev/null +++ b/tests/Fixtures/DummyObjectWithPublicAndPrivateProperty.php @@ -0,0 +1,20 @@ + + * + * 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\Core\Tests\Fixtures; + +class DummyObjectWithPublicAndPrivateProperty +{ + public $foo; + private $bar; +} diff --git a/tests/Fixtures/DummyObjectWithoutProperty.php b/tests/Fixtures/DummyObjectWithoutProperty.php new file mode 100644 index 00000000000..3171e2fd992 --- /dev/null +++ b/tests/Fixtures/DummyObjectWithoutProperty.php @@ -0,0 +1,18 @@ + + * + * 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\Core\Tests\Fixtures; + +class DummyObjectWithoutProperty +{ +} diff --git a/tests/Fixtures/TestBundle/DataTransformer/OutputDtoSameClassTransformer.php b/tests/Fixtures/TestBundle/DataTransformer/OutputDtoSameClassTransformer.php new file mode 100644 index 00000000000..53a222e3cb0 --- /dev/null +++ b/tests/Fixtures/TestBundle/DataTransformer/OutputDtoSameClassTransformer.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\Core\Tests\Fixtures\TestBundle\DataTransformer; + +use ApiPlatform\Core\DataTransformer\DataTransformerInterface; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoOutputFallbackToSameClass as DummyDtoOutputFallbackToSameClassDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoOutputSameClass as DummyDtoOutputSameClassDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\OutputDtoDummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoOutputFallbackToSameClass; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoOutputSameClass; + +/** + * @author Daniel West + */ +final class OutputDtoSameClassTransformer implements DataTransformerInterface +{ + /** + * {@inheritdoc} + */ + public function transform($object, string $to, array $context = []) + { + if ( + !$object instanceof DummyDtoOutputFallbackToSameClass && + !$object instanceof DummyDtoOutputFallbackToSameClassDocument && + !$object instanceof DummyDtoOutputSameClass && + !$object instanceof DummyDtoOutputSameClassDocument + ) { + throw new \InvalidArgumentException(); + } + $object->ipsum = 'modified'; + + return $object; + } + + /** + * {@inheritdoc} + */ + public function supportsTransformation($data, string $to, array $context = []): bool + { + return (($data instanceof DummyDtoOutputFallbackToSameClass || $data instanceof DummyDtoOutputFallbackToSameClassDocument) && OutputDtoDummy::class === $to) || + (($data instanceof DummyDtoOutputSameClass || $data instanceof DummyDtoOutputSameClassDocument) && (DummyDtoOutputSameClass::class === $to || DummyDtoOutputSameClassDocument::class === $to)); + } +} diff --git a/tests/Fixtures/TestBundle/Document/DummyDtoOutputFallbackToSameClass.php b/tests/Fixtures/TestBundle/Document/DummyDtoOutputFallbackToSameClass.php new file mode 100644 index 00000000000..7a7d796d4a0 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/DummyDtoOutputFallbackToSameClass.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\OutputDtoDummy; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Dummy InputOutput. + * + * @author Daniel West + * + * @ApiResource(attributes={"output"=OutputDtoDummy::class}) + * @ODM\Document + */ +class DummyDtoOutputFallbackToSameClass +{ + /** + * @var int The id + * + * @ODM\Id(strategy="INCREMENT", type="integer", nullable=true) + */ + private $id; + + /** + * @var string + * + * @ODM\Field + */ + public $lorem; + + /** + * @var string + * + * @ODM\Field + */ + public $ipsum; + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Document/DummyDtoOutputSameClass.php b/tests/Fixtures/TestBundle/Document/DummyDtoOutputSameClass.php new file mode 100644 index 00000000000..fe121b03439 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/DummyDtoOutputSameClass.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\Core\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Dummy InputOutput. + * + * @author Daniel West + * + * @ApiResource(attributes={"output"=DummyDtoOutputSameClass::class}) + * @ODM\Document + */ +class DummyDtoOutputSameClass +{ + /** + * @var int The id + * + * @ODM\Id(strategy="INCREMENT", type="integer", nullable=true) + */ + private $id; + + /** + * @var string + * + * @ODM\Field + */ + public $lorem; + + /** + * @var string + * + * @ODM\Field + */ + public $ipsum; + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Document/VoDummyInspection.php b/tests/Fixtures/TestBundle/Document/VoDummyInspection.php index 60fd02c77fb..6e0ad90c45a 100644 --- a/tests/Fixtures/TestBundle/Document/VoDummyInspection.php +++ b/tests/Fixtures/TestBundle/Document/VoDummyInspection.php @@ -19,10 +19,13 @@ use Symfony\Component\Serializer\Annotation\Groups; /** - * @ApiResource(attributes={ - * "normalization_context"={"groups"={"inspection_read"}}, - * "denormalization_context"={"groups"={"inspection_write"}} - * }) + * @ApiResource( + * attributes={ + * "normalization_context"={"groups"={"inspection_read"}}, + * "denormalization_context"={"groups"={"inspection_write"}} + * }, + * graphql={} + * ) * @ODM\Document */ class VoDummyInspection diff --git a/tests/Fixtures/TestBundle/Dto/OutputDtoDummy.php b/tests/Fixtures/TestBundle/Dto/OutputDtoDummy.php new file mode 100644 index 00000000000..3f319e4d987 --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/OutputDtoDummy.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\Core\Tests\Fixtures\TestBundle\Dto; + +/** + * OutputDtoDummy. + * + * @author Daniel West + */ +class OutputDtoDummy +{ + public $foo = 'foo'; +} diff --git a/tests/Fixtures/TestBundle/Entity/Container.php b/tests/Fixtures/TestBundle/Entity/Container.php deleted file mode 100644 index 09e0c75922d..00000000000 --- a/tests/Fixtures/TestBundle/Entity/Container.php +++ /dev/null @@ -1,68 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; - -use ApiPlatform\Core\Annotation\ApiResource; -use ApiPlatform\Core\Annotation\ApiSubresource; -use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\ORM\Mapping as ORM; - -/** - * @ApiResource - * @ORM\Entity - */ -class Container -{ - /** - * @ORM\Id - * @ORM\Column(type="guid") - * - * @var string UUID - */ - private $id; - - /** - * @ApiSubresource - * @ORM\OneToMany( - * targetEntity="Node", - * mappedBy="container", - * indexBy="serial", - * fetch="LAZY", - * cascade={}, - * orphanRemoval=false - * ) - * @ORM\OrderBy({"serial"="ASC"}) - * - * @var ArrayCollection|Node[] - */ - private $nodes; - - public function getId(): string - { - return $this->id; - } - - public function setId(string $id) - { - $this->id = $id; - } - - /** - * @return ArrayCollection|Node[] - */ - public function getNodes() - { - return $this->nodes; - } -} diff --git a/tests/Fixtures/TestBundle/Entity/DummyDtoOutputFallbackToSameClass.php b/tests/Fixtures/TestBundle/Entity/DummyDtoOutputFallbackToSameClass.php new file mode 100644 index 00000000000..a34152e2730 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyDtoOutputFallbackToSameClass.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\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\OutputDtoDummy; +use Doctrine\ORM\Mapping as ORM; + +/** + * Dummy InputOutput. + * + * @author Daniel West + * + * @ApiResource(attributes={"output"=OutputDtoDummy::class}) + * @ORM\Entity + */ +class DummyDtoOutputFallbackToSameClass +{ + /** + * @var int The id + * + * @ORM\Column(type="integer") + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + */ + private $id; + + /** + * @var string + * + * @ORM\Column + */ + public $lorem; + + /** + * @var string + * + * @ORM\Column + */ + public $ipsum; + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/DummyDtoOutputSameClass.php b/tests/Fixtures/TestBundle/Entity/DummyDtoOutputSameClass.php new file mode 100644 index 00000000000..4bd43af9659 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyDtoOutputSameClass.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +/** + * Dummy InputOutput. + * + * @author Daniel West + * + * @ApiResource(attributes={"output"=DummyDtoOutputSameClass::class}) + * @ORM\Entity + */ +class DummyDtoOutputSameClass +{ + /** + * @var int The id + * + * @ORM\Column(type="integer") + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + */ + private $id; + + /** + * @var string + * + * @ORM\Column + */ + public $lorem; + + /** + * @var string + * + * @ORM\Column + */ + public $ipsum; + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Node.php b/tests/Fixtures/TestBundle/Entity/Node.php deleted file mode 100644 index e0985dc8853..00000000000 --- a/tests/Fixtures/TestBundle/Entity/Node.php +++ /dev/null @@ -1,62 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; - -use ApiPlatform\Core\Annotation\ApiResource; -use Doctrine\ORM\Mapping as ORM; - -/** - * @see https://github.com/api-platform/core/pull/904#issuecomment-294132077 - * @ApiResource(graphql={}) - * @ORM\Entity - */ -class Node -{ - /** - * @ORM\Id - * @ORM\Column(name="serial", type="integer") - * - * @var int Node serial - */ - private $serial; - - /** - * @ORM\Id - * @ORM\ManyToOne(targetEntity="Container", fetch="LAZY") - * @ORM\JoinColumn(name="container_id", referencedColumnName="id", onDelete="RESTRICT") - * - * @var Container - */ - private $container; - - public function setContainer(Container $container) - { - $this->container = $container; - } - - public function getContainer(): Container - { - return $this->container; - } - - public function setSerial(int $serial) - { - $this->serial = $serial; - } - - public function getSerial(): int - { - return $this->serial; - } -} diff --git a/tests/Fixtures/TestBundle/Entity/VoDummyInspection.php b/tests/Fixtures/TestBundle/Entity/VoDummyInspection.php index a0aca6022a6..dcb13c5d6ca 100644 --- a/tests/Fixtures/TestBundle/Entity/VoDummyInspection.php +++ b/tests/Fixtures/TestBundle/Entity/VoDummyInspection.php @@ -19,10 +19,13 @@ use Symfony\Component\Serializer\Annotation\Groups; /** - * @ApiResource(attributes={ - * "normalization_context"={"groups"={"inspection_read"}}, - * "denormalization_context"={"groups"={"inspection_write"}} - * }) + * @ApiResource( + * attributes={ + * "normalization_context"={"groups"={"inspection_read"}}, + * "denormalization_context"={"groups"={"inspection_write"}} + * }, + * graphql={} + * ) * @ORM\Entity */ class VoDummyInspection diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index 6ab28fbb047..e5d2e9b52de 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -17,6 +17,8 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\TestBundle; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Doctrine\Bundle\MongoDBBundle\DoctrineMongoDBBundle; +use Doctrine\Common\Inflector\Inflector; +use Doctrine\Inflector\InflectorFactory; use FOS\UserBundle\FOSUserBundle; use Nelmio\ApiDocBundle\NelmioApiDocBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; @@ -48,6 +50,12 @@ public function __construct(string $environment, bool $debug) // patch for behat/symfony2-extension not supporting %env(APP_ENV)% $this->environment = $_SERVER['APP_ENV'] ?? $environment; + + // patch for old versions of Doctrine Inflector, to delete when we'll drop support for v1 + // see https://github.com/doctrine/inflector/issues/147#issuecomment-628807276 + if (!class_exists(InflectorFactory::class)) { + Inflector::rules('plural', ['/taxon/i' => 'taxa']); + } } public function registerBundles(): array diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index caf23a48d8c..5736d354ac9 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -255,6 +255,12 @@ services: tags: - { name: 'api_platform.data_transformer' } + app.data_transformer.custom_output_dto_fallback_same_class: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer\OutputDtoSameClassTransformer' + public: false + tags: + - { name: 'api_platform.data_transformer' } + app.data_transformer.input_dto: class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer\InputDtoDataTransformer' public: false diff --git a/tests/GraphQl/Type/TypeConverterTest.php b/tests/GraphQl/Type/TypeConverterTest.php index af34e81465f..876db1df599 100644 --- a/tests/GraphQl/Type/TypeConverterTest.php +++ b/tests/GraphQl/Type/TypeConverterTest.php @@ -96,6 +96,19 @@ public function testConvertTypeNoGraphQlResourceMetadata(): void $this->assertNull($graphqlType); } + public function testConvertTypeNodeResource(): void + { + $type = new Type(Type::BUILTIN_TYPE_OBJECT, false, 'node'); + + $this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(false); + $this->resourceMetadataFactoryProphecy->create('node')->shouldBeCalled()->willReturn((new ResourceMetadata('Node'))->withGraphql(['test'])); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('A "Node" resource cannot be used with GraphQL because the type is already used by the Relay specification.'); + + $this->typeConverter->convertType($type, false, null, null, null, 'resourceClass', 'rootClass', null, 0); + } + public function testConvertTypeResourceClassNotFound(): void { $type = new Type(Type::BUILTIN_TYPE_OBJECT, false, 'dummy'); diff --git a/tests/Hal/Serializer/CollectionNormalizerTest.php b/tests/Hal/Serializer/CollectionNormalizerTest.php index 96d2e1fe7ce..9c63cd8f7f1 100644 --- a/tests/Hal/Serializer/CollectionNormalizerTest.php +++ b/tests/Hal/Serializer/CollectionNormalizerTest.php @@ -112,7 +112,11 @@ public function testNormalizePartialPaginator() private function normalizePaginator($partial = false) { - $paginatorProphecy = $this->prophesize($partial ? PartialPaginatorInterface::class : PaginatorInterface::class); + $paginatorProphecy = $this->prophesize(PaginatorInterface::class); + if ($partial) { + $paginatorProphecy = $this->prophesize(PartialPaginatorInterface::class); + } + $paginatorProphecy->getCurrentPage()->willReturn(3); $paginatorProphecy->getItemsPerPage()->willReturn(12); $paginatorProphecy->rewind()->will(function () {}); diff --git a/tests/Hydra/Serializer/CollectionNormalizerTest.php b/tests/Hydra/Serializer/CollectionNormalizerTest.php index c98cc1a1cd1..2bc9d476b30 100644 --- a/tests/Hydra/Serializer/CollectionNormalizerTest.php +++ b/tests/Hydra/Serializer/CollectionNormalizerTest.php @@ -291,7 +291,10 @@ public function testNormalizePartialPaginator() private function normalizePaginator($partial = false) { - $paginatorProphecy = $this->prophesize($partial ? PartialPaginatorInterface::class : PaginatorInterface::class); + $paginatorProphecy = $this->prophesize(PaginatorInterface::class); + if ($partial) { + $paginatorProphecy = $this->prophesize(PartialPaginatorInterface::class); + } if (!$partial) { $paginatorProphecy->getTotalItems()->willReturn(1312);