diff --git a/features/bootstrap/DoctrineContext.php b/features/bootstrap/DoctrineContext.php index 47a1d773356..96f58447574 100644 --- a/features/bootstrap/DoctrineContext.php +++ b/features/bootstrap/DoctrineContext.php @@ -21,6 +21,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyCar as DummyCarDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyCarColor as DummyCarColorDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDate as DummyDateDocument; +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\DummyFriend as DummyFriendDocument; @@ -59,6 +60,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCar; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCarColor; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDate; +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\DummyFriend; @@ -1182,6 +1184,36 @@ public function thereIsAMaxDepthDummyWithLevelOfDescendants(int $level) $this->manager->flush(); } + /** + * @Given there is a DummyCustomDto + */ + public function thereIsADummyCustomDto() + { + $dto = $this->isOrm() ? new DummyDtoCustom() : new DummyDtoCustomDocument(); + $dto->lorem = 'test'; + $dto->ipsum = '0'; + $this->manager->persist($dto); + + $this->manager->flush(); + $this->manager->clear(); + } + + /** + * @Given there are :nb DummyCustomDto + */ + public function thereAreNbDummyCustomDto($nb) + { + for ($i = 1; $i <= $nb; ++$i) { + $dto = $this->isOrm() ? new DummyDtoCustom() : new DummyDtoCustomDocument(); + $dto->lorem = 'test'; + $dto->ipsum = (string) $i; + $this->manager->persist($dto); + } + + $this->manager->flush(); + $this->manager->clear(); + } + private function isOrm(): bool { return null !== $this->schemaTool; diff --git a/features/graphql/mutation.feature b/features/graphql/mutation.feature index 074c819bf4a..4af8417a29a 100644 --- a/features/graphql/mutation.feature +++ b/features/graphql/mutation.feature @@ -27,7 +27,7 @@ Feature: GraphQL mutation support And the response should be in JSON And the header "Content-Type" should be equal to "application/json" And the JSON node "data.__type.fields[0].name" should contain "delete" - And the JSON node "data.__type.fields[0].description" should match '/^Deletes a [A-z0-9]+\.$/' + And the JSON node "data.__type.fields[0].description" should match '/^Deletes a [A-z0-9]+.$/' And the JSON node "data.__type.fields[0].type.name" should match "/^delete[A-z0-9]+Payload$/" And the JSON node "data.__type.fields[0].type.kind" should be equal to "OBJECT" And the JSON node "data.__type.fields[0].args[0].name" should be equal to "input" @@ -310,7 +310,7 @@ Feature: GraphQL mutation support When I send the following GraphQL request: """ mutation { - createDummyDtoNoInput(input: {foo: "A new one", bar: 3, clientMutationId: "myId"}) { + createDummyDtoNoInput(input: {lorem: "A new one", ipsum: 3, clientMutationId: "myId"}) { clientMutationId } } @@ -323,7 +323,19 @@ Feature: GraphQL mutation support { "errors": [ { - "message": "Field \"foo\" is not defined by type createDummyDtoNoInputInput.", + "message": "Field createDummyDtoNoInputInput.id of required type ID! was not provided.", + "extensions": { + "category": "graphql" + }, + "locations": [ + { + "line": 2, + "column": 32 + } + ] + }, + { + "message": "Field \"lorem\" is not defined by type createDummyDtoNoInputInput.", "extensions": { "category": "graphql" }, @@ -335,14 +347,14 @@ Feature: GraphQL mutation support ] }, { - "message": "Field \"bar\" is not defined by type createDummyDtoNoInputInput.", + "message": "Field \"ipsum\" is not defined by type createDummyDtoNoInputInput.", "extensions": { "category": "graphql" }, "locations": [ { "line": 2, - "column": 51 + "column": 53 } ] } diff --git a/features/jsonapi/related-resouces-inclusion.feature b/features/jsonapi/related-resouces-inclusion.feature index 2f00db1fac5..ebc03e8d0f5 100644 --- a/features/jsonapi/related-resouces-inclusion.feature +++ b/features/jsonapi/related-resouces-inclusion.feature @@ -363,11 +363,11 @@ Feature: JSON API Inclusion of Related Resources "dummyDate": null, "dummyBoolean": null, "embeddedDummy": { - "dummyName": null, "dummyBoolean": null, "dummyDate": null, "dummyFloat": null, "dummyPrice": null, + "dummyName": null, "symfony": null }, "_id": 1, diff --git a/features/main/input_output.feature b/features/main/input_output.feature new file mode 100644 index 00000000000..75463be8d35 --- /dev/null +++ b/features/main/input_output.feature @@ -0,0 +1,212 @@ +Feature: DTO input and output + In order to use an hypermedia API + As a client software developer + I need to be able to use DTOs on my resources as Input or Output objects. + + @createSchema + Scenario: Create a resource with a custom Input. + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/dummy_dto_customs" with body: + """ + { + "foo": "test", + "bar": 1 + } + """ + 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" + And the JSON should be equal to: + """ + { + "@context": "/contexts/DummyDtoCustom", + "@id": "/dummy_dto_customs/1", + "@type": "DummyDtoCustom", + "lorem": "test", + "ipsum": "1", + "id": 1 + } + """ + + @createSchema + Scenario: Get an item with a custom output + Given there is a DummyCustomDto + When I send a "GET" request to "/dummy_dto_custom_output/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 a superset of: + """ + { + "@context": { + "@vocab": "http://example.com/docs.jsonld#", + "hydra": "http://www.w3.org/ns/hydra/core#", + "foo": { + "@type": "@id" + }, + "bar": { + "@type": "@id" + } + }, + "@type": "CustomOutputDto", + "foo": "test", + "bar": 0 + } + """ + + @createSchema + Scenario: Get a collection with a custom output + Given there are 2 DummyCustomDto + When I send a "GET" request to "/dummy_dto_custom_output" + 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 a superset of: + """ + { + "@context": "/contexts/DummyDtoCustom", + "@id": "/dummy_dto_customs", + "@type": "hydra:Collection", + "hydra:member": [ + { + "foo": "test", + "bar": 1 + }, + { + "foo": "test", + "bar": 2 + } + ], + "hydra:totalItems": 2 + } + """ + + @createSchema + Scenario: Create a DummyCustomDto object without output + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/dummy_dto_custom_post_without_output" with body: + """ + { + "lorem": "test", + "ipsum": "1" + } + """ + Then the response status code should be 201 + And the response should be empty + + @createSchema + Scenario: Create and update a DummyInputOutput + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/dummy_dto_input_outputs" with body: + """ + { + "foo": "test", + "bar": 1 + } + """ + Then the response status code should be 201 + And the JSON should be a superset of: + """ + { + "@context": { + "@vocab": "http://example.com/docs.jsonld#", + "hydra": "http://www.w3.org/ns/hydra/core#", + "baz": { + "@type": "@id" + }, + "bat": { + "@type": "@id" + } + }, + "@type": "OutputDto", + "baz": 1, + "bat": "test" + } + """ + Then I add "Content-Type" header equal to "application/ld+json" + And I send a "PUT" request to "/dummy_dto_input_outputs/1" with body: + """ + { + "foo": "test", + "bar": 2 + } + """ + Then the response status code should be 200 + And the JSON should be a superset of: + """ + { + "@context": { + "@vocab": "http:\/\/example.com\/docs.jsonld#", + "hydra": "http:\/\/www.w3.org\/ns\/hydra\/core#", + "baz": { + "@type": "@id" + }, + "bat": { + "@type": "@id" + } + }, + "@type": "OutputDto", + "baz": 2, + "bat": "test" + } + """ + + @createSchema + Scenario: Use DTO with relations on User + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/users" with body: + """ + { + "username": "soyuka", + "plainPassword": "a real password", + "email": "soyuka@example.com" + } + """ + Then the response status code should be 201 + Then I add "Content-Type" header equal to "application/ld+json" + And I send a "PUT" request to "/users/recover/1" with body: + """ + { + "user": "/users/1" + } + """ + Then the response status code should be 200 + And the JSON should be a superset of: + """ + { + "@context": { + "@vocab": "http://example.com/docs.jsonld#", + "hydra": "http://www.w3.org/ns/hydra/core#", + "user": { + "@type": "@id" + } + }, + "@type": "RecoverPasswordOutput", + "user": { + "@id": "/users/1", + "@type": "User", + "email": "soyuka@example.com", + "fullname": null, + "username": "soyuka" + } + } + """ + +# @createSchema +# Scenario: Execute a GraphQL query on DTO +# Given there are 2 DummyCustomDto +# When I send the following GraphQL request: +# """ +# { +# dummyDtoCustom(id: "/dummy_dto_customs/1") { +# lorem +# ipsum +# } +# } +# """ +# 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" +# Then print last JSON response +# And the JSON node "data.dummy.id" should be equal to "/dummies/1" +# And the JSON node "data.dummy.name" should be equal to "Dummy #1" diff --git a/features/main/table_inheritance.feature b/features/main/table_inheritance.feature index a4bc49a9dcd..230d877e693 100644 --- a/features/main/table_inheritance.feature +++ b/features/main/table_inheritance.feature @@ -292,34 +292,34 @@ Feature: Table inheritance } """ - Scenario: Get the parent interface collection - When I send a "GET" request to "/resource_interfaces" - 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 valid according to this schema: - """ - { - "type": "object", - "properties": { - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^ResourceInterface$" - }, - "foo": { - "type": "string", - "required": "true" - } - } - }, - "minItems": 1 - } - }, - "required": ["hydra:member"] - } - """ + # Scenario: Get the parent interface collection + # When I send a "GET" request to "/resource_interfaces" + # 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 valid according to this schema: + # """ + # { + # "type": "object", + # "properties": { + # "hydra:member": { + # "type": "array", + # "items": { + # "type": "object", + # "properties": { + # "@type": { + # "type": "string", + # "pattern": "^ResourceInterface$" + # }, + # "foo": { + # "type": "string", + # "required": "true" + # } + # } + # }, + # "minItems": 1 + # } + # }, + # "required": ["hydra:member"] + # } + # """ diff --git a/src/Annotation/ApiResource.php b/src/Annotation/ApiResource.php index 35f4f4f7b60..899b841e5eb 100644 --- a/src/Annotation/ApiResource.php +++ b/src/Annotation/ApiResource.php @@ -37,7 +37,7 @@ * @Attribute("filters", type="string[]"), * @Attribute("graphql", type="array"), * @Attribute("hydraContext", type="array"), - * @Attribute("inputClass", type="mixed"), + * @Attribute("input", type="mixed"), * @Attribute("iri", type="string"), * @Attribute("itemOperations", type="array"), * @Attribute("maximumItemsPerPage", type="int"), @@ -46,7 +46,7 @@ * @Attribute("normalizationContext", type="array"), * @Attribute("openapiContext", type="array"), * @Attribute("order", type="array"), - * @Attribute("outputClass", type="mixed"), + * @Attribute("output", type="mixed"), * @Attribute("paginationClientEnabled", type="bool"), * @Attribute("paginationClientItemsPerPage", type="bool"), * @Attribute("paginationClientPartial", type="bool"), @@ -288,14 +288,14 @@ final class ApiResource * * @var string|false */ - private $inputClass; + private $input; /** * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 * * @var string|false */ - private $outputClass; + private $output; /** * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index fa80ff8b19b..f25272af613 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -98,8 +98,11 @@ %api_platform.allow_plain_identifiers% + null + true - + + @@ -165,6 +168,7 @@ + @@ -172,6 +176,7 @@ + @@ -282,6 +287,14 @@ + + + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml b/src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml index 40949dfd961..c47f1c6ca6c 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml @@ -56,7 +56,8 @@ - + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml index bdc491d1b38..4206a4e70d4 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml @@ -76,8 +76,11 @@ %api_platform.allow_plain_identifiers% + null + true - + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/hal.xml b/src/Bridge/Symfony/Bundle/Resources/config/hal.xml index 9e6a3f5d358..55c4474e3c7 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/hal.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/hal.xml @@ -16,14 +16,14 @@ - + %api_platform.collection.pagination.page_parameter_name% - + @@ -34,8 +34,13 @@ + null + false + + true - + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/hydra.xml b/src/Bridge/Symfony/Bundle/Resources/config/hydra.xml index b4b4dd3b094..864e2aa118e 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/hydra.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/hydra.xml @@ -15,7 +15,7 @@ - + @@ -34,7 +34,7 @@ %api_platform.validator.serialize_payload_fields% - + @@ -42,14 +42,14 @@ - + %kernel.debug% - + @@ -57,7 +57,7 @@ - + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml b/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml index ccebf5ceb3c..70ef377030e 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml @@ -20,7 +20,7 @@ - + @@ -28,7 +28,7 @@ %api_platform.collection.pagination.page_parameter_name% - + @@ -39,21 +39,24 @@ + + true - + + - + %kernel.debug% - + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml b/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml index 999ce4ff92b..548a34cd4f7 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml @@ -25,8 +25,11 @@ + + true - + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/metadata/metadata.xml b/src/Bridge/Symfony/Bundle/Resources/config/metadata/metadata.xml index 8b8386c6775..02d7c6abdd5 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/metadata/metadata.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/metadata/metadata.xml @@ -14,6 +14,9 @@ + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/problem.xml b/src/Bridge/Symfony/Bundle/Resources/config/problem.xml index dee6616a9fa..da6c5fd730a 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/problem.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/problem.xml @@ -15,14 +15,14 @@ %api_platform.validator.serialize_payload_fields% - - + + %kernel.debug% - + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml b/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml index 61244f4ec38..af3ac59bd50 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml @@ -31,12 +31,12 @@ %api_platform.collection.pagination.client_enabled% %api_platform.collection.pagination.enabled_parameter_name% - + - + diff --git a/src/DataTransformer/ChainInputDataTransformer.php b/src/DataTransformer/ChainInputDataTransformer.php new file mode 100644 index 00000000000..32071202f56 --- /dev/null +++ b/src/DataTransformer/ChainInputDataTransformer.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\DataTransformer; + +/** + * Transforms an Input to a Resource object. + * + * @author Antoine Bluchet + */ +final class ChainInputDataTransformer implements DataTransformerInterface +{ + private $transformers; + + public function __construct(iterable $transformers) + { + $this->transformers = $transformers; + } + + /** + * {@inheritdoc} + */ + public function transform($object, array $context = []) + { + foreach ($this->transformers as $transformer) { + if ($transformer->supportsTransformation($object, $context)) { + return $transformer->transform($object, $context); + } + } + + return $object; + } + + /** + * {@inheritdoc} + */ + public function supportsTransformation($object, array $context = []): bool + { + foreach ($this->transformers as $transformer) { + if ($transformer->supportsTransformation($object, $context)) { + return true; + } + } + + return false; + } +} diff --git a/src/DataTransformer/ChainOutputDataTransformer.php b/src/DataTransformer/ChainOutputDataTransformer.php new file mode 100644 index 00000000000..fd5e2d3a530 --- /dev/null +++ b/src/DataTransformer/ChainOutputDataTransformer.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\DataTransformer; + +/** + * Transforms an Output to a Resource object. + * + * @author Antoine Bluchet + */ +final class ChainOutputDataTransformer implements DataTransformerInterface +{ + private $transformers; + + public function __construct(iterable $transformers) + { + $this->transformers = $transformers; + } + + /** + * {@inheritdoc} + */ + public function transform($object, array $context = []) + { + foreach ($this->transformers as $transformer) { + if ($transformer->supportsTransformation($object, $context)) { + return $transformer->transform($object, $context); + } + } + + return $object; + } + + /** + * {@inheritdoc} + */ + public function supportsTransformation($object, array $context = []): bool + { + foreach ($this->transformers as $transformer) { + if ($transformer->supportsTransformation($object, $context)) { + return true; + } + } + + return false; + } +} diff --git a/src/DataTransformer/DataTransformerInterface.php b/src/DataTransformer/DataTransformerInterface.php new file mode 100644 index 00000000000..0c91ec19675 --- /dev/null +++ b/src/DataTransformer/DataTransformerInterface.php @@ -0,0 +1,39 @@ + + * + * 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\DataTransformer; + +/** + * Transforms a DTO or an Anonymous class to a Resource object. + * + * @author Antoine Bluchet + */ +interface DataTransformerInterface +{ + /** + * Transforms the given object to something else, usually another object. + * This must return the original object if no transformation has been done. + * + * @param object $object + * + * @return object + */ + public function transform($object, array $context = []); + + /** + * Checks whether the transformation is supported for a given object and context. + * + * @param object $object + */ + public function supportsTransformation($object, array $context = []): bool; +} diff --git a/src/EventListener/DeserializeListener.php b/src/EventListener/DeserializeListener.php index d25848beff1..9f47cae002b 100644 --- a/src/EventListener/DeserializeListener.php +++ b/src/EventListener/DeserializeListener.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\Api\FormatMatcher; use ApiPlatform\Core\Api\FormatsProviderInterface; +use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Serializer\SerializerContextBuilderInterface; use ApiPlatform\Core\Util\RequestAttributesExtractor; @@ -36,11 +37,12 @@ final class DeserializeListener private $formats = []; private $formatsProvider; private $formatMatcher; + private $dataTransformer; /** * @throws InvalidArgumentException */ - public function __construct(SerializerInterface $serializer, SerializerContextBuilderInterface $serializerContextBuilder, /* FormatsProviderInterface */ $formatsProvider) + public function __construct(SerializerInterface $serializer, SerializerContextBuilderInterface $serializerContextBuilder, /* FormatsProviderInterface */$formatsProvider, DataTransformerInterface $dataTransformer = null) { $this->serializer = $serializer; $this->serializerContextBuilder = $serializerContextBuilder; @@ -54,6 +56,8 @@ public function __construct(SerializerInterface $serializer, SerializerContextBu $this->formatsProvider = $formatsProvider; } + + $this->dataTransformer = $dataTransformer; } /** @@ -78,7 +82,7 @@ public function onKernelRequest(GetResponseEvent $event) } $context = $this->serializerContextBuilder->createFromRequest($request, false, $attributes); - if (false === $context['input_class']) { + if (isset($context['input']) && \array_key_exists('class', $context['input']) && null === $context['input']['class']) { return; } @@ -94,12 +98,15 @@ public function onKernelRequest(GetResponseEvent $event) $context[AbstractNormalizer::OBJECT_TO_POPULATE] = $data; } - $request->attributes->set( - 'data', - $this->serializer->deserialize( - $requestContent, $context['input_class'], $format, $context - ) + $data = $this->serializer->deserialize( + $requestContent, $context['input']['class'] ?? $context['resource_class'], $format, $context ); + + if (null !== ($context['input']['class'] ?? null) && null !== $this->dataTransformer && $this->dataTransformer->supportsTransformation($data, $context)) { + $data = $this->dataTransformer->transform($data, $context); + } + + $request->attributes->set('data', $data); } /** diff --git a/src/EventListener/SerializeListener.php b/src/EventListener/SerializeListener.php index 803c25296cc..3044d88dc3a 100644 --- a/src/EventListener/SerializeListener.php +++ b/src/EventListener/SerializeListener.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\EventListener; +use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Exception\RuntimeException; use ApiPlatform\Core\Serializer\ResourceList; use ApiPlatform\Core\Serializer\SerializerContextBuilderInterface; @@ -34,11 +35,13 @@ final class SerializeListener { private $serializer; private $serializerContextBuilder; + private $dataTransformer; - public function __construct(SerializerInterface $serializer, SerializerContextBuilderInterface $serializerContextBuilder) + public function __construct(SerializerInterface $serializer, SerializerContextBuilderInterface $serializerContextBuilder, DataTransformerInterface $dataTransformer = null) { $this->serializer = $serializer; $this->serializerContextBuilder = $serializerContextBuilder; + $this->dataTransformer = $dataTransformer; } /** @@ -62,15 +65,10 @@ public function onKernelView(GetResponseForControllerResultEvent $event) $request->attributes->set('_api_respond', true); $context = $this->serializerContextBuilder->createFromRequest($request, true, $attributes); - if (isset($context['output_class'])) { - if (false === $context['output_class']) { - // If the output class is explicitly set to false, the response must be empty - $event->setControllerResult(''); + if (isset($context['output']) && \array_key_exists('class', $context['output']) && null === $context['output']['class']) { + $event->setControllerResult(''); - return; - } - - $context['resource_class'] = $context['output_class']; + return; } if ($included = $request->attributes->get('_api_included')) { @@ -84,6 +82,10 @@ public function onKernelView(GetResponseForControllerResultEvent $event) $request->attributes->set('_api_normalization_context', $context); + if (null !== ($context['output']['class'] ?? null) && null !== $this->dataTransformer && $this->dataTransformer->supportsTransformation($controllerResult, $context)) { + $controllerResult = $this->dataTransformer->transform($controllerResult, $context); + } + $event->setControllerResult($this->serializer->serialize($controllerResult, $request->getRequestFormat(), $context)); $request->attributes->set('_resources', $request->attributes->get('_resources', []) + (array) $resources); diff --git a/src/EventListener/WriteListener.php b/src/EventListener/WriteListener.php index 7752f049ccf..2b85fbaef07 100644 --- a/src/EventListener/WriteListener.php +++ b/src/EventListener/WriteListener.php @@ -75,7 +75,8 @@ public function onKernelView(GetResponseForControllerResultEvent $event) $hasOutput = true; if (null !== $this->resourceMetadataFactory) { $resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']); - $hasOutput = false !== $resourceMetadata->getOperationAttribute($attributes, 'output_class', null, true); + $outputMetadata = $resourceMetadata->getOperationAttribute($attributes, 'output', ['class' => $attributes['resource_class']], true); + $hasOutput = \array_key_exists('class', $outputMetadata) && null !== $outputMetadata['class']; } $class = \get_class($controllerResult); diff --git a/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php b/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php index 46f77aa08b5..875b678fd48 100644 --- a/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php @@ -93,8 +93,10 @@ public function __invoke(string $resourceClass = null, string $rootClass = null, $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); $this->canAccess($this->resourceAccessChecker, $resourceMetadata, $resourceClass, $info, $item, $operationName); - if (false === $resourceClass = $resourceMetadata->getAttribute('input_class', $resourceClass)) { - return null; + + $inputMetadata = $resourceMetadata->getAttribute('input', ['class' => $resourceClass]); + if (null === $resourceClass = $inputMetadata['class'] ?? null) { + return true; } switch ($operationName) { diff --git a/src/GraphQl/Type/SchemaBuilder.php b/src/GraphQl/Type/SchemaBuilder.php index d92cbdeb41c..e819cb4a6a6 100644 --- a/src/GraphQl/Type/SchemaBuilder.php +++ b/src/GraphQl/Type/SchemaBuilder.php @@ -381,10 +381,7 @@ private function convertType(Type $type, bool $input = false, string $mutationNa return null; } - if (false !== $dtoClass = $resourceMetadata->getAttribute($input ? 'input_class' : 'output_class', $resourceClass)) { - $resourceClass = $dtoClass; - } - $graphqlType = $this->getResourceObjectType(false === $dtoClass ? null : $resourceClass, $resourceMetadata, $input, $mutationName, $depth); + $graphqlType = $this->getResourceObjectType($resourceClass, $resourceMetadata, $input, $mutationName, $depth); break; default: throw new InvalidTypeException(sprintf('The type "%s" is not supported.', $builtinType)); @@ -418,12 +415,20 @@ private function getResourceObjectType(?string $resourceClass, ResourceMetadata return $this->graphqlTypes[$shortName]; } + $ioMetadata = $resourceMetadata->getAttribute($input ? 'input' : 'output'); + + if (null !== $ioMetadata && \array_key_exists('class', $ioMetadata)) { + if (null !== $ioMetadata['class']) { + $resourceClass = $ioMetadata['class']; + } + } + $configuration = [ 'name' => $shortName, 'description' => $resourceMetadata->getDescription(), 'resolveField' => $this->defaultFieldResolver, - 'fields' => function () use ($resourceClass, $resourceMetadata, $input, $mutationName, $depth) { - return $this->getResourceObjectTypeFields($resourceClass, $resourceMetadata, $input, $mutationName, $depth); + 'fields' => function () use ($resourceClass, $resourceMetadata, $input, $mutationName, $depth, $ioMetadata) { + return $this->getResourceObjectTypeFields($resourceClass, $resourceMetadata, $input, $mutationName, $depth, $ioMetadata); }, 'interfaces' => [$this->getNodeInterface()], ]; @@ -434,13 +439,13 @@ private function getResourceObjectType(?string $resourceClass, ResourceMetadata /** * Gets the fields of the type of the given resource. */ - private function getResourceObjectTypeFields(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input = false, string $mutationName = null, int $depth = 0): array + private function getResourceObjectTypeFields(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input = false, string $mutationName = null, int $depth = 0, ?array $ioMetadata = null): array { $fields = []; $idField = ['type' => GraphQLType::nonNull(GraphQLType::id())]; $clientMutationId = GraphQLType::nonNull(GraphQLType::string()); - if ('delete' === $mutationName) { + if ('delete' === $mutationName || (null !== $ioMetadata && null === $ioMetadata['class'])) { return [ 'id' => $idField, 'clientMutationId' => $clientMutationId, diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php index 942d5f84d5c..b5eee5b5870 100644 --- a/src/Hal/Serializer/ItemNormalizer.php +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Hal\Serializer; +use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Exception\RuntimeException; use ApiPlatform\Core\Serializer\AbstractItemNormalizer; use ApiPlatform\Core\Serializer\ContextTrait; @@ -51,7 +52,18 @@ public function normalize($object, $format = null, array $context = []) $context['cache_key'] = $this->getHalCacheKey($format, $context); } - $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); + try { + $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); + } catch (InvalidArgumentException $e) { + $context = $this->initContext(\get_class($object), $context); + $rawData = parent::normalize($object, $format, $context); + if (!\is_array($rawData)) { + return $rawData; + } + + return $rawData; + } + $context = $this->initContext($resourceClass, $context); $context['iri'] = $this->iriConverter->getIriFromItem($object); $context['api_normalize'] = true; diff --git a/src/Hydra/Serializer/DocumentationNormalizer.php b/src/Hydra/Serializer/DocumentationNormalizer.php index 10f38bb7762..1bbf93231ce 100644 --- a/src/Hydra/Serializer/DocumentationNormalizer.php +++ b/src/Hydra/Serializer/DocumentationNormalizer.php @@ -183,12 +183,14 @@ private function getHydraProperties(string $resourceClass, ResourceMetadata $res { $classes = []; foreach ($resourceMetadata->getCollectionOperations() as $operationName => $operation) { - if (false !== $class = $resourceMetadata->getCollectionOperationAttribute($operationName, 'input_class', $resourceClass, true)) { - $classes[$class] = true; + $inputMetadata = $resourceMetadata->getTypedOperationAttribute(OperationType::COLLECTION, $operationName, 'input', ['class' => $resourceClass], true); + if (null !== $inputClass = $inputMetadata['class'] ?? null) { + $classes[$inputClass] = true; } - if (false !== $class = $resourceMetadata->getCollectionOperationAttribute($operationName, 'output_class', $resourceClass, true)) { - $classes[$class] = true; + $outputMetadata = $resourceMetadata->getTypedOperationAttribute(OperationType::COLLECTION, $operationName, 'output', ['class' => $resourceClass], true); + if (null !== $outputClass = $outputMetadata['class'] ?? null) { + $classes[$outputClass] = true; } } @@ -257,8 +259,10 @@ private function getHydraOperation(string $resourceClass, ResourceMetadata $reso } $shortName = $resourceMetadata->getShortName(); - $inputClass = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'input_class', null, true); - $outputClass = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'output_class', null, true); + $inputMetadata = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'input', ['class' => false]); + $inputClass = \array_key_exists('class', $inputMetadata) ? $inputMetadata['class'] : false; + $outputMetadata = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'output', ['class' => false]); + $outputClass = \array_key_exists('class', $outputMetadata) ? $outputMetadata['class'] : false; if ('GET' === $method && OperationType::COLLECTION === $operationType) { $hydraOperation += [ @@ -270,34 +274,34 @@ private function getHydraOperation(string $resourceClass, ResourceMetadata $reso $hydraOperation += [ '@type' => ['hydra:Operation', 'schema:FindAction'], 'hydra:title' => $subresourceMetadata && $subresourceMetadata->isCollection() ? "Retrieves the collection of $shortName resources." : "Retrieves a $shortName resource.", - 'returns' => false === $outputClass ? 'owl:Nothing' : "#$shortName", + 'returns' => null === $outputClass ? 'owl:Nothing' : "#$shortName", ]; } elseif ('GET' === $method) { $hydraOperation += [ '@type' => ['hydra:Operation', 'schema:FindAction'], 'hydra:title' => "Retrieves $shortName resource.", - 'returns' => false === $outputClass ? 'owl:Nothing' : $prefixedShortName, + 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, ]; } elseif ('PATCH' === $method) { $hydraOperation += [ '@type' => 'hydra:Operation', 'hydra:title' => "Updates the $shortName resource.", - 'returns' => false === $outputClass ? 'owl:Nothing' : $prefixedShortName, - 'expects' => false === $inputClass ? 'owl:Nothing' : $prefixedShortName, + 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, + 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, ]; } elseif ('POST' === $method) { $hydraOperation += [ '@type' => ['hydra:Operation', 'schema:CreateAction'], 'hydra:title' => "Creates a $shortName resource.", - 'returns' => false === $outputClass ? 'owl:Nothing' : $prefixedShortName, - 'expects' => false === $inputClass ? 'owl:Nothing' : $prefixedShortName, + 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, + 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, ]; } elseif ('PUT' === $method) { $hydraOperation += [ '@type' => ['hydra:Operation', 'schema:ReplaceAction'], 'hydra:title' => "Replaces the $shortName resource.", - 'returns' => false === $outputClass ? 'owl:Nothing' : $prefixedShortName, - 'expects' => false === $inputClass ? 'owl:Nothing' : $prefixedShortName, + 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, + 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, ]; } elseif ('DELETE' === $method) { $hydraOperation += [ diff --git a/src/Identifier/IdentifierConverter.php b/src/Identifier/IdentifierConverter.php index d392925afb3..53fc196f9a5 100644 --- a/src/Identifier/IdentifierConverter.php +++ b/src/Identifier/IdentifierConverter.php @@ -46,7 +46,7 @@ public function convert(string $data, string $class, array $context = []): array { if (null !== $this->resourceMetadataFactory) { $resourceMetadata = $this->resourceMetadataFactory->create($class); - $class = $resourceMetadata->getOperationAttribute($context, 'output_class', $class, true); + $class = $resourceMetadata->getOperationAttribute($context, 'output', ['class' => $class], true)['class']; } $keys = $this->identifiersExtractor->getIdentifiersFromResourceClass($class); diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index 8dbd25bcadb..95eac6c24f0 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -42,9 +42,9 @@ final class ItemNormalizer extends AbstractItemNormalizer private $componentsCache = []; private $resourceMetadataFactory; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ResourceMetadataFactoryInterface $resourceMetadataFactory, array $defaultContext = []) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ResourceMetadataFactoryInterface $resourceMetadataFactory, array $defaultContext = [], bool $allowUnmappedClass = false) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, null, null, false, $defaultContext); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, null, null, false, $defaultContext, $allowUnmappedClass); $this->resourceMetadataFactory = $resourceMetadataFactory; } @@ -74,7 +74,17 @@ public function normalize($object, $format = null, array $context = []) } // Get and populate item type - $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); + try { + $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); + } catch (InvalidArgumentException $e) { + $rawData = parent::normalize($object, $format, $context); + if (!\is_array($rawData)) { + return $rawData; + } + + return $rawData; + } + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); // Get and populate relations diff --git a/src/JsonLd/AnonymousContextBuilderInterface.php b/src/JsonLd/AnonymousContextBuilderInterface.php new file mode 100644 index 00000000000..20fdaf427ed --- /dev/null +++ b/src/JsonLd/AnonymousContextBuilderInterface.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\JsonLd; + +use ApiPlatform\Core\Api\UrlGeneratorInterface; + +/** + * JSON-LD context builder with Input Output DTO support interface. + * + * @author Antoine Bluchet + */ +interface AnonymousContextBuilderInterface extends ContextBuilderInterface +{ + /** + * Creates a JSON-LD context based on the given object. + * Usually this is used with an Input or Output DTO object. + */ + public function getAnonymousResourceContext($object, array $context, int $referenceType = UrlGeneratorInterface::ABS_PATH): array; +} diff --git a/src/JsonLd/ContextBuilder.php b/src/JsonLd/ContextBuilder.php index 9a34baf0c41..0ff111b05d5 100644 --- a/src/JsonLd/ContextBuilder.php +++ b/src/JsonLd/ContextBuilder.php @@ -25,7 +25,7 @@ * * @author Kévin Dunglas */ -final class ContextBuilder implements ContextBuilderInterface +final class ContextBuilder implements AnonymousContextBuilderInterface { const FORMAT = 'jsonld'; @@ -87,9 +87,40 @@ public function getEntrypointContext(int $referenceType = UrlGeneratorInterface: */ public function getResourceContext(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH): array { - $context = $this->getBaseContext($referenceType); + return $this->getResourceContextWithShortname($resourceClass, $referenceType, $this->resourceMetadataFactory->create($resourceClass)->getShortName()); + } + + /** + * {@inheritdoc} + */ + public function getResourceContextUri(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH): string + { $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); - $shortName = $resourceMetadata->getShortName(); + + return $this->urlGenerator->generate('api_jsonld_context', ['shortName' => $resourceMetadata->getShortName()], $referenceType); + } + + /** + * {@inheritdoc} + */ + public function getAnonymousResourceContext($object, array $context, int $referenceType = UrlGeneratorInterface::ABS_PATH): array + { + $id = $context['iri'] ?? '_:'.spl_object_id($object); + $jsonLdContext = [ + '@context' => $this->getResourceContextWithShortname(\get_class($object), $referenceType, $id), + '@id' => $id, + ]; + + if ($context['name'] ?? false) { + $jsonLdContext['@type'] = $context['name']; + } + + return $jsonLdContext; + } + + private function getResourceContextWithShortname(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH, string $shortName) + { + $context = $this->getBaseContext($referenceType); foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $propertyName) { $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName); @@ -123,14 +154,4 @@ public function getResourceContext(string $resourceClass, int $referenceType = U return $context; } - - /** - * {@inheritdoc} - */ - public function getResourceContextUri(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH): string - { - $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); - - return $this->urlGenerator->generate('api_jsonld_context', ['shortName' => $resourceMetadata->getShortName()], $referenceType); - } } diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 5506a965b5a..18645707fe6 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -41,9 +41,9 @@ final class ItemNormalizer extends AbstractItemNormalizer private $resourceMetadataFactory; private $contextBuilder; - public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ContextBuilderInterface $contextBuilder, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = []) + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ContextBuilderInterface $contextBuilder, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], bool $allowUnmappedClass = false) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, null, false, $defaultContext); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, null, false, $defaultContext, $allowUnmappedClass); $this->resourceMetadataFactory = $resourceMetadataFactory; $this->contextBuilder = $contextBuilder; @@ -62,7 +62,18 @@ public function supportsNormalization($data, $format = null) */ public function normalize($object, $format = null, array $context = []) { - $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); + try { + $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); + } catch (InvalidArgumentException $e) { + $data = $this->createJsonLdContext($this->contextBuilder, $object, $context); + $rawData = parent::normalize($object, $format, $context); + if (!\is_array($rawData)) { + return $rawData; + } + + return $data + $rawData; + } + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); $data = $this->addJsonLdContext($this->contextBuilder, $resourceClass, $context); diff --git a/src/JsonLd/Serializer/JsonLdContextTrait.php b/src/JsonLd/Serializer/JsonLdContextTrait.php index 3ade372f4c8..cb138258ab8 100644 --- a/src/JsonLd/Serializer/JsonLdContextTrait.php +++ b/src/JsonLd/Serializer/JsonLdContextTrait.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\JsonLd\Serializer; +use ApiPlatform\Core\JsonLd\AnonymousContextBuilderInterface; use ApiPlatform\Core\JsonLd\ContextBuilderInterface; /** @@ -45,4 +46,15 @@ private function addJsonLdContext(ContextBuilderInterface $contextBuilder, strin return $data; } + + private function createJsonLdContext(AnonymousContextBuilderInterface $contextBuilder, $object, array &$context, array $data = []): array + { + if (isset($context['jsonld_has_context'])) { + return $data; + } + + $context['jsonld_has_context'] = true; + + return $contextBuilder->getAnonymousResourceContext($object, $context['output']); + } } diff --git a/src/Metadata/Resource/Factory/InputOutputResourceMetadataFactory.php b/src/Metadata/Resource/Factory/InputOutputResourceMetadataFactory.php new file mode 100644 index 00000000000..ad9aa3f73b0 --- /dev/null +++ b/src/Metadata/Resource/Factory/InputOutputResourceMetadataFactory.php @@ -0,0 +1,88 @@ + + * + * 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\Metadata\Resource\Factory; + +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; + +/** + * Transforms the given input/output metadata to a normalized one. + * + * @author Antoine Bluchet + */ +final class InputOutputResourceMetadataFactory implements ResourceMetadataFactoryInterface +{ + private $decorated; + + public function __construct(ResourceMetadataFactoryInterface $decorated) + { + $this->decorated = $decorated; + } + + /** + * {@inheritdoc} + */ + public function create(string $resourceClass): ResourceMetadata + { + $resourceMetadata = $this->decorated->create($resourceClass); + + $attributes = $resourceMetadata->getAttributes(); + $attributes['input'] = isset($attributes['input']) ? $this->transformInputOutput($attributes['input']) : null; + $attributes['output'] = isset($attributes['output']) ? $this->transformInputOutput($attributes['output']) : null; + + if (null !== $collectionOperations = $resourceMetadata->getCollectionOperations()) { + $resourceMetadata = $resourceMetadata->withCollectionOperations($this->getTransformedOperations($collectionOperations, $attributes)); + } + + if (null !== $itemOperations = $resourceMetadata->getItemOperations()) { + $resourceMetadata = $resourceMetadata->withItemOperations($this->getTransformedOperations($itemOperations, $attributes)); + } + + return $resourceMetadata->withAttributes($attributes); + } + + private function getTransformedOperations(array $operations, array $resourceAttributes): array + { + foreach ($operations as $key => &$operation) { + if (!\is_array($operation)) { + continue; + } + + $operation['input'] = isset($operation['input']) ? $this->transformInputOutput($operation['input']) : $resourceAttributes['input']; + $operation['output'] = isset($operation['output']) ? $this->transformInputOutput($operation['output']) : $resourceAttributes['output']; + } + + return $operations; + } + + private function transformInputOutput($attribute): ?array + { + if (null === $attribute) { + return null; + } + + if (false === $attribute) { + return ['class' => null]; + } + + if (\is_string($attribute)) { + $attribute = ['class' => $attribute]; + } + + if (!isset($attribute['name'])) { + $attribute['name'] = (new \ReflectionClass($attribute['class']))->getShortName(); + } + + return $attribute; + } +} diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 9f5c62b46ff..c6df428530e 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -54,8 +54,9 @@ abstract class AbstractItemNormalizer extends AbstractObjectNormalizer protected $localCache = []; protected $itemDataProvider; protected $allowPlainIdentifiers; + protected $allowUnmappedClass; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, ItemDataProviderInterface $itemDataProvider = null, bool $allowPlainIdentifiers = false, array $defaultContext = []) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, ItemDataProviderInterface $itemDataProvider = null, bool $allowPlainIdentifiers = false, array $defaultContext = [], bool $allowUnmappedClass = false) { if (!isset($defaultContext['circular_reference_handler'])) { $defaultContext['circular_reference_handler'] = function ($object) { @@ -66,6 +67,10 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName $this->setCircularReferenceHandler($defaultContext['circular_reference_handler']); } + if (false === $allowUnmappedClass) { + @trigger_error(sprintf('Passing a falsy $allowUnmappedClass flag in %s is deprecated since version 2.4 and will default to true in 3.0.', self::class), E_USER_DEPRECATED); + } + parent::__construct($classMetadataFactory, $nameConverter, null, null, \Closure::fromCallable([$this, 'getObjectClass']), $defaultContext); $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; @@ -75,6 +80,7 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); $this->itemDataProvider = $itemDataProvider; $this->allowPlainIdentifiers = $allowPlainIdentifiers; + $this->allowUnmappedClass = $allowUnmappedClass; } /** @@ -82,11 +88,15 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName */ public function supportsNormalization($data, $format = null) { - if (!\is_object($data)) { + if (!\is_object($data) || $data instanceof \Traversable) { return false; } - return $this->resourceClassResolver->isResourceClass($this->getObjectClass($data)); + if (false === $this->allowUnmappedClass) { + return $this->resourceClassResolver->isResourceClass($this->getObjectClass($data)); + } + + return true; } /** @@ -102,7 +112,15 @@ public function hasCacheableSupportsMethod(): bool */ public function normalize($object, $format = null, array $context = []) { - $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); + try { + $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); + } catch (InvalidArgumentException $e) { + $context = $this->initContext(\get_class($object), $context); + $context['api_normalize'] = true; + + return parent::normalize($object, $format, $context); + } + $context = $this->initContext($resourceClass, $context); $context['api_normalize'] = true; @@ -119,7 +137,15 @@ public function normalize($object, $format = null, array $context = []) */ public function supportsDenormalization($data, $type, $format = null) { - return $this->localCache[$type] ?? $this->localCache[$type] = $this->resourceClassResolver->isResourceClass($type); + if ('elasticsearch' === $format) { + return false; + } + + if (false === $this->allowUnmappedClass) { + return $this->localCache[$type] ?? $this->localCache[$type] = $this->resourceClassResolver->isResourceClass($type); + } + + return true; } /** diff --git a/src/Serializer/ItemNormalizer.php b/src/Serializer/ItemNormalizer.php index f34540440d8..9e4a13052f2 100644 --- a/src/Serializer/ItemNormalizer.php +++ b/src/Serializer/ItemNormalizer.php @@ -36,9 +36,9 @@ class ItemNormalizer extends AbstractItemNormalizer { private $logger; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, ItemDataProviderInterface $itemDataProvider = null, bool $allowPlainIdentifiers = false, LoggerInterface $logger = null) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, ItemDataProviderInterface $itemDataProvider = null, bool $allowPlainIdentifiers = false, LoggerInterface $logger = null, $allowUnmappedClass = false) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $itemDataProvider, $allowPlainIdentifiers); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $itemDataProvider, $allowPlainIdentifiers, [], $allowUnmappedClass); $this->logger = $logger ?: new NullLogger(); } diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index b14d8a7ca2d..6d02e1956dd 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -65,8 +65,8 @@ public function createFromRequest(Request $request, bool $normalization, array $ } $context['resource_class'] = $attributes['resource_class']; - $context['input_class'] = $resourceMetadata->getTypedOperationAttribute($operationType, $attributes[$operationKey], 'input_class', $attributes['resource_class'], true); - $context['output_class'] = $resourceMetadata->getTypedOperationAttribute($operationType, $attributes[$operationKey], 'output_class', $attributes['resource_class'], true); + $context['input'] = $resourceMetadata->getTypedOperationAttribute($operationType, $attributes[$operationKey], 'input', $resourceMetadata->getAttribute('input')); + $context['output'] = $resourceMetadata->getTypedOperationAttribute($operationType, $attributes[$operationKey], 'output', $resourceMetadata->getAttribute('output')); $context['request_uri'] = $request->getRequestUri(); $context['uri'] = $request->getUri(); diff --git a/src/Swagger/Serializer/DocumentationNormalizer.php b/src/Swagger/Serializer/DocumentationNormalizer.php index 411adbf98b4..e68beb96bb1 100644 --- a/src/Swagger/Serializer/DocumentationNormalizer.php +++ b/src/Swagger/Serializer/DocumentationNormalizer.php @@ -155,7 +155,9 @@ public function normalize($object, $format = null, array $context = []) $serializerContext = $this->getSerializerContext(OperationType::SUBRESOURCE, false, $subResourceMetadata, $operationName); $responseDefinitionKey = false; - if (false !== $outputClass = $subResourceMetadata->getSubresourceOperationAttribute($operationName, 'output_class')) { + $outputMetadata = $resourceMetadata->getTypedOperationAttribute(OperationType::SUBRESOURCE, $operationName, 'output', ['class' => $subresourceOperation['resource_class']], true); + + if (null !== $outputClass = $outputMetadata['class'] ?? null) { $responseDefinitionKey = $this->getDefinition($v3, $definitions, $subResourceMetadata, $subresourceOperation['resource_class'], $outputClass, $serializerContext); } @@ -315,7 +317,8 @@ private function updateGetOperation(bool $v3, \ArrayObject $pathOperation, array $serializerContext = $this->getSerializerContext($operationType, false, $resourceMetadata, $operationName); $responseDefinitionKey = false; - if (false !== $outputClass = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'output_class', null, true)) { + $outputMetadata = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'output', ['class' => $resourceClass], true); + if (null !== $outputClass = $outputMetadata['class'] ?? null) { $responseDefinitionKey = $this->getDefinition($v3, $definitions, $resourceMetadata, $resourceClass, $outputClass, $serializerContext); } @@ -423,7 +426,8 @@ private function updatePostOperation(bool $v3, \ArrayObject $pathOperation, arra $pathOperation['summary'] ?? $pathOperation['summary'] = sprintf('Creates a %s resource.', $resourceShortName); $responseDefinitionKey = false; - if (false !== $outputClass = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'output_class', null, true)) { + $outputMetadata = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'output', ['class' => $resourceClass], true); + if (null !== $outputClass = $outputMetadata['class'] ?? null) { $responseDefinitionKey = $this->getDefinition($v3, $definitions, $resourceMetadata, $resourceClass, $outputClass, $this->getSerializerContext($operationType, false, $resourceMetadata, $operationName)); } @@ -445,7 +449,8 @@ private function updatePostOperation(bool $v3, \ArrayObject $pathOperation, arra '404' => ['description' => 'Resource not found'], ]; - if (false === $inputClass = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'input_class', null, true)) { + $inputMetadata = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'input', ['class' => $resourceClass], true); + if (null === $inputClass = $inputMetadata['class'] ?? null) { return $pathOperation; } @@ -485,7 +490,8 @@ private function updatePutOperation(bool $v3, \ArrayObject $pathOperation, array $pathOperation['parameters'] ?? $pathOperation['parameters'] = [$parameter]; $responseDefinitionKey = false; - if (false !== $outputClass = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'output_class', null, true)) { + $outputMetadata = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'output', ['class' => $resourceClass], true); + if (null !== $outputClass = $outputMetadata['class'] ?? null) { $responseDefinitionKey = $this->getDefinition($v3, $definitions, $resourceMetadata, $resourceClass, $outputClass, $this->getSerializerContext($operationType, false, $resourceMetadata, $operationName)); } @@ -504,7 +510,8 @@ private function updatePutOperation(bool $v3, \ArrayObject $pathOperation, array '404' => ['description' => 'Resource not found'], ]; - if (false === $inputClass = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'input_class', null, true)) { + $inputMetadata = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'input', ['class' => $resourceClass], true); + if (null === $inputClass = $inputMetadata['class'] ?? null) { return $pathOperation; } @@ -549,7 +556,7 @@ private function updateDeleteOperation(bool $v3, \ArrayObject $pathOperation, st private function getDefinition(bool $v3, \ArrayObject $definitions, ResourceMetadata $resourceMetadata, string $resourceClass, ?string $publicClass, array $serializerContext = null): string { $keyPrefix = $resourceMetadata->getShortName(); - if (null !== $publicClass) { + if (null !== $publicClass && $resourceClass !== $publicClass) { $keyPrefix .= ':'.md5($publicClass); } @@ -680,7 +687,7 @@ private function getType(bool $v3, string $type, bool $isCollection, string $cla return [ '$ref' => sprintf( $v3 ? '#/components/schemas/%s' : '#/definitions/%s', - $this->getDefinition($v3, $definitions, $resourceMetadata = $this->resourceMetadataFactory->create($className), $className, $resourceMetadata->getAttribute('output_class'), $serializerContext) + $this->getDefinition($v3, $definitions, $resourceMetadata = $this->resourceMetadataFactory->create($className), $className, $resourceMetadata->getAttribute('output')['class'] ?? $className, $serializerContext) ), ]; } diff --git a/tests/Annotation/ApiResourceTest.php b/tests/Annotation/ApiResourceTest.php index 51f2088609c..ce0f86e7cdf 100644 --- a/tests/Annotation/ApiResourceTest.php +++ b/tests/Annotation/ApiResourceTest.php @@ -38,7 +38,7 @@ public function testConstruct() 'formats' => ['foo', 'bar' => ['application/bar']], 'filters' => ['foo', 'bar'], 'graphql' => ['query' => ['normalization_context' => ['groups' => ['foo', 'bar']]]], - 'inputClass' => 'Foo', + 'input' => 'Foo', 'iri' => 'http://example.com/res', 'itemOperations' => ['foo' => ['bar']], 'maximumItemsPerPage' => 42, @@ -47,7 +47,7 @@ public function testConstruct() 'normalizationContext' => ['groups' => ['bar']], 'order' => ['foo', 'bar' => 'ASC'], 'openapiContext' => ['description' => 'foo'], - 'outputClass' => 'Bar', + 'output' => 'Bar', 'paginationClientEnabled' => true, 'paginationClientItemsPerPage' => true, 'paginationClientPartial' => true, @@ -79,14 +79,14 @@ public function testConstruct() 'force_eager' => false, 'formats' => ['foo', 'bar' => ['application/bar']], 'filters' => ['foo', 'bar'], - 'input_class' => 'Foo', + 'input' => 'Foo', 'maximum_items_per_page' => 42, 'mercure' => '[\'foo\', object.owner]', 'messenger' => true, 'normalization_context' => ['groups' => ['bar']], 'order' => ['foo', 'bar' => 'ASC'], 'openapi_context' => ['description' => 'foo'], - 'output_class' => 'Bar', + 'output' => 'Bar', 'pagination_client_enabled' => true, 'pagination_client_items_per_page' => true, 'pagination_client_partial' => true, diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 0dc3c51934f..d2ccc64d9cf 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -735,6 +735,8 @@ private function getPartialContainerBuilderProphecy() 'api_platform.cache.route_name_resolver', 'api_platform.cache.subresource_operation_factory', 'api_platform.collection_data_provider', + 'api_platform.data_transformer.chain_input_transformer', + 'api_platform.data_transformer.chain_output_transformer', 'api_platform.formats_provider', 'api_platform.filter_locator', 'api_platform.filter_collection_factory', @@ -772,6 +774,7 @@ private function getPartialContainerBuilderProphecy() 'api_platform.metadata.property.name_collection_factory.xml', 'api_platform.metadata.resource.metadata_factory.cached', 'api_platform.metadata.resource.metadata_factory.operation', + 'api_platform.metadata.resource.metadata_factory.input_output', 'api_platform.metadata.resource.metadata_factory.short_name', 'api_platform.metadata.resource.metadata_factory.xml', 'api_platform.metadata.resource.name_collection_factory.cached', diff --git a/tests/EventListener/DeserializeListenerTest.php b/tests/EventListener/DeserializeListenerTest.php index dec97154af0..20345460d3f 100644 --- a/tests/EventListener/DeserializeListenerTest.php +++ b/tests/EventListener/DeserializeListenerTest.php @@ -136,7 +136,7 @@ public function testDoNotCallWhenInputClassDisabled() $serializerProphecy->deserialize()->shouldNotBeCalled(); $serializerContextBuilderProphecy = $this->prophesize(SerializerContextBuilderInterface::class); - $serializerContextBuilderProphecy->createFromRequest(Argument::type(Request::class), false, Argument::type('array'))->willReturn(['input_class' => false, 'output_class' => false]); + $serializerContextBuilderProphecy->createFromRequest(Argument::type(Request::class), false, Argument::type('array'))->willReturn(['input' => ['class' => null], 'output' => ['class' => null]]); $formatsProviderProphecy = $this->prophesize(FormatsProviderInterface::class); $formatsProviderProphecy->getFormatsFromAttributes(Argument::type('array'))->shouldNotBeCalled(); @@ -159,14 +159,17 @@ public function testDeserialize(string $method, bool $populateObject) $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); $serializerProphecy = $this->prophesize(SerializerInterface::class); - $context = $populateObject ? [AbstractNormalizer::OBJECT_TO_POPULATE => $populateObject, 'input_class' => 'Foo', 'output_class' => 'Foo'] : ['input_class' => 'Foo', 'output_class' => 'Foo']; + $context = $populateObject ? [AbstractNormalizer::OBJECT_TO_POPULATE => $populateObject] : []; + $context['input'] = ['class' => 'Foo']; + $context['output'] = ['class' => 'Foo']; + $context['resource_class'] = 'Foo'; $serializerProphecy->deserialize('{}', 'Foo', 'json', $context)->willReturn($result)->shouldBeCalled(); $formatsProviderProphecy = $this->prophesize(FormatsProviderInterface::class); $formatsProviderProphecy->getFormatsFromAttributes(Argument::type('array'))->willReturn(self::FORMATS)->shouldBeCalled(); $serializerContextBuilderProphecy = $this->prophesize(SerializerContextBuilderInterface::class); - $serializerContextBuilderProphecy->createFromRequest(Argument::type(Request::class), false, Argument::type('array'))->willReturn(['input_class' => 'Foo', 'output_class' => 'Foo'])->shouldBeCalled(); + $serializerContextBuilderProphecy->createFromRequest(Argument::type(Request::class), false, Argument::type('array'))->willReturn(['input' => ['class' => 'Foo'], 'output' => ['class' => 'Foo'], 'resource_class' => 'Foo'])->shouldBeCalled(); $listener = new DeserializeListener($serializerProphecy->reveal(), $serializerContextBuilderProphecy->reveal(), $formatsProviderProphecy->reveal()); $listener->onKernelRequest($eventProphecy->reveal()); @@ -186,11 +189,14 @@ public function testDeserializeResourceClassSupportedFormat(string $method, bool $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); $serializerProphecy = $this->prophesize(SerializerInterface::class); - $context = $populateObject ? [AbstractNormalizer::OBJECT_TO_POPULATE => $populateObject, 'input_class' => 'Foo', 'output_class' => 'Foo'] : ['input_class' => 'Foo', 'output_class' => 'Foo']; + $context = $populateObject ? [AbstractNormalizer::OBJECT_TO_POPULATE => $populateObject] : []; + $context['input'] = ['class' => 'Foo']; + $context['output'] = ['class' => 'Foo']; + $context['resource_class'] = 'Foo'; $serializerProphecy->deserialize('{}', 'Foo', 'json', $context)->willReturn($result)->shouldBeCalled(); $serializerContextBuilderProphecy = $this->prophesize(SerializerContextBuilderInterface::class); - $serializerContextBuilderProphecy->createFromRequest(Argument::type(Request::class), false, Argument::type('array'))->willReturn(['input_class' => 'Foo', 'output_class' => 'Foo'])->shouldBeCalled(); + $serializerContextBuilderProphecy->createFromRequest(Argument::type(Request::class), false, Argument::type('array'))->willReturn(['input' => ['class' => 'Foo'], 'output' => ['class' => 'Foo'], 'resource_class' => 'Foo'])->shouldBeCalled(); $formatsProviderProphecy = $this->prophesize(FormatsProviderInterface::class); $formatsProviderProphecy->getFormatsFromAttributes(['resource_class' => 'Foo', 'collection_operation_name' => 'post', 'receive' => true, 'persist' => true])->willReturn(self::FORMATS)->shouldBeCalled(); @@ -215,11 +221,13 @@ public function testContentNegotiation() $request->setFormat('xml', 'text/xml'); // Workaround to avoid weird behaviors $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); + $context = ['input' => ['class' => 'Foo'], 'output' => ['class' => 'Foo'], 'resource_class' => 'Foo']; + $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->deserialize('{}', 'Foo', 'xml', ['input_class' => 'Foo', 'output_class' => 'Foo'])->willReturn(new \stdClass())->shouldBeCalled(); + $serializerProphecy->deserialize('{}', 'Foo', 'xml', $context)->willReturn(new \stdClass())->shouldBeCalled(); $serializerContextBuilderProphecy = $this->prophesize(SerializerContextBuilderInterface::class); - $serializerContextBuilderProphecy->createFromRequest(Argument::type(Request::class), false, Argument::type('array'))->willReturn(['input_class' => 'Foo', 'output_class' => 'Foo'])->shouldBeCalled(); + $serializerContextBuilderProphecy->createFromRequest(Argument::type(Request::class), false, Argument::type('array'))->willReturn($context)->shouldBeCalled(); $formatsProviderProphecy = $this->prophesize(FormatsProviderInterface::class); $formatsProviderProphecy->getFormatsFromAttributes(Argument::type('array'))->willReturn(['jsonld' => ['application/ld+json'], 'xml' => ['text/xml']])->shouldBeCalled(); @@ -249,7 +257,7 @@ public function testNotSupportedContentType() $serializerProphecy->deserialize()->shouldNotBeCalled(); $serializerContextBuilderProphecy = $this->prophesize(SerializerContextBuilderInterface::class); - $serializerContextBuilderProphecy->createFromRequest(Argument::type(Request::class), false, Argument::type('array'))->willReturn(['input_class' => 'Foo', 'output_class' => 'Foo']); + $serializerContextBuilderProphecy->createFromRequest(Argument::type(Request::class), false, Argument::type('array'))->willReturn(['input' => ['class' => 'Foo'], 'output' => ['class' => 'Foo']]); $formatsProviderProphecy = $this->prophesize(FormatsProviderInterface::class); $formatsProviderProphecy->getFormatsFromAttributes(Argument::type('array'))->willReturn(['jsonld' => ['application/ld+json'], 'xml' => ['text/xml']])->shouldBeCalled(); @@ -278,7 +286,7 @@ public function testNoContentType() $serializerProphecy->deserialize()->shouldNotBeCalled(); $serializerContextBuilderProphecy = $this->prophesize(SerializerContextBuilderInterface::class); - $serializerContextBuilderProphecy->createFromRequest(Argument::type(Request::class), false, Argument::type('array'))->willReturn(['input_class' => 'Foo', 'output_class' => 'Foo']); + $serializerContextBuilderProphecy->createFromRequest(Argument::type(Request::class), false, Argument::type('array'))->willReturn(['input' => ['class' => 'Foo'], 'output' => ['class' => 'Foo']]); $formatsProviderProphecy = $this->prophesize(FormatsProviderInterface::class); $formatsProviderProphecy->getFormatsFromAttributes(Argument::type('array'))->willReturn(['jsonld' => ['application/ld+json'], 'xml' => ['text/xml']])->shouldBeCalled(); diff --git a/tests/EventListener/SerializeListenerTest.php b/tests/EventListener/SerializeListenerTest.php index b6f43acadd7..48414dc9fbc 100644 --- a/tests/EventListener/SerializeListenerTest.php +++ b/tests/EventListener/SerializeListenerTest.php @@ -104,7 +104,7 @@ public function testSerializeCollectionOperation() public function testSerializeCollectionOperationWithOutputClassDisabled() { - $expectedContext = ['request_uri' => '', 'resource_class' => 'Foo', 'collection_operation_name' => 'post', 'output_class' => false]; + $expectedContext = ['request_uri' => '', 'resource_class' => 'Foo', 'collection_operation_name' => 'post', 'output' => ['class' => null]]; $serializerProphecy = $this->prophesize(SerializerInterface::class); $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'get', '_api_output_class' => false]); diff --git a/tests/EventListener/WriteListenerTest.php b/tests/EventListener/WriteListenerTest.php index 0ed15abd39e..794f15ee2fc 100644 --- a/tests/EventListener/WriteListenerTest.php +++ b/tests/EventListener/WriteListenerTest.php @@ -148,11 +148,11 @@ public function testOnKernelViewDoNotCallIriConverterWhenOutputClassDisabled() $iriConverterProphecy->getIriFromItem($dummy)->shouldNotBeCalled(); $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['output_class' => false])); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['output' => ['class' => null]])); $dataPersisterProphecy->persist($dummy)->willReturn($dummy)->shouldBeCalled(); - $request = new Request([], [], ['_api_resource_class' => Dummy::class, '_api_collection_operation_name' => 'post', '_api_output_class' => false]); + $request = new Request([], [], ['_api_resource_class' => Dummy::class, '_api_collection_operation_name' => 'post']); $request->setMethod('POST'); $event = new GetResponseForControllerResultEvent( diff --git a/tests/Fixtures/TestBundle/DataPersister/DummyInputDataPersister.php b/tests/Fixtures/TestBundle/DataPersister/DummyInputDataPersister.php deleted file mode 100644 index 18a35d0f567..00000000000 --- a/tests/Fixtures/TestBundle/DataPersister/DummyInputDataPersister.php +++ /dev/null @@ -1,46 +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\DataPersister; - -use ApiPlatform\Core\DataPersister\DataPersisterInterface; -use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyInput; -use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyOutput; - -class DummyInputDataPersister implements DataPersisterInterface -{ - public function supports($data): bool - { - return $data instanceof DummyInput; - } - - /** - * {@inheritdoc} - */ - public function persist($data) - { - $output = new DummyOutput(); - $output->name = $data->name; - $output->id = 1; - - return $output; - } - - /** - * {@inheritdoc} - */ - public function remove($data) - { - return null; - } -} diff --git a/tests/Fixtures/TestBundle/DataTransformer/CustomInputDtoDataTransformer.php b/tests/Fixtures/TestBundle/DataTransformer/CustomInputDtoDataTransformer.php new file mode 100644 index 00000000000..279a1b8340a --- /dev/null +++ b/tests/Fixtures/TestBundle/DataTransformer/CustomInputDtoDataTransformer.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer; + +use ApiPlatform\Core\DataTransformer\DataTransformerInterface; +use ApiPlatform\Core\Serializer\AbstractItemNormalizer; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\CustomInputDto; + +final class CustomInputDtoDataTransformer implements DataTransformerInterface +{ + /** + * {@inheritdoc} + */ + public function transform($object, array $context = []) + { + if (!$object instanceof CustomInputDto) { + throw new \InvalidArgumentException(); + } + + /** + * @var \ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoCustom + */ + $resourceObject = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] ?? new $context['resource_class'](); + $resourceObject->lorem = $object->foo; + $resourceObject->ipsum = (string) $object->bar; + + return $resourceObject; + } + + /** + * {@inheritdoc} + */ + public function supportsTransformation($object, array $context = []): bool + { + return CustomInputDto::class === ($context['input']['class'] ?? null); + } +} diff --git a/tests/Fixtures/TestBundle/DataTransformer/CustomOutputDtoDataTransformer.php b/tests/Fixtures/TestBundle/DataTransformer/CustomOutputDtoDataTransformer.php new file mode 100644 index 00000000000..38a2ecbc223 --- /dev/null +++ b/tests/Fixtures/TestBundle/DataTransformer/CustomOutputDtoDataTransformer.php @@ -0,0 +1,62 @@ + + * + * 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\DummyDtoCustom as DummyDtoCustomDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\CustomOutputDto; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoCustom; + +final class CustomOutputDtoDataTransformer implements DataTransformerInterface +{ + /** + * {@inheritdoc} + */ + public function transform($object, array $context = []) + { + if ($object instanceof \Traversable) { + foreach ($object as &$value) { + $value = $this->doTransformation($value); + } + + return $object; + } + + return $this->doTransformation($object); + } + + private function doTransformation($object): CustomOutputDto + { + $output = new CustomOutputDto(); + $output->foo = $object->lorem; + $output->bar = (int) $object->ipsum; + + return $output; + } + + /** + * {@inheritdoc} + */ + public function supportsTransformation($object, array $context = []): bool + { + if ($object instanceof \IteratorAggregate) { + $iterator = $object->getIterator(); + if ($iterator instanceof \Iterator) { + $object = $iterator->current(); + } + } + + return ($object instanceof DummyDtoCustom || $object instanceof DummyDtoCustomDocument) && CustomOutputDto::class === ($context['output']['class'] ?? null); + } +} diff --git a/tests/Fixtures/TestBundle/DataTransformer/InputDtoDataTransformer.php b/tests/Fixtures/TestBundle/DataTransformer/InputDtoDataTransformer.php new file mode 100644 index 00000000000..986d9b1ee62 --- /dev/null +++ b/tests/Fixtures/TestBundle/DataTransformer/InputDtoDataTransformer.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer; + +use ApiPlatform\Core\DataTransformer\DataTransformerInterface; +use ApiPlatform\Core\Serializer\AbstractItemNormalizer; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\InputDto; + +final class InputDtoDataTransformer implements DataTransformerInterface +{ + /** + * {@inheritdoc} + */ + public function transform($object, array $context = []) + { + if (!$object instanceof InputDto) { + throw new \InvalidArgumentException(); + } + + /** + * @var \ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoInputOutput + */ + $resourceObject = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] ?? new $context['resource_class'](); + $resourceObject->str = $object->foo; + $resourceObject->num = $object->bar; + + return $resourceObject; + } + + /** + * {@inheritdoc} + */ + public function supportsTransformation($data, array $context = []): bool + { + return InputDto::class === ($context['input']['class'] ?? null); + } +} diff --git a/tests/Fixtures/TestBundle/DataTransformer/OutputDtoDataTransformer.php b/tests/Fixtures/TestBundle/DataTransformer/OutputDtoDataTransformer.php new file mode 100644 index 00000000000..044381ccfa9 --- /dev/null +++ b/tests/Fixtures/TestBundle/DataTransformer/OutputDtoDataTransformer.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer; + +use ApiPlatform\Core\DataTransformer\DataTransformerInterface; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoInputOutput as DummyDtoInputOutputDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\OutputDto; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoInputOutput; + +final class OutputDtoDataTransformer implements DataTransformerInterface +{ + /** + * {@inheritdoc} + */ + public function transform($object, array $context = []) + { + if (!$object instanceof DummyDtoInputOutput && !$object instanceof DummyDtoInputOutputDocument) { + throw new \InvalidArgumentException(); + } + + $output = new OutputDto(); + $output->bat = (string) $object->str; + $output->baz = (float) $object->num; + + return $output; + } + + /** + * {@inheritdoc} + */ + public function supportsTransformation($data, array $context = []): bool + { + return ($data instanceof DummyDtoInputOutput || $data instanceof DummyDtoInputOutputDocument) && OutputDto::class === ($context['output']['class'] ?? null); + } +} diff --git a/tests/Fixtures/TestBundle/DataTransformer/RecoverPasswordInputDataTransformer.php b/tests/Fixtures/TestBundle/DataTransformer/RecoverPasswordInputDataTransformer.php new file mode 100644 index 00000000000..9ad91a1de46 --- /dev/null +++ b/tests/Fixtures/TestBundle/DataTransformer/RecoverPasswordInputDataTransformer.php @@ -0,0 +1,42 @@ + + * + * 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\Serializer\AbstractItemNormalizer; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\RecoverPasswordInput; + +final class RecoverPasswordInputDataTransformer implements DataTransformerInterface +{ + /** + * {@inheritdoc} + */ + public function transform($data, array $context = []) + { + // Because we're in a PUT operation, we will use the retrieved object... + $resourceObject = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] ?? new $context['resource_class'](); + // ...where we remove the credentials + $resourceObject->eraseCredentials(); + + return $resourceObject; + } + + /** + * {@inheritdoc} + */ + public function supportsTransformation($data, array $context = []): bool + { + return RecoverPasswordInput::class === ($context['input']['class'] ?? null); + } +} diff --git a/tests/Fixtures/TestBundle/DataTransformer/RecoverPasswordOutputDataTransformer.php b/tests/Fixtures/TestBundle/DataTransformer/RecoverPasswordOutputDataTransformer.php new file mode 100644 index 00000000000..78cb77610b5 --- /dev/null +++ b/tests/Fixtures/TestBundle/DataTransformer/RecoverPasswordOutputDataTransformer.php @@ -0,0 +1,45 @@ + + * + * 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\User as UserDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\RecoverPasswordOutput; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\User; + +final class RecoverPasswordOutputDataTransformer implements DataTransformerInterface +{ + /** + * {@inheritdoc} + */ + public function transform($object, array $context = []) + { + if (!$object instanceof User && !$object instanceof UserDocument) { + throw new \InvalidArgumentException(); + } + + $output = new RecoverPasswordOutput(); + $output->user = $object; + + return $output; + } + + /** + * {@inheritdoc} + */ + public function supportsTransformation($object, array $context = []): bool + { + return ($object instanceof User || $object instanceof UserDocument) && RecoverPasswordOutput::class === ($context['output']['class'] ?? null); + } +} diff --git a/tests/Fixtures/TestBundle/Document/DummyDtoCustom.php b/tests/Fixtures/TestBundle/Document/DummyDtoCustom.php new file mode 100644 index 00000000000..af10f191639 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/DummyDtoCustom.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\CustomInputDto; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\CustomOutputDto; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * DummyDtoCustom. + * + * @ODM\Document + * + * @ApiResource( + * collectionOperations={"post"={"input"=CustomInputDto::class}, "get", "custom_output"={"output"=CustomOutputDto::class, "path"="dummy_dto_custom_output", "method"="GET"}, "post_without_output"={"output"=false, "method"="POST", "path"="dummy_dto_custom_post_without_output"}}, + * itemOperations={"get", "custom_output"={"output"=CustomOutputDto::class, "method"="GET", "path"="dummy_dto_custom_output/{id}"}, "put", "delete"} + * ) + */ +class DummyDtoCustom +{ + /** + * @var int The id + * + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + 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/DummyDtoInputOutput.php b/tests/Fixtures/TestBundle/Document/DummyDtoInputOutput.php new file mode 100644 index 00000000000..12a5642ff59 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/DummyDtoInputOutput.php @@ -0,0 +1,50 @@ + + * + * 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\ApiProperty; +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\InputDto; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\OutputDto; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Dummy InputOutput. + * + * @author Kévin Dunglas + * + * @ApiResource(attributes={"input"=InputDto::class, "output"=OutputDto::class}) + * @ODM\Document + */ +class DummyDtoInputOutput +{ + /** + * @var int The id + * @ApiProperty(identifier=true) + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + public $id; + + /** + * @var int The id + * @ODM\Field + */ + public $str; + + /** + * @var int The id + * @ODM\Field(type="float") + */ + public $num; +} diff --git a/tests/Fixtures/TestBundle/Document/DummyDtoNoInput.php b/tests/Fixtures/TestBundle/Document/DummyDtoNoInput.php index e3d87618bbc..05431be7d7e 100644 --- a/tests/Fixtures/TestBundle/Document/DummyDtoNoInput.php +++ b/tests/Fixtures/TestBundle/Document/DummyDtoNoInput.php @@ -26,8 +26,8 @@ * * @ApiResource( * attributes={ - * "input_class"=false, - * "output_class"=OutputDto::class + * "input"=false, + * "output"=OutputDto::class * } * ) */ diff --git a/tests/Fixtures/TestBundle/Document/DummyDtoNoOutput.php b/tests/Fixtures/TestBundle/Document/DummyDtoNoOutput.php index c4c66eb5694..89b0940c620 100644 --- a/tests/Fixtures/TestBundle/Document/DummyDtoNoOutput.php +++ b/tests/Fixtures/TestBundle/Document/DummyDtoNoOutput.php @@ -26,8 +26,8 @@ * * @ApiResource( * attributes={ - * "input_class"=InputDto::class, - * "output_class"=false + * "input"=InputDto::class, + * "output"=false * } * ) */ diff --git a/tests/Fixtures/TestBundle/Document/User.php b/tests/Fixtures/TestBundle/Document/User.php index 5891f2e1344..9d559b87218 100644 --- a/tests/Fixtures/TestBundle/Document/User.php +++ b/tests/Fixtures/TestBundle/Document/User.php @@ -14,6 +14,8 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document; use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\RecoverPasswordInput; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\RecoverPasswordOutput; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; use FOS\UserBundle\Model\User as BaseUser; use FOS\UserBundle\Model\UserInterface; @@ -23,10 +25,17 @@ * A User. * * @ODM\Document(collection="user_test") - * @ApiResource(attributes={ - * "normalization_context"={"groups"={"user", "user-read"}}, - * "denormalization_context"={"groups"={"user", "user-write"}} - * }) + * @ApiResource( + * attributes={ + * "normalization_context"={"groups"={"user", "user-read"}}, + * "denormalization_context"={"groups"={"user", "user-write"}} + * }, + * itemOperations={"get", "put", "delete", + * "recover_password"={ + * "input"=RecoverPasswordInput::class, "output"=RecoverPasswordOutput::class, "method"="PUT", "path"="users/recover/{id}" + * } + * } + * ) * * @author Théo FIDRY * @author Kévin Dunglas diff --git a/tests/Fixtures/TestBundle/Dto/CustomInputDto.php b/tests/Fixtures/TestBundle/Dto/CustomInputDto.php new file mode 100644 index 00000000000..6873d9bf7d7 --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/CustomInputDto.php @@ -0,0 +1,27 @@ + + * + * 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; + +class CustomInputDto +{ + /** + * @var string + */ + public $foo; + + /** + * @var int + */ + public $bar; +} diff --git a/tests/Fixtures/TestBundle/Dto/CustomOutputDto.php b/tests/Fixtures/TestBundle/Dto/CustomOutputDto.php new file mode 100644 index 00000000000..df07ac289aa --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/CustomOutputDto.php @@ -0,0 +1,27 @@ + + * + * 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; + +class CustomOutputDto +{ + /** + * @var string + */ + public $foo; + + /** + * @var int + */ + public $bar; +} diff --git a/tests/Fixtures/TestBundle/Dto/RecoverPasswordInput.php b/tests/Fixtures/TestBundle/Dto/RecoverPasswordInput.php new file mode 100644 index 00000000000..f0f109bf4ad --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/RecoverPasswordInput.php @@ -0,0 +1,25 @@ + + * + * 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; + +use Symfony\Component\Serializer\Annotation\Groups; + +class RecoverPasswordInput +{ + /** + * @var \ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\User + * @Groups({"user"}) + */ + public $user; +} diff --git a/tests/Fixtures/TestBundle/Dto/RecoverPasswordOutput.php b/tests/Fixtures/TestBundle/Dto/RecoverPasswordOutput.php new file mode 100644 index 00000000000..5a6fa8cd3e1 --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/RecoverPasswordOutput.php @@ -0,0 +1,25 @@ + + * + * 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; + +use Symfony\Component\Serializer\Annotation\Groups; + +class RecoverPasswordOutput +{ + /** + * @var \ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\User + * @Groups({"user"}) + */ + public $user; +} diff --git a/tests/Fixtures/TestBundle/Entity/DummyDtoCustom.php b/tests/Fixtures/TestBundle/Entity/DummyDtoCustom.php new file mode 100644 index 00000000000..6d3d0e06536 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyDtoCustom.php @@ -0,0 +1,60 @@ + + * + * 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\CustomInputDto; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\CustomOutputDto; +use Doctrine\ORM\Mapping as ORM; + +/** + * DummyDtoCustom. + * + * @ORM\Entity + * + * @ApiResource( + * collectionOperations={"post"={"input"=CustomInputDto::class}, "get", "custom_output"={"output"=CustomOutputDto::class, "path"="dummy_dto_custom_output", "method"="GET"}, "post_without_output"={"output"=false, "method"="POST", "path"="dummy_dto_custom_post_without_output"}}, + * itemOperations={"get", "custom_output"={"output"=CustomOutputDto::class, "method"="GET", "path"="dummy_dto_custom_output/{id}"}, "put", "delete"} + * ) + */ +class DummyDtoCustom +{ + /** + * @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/DummyOutput.php b/tests/Fixtures/TestBundle/Entity/DummyDtoInputOutput.php similarity index 61% rename from tests/Fixtures/TestBundle/Entity/DummyOutput.php rename to tests/Fixtures/TestBundle/Entity/DummyDtoInputOutput.php index b7cbb15de90..28f0e064424 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyOutput.php +++ b/tests/Fixtures/TestBundle/Entity/DummyDtoInputOutput.php @@ -15,21 +15,23 @@ use ApiPlatform\Core\Annotation\ApiProperty; use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\InputDto; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\OutputDto; use Doctrine\ORM\Mapping as ORM; /** - * Dummy Output. + * Dummy InputOutput. * * @author Kévin Dunglas * - * @ApiResource + * @ApiResource(attributes={"input"=InputDto::class, "output"=OutputDto::class}) * @ORM\Entity */ -class DummyOutput +class DummyDtoInputOutput { /** * @var int The id - * + * @ApiProperty(identifier=true) * @ORM\Column(type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") @@ -37,10 +39,14 @@ class DummyOutput public $id; /** - * @var string The dummy name - * - * @ORM\Column - * @ApiProperty(iri="http://schema.org/name") + * @var string + * @ORM\Column(type="string") */ - public $name; + public $str; + + /** + * @var int + * @ORM\Column(type="decimal") + */ + public $num; } diff --git a/tests/Fixtures/TestBundle/Entity/DummyDtoNoInput.php b/tests/Fixtures/TestBundle/Entity/DummyDtoNoInput.php index a8c35081e6e..b29654ad92e 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyDtoNoInput.php +++ b/tests/Fixtures/TestBundle/Entity/DummyDtoNoInput.php @@ -26,8 +26,8 @@ * * @ApiResource( * attributes={ - * "input_class"=false, - * "output_class"=OutputDto::class + * "input"=false, + * "output"=OutputDto::class * } * ) */ diff --git a/tests/Fixtures/TestBundle/Entity/DummyDtoNoOutput.php b/tests/Fixtures/TestBundle/Entity/DummyDtoNoOutput.php index 315afdeb820..232364a94e2 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyDtoNoOutput.php +++ b/tests/Fixtures/TestBundle/Entity/DummyDtoNoOutput.php @@ -26,8 +26,8 @@ * * @ApiResource( * attributes={ - * "input_class"=InputDto::class, - * "output_class"=false + * "input"=InputDto::class, + * "output"=false * } * ) */ diff --git a/tests/Fixtures/TestBundle/Entity/DummyInput.php b/tests/Fixtures/TestBundle/Entity/DummyInput.php deleted file mode 100644 index 24ffc48b405..00000000000 --- a/tests/Fixtures/TestBundle/Entity/DummyInput.php +++ /dev/null @@ -1,40 +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\ApiProperty; -use ApiPlatform\Core\Annotation\ApiResource; - -/** - * Dummy Input. - * - * @author Kévin Dunglas - * - * @ApiResource - */ -class DummyInput -{ - /** - * @var int The id - * @ApiProperty(identifier=true) - */ - public $id; - - /** - * @var string The dummy name - * - * @ApiProperty - */ - public $name; -} diff --git a/tests/Fixtures/TestBundle/Entity/DummyInputOutput.php b/tests/Fixtures/TestBundle/Entity/DummyInputOutput.php deleted file mode 100644 index 442a83c1121..00000000000 --- a/tests/Fixtures/TestBundle/Entity/DummyInputOutput.php +++ /dev/null @@ -1,33 +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\ApiProperty; -use ApiPlatform\Core\Annotation\ApiResource; - -/** - * Dummy InputOutput. - * - * @author Kévin Dunglas - * - * @ApiResource(attributes={"input_class"=DummyInput::class, "output_class"=DummyOutput::class}) - */ -class DummyInputOutput -{ - /** - * @var int The id - * @ApiProperty(identifier=true) - */ - public $id; -} diff --git a/tests/Fixtures/TestBundle/Entity/User.php b/tests/Fixtures/TestBundle/Entity/User.php index ac5ecc11ab4..89ca2aa3b07 100644 --- a/tests/Fixtures/TestBundle/Entity/User.php +++ b/tests/Fixtures/TestBundle/Entity/User.php @@ -14,6 +14,8 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\RecoverPasswordInput; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\RecoverPasswordOutput; use Doctrine\ORM\Mapping as ORM; use FOS\UserBundle\Model\User as BaseUser; use FOS\UserBundle\Model\UserInterface; @@ -24,10 +26,17 @@ * * @ORM\Entity * @ORM\Table(name="user_test") - * @ApiResource(attributes={ - * "normalization_context"={"groups"={"user", "user-read"}}, - * "denormalization_context"={"groups"={"user", "user-write"}} - * }) + * @ApiResource( + * attributes={ + * "normalization_context"={"groups"={"user", "user-read"}}, + * "denormalization_context"={"groups"={"user", "user-write"}} + * }, + * itemOperations={"get", "put", "delete", + * "recover_password"={ + * "input"=RecoverPasswordInput::class, "output"=RecoverPasswordOutput::class, "method"="PUT", "path"="users/recover/{id}" + * } + * } + * ) * * @author Théo FIDRY * @author Kévin Dunglas diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 456baa29f73..bfbfe79ea7c 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -189,12 +189,6 @@ services: class: ApiPlatform\Core\Tests\Fixtures\TestBundle\Validator\DummyValidationGroupsGenerator public: true - app.dummy_input_data_persister: - class: ApiPlatform\Core\Tests\Fixtures\TestBundle\DataPersister\DummyInputDataPersister - public: false - tags: - - { name: 'api_platform.data_persister' } - mercure.hub.default.publisher: class: ApiPlatform\Core\Tests\Fixtures\DummyMercurePublisher @@ -203,3 +197,39 @@ services: decorates: api_platform.swagger.normalizer.documentation public: false arguments: ['@app.serializer.normalizer.override_documentation.inner'] + + app.data_transformer.custom_input_dto: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer\CustomInputDtoDataTransformer' + public: false + tags: + - { name: 'api_platform.data_transformer.input' } + + app.data_transformer.custom_output_dto: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer\CustomOutputDtoDataTransformer' + public: false + tags: + - { name: 'api_platform.data_transformer.output' } + + app.data_transformer.input_dto: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer\InputDtoDataTransformer' + public: false + tags: + - { name: 'api_platform.data_transformer.input' } + + app.data_transformer.output_dto: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer\OutputDtoDataTransformer' + public: false + tags: + - { name: 'api_platform.data_transformer.output' } + + app.data_transformer.recover_password_input: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer\RecoverPasswordInputDataTransformer' + public: false + tags: + - { name: 'api_platform.data_transformer.input' } + + app.data_transformer.recover_password_output: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer\RecoverPasswordOutputDataTransformer' + public: false + tags: + - { name: 'api_platform.data_transformer.output' } diff --git a/tests/GraphQl/Serializer/ItemNormalizerTest.php b/tests/GraphQl/Serializer/ItemNormalizerTest.php index 60a1b4ec566..920a3956607 100644 --- a/tests/GraphQl/Serializer/ItemNormalizerTest.php +++ b/tests/GraphQl/Serializer/ItemNormalizerTest.php @@ -32,6 +32,10 @@ */ class ItemNormalizerTest extends TestCase { + /** + * @group legacy + * @expectedDeprecation Passing a falsy $allowUnmappedClass flag in ApiPlatform\Core\Serializer\AbstractItemNormalizer is deprecated since version 2.4 and will default to true in 3.0. + */ public function testSupportNormalization() { $std = new \stdClass(); @@ -89,7 +93,14 @@ public function testNormalize() $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + false, + null, + true ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -119,7 +130,14 @@ public function testDenormalize() $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + false, + null, + true ); $normalizer->setSerializer($serializerProphecy->reveal()); diff --git a/tests/Hal/Serializer/ItemNormalizerTest.php b/tests/Hal/Serializer/ItemNormalizerTest.php index 95b06fd4b61..bd6104173d7 100644 --- a/tests/Hal/Serializer/ItemNormalizerTest.php +++ b/tests/Hal/Serializer/ItemNormalizerTest.php @@ -41,6 +41,10 @@ */ class ItemNormalizerTest extends TestCase { + /** + * @group legacy + * @expectedDeprecation Passing a falsy $allowUnmappedClass flag in ApiPlatform\Core\Serializer\AbstractItemNormalizer is deprecated since version 2.4 and will default to true in 3.0. + */ public function testDoesNotSupportDenormalization() { $this->expectException(RuntimeException::class); @@ -64,6 +68,10 @@ public function testDoesNotSupportDenormalization() $normalizer->denormalize(['foo'], 'Foo'); } + /** + * @group legacy + * @expectedDeprecation Passing a falsy $allowUnmappedClass flag in ApiPlatform\Core\Serializer\AbstractItemNormalizer is deprecated since version 2.4 and will default to true in 3.0. + */ public function testSupportsNormalization() { $std = new \stdClass(); @@ -137,7 +145,12 @@ public function testNormalize() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), null, - $nameConverter->reveal() + $nameConverter->reveal(), + null, + null, + false, + [], + true ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -197,7 +210,12 @@ public function testNormalizeWithoutCache() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), null, - $nameConverter->reveal() + $nameConverter->reveal(), + null, + null, + false, + [], + true ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -271,7 +289,11 @@ public function testMaxDepth() $resourceClassResolverProphecy->reveal(), null, null, - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())) + $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())), + null, + false, + [], + true ); $serializer = new Serializer([$normalizer]); $normalizer->setSerializer($serializer); diff --git a/tests/Hydra/Serializer/DocumentationNormalizerTest.php b/tests/Hydra/Serializer/DocumentationNormalizerTest.php index b6755366491..a69d4c84080 100644 --- a/tests/Hydra/Serializer/DocumentationNormalizerTest.php +++ b/tests/Hydra/Serializer/DocumentationNormalizerTest.php @@ -364,7 +364,7 @@ public function testNormalizeInputOutputClass() $propertyNameCollectionFactoryProphecy->create('inputClass', [])->shouldBeCalled()->willReturn(new PropertyNameCollection(['a', 'b'])); $propertyNameCollectionFactoryProphecy->create('outputClass', [])->shouldBeCalled()->willReturn(new PropertyNameCollection(['c', 'd'])); - $dummyMetadata = new ResourceMetadata('dummy', 'dummy', '#dummy', ['get' => [], 'put' => ['input_class' => false]], ['get' => [], 'post' => ['output_class' => false]], ['input_class' => 'inputClass', 'output_class' => 'outputClass']); + $dummyMetadata = new ResourceMetadata('dummy', 'dummy', '#dummy', ['get' => [], 'put' => ['input' => ['class' => null]]], ['get' => [], 'post' => ['output' => ['class' => null]]], ['input' => ['class' => 'inputClass'], 'output' => ['class' => 'outputClass']]); $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $resourceMetadataFactoryProphecy->create('dummy')->shouldBeCalled()->willReturn($dummyMetadata); diff --git a/tests/Hydra/Serializer/ItemNormalizerTest.php b/tests/Hydra/Serializer/ItemNormalizerTest.php index 9cbf0c9a92a..fa29d630f67 100644 --- a/tests/Hydra/Serializer/ItemNormalizerTest.php +++ b/tests/Hydra/Serializer/ItemNormalizerTest.php @@ -34,6 +34,10 @@ */ class ItemNormalizerTest extends TestCase { + /** + * @group legacy + * @expectedDeprecation Passing a falsy $allowUnmappedClass flag in ApiPlatform\Core\Serializer\AbstractItemNormalizer is deprecated since version 2.4 and will default to true in 3.0. + */ public function testDontSupportDenormalization() { $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); @@ -51,6 +55,10 @@ public function testDontSupportDenormalization() $this->assertTrue($normalizer->hasCacheableSupportsMethod()); } + /** + * @group legacy + * @expectedDeprecation Passing a falsy $allowUnmappedClass flag in ApiPlatform\Core\Serializer\AbstractItemNormalizer is deprecated since version 2.4 and will default to true in 3.0. + */ public function testSupportNormalization() { $std = new \stdClass(); @@ -116,7 +124,12 @@ public function testNormalize() $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), - $contextBuilderProphecy->reveal() + $contextBuilderProphecy->reveal(), + null, + null, + null, + [], + true ); $normalizer->setSerializer($serializerProphecy->reveal()); diff --git a/tests/JsonApi/Serializer/ItemNormalizerTest.php b/tests/JsonApi/Serializer/ItemNormalizerTest.php index 2e5eb92008c..550dea89fb8 100644 --- a/tests/JsonApi/Serializer/ItemNormalizerTest.php +++ b/tests/JsonApi/Serializer/ItemNormalizerTest.php @@ -42,6 +42,10 @@ */ class ItemNormalizerTest extends TestCase { + /** + * @group legacy + * @expectedDeprecation Passing a falsy $allowUnmappedClass flag in ApiPlatform\Core\Serializer\AbstractItemNormalizer is deprecated since version 2.4 and will default to true in 3.0. + */ public function testSupportDenormalization() { $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); @@ -63,6 +67,10 @@ public function testSupportDenormalization() $this->assertTrue($normalizer->hasCacheableSupportsMethod()); } + /** + * @group legacy + * @expectedDeprecation Passing a falsy $allowUnmappedClass flag in ApiPlatform\Core\Serializer\AbstractItemNormalizer is deprecated since version 2.4 and will default to true in 3.0. + */ public function testSupportNormalization() { $std = new \stdClass(); @@ -129,7 +137,9 @@ public function testNormalize() $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), new ReservedAttributeNameConverter(), - $resourceMetadataFactoryProphecy->reveal() + $resourceMetadataFactoryProphecy->reveal(), + [], + true ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -165,7 +175,9 @@ public function testNormalizeIsNotAnArray() $resourceClassResolverProphecy->reveal(), $this->prophesize(PropertyAccessorInterface::class)->reveal(), new ReservedAttributeNameConverter(), - $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal() + $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), + [], + true ); $normalizer->setSerializer(new Serializer([$normalizer])); @@ -219,7 +231,9 @@ public function testNormalizeThrowsNoSuchPropertyException() $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), new ReservedAttributeNameConverter(), - $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal() + $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), + [], + true ); $normalizer->normalize($foo, ItemNormalizer::FORMAT); @@ -298,7 +312,9 @@ public function testDenormalize() $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), new ReservedAttributeNameConverter(), - $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal() + $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), + [], + true ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -348,7 +364,9 @@ public function testDenormalizeUpdateOperationNotAllowed() $this->prophesize(ResourceClassResolverInterface::class)->reveal(), null, null, - $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal() + $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), + [], + true ); $normalizer->denormalize( @@ -399,7 +417,9 @@ public function testDenormalizeCollectionIsNotArray() $this->prophesize(ResourceClassResolverInterface::class)->reveal(), $this->prophesize(PropertyAccessorInterface::class)->reveal(), new ReservedAttributeNameConverter(), - $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal() + $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), + [], + true ); $normalizer->denormalize( @@ -451,7 +471,9 @@ public function testDenormalizeCollectionWithInvalidKey() $this->prophesize(ResourceClassResolverInterface::class)->reveal(), $this->prophesize(PropertyAccessorInterface::class)->reveal(), new ReservedAttributeNameConverter(), - $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal() + $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), + [], + true ); $normalizer->denormalize( @@ -505,7 +527,9 @@ public function testDenormalizeRelationIsNotResourceLinkage() $resourceClassResolverProphecy->reveal(), $this->prophesize(PropertyAccessorInterface::class)->reveal(), new ReservedAttributeNameConverter(), - $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal() + $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), + [], + true ); $normalizer->denormalize( diff --git a/tests/Metadata/Resource/Factory/InputOutputResourceMetadataFactoryTest.php b/tests/Metadata/Resource/Factory/InputOutputResourceMetadataFactoryTest.php new file mode 100644 index 00000000000..0e481704e17 --- /dev/null +++ b/tests/Metadata/Resource/Factory/InputOutputResourceMetadataFactoryTest.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Metadata\Resource\Factory; + +use ApiPlatform\Core\Metadata\Resource\Factory\InputOutputResourceMetadataFactory; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Tests\Fixtures\DummyEntity; +use PHPUnit\Framework\TestCase; + +class InputOutputResourceMetadataFactoryTest extends TestCase +{ + /** + * @dataProvider getAttributes + */ + public function testExistingDescription($attributes, $expected) + { + $resourceMetadata = (new ResourceMetadata(null, 'My desc'))->withAttributes($attributes); + $decoratedProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $decoratedProphecy->create('Foo')->willReturn($resourceMetadata)->shouldBeCalled(); + $decorated = $decoratedProphecy->reveal(); + + $factory = new InputOutputResourceMetadataFactory($decorated); + $this->assertSame($expected, $factory->create('Foo')->getAttributes()['input']); + } + + public function getAttributes(): array + { + return [ + // no input class defined + [[], null], + // input is a string + [['input' => DummyEntity::class], ['class' => DummyEntity::class, 'name' => 'DummyEntity']], + // input is false + [['input' => false], ['class' => null]], + // input is an array + [['input' => ['class' => DummyEntity::class, 'type' => 'Foo']], ['class' => DummyEntity::class, 'type' => 'Foo', 'name' => 'DummyEntity']], + ]; + } +} diff --git a/tests/Serializer/AbstractItemNormalizerTest.php b/tests/Serializer/AbstractItemNormalizerTest.php index 611087bc127..22fa7d3b36c 100644 --- a/tests/Serializer/AbstractItemNormalizerTest.php +++ b/tests/Serializer/AbstractItemNormalizerTest.php @@ -45,7 +45,11 @@ */ class AbstractItemNormalizerTest extends TestCase { - public function testSupportNormalizationAndSupportDenormalization() + /** + * @group legacy + * @expectedDeprecation Passing a falsy $allowUnmappedClass flag in ApiPlatform\Core\Serializer\AbstractItemNormalizer is deprecated since version 2.4 and will default to true in 3.0. + */ + public function testLegacySupportNormalizationAndSupportDenormalization() { $std = new \stdClass(); $dummy = new Dummy(); @@ -74,6 +78,39 @@ public function testSupportNormalizationAndSupportDenormalization() $this->assertTrue($normalizer->hasCacheableSupportsMethod()); } + public function testSupportNormalizationAndSupportDenormalization() + { + $std = new \stdClass(); + $dummy = new Dummy(); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + + $normalizer = $this->getMockForAbstractClass(AbstractItemNormalizer::class, [ + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + true, + ]); + + $this->assertTrue($normalizer->supportsNormalization($dummy)); + $this->assertTrue($normalizer->supportsNormalization($std)); + $this->assertTrue($normalizer->supportsDenormalization($dummy, Dummy::class)); + $this->assertTrue($normalizer->supportsDenormalization($std, \stdClass::class)); + $this->assertTrue($normalizer->hasCacheableSupportsMethod()); + } + public function testNormalize() { $relatedDummy = new RelatedDummy(); @@ -141,6 +178,12 @@ public function testNormalize() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -212,6 +255,12 @@ public function testNormalizeReadableLinks() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -282,6 +331,12 @@ public function testDenormalize() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -318,7 +373,7 @@ public function testCanDenormalizeInputClassWithDifferentFieldsThanResourceClass (new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', true, false))->withInitializable(true) ); - $normalizer = new class($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $this->prophesize(IriConverterInterface::class)->reveal(), $this->prophesize(ResourceClassResolverInterface::class)->reveal()) extends AbstractItemNormalizer { + $normalizer = new class($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $this->prophesize(IriConverterInterface::class)->reveal(), $this->prophesize(ResourceClassResolverInterface::class)->reveal(), null, null, null, null, false, [], true) extends AbstractItemNormalizer { }; /** @var DummyForAdditionalFieldsInput $res */ @@ -326,8 +381,8 @@ public function testCanDenormalizeInputClassWithDifferentFieldsThanResourceClass 'dummyName' => 'Dummy Name', ], DummyForAdditionalFieldsInput::class, 'json', [ 'resource_class' => DummyForAdditionalFields::class, - 'input_class' => DummyForAdditionalFieldsInput::class, - 'output_class' => DummyForAdditionalFields::class, + 'input' => ['class' => DummyForAdditionalFieldsInput::class], + 'output' => ['class' => DummyForAdditionalFields::class], ]); $this->assertInstanceOf(DummyForAdditionalFieldsInput::class, $res); @@ -396,6 +451,12 @@ public function testDenormalizeWritableLinks() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -443,6 +504,12 @@ public function testBadRelationType() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -486,6 +553,12 @@ public function testInnerDocumentNotAllowed() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -520,6 +593,12 @@ public function testBadType() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -552,6 +631,12 @@ public function testJsonAllowIntAsFloat() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -601,6 +686,12 @@ public function testDenormalizeBadKeyType() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -636,6 +727,12 @@ public function testNullable() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -681,6 +778,12 @@ public function testChildInheritedProperty() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -731,6 +834,8 @@ public function testDenormalizeRelationWithPlainId() null, $itemDataProviderProphecy->reveal(), true, + [], + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -781,9 +886,32 @@ public function testDoNotDenormalizeRelationWithPlainIdWhenPlainIdentifiersAreNo null, $itemDataProviderProphecy->reveal(), false, + [], + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); $normalizer->denormalize(['relatedDummy' => 1], Dummy::class, 'jsonld'); } + + /** + * @group legacy + * @expectedDeprecation Passing a falsy $allowUnmappedClass flag in ApiPlatform\Core\Serializer\AbstractItemNormalizer is deprecated since version 2.4 and will default to true in 3.0. + */ + public function testDisallowUnmappedClass() + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + + $this->getMockForAbstractClass(AbstractItemNormalizer::class, [ + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + ]); + } } diff --git a/tests/Serializer/ItemNormalizerTest.php b/tests/Serializer/ItemNormalizerTest.php index d7930b1404c..0563045b3b4 100644 --- a/tests/Serializer/ItemNormalizerTest.php +++ b/tests/Serializer/ItemNormalizerTest.php @@ -33,6 +33,10 @@ */ class ItemNormalizerTest extends TestCase { + /** + * @group legacy + * @expectedDeprecation Passing a falsy $allowUnmappedClass flag in ApiPlatform\Core\Serializer\AbstractItemNormalizer is deprecated since version 2.4 and will default to true in 3.0. + */ public function testSupportNormalization() { $std = new \stdClass(); @@ -91,7 +95,14 @@ public function testNormalize() $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + false, + null, + true ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -121,7 +132,14 @@ public function testDenormalize() $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + false, + null, + true ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -152,7 +170,14 @@ public function testDenormalizeWithIri() $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + false, + null, + true ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -181,7 +206,14 @@ public function testDenormalizeWithIdAndUpdateNotAllowed() $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + false, + null, + true ); $normalizer->setSerializer($serializerProphecy->reveal()); $normalizer->denormalize(['id' => '12', 'name' => 'hello'], Dummy::class, null, $context); @@ -211,7 +243,14 @@ public function testDenormalizeWithIdAndNoResourceClass() $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + false, + null, + true ); $normalizer->setSerializer($serializerProphecy->reveal()); diff --git a/tests/Serializer/SerializerContextBuilderTest.php b/tests/Serializer/SerializerContextBuilderTest.php index 08f539522c7..72c68c8534e 100644 --- a/tests/Serializer/SerializerContextBuilderTest.php +++ b/tests/Serializer/SerializerContextBuilderTest.php @@ -55,32 +55,32 @@ public function testCreateFromRequest() { $request = Request::create('/foos/1'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['foo' => 'bar', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output_class' => 'Foo', 'input_class' => 'Foo']; + $expected = ['foo' => 'bar', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null]; $this->assertEquals($expected, $this->builder->createFromRequest($request, true)); $request = Request::create('/foos'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'pot', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['foo' => 'bar', 'collection_operation_name' => 'pot', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output_class' => 'Foo', 'input_class' => 'Foo']; + $expected = ['foo' => 'bar', 'collection_operation_name' => 'pot', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null]; $this->assertEquals($expected, $this->builder->createFromRequest($request, true)); $request = Request::create('/foos/1'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output_class' => 'Foo', 'input_class' => 'Foo']; + $expected = ['bar' => 'baz', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/foos', 'POST'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'post', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'collection_operation_name' => 'post', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => false, 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output_class' => 'Foo', 'input_class' => 'Foo']; + $expected = ['bar' => 'baz', 'collection_operation_name' => 'post', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => false, 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/foos', 'PUT'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'put', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'collection_operation_name' => 'put', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => true, 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output_class' => 'Foo', 'input_class' => 'Foo']; + $expected = ['bar' => 'baz', 'collection_operation_name' => 'put', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => true, 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/bars/1/foos'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_subresource_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'subresource_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'operation_type' => 'subresource', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output_class' => 'Foo', 'input_class' => 'Foo']; + $expected = ['bar' => 'baz', 'subresource_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'operation_type' => 'subresource', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); } @@ -93,7 +93,7 @@ public function testThrowExceptionOnInvalidRequest() public function testReuseExistingAttributes() { - $expected = ['bar' => 'baz', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output_class' => 'Foo', 'input_class' => 'Foo']; + $expected = ['bar' => 'baz', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null]; $this->assertEquals($expected, $this->builder->createFromRequest(Request::create('/foos/1'), false, ['resource_class' => 'Foo', 'item_operation_name' => 'get'])); } } diff --git a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php index cb5756e69ce..320d1aec282 100644 --- a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php +++ b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php @@ -2318,7 +2318,7 @@ public function testNormalizeWithInputAndOutpusClass() $propertyNameCollectionFactoryProphecy->create(InputDto::class, [])->shouldBeCalled()->willReturn(new PropertyNameCollection(['foo', 'bar'])); $propertyNameCollectionFactoryProphecy->create(OutputDto::class, [])->shouldBeCalled()->willReturn(new PropertyNameCollection(['baz', 'bat'])); - $dummyMetadata = new ResourceMetadata('Dummy', 'This is a dummy.', 'http://schema.example.com/Dummy', ['get' => [], 'put' => []], ['get' => [], 'post' => []], ['input_class' => InputDto::class, 'output_class' => OutputDto::class]); + $dummyMetadata = new ResourceMetadata('Dummy', 'This is a dummy.', 'http://schema.example.com/Dummy', ['get' => [], 'put' => []], ['get' => [], 'post' => []], ['input' => ['class' => InputDto::class], 'output' => ['class' => OutputDto::class]]); $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata);