From bb30d60787e50b94f89ff10c3b38116eda7a9054 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 12 Feb 2019 11:45:22 +0100 Subject: [PATCH 1/2] Missing on-invalid=null see #2483 --- src/Bridge/Symfony/Bundle/Resources/config/api.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index dc15cb59e81..fa80ff8b19b 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -156,7 +156,7 @@ - + From d7885e7f4d8e08217baf2ceb95cbde3775291e2c Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 12 Feb 2019 11:44:42 +0100 Subject: [PATCH 2/2] Implement input/output with data transformers Move data transform to normalizers --- features/bootstrap/DoctrineContext.php | 26 +++ features/graphql/mutation.feature | 22 +- .../related-resouces-inclusion.feature | 2 +- features/main/input_output.feature | 212 +++++++++++++++++ features/main/table_inheritance.feature | 66 +++--- src/Annotation/ApiResource.php | 8 +- src/Api/ResourceClassResolver.php | 5 + .../Symfony/Bundle/Resources/config/api.xml | 13 +- .../Bundle/Resources/config/elasticsearch.xml | 3 +- .../Bundle/Resources/config/graphql.xml | 7 +- .../Symfony/Bundle/Resources/config/hal.xml | 15 +- .../Symfony/Bundle/Resources/config/hydra.xml | 10 +- .../Bundle/Resources/config/jsonapi.xml | 14 +- .../Bundle/Resources/config/jsonld.xml | 6 +- .../Resources/config/metadata/metadata.xml | 3 + .../Bundle/Resources/config/problem.xml | 6 +- .../Bundle/Resources/config/swagger.xml | 4 +- src/DataTransformer/ChainDataTransformer.php | 57 +++++ .../DataTransformerInterface.php | 39 ++++ src/EventListener/DeserializeListener.php | 6 +- src/EventListener/SerializeListener.php | 11 +- src/EventListener/WriteListener.php | 3 +- .../Factory/ItemMutationResolverFactory.php | 6 +- src/GraphQl/Serializer/ItemNormalizer.php | 8 + src/GraphQl/Type/SchemaBuilder.php | 19 +- src/Hal/Serializer/ItemNormalizer.php | 19 +- .../Serializer/DocumentationNormalizer.php | 32 +-- src/Identifier/IdentifierConverter.php | 2 +- src/JsonApi/Serializer/ItemNormalizer.php | 23 +- .../AnonymousContextBuilderInterface.php | 30 +++ src/JsonLd/ContextBuilder.php | 52 +++-- src/JsonLd/Serializer/ItemNormalizer.php | 27 ++- src/JsonLd/Serializer/JsonLdContextTrait.php | 12 + .../InputOutputResourceMetadataFactory.php | 88 +++++++ src/Serializer/AbstractItemNormalizer.php | 54 ++++- src/Serializer/InputOutputMetadataTrait.php | 49 ++++ src/Serializer/ItemNormalizer.php | 6 +- src/Serializer/SerializerContextBuilder.php | 4 +- .../Serializer/DocumentationNormalizer.php | 22 +- tests/Annotation/ApiResourceTest.php | 8 +- tests/Api/ResourceClassResolverTest.php | 4 +- .../ApiPlatformExtensionTest.php | 3 + .../EventListener/DeserializeListenerTest.php | 26 ++- tests/EventListener/SerializeListenerTest.php | 2 +- tests/EventListener/WriteListenerTest.php | 4 +- .../DataPersister/DummyInputDataPersister.php | 46 ---- .../CustomInputDtoDataTransformer.php | 50 ++++ .../CustomOutputDtoDataTransformer.php | 62 +++++ .../InputDtoDataTransformer.php | 50 ++++ .../OutputDtoDataTransformer.php | 46 ++++ .../RecoverPasswordInputDataTransformer.php | 44 ++++ .../RecoverPasswordOutputDataTransformer.php | 46 ++++ .../TestBundle/Document/DummyDtoCustom.php | 58 +++++ .../Document/DummyDtoInputOutput.php | 50 ++++ .../TestBundle/Document/DummyDtoNoInput.php | 4 +- .../TestBundle/Document/DummyDtoNoOutput.php | 4 +- tests/Fixtures/TestBundle/Document/User.php | 17 +- .../TestBundle/Dto/CustomInputDto.php | 27 +++ .../TestBundle/Dto/CustomOutputDto.php | 27 +++ .../TestBundle/Dto/NonResourceClass.php | 27 +++ .../TestBundle/Dto/RecoverPasswordInput.php | 25 ++ .../TestBundle/Dto/RecoverPasswordOutput.php | 25 ++ .../TestBundle/Entity/DummyDtoCustom.php | 60 +++++ ...nputOutput.php => DummyDtoInputOutput.php} | 23 +- .../TestBundle/Entity/DummyDtoNoInput.php | 4 +- .../TestBundle/Entity/DummyDtoNoOutput.php | 4 +- .../Fixtures/TestBundle/Entity/DummyInput.php | 40 ---- ...ummyOutput.php => NonRelationResource.php} | 24 +- tests/Fixtures/TestBundle/Entity/User.php | 17 +- tests/Fixtures/app/config/config_common.yml | 42 +++- .../GraphQl/Serializer/ItemNormalizerTest.php | 26 ++- tests/Hal/Serializer/ItemNormalizerTest.php | 34 ++- .../DocumentationNormalizerTest.php | 2 +- tests/Hydra/Serializer/ItemNormalizerTest.php | 16 +- .../JsonApi/Serializer/ItemNormalizerTest.php | 66 +++++- ...InputOutputResourceMetadataFactoryTest.php | 51 +++++ .../Serializer/AbstractItemNormalizerTest.php | 216 +++++++++++++++++- tests/Serializer/ItemNormalizerTest.php | 59 ++++- .../SerializerContextBuilderTest.php | 14 +- .../DocumentationNormalizerV2Test.php | 2 +- 80 files changed, 2029 insertions(+), 317 deletions(-) create mode 100644 features/main/input_output.feature create mode 100644 src/DataTransformer/ChainDataTransformer.php create mode 100644 src/DataTransformer/DataTransformerInterface.php create mode 100644 src/JsonLd/AnonymousContextBuilderInterface.php create mode 100644 src/Metadata/Resource/Factory/InputOutputResourceMetadataFactory.php create mode 100644 src/Serializer/InputOutputMetadataTrait.php delete mode 100644 tests/Fixtures/TestBundle/DataPersister/DummyInputDataPersister.php create mode 100644 tests/Fixtures/TestBundle/DataTransformer/CustomInputDtoDataTransformer.php create mode 100644 tests/Fixtures/TestBundle/DataTransformer/CustomOutputDtoDataTransformer.php create mode 100644 tests/Fixtures/TestBundle/DataTransformer/InputDtoDataTransformer.php create mode 100644 tests/Fixtures/TestBundle/DataTransformer/OutputDtoDataTransformer.php create mode 100644 tests/Fixtures/TestBundle/DataTransformer/RecoverPasswordInputDataTransformer.php create mode 100644 tests/Fixtures/TestBundle/DataTransformer/RecoverPasswordOutputDataTransformer.php create mode 100644 tests/Fixtures/TestBundle/Document/DummyDtoCustom.php create mode 100644 tests/Fixtures/TestBundle/Document/DummyDtoInputOutput.php create mode 100644 tests/Fixtures/TestBundle/Dto/CustomInputDto.php create mode 100644 tests/Fixtures/TestBundle/Dto/CustomOutputDto.php create mode 100644 tests/Fixtures/TestBundle/Dto/NonResourceClass.php create mode 100644 tests/Fixtures/TestBundle/Dto/RecoverPasswordInput.php create mode 100644 tests/Fixtures/TestBundle/Dto/RecoverPasswordOutput.php create mode 100644 tests/Fixtures/TestBundle/Entity/DummyDtoCustom.php rename tests/Fixtures/TestBundle/Entity/{DummyInputOutput.php => DummyDtoInputOutput.php} (52%) delete mode 100644 tests/Fixtures/TestBundle/Entity/DummyInput.php rename tests/Fixtures/TestBundle/Entity/{DummyOutput.php => NonRelationResource.php} (65%) create mode 100644 tests/Metadata/Resource/Factory/InputOutputResourceMetadataFactoryTest.php diff --git a/features/bootstrap/DoctrineContext.php b/features/bootstrap/DoctrineContext.php index 47a1d773356..43eb85993ea 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,30 @@ public function thereIsAMaxDepthDummyWithLevelOfDescendants(int $level) $this->manager->flush(); } + /** + * @Given there is a DummyCustomDto + */ + public function thereIsADummyCustomDto() + { + $this->thereAreNbDummyCustomDto(1); + } + + /** + * @Given there are :nb DummyCustomDto + */ + public function thereAreNbDummyCustomDto($nb) + { + for ($i = 0; $i < $nb; ++$i) { + $dto = $this->isOrm() ? new DummyDtoCustom() : new DummyDtoCustomDocument(); + $dto->lorem = 'test'; + $dto->ipsum = (string) $i + 1; + $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..27775b0eccb --- /dev/null +++ b/features/main/input_output.feature @@ -0,0 +1,212 @@ +Feature: DTO input and output + In order to use a 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": 1 + } + """ + + @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" + } + """ + When 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" + } + """ + + @!mongodb + @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 + When 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#", + "dummy": { + "@type": "@id" + } + }, + "@type": "RecoverPasswordOutput", + "dummy": "/dummies/1" + } + """ + + @!mongodb + @createSchema + Scenario: Create a resource that has a non-resource DTO relation. + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/non_relation_resources" with body: + """ + {"relation": {"foo": "test"}} + """ + 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/NonRelationResource", + "@id": "/non_relation_resources/1", + "@type": "NonRelationResource", + "relation": { + "foo": "test" + }, + "id": 1 + } + """ diff --git a/features/main/table_inheritance.feature b/features/main/table_inheritance.feature index a4bc49a9dcd..54971c078aa 100644 --- a/features/main/table_inheritance.feature +++ b/features/main/table_inheritance.feature @@ -292,34 +292,38 @@ 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$" + }, + "@id": { + "type": "string", + "pattern": "^_:" + }, + "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/Api/ResourceClassResolver.php b/src/Api/ResourceClassResolver.php index 9f360c1de42..f75f58227d1 100644 --- a/src/Api/ResourceClassResolver.php +++ b/src/Api/ResourceClassResolver.php @@ -54,6 +54,11 @@ public function getResourceClass($value, string $resourceClass = null, bool $str return $resourceClass; } + // The Resource is an interface + if ($value instanceof $resourceClass && $type !== $resourceClass && interface_exists($resourceClass)) { + throw new InvalidArgumentException(sprintf('The given object\'s resource is the interface "%s", finding a class is not possible.', $resourceClass)); + } + if ( ($isResourceClass ?? $this->isResourceClass($type)) || (is_subclass_of($type, $resourceClass) && $this->isResourceClass($resourceClass)) diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index fa80ff8b19b..f5d049cfa11 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -98,8 +98,13 @@ %api_platform.allow_plain_identifiers% + null + + + true - + + @@ -282,6 +287,12 @@ + + + + + + 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..0d9c87fa6c4 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml @@ -76,8 +76,13 @@ %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..c05de3f33a9 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,15 @@ - - + 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..cdf78a92f1a 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,25 @@ + + + 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..c53482a9f4d 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml @@ -25,8 +25,12 @@ + + + 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/ChainDataTransformer.php b/src/DataTransformer/ChainDataTransformer.php new file mode 100644 index 00000000000..0e81dc88be1 --- /dev/null +++ b/src/DataTransformer/ChainDataTransformer.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 ChainDataTransformer implements DataTransformerInterface +{ + private $transformers; + + public function __construct(iterable $transformers) + { + $this->transformers = $transformers; + } + + /** + * {@inheritdoc} + */ + public function transform($object, string $to, array $context = []) + { + foreach ($this->transformers as $transformer) { + if ($transformer->supportsTransformation($object, $to, $context)) { + return $transformer->transform($object, $to, $context); + } + } + + return $object; + } + + /** + * {@inheritdoc} + */ + public function supportsTransformation($object, string $to, array $context = []): bool + { + foreach ($this->transformers as $transformer) { + if ($transformer->supportsTransformation($object, $to, $context)) { + return true; + } + } + + return false; + } +} diff --git a/src/DataTransformer/DataTransformerInterface.php b/src/DataTransformer/DataTransformerInterface.php new file mode 100644 index 00000000000..b00ce557da5 --- /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, string $to, array $context = []); + + /** + * Checks whether the transformation is supported for a given object and context. + * + * @param object $object + */ + public function supportsTransformation($object, string $to, array $context = []): bool; +} diff --git a/src/EventListener/DeserializeListener.php b/src/EventListener/DeserializeListener.php index d25848beff1..206174fe08d 100644 --- a/src/EventListener/DeserializeListener.php +++ b/src/EventListener/DeserializeListener.php @@ -40,7 +40,7 @@ final class DeserializeListener /** * @throws InvalidArgumentException */ - public function __construct(SerializerInterface $serializer, SerializerContextBuilderInterface $serializerContextBuilder, /* FormatsProviderInterface */ $formatsProvider) + public function __construct(SerializerInterface $serializer, SerializerContextBuilderInterface $serializerContextBuilder, /* FormatsProviderInterface */$formatsProvider) { $this->serializer = $serializer; $this->serializerContextBuilder = $serializerContextBuilder; @@ -78,7 +78,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; } @@ -97,7 +97,7 @@ public function onKernelRequest(GetResponseEvent $event) $request->attributes->set( 'data', $this->serializer->deserialize( - $requestContent, $context['input_class'], $format, $context + $requestContent, $context['resource_class'], $format, $context ) ); } diff --git a/src/EventListener/SerializeListener.php b/src/EventListener/SerializeListener.php index 803c25296cc..50d6dd45fba 100644 --- a/src/EventListener/SerializeListener.php +++ b/src/EventListener/SerializeListener.php @@ -62,15 +62,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')) { 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/Serializer/ItemNormalizer.php b/src/GraphQl/Serializer/ItemNormalizer.php index b4e988fc4fc..9b5133a0e46 100644 --- a/src/GraphQl/Serializer/ItemNormalizer.php +++ b/src/GraphQl/Serializer/ItemNormalizer.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Serializer\ItemNormalizer as BaseItemNormalizer; +use ApiPlatform\Core\Util\ClassInfoTrait; /** * GraphQL normalizer. @@ -23,6 +24,8 @@ */ final class ItemNormalizer extends BaseItemNormalizer { + use ClassInfoTrait; + const FORMAT = 'graphql'; const ITEM_KEY = '#item'; @@ -39,6 +42,11 @@ public function supportsNormalization($data, $format = null) */ public function normalize($object, $format = null, array $context = []) { + $outputClass = $this->getOutputClass($this->getObjectClass($object), $context); + if (null !== $outputClass && null !== $this->dataTransformer && $this->dataTransformer->supportsTransformation($object, $outputClass, $context)) { + $object = $this->dataTransformer->transform($object, $outputClass, $context); + } + $data = parent::normalize($object, $format, $context); $data[self::ITEM_KEY] = serialize($object); // calling serialize prevent weird normalization process done by Webonyx's GraphQL PHP diff --git a/src/GraphQl/Type/SchemaBuilder.php b/src/GraphQl/Type/SchemaBuilder.php index d92cbdeb41c..971e060802c 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,18 @@ private function getResourceObjectType(?string $resourceClass, ResourceMetadata return $this->graphqlTypes[$shortName]; } + $ioMetadata = $resourceMetadata->getAttribute($input ? 'input' : 'output'); + + if (null !== $ioMetadata && \array_key_exists('class', $ioMetadata) && 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 +437,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..f05cb65533a 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,23 @@ 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); + $outputClass = $this->getOutputClass($this->getObjectClass($object), $context); + if (null !== $outputClass && null !== $this->dataTransformer && $this->dataTransformer->supportsTransformation($object, $outputClass, $context)) { + $object = $this->dataTransformer->transform($object, $outputClass, $context); + } + + try { + $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); + } catch (InvalidArgumentException $e) { + $data = $this->initContext(\get_class($object), $context); + $rawData = parent::normalize($object, $format, $context); + if (!\is_array($rawData)) { + return $rawData; + } + + return $data + $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..4d06d04fb2f 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Api\IriConverterInterface; use ApiPlatform\Core\Api\OperationType; use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Exception\ItemNotFoundException; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; @@ -23,6 +24,7 @@ use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Serializer\AbstractItemNormalizer; +use ApiPlatform\Core\Util\ClassInfoTrait; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; @@ -37,16 +39,15 @@ */ final class ItemNormalizer extends AbstractItemNormalizer { + use ClassInfoTrait; + const FORMAT = 'jsonapi'; 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 = [], DataTransformerInterface $dataTransformer = null, bool $allowUnmappedClass = false) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, null, null, false, $defaultContext); - - $this->resourceMetadataFactory = $resourceMetadataFactory; + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, null, null, false, $defaultContext, $dataTransformer, $resourceMetadataFactory, $allowUnmappedClass); } /** @@ -66,6 +67,11 @@ public function normalize($object, $format = null, array $context = []) $context['cache_key'] = $this->getJsonApiCacheKey($format, $context); } + $outputClass = $this->getOutputClass($this->getObjectClass($object), $context); + if (null !== $outputClass && null !== $this->dataTransformer && $this->dataTransformer->supportsTransformation($object, $context)) { + $object = $this->dataTransformer->transform($object, $context); + } + // Get and populate attributes data $objectAttributesData = parent::normalize($object, $format, $context); @@ -74,7 +80,12 @@ 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) { + return parent::normalize($object, $format, $context); + } + $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..94b56d44322 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,45 @@ public function getEntrypointContext(int $referenceType = UrlGeneratorInterface: */ public function getResourceContext(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH): array { - $context = $this->getBaseContext($referenceType); + $metadata = $this->resourceMetadataFactory->create($resourceClass); + if (null === $shortName = $metadata->getShortName()) { + return []; + } + + return $this->getResourceContextWithShortname($resourceClass, $referenceType, $shortName); + } + + /** + * {@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'] ?? '_:'.(\function_exists('spl_object_id') ? spl_object_id($object) : spl_object_hash($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 +159,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..6512119fb25 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\Api\IriConverterInterface; use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\JsonLd\ContextBuilderInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; @@ -22,6 +23,7 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Serializer\AbstractItemNormalizer; use ApiPlatform\Core\Serializer\ContextTrait; +use ApiPlatform\Core\Util\ClassInfoTrait; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -33,19 +35,18 @@ */ final class ItemNormalizer extends AbstractItemNormalizer { + use ClassInfoTrait; use ContextTrait; use JsonLdContextTrait; const FORMAT = 'jsonld'; - 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 = [], DataTransformerInterface $dataTransformer = null, 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, $dataTransformer, $resourceMetadataFactory, $allowUnmappedClass); - $this->resourceMetadataFactory = $resourceMetadataFactory; $this->contextBuilder = $contextBuilder; } @@ -62,7 +63,23 @@ public function supportsNormalization($data, $format = null) */ public function normalize($object, $format = null, array $context = []) { - $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); + $outputClass = $this->getOutputClass($this->getObjectClass($object), $context); + if (null !== $outputClass && null !== $this->dataTransformer && $this->dataTransformer->supportsTransformation($object, $outputClass, $context)) { + $object = $this->dataTransformer->transform($object, $outputClass, $context); + } + + 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..6a56332652c --- /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..62285fda664 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -15,13 +15,16 @@ use ApiPlatform\Core\Api\IriConverterInterface; use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\Bridge\Elasticsearch\Serializer\ItemNormalizer; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; +use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Exception\InvalidValueException; use ApiPlatform\Core\Exception\ItemNotFoundException; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Util\ClassInfoTrait; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; @@ -45,6 +48,7 @@ abstract class AbstractItemNormalizer extends AbstractObjectNormalizer { use ClassInfoTrait; use ContextTrait; + use InputOutputMetadataTrait; protected $propertyNameCollectionFactory; protected $propertyMetadataFactory; @@ -54,8 +58,10 @@ abstract class AbstractItemNormalizer extends AbstractObjectNormalizer protected $localCache = []; protected $itemDataProvider; protected $allowPlainIdentifiers; + protected $allowUnmappedClass; + protected $dataTransformer; - 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 = [], DataTransformerInterface $dataTransformer = null, ResourceMetadataFactoryInterface $resourceMetadataFactory = null, bool $allowUnmappedClass = false) { if (!isset($defaultContext['circular_reference_handler'])) { $defaultContext['circular_reference_handler'] = function ($object) { @@ -66,6 +72,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 +85,9 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); $this->itemDataProvider = $itemDataProvider; $this->allowPlainIdentifiers = $allowPlainIdentifiers; + $this->dataTransformer = $dataTransformer; + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->allowUnmappedClass = $allowUnmappedClass; } /** @@ -82,11 +95,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 +119,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 +144,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 (ItemNormalizer::FORMAT === $format) { + return false; + } + + if (false === $this->allowUnmappedClass) { + return $this->localCache[$type] ?? $this->localCache[$type] = $this->resourceClassResolver->isResourceClass($type); + } + + return true; } /** @@ -129,6 +162,17 @@ public function denormalize($data, $class, $format = null, array $context = []) { $context['api_denormalize'] = true; $context['resource_class'] = $class; + $inputClass = $this->getInputClass($class, $context); + + if (null !== $inputClass) { + $data = parent::denormalize($data, $inputClass, $format, ['resource_class' => $inputClass] + $context); + + if (null !== $this->dataTransformer && $this->dataTransformer->supportsTransformation($data, $class, $context)) { + $data = $this->dataTransformer->transform($data, $class, $context); + } + + return $data; + } return parent::denormalize($data, $class, $format, $context); } diff --git a/src/Serializer/InputOutputMetadataTrait.php b/src/Serializer/InputOutputMetadataTrait.php new file mode 100644 index 00000000000..788058b0cb9 --- /dev/null +++ b/src/Serializer/InputOutputMetadataTrait.php @@ -0,0 +1,49 @@ + + * + * 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\Serializer; + +use ApiPlatform\Core\Exception\ResourceClassNotFoundException; + +trait InputOutputMetadataTrait +{ + /** + * @var \ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface + */ + protected $resourceMetadataFactory; + + private function getInputOutputMetadata(string $class, string $inputOrOutput, array $context) + { + if (null === $this->resourceMetadataFactory || null !== ($context[$inputOrOutput]['class'] ?? null)) { + return $context[$inputOrOutput]['class'] ?? null; + } + + try { + $metadata = $this->resourceMetadataFactory->create($class); + } catch (ResourceClassNotFoundException $e) { + return null; + } + + return $metadata->getAttribute($inputOrOutput)['class'] ?? null; + } + + protected function getInputClass(string $class, array $context = []): ?string + { + return $this->getInputOutputMetadata($class, 'input', $context); + } + + protected function getOutputClass(string $class, array $context = []): ?string + { + return $this->getInputOutputMetadata($class, 'output', $context); + } +} diff --git a/src/Serializer/ItemNormalizer.php b/src/Serializer/ItemNormalizer.php index f34540440d8..0169b0dcc88 100644 --- a/src/Serializer/ItemNormalizer.php +++ b/src/Serializer/ItemNormalizer.php @@ -16,9 +16,11 @@ use ApiPlatform\Core\Api\IriConverterInterface; use ApiPlatform\Core\Api\ResourceClassResolverInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; +use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -36,9 +38,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, DataTransformerInterface $dataTransformer = null, ResourceMetadataFactoryInterface $resourceMetadataFactory = 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, [], $dataTransformer, $resourceMetadataFactory, $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..e03ffc4e5ef 100644 --- a/src/Swagger/Serializer/DocumentationNormalizer.php +++ b/src/Swagger/Serializer/DocumentationNormalizer.php @@ -155,7 +155,8 @@ 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 +316,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 +425,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 +448,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 +489,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 +509,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 +555,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 +686,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/Api/ResourceClassResolverTest.php b/tests/Api/ResourceClassResolverTest.php index 31dc028432c..50579549ab7 100644 --- a/tests/Api/ResourceClassResolverTest.php +++ b/tests/Api/ResourceClassResolverTest.php @@ -175,12 +175,12 @@ public function testGetResourceClassWithChildResource() public function testGetResourceClassWithInterfaceResource() { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("The given object's resource is the interface \"ApiPlatform\Core\Tests\Fixtures\DummyResourceInterface\", finding a class is not possible."); $dummy = new DummyResourceImplementation(); $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); - $resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection([DummyResourceInterface::class]))->shouldBeCalled(); $resourceClassResolver = new ResourceClassResolver($resourceNameCollectionFactoryProphecy->reveal()); $resourceClass = $resourceClassResolver->getResourceClass($dummy, DummyResourceInterface::class, true); - $this->assertEquals(DummyResourceImplementation::class, $resourceClass); } } diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 0dc3c51934f..ca9583dc910 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -735,6 +735,7 @@ 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_transformer', 'api_platform.formats_provider', 'api_platform.filter_locator', 'api_platform.filter_collection_factory', @@ -772,6 +773,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', @@ -815,6 +817,7 @@ private function getPartialContainerBuilderProphecy() 'api_platform.action.post_collection' => 'api_platform.action.placeholder', 'api_platform.action.put_item' => 'api_platform.action.placeholder', 'api_platform.action.patch_item' => 'api_platform.action.placeholder', + 'api_platform.data_transformer' => 'api_platform.data_transformer.chain_transformer', 'api_platform.metadata.property.metadata_factory' => 'api_platform.metadata.property.metadata_factory.xml', 'api_platform.metadata.property.name_collection_factory' => 'api_platform.metadata.property.name_collection_factory.property_info', 'api_platform.metadata.resource.metadata_factory' => 'api_platform.metadata.resource.metadata_factory.xml', 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..885ecff4743 --- /dev/null +++ b/tests/Fixtures/TestBundle/DataTransformer/CustomInputDtoDataTransformer.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\DataTransformer; + +use ApiPlatform\Core\DataTransformer\DataTransformerInterface; +use ApiPlatform\Core\Serializer\AbstractItemNormalizer; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoCustom as DummyDtoCustomDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\CustomInputDto; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoCustom; + +final class CustomInputDtoDataTransformer implements DataTransformerInterface +{ + /** + * {@inheritdoc} + */ + public function transform($object, string $to, 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, string $to, array $context = []): bool + { + return (DummyDtoCustom::class === $to || DummyDtoCustomDocument::class === $to) && $object instanceof CustomInputDto; + } +} diff --git a/tests/Fixtures/TestBundle/DataTransformer/CustomOutputDtoDataTransformer.php b/tests/Fixtures/TestBundle/DataTransformer/CustomOutputDtoDataTransformer.php new file mode 100644 index 00000000000..66936c0cfa4 --- /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, string $to, 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, string $to, 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 === $to; + } +} diff --git a/tests/Fixtures/TestBundle/DataTransformer/InputDtoDataTransformer.php b/tests/Fixtures/TestBundle/DataTransformer/InputDtoDataTransformer.php new file mode 100644 index 00000000000..0e51b12346e --- /dev/null +++ b/tests/Fixtures/TestBundle/DataTransformer/InputDtoDataTransformer.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\DataTransformer; + +use ApiPlatform\Core\DataTransformer\DataTransformerInterface; +use ApiPlatform\Core\Serializer\AbstractItemNormalizer; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoInputOutput as DummyDtoInputOutputDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\InputDto; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoInputOutput; + +final class InputDtoDataTransformer implements DataTransformerInterface +{ + /** + * {@inheritdoc} + */ + public function transform($object, string $to, 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($object, string $to, array $context = []): bool + { + return (DummyDtoInputOutput::class === $to || DummyDtoInputOutputDocument::class === $to) && $object instanceof InputDto; + } +} diff --git a/tests/Fixtures/TestBundle/DataTransformer/OutputDtoDataTransformer.php b/tests/Fixtures/TestBundle/DataTransformer/OutputDtoDataTransformer.php new file mode 100644 index 00000000000..fc45aa05425 --- /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, string $to, 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, string $to, array $context = []): bool + { + return ($data instanceof DummyDtoInputOutput || $data instanceof DummyDtoInputOutputDocument) && OutputDto::class === $to; + } +} diff --git a/tests/Fixtures/TestBundle/DataTransformer/RecoverPasswordInputDataTransformer.php b/tests/Fixtures/TestBundle/DataTransformer/RecoverPasswordInputDataTransformer.php new file mode 100644 index 00000000000..ce03f105a19 --- /dev/null +++ b/tests/Fixtures/TestBundle/DataTransformer/RecoverPasswordInputDataTransformer.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer; + +use ApiPlatform\Core\DataTransformer\DataTransformerInterface; +use ApiPlatform\Core\Serializer\AbstractItemNormalizer; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\User as UserDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\RecoverPasswordInput; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\User; + +final class RecoverPasswordInputDataTransformer implements DataTransformerInterface +{ + /** + * {@inheritdoc} + */ + public function transform($data, string $to, 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, string $to, array $context = []): bool + { + return (User::class === $to || UserDocument::class === $to) && $data instanceof RecoverPasswordInput; + } +} diff --git a/tests/Fixtures/TestBundle/DataTransformer/RecoverPasswordOutputDataTransformer.php b/tests/Fixtures/TestBundle/DataTransformer/RecoverPasswordOutputDataTransformer.php new file mode 100644 index 00000000000..9b9ef386ef4 --- /dev/null +++ b/tests/Fixtures/TestBundle/DataTransformer/RecoverPasswordOutputDataTransformer.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\Dto\RecoverPasswordOutput; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\User; + +final class RecoverPasswordOutputDataTransformer implements DataTransformerInterface +{ + /** + * {@inheritdoc} + */ + public function transform($object, string $to, array $context = []) + { + if (!$object instanceof User) { + throw new \InvalidArgumentException(); + } + + $output = new RecoverPasswordOutput(); + $output->dummy = new Dummy(); + $output->dummy->setId(1); + + return $output; + } + + /** + * {@inheritdoc} + */ + public function supportsTransformation($object, string $to, array $context = []): bool + { + return $object instanceof User && RecoverPasswordOutput::class === $to; + } +} 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/NonResourceClass.php b/tests/Fixtures/TestBundle/Dto/NonResourceClass.php new file mode 100644 index 00000000000..787229ad02a --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/NonResourceClass.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 NonResourceClass +{ + /** + * @var string + */ + public $foo; + + public function __toString() + { + return $this->foo; + } +} 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..26da73e4c34 --- /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\Dummy + * @Groups({"user"}) + */ + public $dummy; +} 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/DummyInputOutput.php b/tests/Fixtures/TestBundle/Entity/DummyDtoInputOutput.php similarity index 52% rename from tests/Fixtures/TestBundle/Entity/DummyInputOutput.php rename to tests/Fixtures/TestBundle/Entity/DummyDtoInputOutput.php index 442a83c1121..28f0e064424 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyInputOutput.php +++ b/tests/Fixtures/TestBundle/Entity/DummyDtoInputOutput.php @@ -15,19 +15,38 @@ 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 InputOutput. * * @author Kévin Dunglas * - * @ApiResource(attributes={"input_class"=DummyInput::class, "output_class"=DummyOutput::class}) + * @ApiResource(attributes={"input"=InputDto::class, "output"=OutputDto::class}) + * @ORM\Entity */ -class DummyInputOutput +class DummyDtoInputOutput { /** * @var int The id * @ApiProperty(identifier=true) + * @ORM\Column(type="integer") + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") */ public $id; + + /** + * @var string + * @ORM\Column(type="string") + */ + 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/DummyOutput.php b/tests/Fixtures/TestBundle/Entity/NonRelationResource.php similarity index 65% rename from tests/Fixtures/TestBundle/Entity/DummyOutput.php rename to tests/Fixtures/TestBundle/Entity/NonRelationResource.php index b7cbb15de90..be05f3364de 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyOutput.php +++ b/tests/Fixtures/TestBundle/Entity/NonRelationResource.php @@ -13,34 +13,30 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; -use ApiPlatform\Core\Annotation\ApiProperty; use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\NonResourceClass; use Doctrine\ORM\Mapping as ORM; /** - * Dummy Output. - * - * @author Kévin Dunglas - * * @ApiResource * @ORM\Entity */ -class DummyOutput +class NonRelationResource { /** - * @var int The id - * * @ORM\Column(type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") */ - public $id; + private $id; /** - * @var string The dummy name - * - * @ORM\Column - * @ApiProperty(iri="http://schema.org/name") + * @var NonResourceClass */ - public $name; + public $relation; + + public function getId() + { + return $this->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..cf677ad4153 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' } + + app.data_transformer.custom_output_dto: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer\CustomOutputDtoDataTransformer' + public: false + tags: + - { name: 'api_platform.data_transformer' } + + app.data_transformer.input_dto: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer\InputDtoDataTransformer' + public: false + tags: + - { name: 'api_platform.data_transformer' } + + app.data_transformer.output_dto: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer\OutputDtoDataTransformer' + public: false + tags: + - { name: 'api_platform.data_transformer' } + + app.data_transformer.recover_password_input: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer\RecoverPasswordInputDataTransformer' + public: false + tags: + - { name: 'api_platform.data_transformer' } + + app.data_transformer.recover_password_output: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer\RecoverPasswordOutputDataTransformer' + public: false + tags: + - { name: 'api_platform.data_transformer' } diff --git a/tests/GraphQl/Serializer/ItemNormalizerTest.php b/tests/GraphQl/Serializer/ItemNormalizerTest.php index 60a1b4ec566..fa7f7fe52d1 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,16 @@ public function testNormalize() $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + false, + null, + null, + null, + true ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -119,7 +132,16 @@ public function testDenormalize() $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + false, + null, + null, + null, + true ); $normalizer->setSerializer($serializerProphecy->reveal()); diff --git a/tests/Hal/Serializer/ItemNormalizerTest.php b/tests/Hal/Serializer/ItemNormalizerTest.php index 95b06fd4b61..714a1ba9839 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,14 @@ public function testNormalize() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), null, - $nameConverter->reveal() + $nameConverter->reveal(), + null, + null, + false, + [], + null, + null, + true ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -197,7 +212,14 @@ public function testNormalizeWithoutCache() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), null, - $nameConverter->reveal() + $nameConverter->reveal(), + null, + null, + false, + [], + null, + null, + true ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -271,7 +293,13 @@ public function testMaxDepth() $resourceClassResolverProphecy->reveal(), null, null, - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())) + $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())), + null, + false, + [], + null, + null, + 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..01dd49c42ee 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,13 @@ public function testNormalize() $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), - $contextBuilderProphecy->reveal() + $contextBuilderProphecy->reveal(), + null, + null, + null, + [], + null, + true ); $normalizer->setSerializer($serializerProphecy->reveal()); diff --git a/tests/JsonApi/Serializer/ItemNormalizerTest.php b/tests/JsonApi/Serializer/ItemNormalizerTest.php index 2e5eb92008c..6230b369b66 100644 --- a/tests/JsonApi/Serializer/ItemNormalizerTest.php +++ b/tests/JsonApi/Serializer/ItemNormalizerTest.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Api\IriConverterInterface; use ApiPlatform\Core\Api\ResourceClassResolverInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; +use ApiPlatform\Core\Exception\ResourceClassNotFoundException; use ApiPlatform\Core\JsonApi\Serializer\ItemNormalizer; use ApiPlatform\Core\JsonApi\Serializer\ReservedAttributeNameConverter; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; @@ -42,6 +43,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 +68,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 +138,10 @@ public function testNormalize() $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), new ReservedAttributeNameConverter(), - $resourceMetadataFactoryProphecy->reveal() + $resourceMetadataFactoryProphecy->reveal(), + [], + null, + true ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -157,6 +169,8 @@ public function testNormalizeIsNotAnArray() $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass($object, null, true)->willReturn(\stdClass::class)->shouldBeCalled(); + $resourceMetadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactory->create(\stdClass::class)->willThrow(ResourceClassNotFoundException::class); $normalizer = new ItemNormalizer( $this->prophesize(PropertyNameCollectionFactoryInterface::class)->reveal(), @@ -165,7 +179,10 @@ public function testNormalizeIsNotAnArray() $resourceClassResolverProphecy->reveal(), $this->prophesize(PropertyAccessorInterface::class)->reveal(), new ReservedAttributeNameConverter(), - $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal() + $resourceMetadataFactory->reveal(), + [], + null, + true ); $normalizer->setSerializer(new Serializer([$normalizer])); @@ -212,6 +229,9 @@ public function testNormalizeThrowsNoSuchPropertyException() $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); $propertyAccessorProphecy->getValue($foo, 'bar')->willThrow(new NoSuchPropertyException()); + $resourceMetadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactory->create(\stdClass::class)->willThrow(ResourceClassNotFoundException::class); + $normalizer = new ItemNormalizer( $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), @@ -219,7 +239,10 @@ public function testNormalizeThrowsNoSuchPropertyException() $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), new ReservedAttributeNameConverter(), - $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal() + $resourceMetadataFactory->reveal(), + [], + null, + true ); $normalizer->normalize($foo, ItemNormalizer::FORMAT); @@ -291,6 +314,9 @@ public function testDenormalize() $serializerProphecy = $this->prophesize(SerializerInterface::class); $serializerProphecy->willImplement(NormalizerInterface::class); + $resourceMetadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactory->create(Dummy::class)->willThrow(ResourceClassNotFoundException::class); + $normalizer = new ItemNormalizer( $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), @@ -298,7 +324,10 @@ public function testDenormalize() $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), new ReservedAttributeNameConverter(), - $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal() + $resourceMetadataFactory->reveal(), + [], + null, + true ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -348,7 +377,10 @@ public function testDenormalizeUpdateOperationNotAllowed() $this->prophesize(ResourceClassResolverInterface::class)->reveal(), null, null, - $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal() + $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), + [], + null, + true ); $normalizer->denormalize( @@ -392,6 +424,9 @@ public function testDenormalizeCollectionIsNotArray() ) )->shouldBeCalled(); + $resourceMetadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactory->create(Dummy::class)->willThrow(ResourceClassNotFoundException::class); + $normalizer = new ItemNormalizer( $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), @@ -399,7 +434,10 @@ public function testDenormalizeCollectionIsNotArray() $this->prophesize(ResourceClassResolverInterface::class)->reveal(), $this->prophesize(PropertyAccessorInterface::class)->reveal(), new ReservedAttributeNameConverter(), - $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal() + $resourceMetadataFactory->reveal(), + [], + null, + true ); $normalizer->denormalize( @@ -444,6 +482,9 @@ public function testDenormalizeCollectionWithInvalidKey() ) )->shouldBeCalled(); + $resourceMetadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactory->create(Dummy::class)->willThrow(ResourceClassNotFoundException::class); + $normalizer = new ItemNormalizer( $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), @@ -451,7 +492,10 @@ public function testDenormalizeCollectionWithInvalidKey() $this->prophesize(ResourceClassResolverInterface::class)->reveal(), $this->prophesize(PropertyAccessorInterface::class)->reveal(), new ReservedAttributeNameConverter(), - $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal() + $resourceMetadataFactory->reveal(), + [], + null, + true ); $normalizer->denormalize( @@ -498,6 +542,9 @@ public function testDenormalizeRelationIsNotResourceLinkage() $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true)->shouldBeCalled(); + $resourceMetadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactory->create(Dummy::class)->willThrow(ResourceClassNotFoundException::class); + $normalizer = new ItemNormalizer( $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), @@ -505,7 +552,10 @@ public function testDenormalizeRelationIsNotResourceLinkage() $resourceClassResolverProphecy->reveal(), $this->prophesize(PropertyAccessorInterface::class)->reveal(), new ReservedAttributeNameConverter(), - $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal() + $resourceMetadataFactory->reveal(), + [], + null, + 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..b218865d9ed 100644 --- a/tests/Serializer/AbstractItemNormalizerTest.php +++ b/tests/Serializer/AbstractItemNormalizerTest.php @@ -16,11 +16,14 @@ use ApiPlatform\Core\Api\IriConverterInterface; use ApiPlatform\Core\Api\ResourceClassResolverInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; +use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Serializer\AbstractItemNormalizer; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyForAdditionalFields; @@ -45,7 +48,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 +81,41 @@ 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, + [], + null, + null, + 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 +183,14 @@ public function testNormalize() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + null, + null, + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -212,6 +262,14 @@ public function testNormalizeReadableLinks() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + null, + null, + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -282,6 +340,14 @@ public function testDenormalize() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + null, + null, + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -318,7 +384,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, [], null, null, true) extends AbstractItemNormalizer { }; /** @var DummyForAdditionalFieldsInput $res */ @@ -326,8 +392,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 +462,14 @@ public function testDenormalizeWritableLinks() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + null, + null, + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -443,6 +517,14 @@ public function testBadRelationType() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + null, + null, + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -486,6 +568,14 @@ public function testInnerDocumentNotAllowed() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + null, + null, + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -520,6 +610,14 @@ public function testBadType() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + null, + null, + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -552,6 +650,14 @@ public function testJsonAllowIntAsFloat() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + null, + null, + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -601,6 +707,14 @@ public function testDenormalizeBadKeyType() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + null, + null, + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -636,6 +750,14 @@ public function testNullable() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + null, + null, + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -681,6 +803,14 @@ public function testChildInheritedProperty() $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + null, + null, + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -731,6 +861,10 @@ public function testDenormalizeRelationWithPlainId() null, $itemDataProviderProphecy->reveal(), true, + [], + null, + null, + true, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -781,9 +915,83 @@ public function testDoNotDenormalizeRelationWithPlainIdWhenPlainIdentifiersAreNo null, $itemDataProviderProphecy->reveal(), false, + [], + null, + null, + 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(), + ]); + } + + public function testNormalizationWithDataTransformer() + { + $transformed = new \stdClass(); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(RelatedDummy::class, [])->willReturn( + new PropertyNameCollection() + )->shouldBeCalled(); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $itemDataProviderProphecy = $this->prophesize(ItemDataProviderInterface::class); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata( + 'dummy', '', '', null, null, ['input' => ['class' => RelatedDummy::class]] + )); + + $dataTransformerProphecy = $this->prophesize(DataTransformerInterface::class); + $dataTransformerProphecy->supportsTransformation(Argument::any(), Dummy::class, Argument::any())->shouldBeCalled()->willReturn(true); + $dataTransformerProphecy->transform(Argument::any(), Dummy::class, Argument::any())->shouldBeCalled()->willReturn($transformed); + + $normalizer = $this->getMockForAbstractClass(AbstractItemNormalizer::class, [ + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + null, + null, + $itemDataProviderProphecy->reveal(), + false, + [], + $dataTransformerProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + true, + ]); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $this->assertEquals($transformed, $normalizer->denormalize(['foo' => 't'], Dummy::class, 'jsonld')); + } } diff --git a/tests/Serializer/ItemNormalizerTest.php b/tests/Serializer/ItemNormalizerTest.php index d7930b1404c..8688a91acb6 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,16 @@ public function testNormalize() $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + false, + null, + null, + null, + true ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -121,7 +134,16 @@ public function testDenormalize() $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + false, + null, + null, + null, + true ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -152,7 +174,16 @@ public function testDenormalizeWithIri() $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + false, + null, + null, + null, + true ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -181,7 +212,16 @@ public function testDenormalizeWithIdAndUpdateNotAllowed() $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + false, + null, + null, + null, + true ); $normalizer->setSerializer($serializerProphecy->reveal()); $normalizer->denormalize(['id' => '12', 'name' => 'hello'], Dummy::class, null, $context); @@ -211,7 +251,16 @@ public function testDenormalizeWithIdAndNoResourceClass() $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + false, + null, + null, + 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);