From 2bb4db1583521be72144d1da7da9cbe0b09808a2 Mon Sep 17 00:00:00 2001 From: Teoh Han Hui Date: Tue, 2 Apr 2019 20:36:07 +0200 Subject: [PATCH 1/2] Normalize non-resource objects in a standalone normalizer --- .circleci/config.yml | 2 +- .travis.yml | 8 +- appveyor.yml | 2 +- features/bootstrap/JsonContext.php | 42 ++-- features/doctrine/search_filter.feature | 2 +- features/graphql/input_output.feature | 190 ++++++++++++++++++ features/graphql/mutation.feature | 107 ---------- features/graphql/non_resource.feature | 92 +++++++++ features/hal/non_resource.feature | 45 +++++ features/jsonapi/non_resource.feature | 88 ++++++++ .../related-resouces-inclusion.feature | 6 +- .../{main => jsonld}/input_output.feature | 107 ++-------- features/jsonld/non_resource.feature | 64 ++++++ features/main/non_resource.feature | 54 ----- .../Serializer/ItemNormalizer.php | 9 +- .../Symfony/Bundle/Resources/config/api.xml | 26 +-- .../Bundle/Resources/config/elasticsearch.xml | 2 +- .../Bundle/Resources/config/graphql.xml | 19 +- .../Symfony/Bundle/Resources/config/hal.xml | 19 +- .../Bundle/Resources/config/jsonapi.xml | 14 +- .../Bundle/Resources/config/jsonld.xml | 17 +- src/GraphQl/Serializer/ItemNormalizer.php | 17 +- src/GraphQl/Serializer/ObjectNormalizer.php | 82 ++++++++ src/Hal/Serializer/ItemNormalizer.php | 57 ++---- src/Hal/Serializer/ObjectNormalizer.php | 103 ++++++++++ src/JsonApi/Serializer/ItemNormalizer.php | 104 ++++------ src/JsonApi/Serializer/ObjectNormalizer.php | 98 +++++++++ src/JsonLd/Serializer/ItemNormalizer.php | 58 ++---- src/JsonLd/Serializer/ObjectNormalizer.php | 95 +++++++++ src/Serializer/AbstractItemNormalizer.php | 67 +++--- src/Serializer/ItemNormalizer.php | 4 +- src/Serializer/NoOpScalarNormalizer.php | 53 ----- .../Serializer/ItemNormalizerTest.php | 8 +- .../ApiPlatformExtensionTest.php | 10 +- tests/Fixtures/NotAResource.php | 9 + .../Document/ContainNonResource.php | 16 +- .../TestBundle/Document/RelatedDummy.php | 12 +- .../TestBundle/Entity/ContainNonResource.php | 16 +- .../TestBundle/Entity/RelatedDummy.php | 11 +- .../GraphQl/Serializer/ItemNormalizerTest.php | 6 +- tests/Hal/Serializer/ItemNormalizerTest.php | 9 +- .../JsonApi/Serializer/ItemNormalizerTest.php | 144 +++++++------ .../Serializer/ItemNormalizerTest.php | 5 +- .../Serializer/AbstractItemNormalizerTest.php | 2 +- tests/Serializer/ItemNormalizerTest.php | 18 +- 45 files changed, 1183 insertions(+), 736 deletions(-) create mode 100644 features/graphql/input_output.feature create mode 100644 features/graphql/non_resource.feature create mode 100644 features/hal/non_resource.feature create mode 100644 features/jsonapi/non_resource.feature rename features/{main => jsonld}/input_output.feature (66%) create mode 100644 features/jsonld/non_resource.feature delete mode 100644 features/main/non_resource.feature create mode 100644 src/GraphQl/Serializer/ObjectNormalizer.php create mode 100644 src/Hal/Serializer/ObjectNormalizer.php create mode 100644 src/JsonApi/Serializer/ObjectNormalizer.php create mode 100644 src/JsonLd/Serializer/ObjectNormalizer.php delete mode 100644 src/Serializer/NoOpScalarNormalizer.php rename tests/{Hydra => JsonLd}/Serializer/ItemNormalizerTest.php (98%) diff --git a/.circleci/config.yml b/.circleci/config.yml index f84f63e97e1..e2b41b4698d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -215,7 +215,7 @@ jobs: mkdir -p build/logs/tmp build/cov for f in $(find features -name '*.feature' -not -path 'features/main/exposed_state.feature' -not -path 'features/elasticsearch/*' -not -path 'features/mongodb/*' | circleci tests split --split-by=timings); do _f=${f//\//_} - FEATURE="${_f}" phpdbg -qrr vendor/bin/behat --profile=coverage --suite=default --format=progress --out=std --format=junit --out=build/logs/tmp/"${_f}" "$f" + FEATURE="${_f}" phpdbg -qrr vendor/bin/behat --profile=coverage --suite=default --format=progress --out=std --format=junit --out=build/logs/tmp/"${_f}" --no-interaction "$f" done - run: name: Merge Behat test reports diff --git a/.travis.yml b/.travis.yml index 33df8456376..a498f03d141 100644 --- a/.travis.yml +++ b/.travis.yml @@ -68,13 +68,13 @@ script: fi - tests/Fixtures/app/console cache:clear - if [[ $APP_ENV = 'postgres' ]]; then - vendor/bin/behat --suite=postgres --format=progress; + vendor/bin/behat --suite=postgres --format=progress --no-interaction; elif [[ $APP_ENV = 'mongodb' ]]; then - vendor/bin/behat --suite=mongodb --format=progress; + vendor/bin/behat --suite=mongodb --format=progress --no-interaction; elif [[ $APP_ENV = 'elasticsearch' ]]; then - vendor/bin/behat --suite=elasticsearch --format=progress; + vendor/bin/behat --suite=elasticsearch --format=progress --no-interaction; else - vendor/bin/behat --suite=default --format=progress; + vendor/bin/behat --suite=default --format=progress --no-interaction; fi - tests/Fixtures/app/console api:swagger:export > swagger.json && npx swagger-cli validate swagger.json && rm swagger.json - tests/Fixtures/app/console api:swagger:export --yaml > swagger.yaml && npx swagger-cli validate swagger.yaml && rm swagger.yaml diff --git a/appveyor.yml b/appveyor.yml index 8b3a9cdbde2..cfbced8c7f8 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -33,6 +33,6 @@ services: test_script: - cd %APPVEYOR_BUILD_FOLDER% - - php vendor\behat\behat\bin\behat --format=progress --suite=default + - php vendor\behat\behat\bin\behat --format=progress --suite=default --no-interaction - rmdir tests\Fixtures\app\var\cache /s /q - php vendor\phpunit\phpunit\phpunit diff --git a/features/bootstrap/JsonContext.php b/features/bootstrap/JsonContext.php index b56a60523b1..83d54a9f051 100644 --- a/features/bootstrap/JsonContext.php +++ b/features/bootstrap/JsonContext.php @@ -24,27 +24,6 @@ public function __construct(HttpCallResultPool $httpCallResultPool) parent::__construct($httpCallResultPool); } - private function sortArrays($obj) - { - $isObject = is_object($obj); - - foreach ($obj as $key => $value) { - if (null === $value || is_scalar($value)) { - continue; - } - - if (is_array($value)) { - sort($value); - } - - $value = $this->sortArrays($value); - - $isObject ? $obj->{$key} = $value : $obj[$key] = $value; - } - - return $obj; - } - /** * @Then /^the JSON should be deep equal to:$/ */ @@ -75,4 +54,25 @@ public function theJsonIsASupersetOf(PyStringNode $content) $actual = json_decode($this->httpCallResultPool->getResult()->getValue(), true); Assert::assertArraySubset(json_decode($content->getRaw(), true), $actual); } + + private function sortArrays($obj) + { + $isObject = is_object($obj); + + foreach ($obj as $key => $value) { + if (null === $value || is_scalar($value)) { + continue; + } + + if (is_array($value)) { + sort($value); + } + + $value = $this->sortArrays($value); + + $isObject ? $obj->{$key} = $value : $obj[$key] = $value; + } + + return $obj; + } } diff --git a/features/doctrine/search_filter.feature b/features/doctrine/search_filter.feature index ea6de7d1bfd..00bb91b9056 100644 --- a/features/doctrine/search_filter.feature +++ b/features/doctrine/search_filter.feature @@ -50,7 +50,7 @@ Feature: Search filter on collections }, "hydra:search": { "@type": "hydra:IriTemplate", - "hydra:template": "\/dummy_cars{?availableAt[before],availableAt[strictly_before],availableAt[after],availableAt[strictly_after],canSell,foobar[],foobargroups[],foobargroups_override[],colors.prop,name}", + "hydra:template": "/dummy_cars{?availableAt[before],availableAt[strictly_before],availableAt[after],availableAt[strictly_after],canSell,foobar[],foobargroups[],foobargroups_override[],colors.prop,name}", "hydra:variableRepresentation": "BasicRepresentation", "hydra:mapping": [ { diff --git a/features/graphql/input_output.feature b/features/graphql/input_output.feature new file mode 100644 index 00000000000..c29b9a1e921 --- /dev/null +++ b/features/graphql/input_output.feature @@ -0,0 +1,190 @@ +Feature: GraphQL 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: Retrieve an Output with GraphQl + 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 equal to: + """ + { + "@context": { + "@vocab": "http://example.com/docs.jsonld#", + "hydra": "http://www.w3.org/ns/hydra/core#", + "id": "OutputDto/id", + "baz": "OutputDto/baz", + "bat": "OutputDto/bat" + }, + "@type": "DummyDtoInputOutput", + "@id": "/dummy_dto_input_outputs/1", + "id": 1, + "baz": 1, + "bat": "test" + } + """ + When I send the following GraphQL request: + """ + { + dummyDtoInputOutput(id: "/dummy_dto_input_outputs/1") { + _id, id, baz + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON should be equal to: + """ + { + "data": { + "dummyDtoInputOutput": { + "_id": 1, + "id": "/dummy_dto_input_outputs/1", + "baz": 1 + } + } + } + """ + + Scenario: Create an item using custom inputClass & disabled outputClass + Given there are 2 dummyDtoNoOutput objects + When I send the following GraphQL request: + """ + mutation { + createDummyDtoNoOutput(input: {foo: "A new one", bar: 3, clientMutationId: "myId"}) { + dummyDtoNoOutput { + id + } + clientMutationId + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON should be equal to: + """ + { + "errors": [ + { + "message": "Cannot query field \"id\" on type \"DummyDtoNoOutput\".", + "extensions": { + "category": "graphql" + }, + "locations": [ + { + "line": 4, + "column": 7 + } + ] + } + ] + } + """ + + Scenario: Cannot create an item with input fields using disabled inputClass + When I send the following GraphQL request: + """ + mutation { + createDummyDtoNoInput(input: {lorem: "A new one", ipsum: 3, clientMutationId: "myId"}) { + clientMutationId + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON should be equal to: + """ + { + "errors": [ + { + "message": "Field \"lorem\" is not defined by type createDummyDtoNoInputInput.", + "extensions": { + "category": "graphql" + }, + "locations": [ + { + "line": 2, + "column": 33 + } + ] + }, + { + "message": "Field \"ipsum\" is not defined by type createDummyDtoNoInputInput.", + "extensions": { + "category": "graphql" + }, + "locations": [ + { + "line": 2, + "column": 53 + } + ] + } + ] + } + """ + + Scenario: Create an item with empty input fields using disabled inputClass (no persist done) + When I send the following GraphQL request: + """ + mutation { + createDummyDtoNoInput(input: {clientMutationId: "myId"}) { + dummyDtoNoInput { + id + } + clientMutationId + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON should be equal to: + """ + { + "data": { + "createDummyDtoNoInput": { + "dummyDtoNoInput": null, + "clientMutationId": "myId" + } + } + } + """ + + @!mongodb + Scenario: Use messenger with graphql and an input where the handler gives a synchronous result + When I send the following GraphQL request: + """ + mutation { + createMessengerWithInput(input: {var: "test"}) { + messengerWithInput { id, name } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON should be equal to: + """ + { + "data": { + "createMessengerWithInput": { + "messengerWithInput": { + "id": "/messenger_with_inputs/1", + "name": "test" + } + } + } + } + """ diff --git a/features/graphql/mutation.feature b/features/graphql/mutation.feature index 847ca6c0ae2..9e3431a85a6 100644 --- a/features/graphql/mutation.feature +++ b/features/graphql/mutation.feature @@ -328,110 +328,3 @@ 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 "errors[0].message" should be equal to "name: This value should not be blank." - - Scenario: Create an item using custom inputClass & disabled outputClass - Given there are 2 dummyDtoNoOutput objects - When I send the following GraphQL request: - """ - mutation { - createDummyDtoNoOutput(input: {foo: "A new one", bar: 3, clientMutationId: "myId"}) { - dummyDtoNoOutput { - id - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "errors": [ - { - "message": "Cannot query field \"id\" on type \"DummyDtoNoOutput\".", - "extensions": { - "category": "graphql" - }, - "locations": [ - { - "line": 4, - "column": 7 - } - ] - } - ] - } - """ - - Scenario: Cannot create an item with input fields using disabled inputClass - When I send the following GraphQL request: - """ - mutation { - createDummyDtoNoInput(input: {lorem: "A new one", ipsum: 3, clientMutationId: "myId"}) { - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "errors": [ - { - "message": "Field \"lorem\" is not defined by type createDummyDtoNoInputInput.", - "extensions": { - "category": "graphql" - }, - "locations": [ - { - "line": 2, - "column": 33 - } - ] - }, - { - "message": "Field \"ipsum\" is not defined by type createDummyDtoNoInputInput.", - "extensions": { - "category": "graphql" - }, - "locations": [ - { - "line": 2, - "column": 53 - } - ] - } - ] - } - """ - - Scenario: Create an item with empty input fields using disabled inputClass (no persist done) - When I send the following GraphQL request: - """ - mutation { - createDummyDtoNoInput(input: {clientMutationId: "myId"}) { - dummyDtoNoInput { - id - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "createDummyDtoNoInput": { - "dummyDtoNoInput": null, - "clientMutationId": "myId" - } - } - } - """ diff --git a/features/graphql/non_resource.feature b/features/graphql/non_resource.feature new file mode 100644 index 00000000000..909e61390b6 --- /dev/null +++ b/features/graphql/non_resource.feature @@ -0,0 +1,92 @@ +# TODO: FIXME: GraphQL support for non-resources is non-existent + +Feature: GraphQL non-resource handling + In order to use non-resource types + As a developer + I should be able to serialize types not mapped to an API resource. + + Scenario: Get a resource containing a raw object + Then it is not supported +# When I send the following GraphQL request: +# """ +# { +# containNonResource(id: "/contain_non_resources/1") { +# _id +# id +# nested { +# _id +# id +# notAResource { +# foo +# bar +# } +# } +# notAResource { +# foo +# bar +# } +# } +# } +# """ +# Then the response status code should be 200 +# And the response should be in JSON +# And the header "Content-Type" should be equal to "application/json" +# And the JSON should be equal to: +# """ +# { +# "data": { +# "containNonResource": { +# "_id": 1, +# "id": "/contain_non_resources/1", +# "nested": { +# "_id": "1-nested", +# "id": "/contain_non_resources/1-nested", +# "notAResource": { +# "foo": "f2", +# "bar": "b2" +# } +# }, +# "notAResource": { +# "foo": "f1", +# "bar": "b1" +# } +# } +# } +# } +# """ + + @!mongodb + @createSchema + Scenario: Create a resource that has a non-resource relation. + Then it is not supported +# When I send the following GraphQL request: +# """ +# mutation { +# createNonRelationResource(input: {relation: {foo: "test"}}) { +# nonRelationResource { +# _id +# id +# relation { +# foo +# } +# } +# } +# } +# """ +# Then the response status code should be 200 +# And the response should be in JSON +# And the header "Content-Type" should be equal to "application/json" +# And the JSON should be equal to: +# """ +# { +# "data": { +# "nonRelationResource": { +# "_id": 1, +# "id": "/non_relation_resources/1", +# "relation": { +# "foo": "test" +# } +# } +# } +# } +# """ diff --git a/features/hal/non_resource.feature b/features/hal/non_resource.feature new file mode 100644 index 00000000000..320ea122b83 --- /dev/null +++ b/features/hal/non_resource.feature @@ -0,0 +1,45 @@ +Feature: HAL non-resource handling + In order to use non-resource types + As a developer + I should be able to serialize types not mapped to an API resource. + + Background: + Given I add "Accept" header equal to "application/hal+json" + + Scenario: Get a resource containing a raw object + When I send a "GET" request to "/contain_non_resources/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/hal+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "/contain_non_resources/1" + }, + "nested": { + "href": "/contain_non_resources/1-nested" + } + }, + "_embedded": { + "nested": { + "_links": { + "self": { + "href": "/contain_non_resources/1-nested" + } + }, + "id": "1-nested", + "notAResource": { + "foo": "f2", + "bar": "b2" + } + } + }, + "id": 1, + "notAResource": { + "foo": "f1", + "bar": "b1" + } + } + """ diff --git a/features/jsonapi/non_resource.feature b/features/jsonapi/non_resource.feature new file mode 100644 index 00000000000..b4654ac0f0e --- /dev/null +++ b/features/jsonapi/non_resource.feature @@ -0,0 +1,88 @@ +Feature: JSON API non-resource handling + In order to use non-resource types + As a developer + I should be able to serialize types not mapped to an API resource. + + Background: + Given I add "Accept" header equal to "application/vnd.api+json" + And I add "Content-Type" header equal to "application/vnd.api+json" + + Scenario: Get a resource containing a raw object + When I send a "GET" request to "/contain_non_resources/1?include=nested" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" + And the JSON should be valid according to the JSON API schema + And the JSON should be a superset of: + """ + { + "data": { + "id": "/contain_non_resources/1", + "type": "ContainNonResource", + "attributes": { + "_id": 1, + "notAResource": { + "foo": "f1", + "bar": "b1" + } + }, + "relationships": { + "nested": { + "data": { + "id": "/contain_non_resources/1-nested", + "type": "ContainNonResource" + } + } + } + }, + "included": [ + { + "id": "/contain_non_resources/1-nested", + "type": "ContainNonResource", + "attributes": { + "_id": "1-nested", + "notAResource": { + "foo": "f2", + "bar": "b2" + } + } + } + ] + } + """ + + @!mongodb + @createSchema + Scenario: Create a resource that has a non-resource relation. + When I send a "POST" request to "/non_relation_resources" with body: + """ + { + "data": { + "type": "NonRelationResource", + "attributes": { + "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/vnd.api+json; charset=utf-8" + And the JSON should be valid according to the JSON API schema + And the JSON should be a superset of: + """ + { + "data": { + "id": "/non_relation_resources/1", + "type": "NonRelationResource", + "attributes": { + "_id": 1, + "relation": { + "foo": "test" + } + } + } + } + """ diff --git a/features/jsonapi/related-resouces-inclusion.feature b/features/jsonapi/related-resouces-inclusion.feature index ebc03e8d0f5..a2864df22eb 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, @@ -377,8 +377,8 @@ Feature: JSON API Inclusion of Related Resources "relationships": { "thirdLevel": { "data": { - "id": "/third_levels/1", - "type": "ThirdLevel" + "type": "ThirdLevel", + "id": "/third_levels/1" } } } diff --git a/features/main/input_output.feature b/features/jsonld/input_output.feature similarity index 66% rename from features/main/input_output.feature rename to features/jsonld/input_output.feature index 7c132ab80da..7d3880aa633 100644 --- a/features/main/input_output.feature +++ b/features/jsonld/input_output.feature @@ -1,12 +1,15 @@ -Feature: DTO input and output +Feature: JSON-LD 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. + Background: + Given I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + @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: + When I send a "POST" request to "/dummy_dto_customs" with body: """ { "foo": "test", @@ -82,8 +85,7 @@ Feature: DTO input and output @createSchema Scenario: Create a DummyDtoCustom 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: + When I send a "POST" request to "/dummy_dto_custom_post_without_output" with body: """ { "lorem": "test", @@ -95,8 +97,7 @@ Feature: DTO input and output @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: + When I send a "POST" request to "/dummy_dto_input_outputs" with body: """ { "foo": "test", @@ -121,7 +122,8 @@ Feature: DTO input and output "bat": "test" } """ - When I add "Content-Type" header equal to "application/ld+json" + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" And I send a "PUT" request to "/dummy_dto_input_outputs/1" with body: """ { @@ -151,8 +153,7 @@ Feature: DTO input and output @!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: + When I send a "POST" request to "/users" with body: """ { "username": "soyuka", @@ -161,7 +162,8 @@ Feature: DTO input and output } """ Then the response status code should be 201 - When I add "Content-Type" header equal to "application/ld+json" + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" And I send a "PUT" request to "/users/recover/1" with body: """ { @@ -183,60 +185,9 @@ Feature: DTO input and output } """ - @createSchema - Scenario: Retrieve an Output with GraphQl - 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 equal to: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "id": "OutputDto/id", - "baz": "OutputDto/baz", - "bat": "OutputDto/bat" - }, - "@type": "DummyDtoInputOutput", - "@id": "/dummy_dto_input_outputs/1", - "id": 1, - "baz": 1, - "bat": "test" - } - """ - When I send the following GraphQL request: - """ - { - dummyDtoInputOutput(id: "/dummy_dto_input_outputs/1") { - _id, id, baz - } - } - """ - Then the response status code should be 200 - And the JSON should be equal to: - """ - { - "data": { - "dummyDtoInputOutput": { - "_id": 1, - "id": "/dummy_dto_input_outputs/1", - "baz": 1 - } - } - } - """ - @createSchema Scenario: Create a resource with no input - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_dto_no_inputs" with body: + When I send a "POST" request to "/dummy_dto_no_inputs" with body: """ { "foo": "test", @@ -248,7 +199,6 @@ Feature: DTO input and output @!mongodb Scenario: Use messenger with an input where the handler gives a synchronous result - When I add "Content-Type" header equal to "application/ld+json" And I send a "POST" request to "/messenger_with_inputs" with body: """ { @@ -271,8 +221,7 @@ Feature: DTO input and output @!mongodb Scenario: Use messenger with an input where the handler gives a synchronous Response result - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/messenger_with_responses" with body: + When I send a "POST" request to "/messenger_with_responses" with body: """ { "var": "test" @@ -287,29 +236,3 @@ Feature: DTO input and output "data": 123 } """ - - @!mongodb - Scenario: Use messenger with graphql and an input where the handler gives a synchronous result - When I send the following GraphQL request: - """ - mutation { - createMessengerWithInput(input: {var: "test"}) { - messengerWithInput { id, name } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "data": { - "createMessengerWithInput": { - "messengerWithInput": { - "id": "/messenger_with_inputs/1", - "name": "test" - } - } - } - } - """ diff --git a/features/jsonld/non_resource.feature b/features/jsonld/non_resource.feature new file mode 100644 index 00000000000..e967e44f3e3 --- /dev/null +++ b/features/jsonld/non_resource.feature @@ -0,0 +1,64 @@ +Feature: JSON-LD non-resource handling + In order to use non-resource types + As a developer + I should be able to serialize types not mapped to an API resource. + + Background: + Given I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + + Scenario: Get a resource containing a raw object + When I send a "GET" request to "/contain_non_resources/1" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/ContainNonResource", + "@id": "/contain_non_resources/1", + "@type": "ContainNonResource", + "id": 1, + "nested": { + "@id": "/contain_non_resources/1-nested", + "@type": "ContainNonResource", + "id": "1-nested", + "nested": null, + "notAResource": { + "foo": "f2", + "bar": "b2" + } + }, + "notAResource": { + "foo": "f1", + "bar": "b1" + } + } + """ + + @!mongodb + @createSchema + Scenario: Create a resource that has a non-resource relation. + When 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/non_resource.feature b/features/main/non_resource.feature deleted file mode 100644 index 97751d396ac..00000000000 --- a/features/main/non_resource.feature +++ /dev/null @@ -1,54 +0,0 @@ -Feature: Non-resources handling - In order to handle use non-resource types - As a developer - I should be able serialize types not mapped to an API resource. - - Scenario: Get a resource containing a raw object - When I send a "GET" request to "/contain_non_resources/1" - Then the JSON should be equal to: - """ - { - "@context": "/contexts/ContainNonResource", - "@id": "/contain_non_resources/1", - "@type": "ContainNonResource", - "id": 1, - "nested": { - "@id": "/contain_non_resources/1-nested", - "@type": "ContainNonResource", - "id": "1-nested", - "nested": null, - "notAResource": { - "foo": "f2", - "bar": "b2" - } - }, - "notAResource": { - "foo": "f1", - "bar": "b1" - } - } - """ - - @!mongodb - @createSchema - Scenario: Create a resource that has a non-resource 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/src/Bridge/Elasticsearch/Serializer/ItemNormalizer.php b/src/Bridge/Elasticsearch/Serializer/ItemNormalizer.php index 7dd977ffe65..c5bc46ffedf 100644 --- a/src/Bridge/Elasticsearch/Serializer/ItemNormalizer.php +++ b/src/Bridge/Elasticsearch/Serializer/ItemNormalizer.php @@ -14,9 +14,9 @@ namespace ApiPlatform\Core\Bridge\Elasticsearch\Serializer; use ApiPlatform\Core\Bridge\Elasticsearch\Api\IdentifierExtractorInterface; -use ApiPlatform\Core\Exception\RuntimeException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; +use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -67,15 +67,18 @@ public function denormalize($data, $class, $format = null, array $context = []) */ public function supportsNormalization($data, $format = null): bool { - return false; + // prevent the use of lower priority normalizers (e.g. serializer.normalizer.object) for this format + return self::FORMAT === $format; } /** * {@inheritdoc} + * + * @throws LogicException */ public function normalize($object, $format = null, array $context = []) { - throw new RuntimeException(sprintf('%s is a read-only format.', self::FORMAT)); + throw new LogicException(sprintf('%s is a write-only format.', self::FORMAT)); } /** diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index e9536d7e6ef..458a990747c 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -103,34 +103,10 @@ false - + - - - - - - - - - - %api_platform.allow_plain_identifiers% - null - - - true - - - - - - - - - - diff --git a/src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml b/src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml index 418a1c604d0..f2049f82875 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml @@ -56,7 +56,7 @@ - + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml index 19ed3520214..1d156febbeb 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml @@ -82,26 +82,15 @@ false - + - - - + + - - - - - - %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 f20c624eb3d..ce96df6c777 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/hal.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/hal.xml @@ -41,26 +41,15 @@ false - + - - - + + - - - - - null - false - - - - true - + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml b/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml index 5cddfc7a6e3..4efa79dfa59 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml @@ -43,23 +43,17 @@ false - + - - - + + - - - - - true - + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml b/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml index 19d6227e158..f1de0a7a23c 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml @@ -29,25 +29,16 @@ false - + - - - - + + - - - - - - - true - + diff --git a/src/GraphQl/Serializer/ItemNormalizer.php b/src/GraphQl/Serializer/ItemNormalizer.php index 43064b86e4a..cb9e5b829c0 100644 --- a/src/GraphQl/Serializer/ItemNormalizer.php +++ b/src/GraphQl/Serializer/ItemNormalizer.php @@ -33,7 +33,7 @@ final class ItemNormalizer extends BaseItemNormalizer /** * {@inheritdoc} */ - public function supportsNormalization($data, $format = null, array $context = []) + public function supportsNormalization($data, $format = null, array $context = []): bool { return self::FORMAT === $format && parent::supportsNormalization($data, $format, $context); } @@ -45,7 +45,7 @@ public function supportsNormalization($data, $format = null, array $context = [] */ public function normalize($object, $format = null, array $context = []) { - if (!$this->handleNonResource && null !== $outputClass = $this->getOutputClass($this->getObjectClass($object), $context)) { + if (null !== $outputClass = $this->getOutputClass($this->getObjectClass($object), $context)) { return parent::normalize($object, $format, $context); } @@ -54,15 +54,6 @@ public function normalize($object, $format = null, array $context = []) throw new UnexpectedValueException('Expected data to be an array'); } - if ($this->handleNonResource) { - // when using an output class, get the IRI from the resource - if (isset($context['api_resource']) && isset($data['id'])) { - $data['_id'] = $data['id']; - $data['id'] = $this->iriConverter->getIriFromItem($context['api_resource']); - unset($context['api_resource']); - } - } - $data[self::ITEM_KEY] = serialize($object); // calling serialize prevent weird normalization process done by Webonyx's GraphQL PHP return $data; @@ -80,7 +71,7 @@ protected function normalizeCollectionOfRelations(PropertyMetadata $propertyMeta /** * {@inheritdoc} */ - public function supportsDenormalization($data, $type, $format = null, array $context = []) + public function supportsDenormalization($data, $type, $format = null, array $context = []): bool { return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); } @@ -103,7 +94,7 @@ protected function getAllowedAttributes($classOrObject, array $context, $attribu /** * {@inheritdoc} */ - protected function setAttributeValue($object, $attribute, $value, $format = null, array $context = []) + protected function setAttributeValue($object, $attribute, $value, $format = null, array $context = []): void { if ('_id' === $attribute) { $attribute = 'id'; diff --git a/src/GraphQl/Serializer/ObjectNormalizer.php b/src/GraphQl/Serializer/ObjectNormalizer.php new file mode 100644 index 00000000000..af40b9fcc29 --- /dev/null +++ b/src/GraphQl/Serializer/ObjectNormalizer.php @@ -0,0 +1,82 @@ + + * + * 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\GraphQl\Serializer; + +use ApiPlatform\Core\Api\IriConverterInterface; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Decorates the output with GraphQL metadata when appropriate, but otherwise just + * passes through to the decorated normalizer. + */ +final class ObjectNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface +{ + public const FORMAT = 'graphql'; + public const ITEM_KEY = '#item'; + + private $decorated; + private $iriConverter; + + public function __construct(NormalizerInterface $decorated, IriConverterInterface $iriConverter) + { + $this->decorated = $decorated; + $this->iriConverter = $iriConverter; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null, array $context = []): bool + { + return self::FORMAT === $format && $this->decorated->supportsNormalization($data, $format, $context); + } + + /** + * {@inheritdoc} + */ + public function hasCacheableSupportsMethod(): bool + { + return $this->decorated->hasCacheableSupportsMethod(); + } + + /** + * {@inheritdoc} + * + * @throws UnexpectedValueException + */ + public function normalize($object, $format = null, array $context = []) + { + if (isset($context['api_resource'])) { + $originalResource = $context['api_resource']; + unset($context['api_resource']); + } + + $data = $this->decorated->normalize($object, $format, $context); + if (!\is_array($data)) { + throw new UnexpectedValueException('Expected data to be an array'); + } + + // when using an output class, get the IRI from the resource + if (isset($originalResource) && isset($data['id'])) { + $data['_id'] = $data['id']; + $data['id'] = $this->iriConverter->getIriFromItem($originalResource); + } + + $data[self::ITEM_KEY] = serialize($object); // calling serialize prevent weird normalization process done by Webonyx's GraphQL PHP + + return $data; + } +} diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php index 9e628c7e41e..32094c77b95 100644 --- a/src/Hal/Serializer/ItemNormalizer.php +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -38,7 +38,7 @@ final class ItemNormalizer extends AbstractItemNormalizer /** * {@inheritdoc} */ - public function supportsNormalization($data, $format = null, array $context = []) + public function supportsNormalization($data, $format = null, array $context = []): bool { return self::FORMAT === $format && parent::supportsNormalization($data, $format, $context); } @@ -48,7 +48,7 @@ public function supportsNormalization($data, $format = null, array $context = [] */ public function normalize($object, $format = null, array $context = []) { - if (!$this->handleNonResource && null !== $outputClass = $this->getOutputClass($this->getObjectClass($object), $context)) { + if (null !== $outputClass = $this->getOutputClass($this->getObjectClass($object), $context)) { return parent::normalize($object, $format, $context); } @@ -56,62 +56,39 @@ public function normalize($object, $format = null, array $context = []) $context['cache_key'] = $this->getHalCacheKey($format, $context); } - if ($this->handleNonResource) { - if (isset($context['api_resource'])) { - $originalResource = $context['api_resource']; - unset($context['api_resource']); - } - - $rawData = parent::normalize($object, $format, $context); - if (!\is_array($rawData)) { - return $rawData; - } - - if (!isset($originalResource)) { - return $rawData; - } - - $metadataData = [ - '_links' => [ - 'self' => [ - 'href' => $this->iriConverter->getIriFromItem($originalResource), - ], - ], - ]; - - return $metadataData + $rawData; - } - + // Use resolved resource class instead of given resource class to support multiple inheritance child types $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); $context = $this->initContext($resourceClass, $context); - $context['iri'] = $this->iriConverter->getIriFromItem($object); + $iri = $this->iriConverter->getIriFromItem($object); + $context['iri'] = $iri; $context['api_normalize'] = true; - $rawData = parent::normalize($object, $format, $context); - if (!\is_array($rawData)) { - return $rawData; + $data = parent::normalize($object, $format, $context); + if (!\is_array($data)) { + return $data; } - $metadataData = [ + $metadata = [ '_links' => [ 'self' => [ - 'href' => $context['iri'], + 'href' => $iri, ], ], ]; $components = $this->getComponents($object, $format, $context); - $metadataData = $this->populateRelation($metadataData, $object, $format, $context, $components, 'links'); - $metadataData = $this->populateRelation($metadataData, $object, $format, $context, $components, 'embedded'); + $metadata = $this->populateRelation($metadata, $object, $format, $context, $components, 'links'); + $metadata = $this->populateRelation($metadata, $object, $format, $context, $components, 'embedded'); - return $metadataData + $rawData; + return $metadata + $data; } /** * {@inheritdoc} */ - public function supportsDenormalization($data, $type, $format = null, array $context = []) + public function supportsDenormalization($data, $type, $format = null, array $context = []): bool { - return false; + // prevent the use of lower priority normalizers (e.g. serializer.normalizer.object) for this format + return self::FORMAT === $format; } /** @@ -127,7 +104,7 @@ public function denormalize($data, $class, $format = null, array $context = []) /** * {@inheritdoc} */ - protected function getAttributes($object, $format = null, array $context) + protected function getAttributes($object, $format = null, array $context): array { return $this->getComponents($object, $format, $context)['states']; } diff --git a/src/Hal/Serializer/ObjectNormalizer.php b/src/Hal/Serializer/ObjectNormalizer.php new file mode 100644 index 00000000000..2c959e1355b --- /dev/null +++ b/src/Hal/Serializer/ObjectNormalizer.php @@ -0,0 +1,103 @@ + + * + * 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\Hal\Serializer; + +use ApiPlatform\Core\Api\IriConverterInterface; +use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Decorates the output with JSON HAL metadata when appropriate, but otherwise + * just passes through to the decorated normalizer. + */ +final class ObjectNormalizer implements NormalizerInterface, DenormalizerInterface, CacheableSupportsMethodInterface +{ + public const FORMAT = 'jsonhal'; + + private $decorated; + private $iriConverter; + + public function __construct(NormalizerInterface $decorated, IriConverterInterface $iriConverter) + { + $this->decorated = $decorated; + $this->iriConverter = $iriConverter; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null, array $context = []): bool + { + return self::FORMAT === $format && $this->decorated->supportsNormalization($data, $format, $context); + } + + /** + * {@inheritdoc} + */ + public function hasCacheableSupportsMethod(): bool + { + return $this->decorated->hasCacheableSupportsMethod(); + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []) + { + if (isset($context['api_resource'])) { + $originalResource = $context['api_resource']; + unset($context['api_resource']); + } + + $data = $this->decorated->normalize($object, $format, $context); + if (!\is_array($data)) { + return $data; + } + + if (!isset($originalResource)) { + return $data; + } + + $metadata = [ + '_links' => [ + 'self' => [ + 'href' => $this->iriConverter->getIriFromItem($originalResource), + ], + ], + ]; + + return $metadata + $data; + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, $type, $format = null, array $context = []): bool + { + // prevent the use of lower priority normalizers (e.g. serializer.normalizer.object) for this format + return self::FORMAT === $format; + } + + /** + * {@inheritdoc} + * + * @throws LogicException + */ + public function denormalize($data, $class, $format = null, array $context = []) + { + throw new LogicException(sprintf('%s is a read-only format.', self::FORMAT)); + } +} diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index 4018c229df5..58bd954a0bd 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -22,8 +22,10 @@ use ApiPlatform\Core\Metadata\Property\PropertyMetadata; 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\Exception\LogicException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\RuntimeException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; @@ -41,20 +43,21 @@ final class ItemNormalizer extends AbstractItemNormalizer { use ClassInfoTrait; + use ContextTrait; public const FORMAT = 'jsonapi'; private $componentsCache = []; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor, ?NameConverterInterface $nameConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, array $defaultContext = [], iterable $dataTransformers = [], bool $handleNonResource = false) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor, ?NameConverterInterface $nameConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, array $defaultContext = [], iterable $dataTransformers = []) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, null, null, false, $defaultContext, $dataTransformers, $resourceMetadataFactory, $handleNonResource); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, null, null, false, $defaultContext, $dataTransformers, $resourceMetadataFactory); } /** * {@inheritdoc} */ - public function supportsNormalization($data, $format = null, array $context = []) + public function supportsNormalization($data, $format = null, array $context = []): bool { return self::FORMAT === $format && parent::supportsNormalization($data, $format, $context); } @@ -64,7 +67,7 @@ public function supportsNormalization($data, $format = null, array $context = [] */ public function normalize($object, $format = null, array $context = []) { - if (!$this->handleNonResource && null !== $outputClass = $this->getOutputClass($this->getObjectClass($object), $context)) { + if (null !== $outputClass = $this->getOutputClass($this->getObjectClass($object), $context)) { return parent::normalize($object, $format, $context); } @@ -72,49 +75,18 @@ public function normalize($object, $format = null, array $context = []) $context['cache_key'] = $this->getJsonApiCacheKey($format, $context); } - if ($this->handleNonResource) { - if (isset($context['api_resource'])) { - $originalResource = $context['api_resource']; - unset($context['api_resource']); - } - - $attributesData = parent::normalize($object, $format, $context); - if (!\is_array($attributesData)) { - return $attributesData; - } - - if (isset($context['api_attribute'])) { - return $attributesData; - } - - if (isset($originalResource)) { - $resourceClass = $this->resourceClassResolver->getResourceClass($originalResource, $context['resource_class'] ?? null, true); - $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); - - $resourceData = [ - 'id' => $this->iriConverter->getIriFromItem($originalResource), - 'type' => $resourceMetadata->getShortName(), - ]; - } else { - $resourceData = [ - 'id' => \function_exists('spl_object_id') ? spl_object_id($object) : spl_object_hash($object), - 'type' => (new \ReflectionClass($this->getObjectClass($object)))->getShortName(), - ]; - } - - if ($attributesData) { - $resourceData['attributes'] = $attributesData; - } - - return ['data' => $resourceData]; - } + // Use resolved resource class instead of given resource class to support multiple inheritance child types + $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); + $context = $this->initContext($resourceClass, $context); + $iri = $this->iriConverter->getIriFromItem($object); + $context['iri'] = $iri; + $context['api_normalize'] = true; - $attributesData = parent::normalize($object, $format, $context); - if (!\is_array($attributesData)) { - return $attributesData; + $data = parent::normalize($object, $format, $context); + if (!\is_array($data)) { + return $data; } - $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); // Get and populate relations @@ -124,12 +96,12 @@ public function normalize($object, $format = null, array $context = []) $includedResourcesData = $this->getRelatedResources($object, $format, $context, $allRelationshipsData); $resourceData = [ - 'id' => $this->iriConverter->getIriFromItem($object), + 'id' => $context['iri'], 'type' => $resourceMetadata->getShortName(), ]; - if ($attributesData) { - $resourceData['attributes'] = $attributesData; + if ($data) { + $resourceData['attributes'] = $data; } if ($relationshipsData) { @@ -148,7 +120,7 @@ public function normalize($object, $format = null, array $context = []) /** * {@inheritdoc} */ - public function supportsDenormalization($data, $type, $format = null, array $context = []) + public function supportsDenormalization($data, $type, $format = null, array $context = []): bool { return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); } @@ -162,7 +134,7 @@ public function denormalize($data, $class, $format = null, array $context = []) { // Avoid issues with proxies if we populated the object if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) { - if (isset($context['api_allow_update']) && true !== $context['api_allow_update']) { + if (true !== ($context['api_allow_update'] ?? true)) { throw new NotNormalizableValueException('Update is not allowed for this operation.'); } @@ -172,7 +144,7 @@ public function denormalize($data, $class, $format = null, array $context = []) ); } - // Merge attributes and relations previous to apply parents denormalizing + // Merge attributes and relationships, into format expected by the parent normalizer $dataToDenormalize = array_merge( $data['data']['attributes'] ?? [], $data['data']['relationships'] ?? [] @@ -189,7 +161,7 @@ public function denormalize($data, $class, $format = null, array $context = []) /** * {@inheritdoc} */ - protected function getAttributes($object, $format = null, array $context) + protected function getAttributes($object, $format = null, array $context): array { return $this->getComponents($object, $format, $context)['attributes']; } @@ -197,7 +169,7 @@ protected function getAttributes($object, $format = null, array $context) /** * {@inheritdoc} */ - protected function setAttributeValue($object, $attribute, $value, $format = null, array $context = []) + protected function setAttributeValue($object, $attribute, $value, $format = null, array $context = []): void { parent::setAttributeValue($object, $attribute, \is_array($value) && \array_key_exists('data', $value) ? $value['data'] : $value, $format, $context); } @@ -207,6 +179,7 @@ protected function setAttributeValue($object, $attribute, $value, $format = null * * @see http://jsonapi.org/format/#document-resource-object-linkage * + * @throws LogicException * @throws RuntimeException * @throws NotNormalizableValueException */ @@ -219,7 +192,7 @@ protected function denormalizeRelation(string $attributeName, PropertyMetadata $ if ($this->serializer instanceof DenormalizerInterface) { return $this->serializer->denormalize($value, $className, $format, $context); } - throw new RuntimeException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); + throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); } if (!\is_array($value) || !isset($value['id'], $value['type'])) { @@ -238,7 +211,7 @@ protected function denormalizeRelation(string $attributeName, PropertyMetadata $ * * @see http://jsonapi.org/format/#document-resource-object-linkage * - * @throws RuntimeException + * @throws LogicException */ protected function normalizeRelation(PropertyMetadata $propertyMetadata, $relatedObject, string $resourceClass, ?string $format, array $context) { @@ -251,37 +224,36 @@ protected function normalizeRelation(PropertyMetadata $propertyMetadata, $relate if ($this->serializer instanceof NormalizerInterface) { return $this->serializer->normalize($relatedObject, $format, $context); } - throw new RuntimeException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class)); + throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class)); } } else { $iri = $this->iriConverter->getIriFromItem($relatedObject); + $context['iri'] = $iri; if (isset($context['resources'])) { $context['resources'][$iri] = $iri; } if (isset($context['api_included'])) { - $context['api_sub_level'] = true; - if (!$this->serializer instanceof NormalizerInterface) { - throw new RuntimeException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class)); + throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class)); } - $data = $this->serializer->normalize($relatedObject, $format, $context); - unset($context['api_sub_level']); - return $data; + return $this->serializer->normalize($relatedObject, $format, $context); } } - return ['data' => [ - 'type' => $this->resourceMetadataFactory->create($resourceClass)->getShortName(), - 'id' => $iri, - ]]; + return [ + 'data' => [ + 'type' => $this->resourceMetadataFactory->create($resourceClass)->getShortName(), + 'id' => $iri, + ], + ]; } /** * {@inheritdoc} */ - protected function isAllowedAttribute($classOrObject, $attribute, $format = null, array $context = []) + protected function isAllowedAttribute($classOrObject, $attribute, $format = null, array $context = []): bool { return preg_match('/^\\w[-\\w_]*$/', $attribute) && parent::isAllowedAttribute($classOrObject, $attribute, $format, $context); } diff --git a/src/JsonApi/Serializer/ObjectNormalizer.php b/src/JsonApi/Serializer/ObjectNormalizer.php new file mode 100644 index 00000000000..5066b8429fd --- /dev/null +++ b/src/JsonApi/Serializer/ObjectNormalizer.php @@ -0,0 +1,98 @@ + + * + * 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\JsonApi\Serializer; + +use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Util\ClassInfoTrait; +use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Decorates the output with JSON API metadata when appropriate, but otherwise + * just passes through to the decorated normalizer. + */ +final class ObjectNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface +{ + use ClassInfoTrait; + + public const FORMAT = 'jsonapi'; + + private $decorated; + private $iriConverter; + private $resourceClassResolver; + private $resourceMetadataFactory; + + public function __construct(NormalizerInterface $decorated, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ResourceMetadataFactoryInterface $resourceMetadataFactory) + { + $this->decorated = $decorated; + $this->iriConverter = $iriConverter; + $this->resourceClassResolver = $resourceClassResolver; + $this->resourceMetadataFactory = $resourceMetadataFactory; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null, array $context = []): bool + { + return self::FORMAT === $format && $this->decorated->supportsNormalization($data, $format, $context); + } + + /** + * {@inheritdoc} + */ + public function hasCacheableSupportsMethod(): bool + { + return $this->decorated->hasCacheableSupportsMethod(); + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []) + { + if (isset($context['api_resource'])) { + $originalResource = $context['api_resource']; + unset($context['api_resource']); + } + + $data = $this->decorated->normalize($object, $format, $context); + if (!\is_array($data) || isset($context['api_attribute'])) { + return $data; + } + + if (isset($originalResource)) { + $resourceClass = $this->resourceClassResolver->getResourceClass($originalResource, $context['resource_class'] ?? null, true); + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + + $resourceData = [ + 'id' => $this->iriConverter->getIriFromItem($originalResource), + 'type' => $resourceMetadata->getShortName(), + ]; + } else { + $resourceData = [ + 'id' => \function_exists('spl_object_id') ? spl_object_id($object) : spl_object_hash($object), + 'type' => (new \ReflectionClass($this->getObjectClass($object)))->getShortName(), + ]; + } + + if ($data) { + $resourceData['attributes'] = $data; + } + + return ['data' => $resourceData]; + } +} diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index ba25688eed9..211789ff286 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -43,9 +43,9 @@ final class ItemNormalizer extends AbstractItemNormalizer 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 = [], iterable $dataTransformers = [], bool $handleNonResource = false) + 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 = [], iterable $dataTransformers = []) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, null, false, $defaultContext, $dataTransformers, $resourceMetadataFactory, $handleNonResource); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, null, false, $defaultContext, $dataTransformers, $resourceMetadataFactory); $this->contextBuilder = $contextBuilder; } @@ -53,7 +53,7 @@ public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFa /** * {@inheritdoc} */ - public function supportsNormalization($data, $format = null, array $context = []) + public function supportsNormalization($data, $format = null, array $context = []): bool { return self::FORMAT === $format && parent::supportsNormalization($data, $format, $context); } @@ -65,56 +65,36 @@ public function supportsNormalization($data, $format = null, array $context = [] */ public function normalize($object, $format = null, array $context = []) { - if (!$this->handleNonResource && null !== $outputClass = $this->getOutputClass($this->getObjectClass($object), $context)) { + if (null !== $outputClass = $this->getOutputClass($this->getObjectClass($object), $context)) { return parent::normalize($object, $format, $context); } - if ($this->handleNonResource) { - if (!($context['api_normalize'] ?? false)) { - throw new LogicException('"api_normalize" must be set to true in context to normalize non-resource'); - } - - if (isset($context['api_resource'])) { - $context['output']['iri'] = $this->iriConverter->getIriFromItem($context['api_resource']); - } - - $data = $this->createJsonLdContext($this->contextBuilder, $object, $context); - - if (isset($context['api_resource'])) { - unset($context['api_resource']); - } + // Use resolved resource class instead of given resource class to support multiple inheritance child types + $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); + $context = $this->initContext($resourceClass, $context); + $iri = $this->iriConverter->getIriFromItem($object); + $context['iri'] = $iri; + $context['api_normalize'] = true; - $rawData = parent::normalize($object, $format, $context); - if (!\is_array($rawData)) { - return $rawData; - } + $metadata = $this->addJsonLdContext($this->contextBuilder, $resourceClass, $context); - return $data + $rawData; + $data = parent::normalize($object, $format, $context); + if (!\is_array($data)) { + return $data; } - $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); - $data = $this->addJsonLdContext($this->contextBuilder, $resourceClass, $context); - - // Use resolved resource class instead of given resource class to support multiple inheritance child types - $context['resource_class'] = $resourceClass; - $context['iri'] = $this->iriConverter->getIriFromItem($object); - - $rawData = parent::normalize($object, $format, $context); - if (!\is_array($rawData)) { - return $rawData; - } - $data['@id'] = $context['iri']; - $data['@type'] = $resourceMetadata->getIri() ?: $resourceMetadata->getShortName(); + $metadata['@id'] = $iri; + $metadata['@type'] = $resourceMetadata->getIri() ?: $resourceMetadata->getShortName(); - return $data + $rawData; + return $metadata + $data; } /** * {@inheritdoc} */ - public function supportsDenormalization($data, $type, $format = null, array $context = []) + public function supportsDenormalization($data, $type, $format = null, array $context = []): bool { return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); } @@ -128,7 +108,7 @@ public function denormalize($data, $class, $format = null, array $context = []) { // Avoid issues with proxies if we populated the object if (isset($data['@id']) && !isset($context[self::OBJECT_TO_POPULATE])) { - if (isset($context['api_allow_update']) && true !== $context['api_allow_update']) { + if (true !== ($context['api_allow_update'] ?? true)) { throw new NotNormalizableValueException('Update is not allowed for this operation.'); } diff --git a/src/JsonLd/Serializer/ObjectNormalizer.php b/src/JsonLd/Serializer/ObjectNormalizer.php new file mode 100644 index 00000000000..70f0e47cc56 --- /dev/null +++ b/src/JsonLd/Serializer/ObjectNormalizer.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\JsonLd\Serializer; + +use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\JsonLd\AnonymousContextBuilderInterface; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Decorates the output with JSON-LD metadata when appropriate, but otherwise just + * passes through to the decorated normalizer. + */ +final class ObjectNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface +{ + use JsonLdContextTrait; + + public const FORMAT = 'jsonld'; + + private $decorated; + private $iriConverter; + private $anonymousContextBuilder; + + public function __construct(NormalizerInterface $decorated, IriConverterInterface $iriConverter, AnonymousContextBuilderInterface $anonymousContextBuilder) + { + $this->decorated = $decorated; + $this->iriConverter = $iriConverter; + $this->anonymousContextBuilder = $anonymousContextBuilder; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null, array $context = []): bool + { + return self::FORMAT === $format && $this->decorated->supportsNormalization($data, $format, $context); + } + + /** + * {@inheritdoc} + */ + public function hasCacheableSupportsMethod(): bool + { + return $this->decorated->hasCacheableSupportsMethod(); + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []) + { + if (isset($context['api_resource'])) { + $originalResource = $context['api_resource']; + unset($context['api_resource']); + } + + /* + * Converts the normalized data array of a resource into an IRI, if the + * normalized data array is empty. + * + * This is useful when traversing from a non-resource towards an attribute + * which is a resource, as we do not have the benefit of {@see PropertyMetadata::isReadableLink}. + * + * It must not be propagated to subresources, as {@see PropertyMetadata::isReadableLink} + * should take effect. + */ + $context['api_empty_resource_as_iri'] = true; + + $data = $this->decorated->normalize($object, $format, $context); + if (!\is_array($data)) { + return $data; + } + + if (isset($originalResource)) { + $context['output']['iri'] = $this->iriConverter->getIriFromItem($originalResource); + $context['api_resource'] = $originalResource; + } + + $metadata = $this->createJsonLdContext($this->anonymousContextBuilder, $object, $context); + + return $metadata + $data; + } +} diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index a23b95d70c5..3fde229d47d 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -61,10 +61,9 @@ abstract class AbstractItemNormalizer extends AbstractObjectNormalizer implement protected $itemDataProvider; protected $allowPlainIdentifiers; protected $dataTransformers = []; - protected $handleNonResource; protected $localCache = []; - 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 = [], iterable $dataTransformers = [], ResourceMetadataFactoryInterface $resourceMetadataFactory = null, bool $handleNonResource = false) + 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 = [], iterable $dataTransformers = [], ResourceMetadataFactoryInterface $resourceMetadataFactory = null) { if (!isset($defaultContext['circular_reference_handler'])) { $defaultContext['circular_reference_handler'] = function ($object) { @@ -86,7 +85,6 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName $this->allowPlainIdentifiers = $allowPlainIdentifiers; $this->dataTransformers = $dataTransformers; $this->resourceMetadataFactory = $resourceMetadataFactory; - $this->handleNonResource = $handleNonResource; } /** @@ -98,10 +96,6 @@ public function supportsNormalization($data, $format = null, array $context = [] return false; } - if ($this->handleNonResource) { - return $context['api_normalize'] ?? false; - } - return $this->resourceClassResolver->isResourceClass($this->getObjectClass($data)); } @@ -110,7 +104,7 @@ public function supportsNormalization($data, $format = null, array $context = [] */ public function hasCacheableSupportsMethod(): bool { - return !$this->handleNonResource; + return true; } /** @@ -120,37 +114,48 @@ public function hasCacheableSupportsMethod(): bool */ public function normalize($object, $format = null, array $context = []) { - if (!$this->handleNonResource && $object !== $transformed = $this->transformOutput($object, $context)) { + if ($object !== $transformed = $this->transformOutput($object, $context)) { if (!$this->serializer instanceof NormalizerInterface) { - throw new RuntimeException('Cannot normalize the transformed value because the injected serializer is not a normalizer'); + throw new LogicException('Cannot normalize the transformed value because the injected serializer is not a normalizer'); } $context['api_normalize'] = true; $context['api_resource'] = $object; + unset($context['output']); return $this->serializer->normalize($transformed, $format, $context); } - if ($this->handleNonResource) { - if (!($context['api_normalize'] ?? false)) { - throw new LogicException('"api_normalize" must be set to true in context to normalize non-resource'); - } - - $context = $this->initContext($this->getObjectClass($object), $context); - - return parent::normalize($object, $format, $context); - } - + // Use resolved resource class instead of given resource class to support multiple inheritance child types $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); $context = $this->initContext($resourceClass, $context); + $iri = $context['iri'] ?? $this->iriConverter->getIriFromItem($object); + $context['iri'] = $iri; $context['api_normalize'] = true; + /* + * When true, converts the normalized data array of a resource into an + * IRI, if the normalized data array is empty. + * + * This is useful when traversing from a non-resource towards an attribute + * which is a resource, as we do not have the benefit of {@see PropertyMetadata::isReadableLink}. + * + * It must not be propagated to subresources, as {@see PropertyMetadata::isReadableLink} + * should take effect. + */ + $emptyResourceAsIri = $context['api_empty_resource_as_iri'] ?? false; + unset($context['api_empty_resource_as_iri']); + if (isset($context['resources'])) { - $resource = $context['iri'] ?? $this->iriConverter->getIriFromItem($object); - $context['resources'][$resource] = $resource; + $context['resources'][$iri] = $iri; + } + + $data = parent::normalize($object, $format, $context); + if ($emptyResourceAsIri && \is_array($data) && 0 === \count($data)) { + return $iri; } - return parent::normalize($object, $format, $context); + return $data; } /** @@ -158,10 +163,6 @@ public function normalize($object, $format = null, array $context = []) */ public function supportsDenormalization($data, $type, $format = null, array $context = []) { - if ($this->handleNonResource) { - return $context['api_denormalize'] ?? false; - } - return $this->localCache[$type] ?? $this->localCache[$type] = $this->resourceClassResolver->isResourceClass($type); } @@ -410,7 +411,7 @@ protected function denormalizeCollection(string $attribute, PropertyMetadata $pr /** * Denormalizes a relation. * - * @throws RuntimeException + * @throws LogicException * @throws UnexpectedValueException * * @return object|null @@ -436,7 +437,7 @@ protected function denormalizeRelation(string $attributeName, PropertyMetadata $ try { if (!$this->serializer instanceof DenormalizerInterface) { - throw new RuntimeException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); + throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); } return $this->serializer->denormalize($value, $className, $format, $context); @@ -520,7 +521,7 @@ protected function createRelationSerializationContext(string $resourceClass, arr * {@inheritdoc} * * @throws NoSuchPropertyException - * @throws RuntimeException + * @throws LogicException */ protected function getAttributeValue($object, $attribute, $format = null, array $context = []) { @@ -561,7 +562,7 @@ protected function getAttributeValue($object, $attribute, $format = null, array unset($context['resource_class']); if (!$this->serializer instanceof NormalizerInterface) { - throw new RuntimeException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class)); + throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class)); } return $this->serializer->normalize($attributeValue, $format, $context); @@ -585,7 +586,7 @@ protected function normalizeCollectionOfRelations(PropertyMetadata $propertyMeta /** * Normalizes a relation as an object if is a Link or as an URI. * - * @throws RuntimeException + * @throws LogicException * * @return string|array */ @@ -599,7 +600,7 @@ protected function normalizeRelation(PropertyMetadata $propertyMetadata, $relate } if (!$this->serializer instanceof NormalizerInterface) { - throw new RuntimeException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class)); + throw new LogicException(sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class)); } return $this->serializer->normalize($relatedObject, $format, $context); diff --git a/src/Serializer/ItemNormalizer.php b/src/Serializer/ItemNormalizer.php index 0e6ab8b2c7f..f1e18b6d24d 100644 --- a/src/Serializer/ItemNormalizer.php +++ b/src/Serializer/ItemNormalizer.php @@ -38,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, iterable $dataTransformers = [], ResourceMetadataFactoryInterface $resourceMetadataFactory = null, bool $handleNonResource = false) + 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, iterable $dataTransformers = [], ResourceMetadataFactoryInterface $resourceMetadataFactory = null) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $itemDataProvider, $allowPlainIdentifiers, [], $dataTransformers, $resourceMetadataFactory, $handleNonResource); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $itemDataProvider, $allowPlainIdentifiers, [], $dataTransformers, $resourceMetadataFactory); $this->logger = $logger ?: new NullLogger(); } diff --git a/src/Serializer/NoOpScalarNormalizer.php b/src/Serializer/NoOpScalarNormalizer.php deleted file mode 100644 index e4144e549d1..00000000000 --- a/src/Serializer/NoOpScalarNormalizer.php +++ /dev/null @@ -1,53 +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\Serializer; - -use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; -use Symfony\Component\Serializer\Normalizer\NormalizerInterface; - -/** - * A no-op normalizer that passes through scalar values. - * - * When there are non-cacheable normalizers in use, and you don't need to normalize - * scalar values, register this normalizer with a higher priority than the non-cacheable - * normalizers. This allows caching supportsNormalization calls for scalar values. - * - * @internal - */ -final class NoOpScalarNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface -{ - /** - * {@inheritdoc} - */ - public function supportsNormalization($data, $format = null): bool - { - return is_scalar($data); - } - - /** - * {@inheritdoc} - */ - public function normalize($object, $format = null, array $context = []) - { - return $object; - } - - /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool - { - return true; - } -} diff --git a/tests/Bridge/Elasticsearch/Serializer/ItemNormalizerTest.php b/tests/Bridge/Elasticsearch/Serializer/ItemNormalizerTest.php index b753e690d19..1ec70f7ccec 100644 --- a/tests/Bridge/Elasticsearch/Serializer/ItemNormalizerTest.php +++ b/tests/Bridge/Elasticsearch/Serializer/ItemNormalizerTest.php @@ -15,9 +15,9 @@ use ApiPlatform\Core\Bridge\Elasticsearch\Api\IdentifierExtractorInterface; use ApiPlatform\Core\Bridge\Elasticsearch\Serializer\ItemNormalizer; -use ApiPlatform\Core\Exception\RuntimeException; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Foo; use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -82,13 +82,13 @@ public function testSupportsNormalization() { $itemNormalizer = new ItemNormalizer($this->prophesize(IdentifierExtractorInterface::class)->reveal()); - self::assertFalse($itemNormalizer->supportsNormalization(new Foo(), ItemNormalizer::FORMAT)); + self::assertTrue($itemNormalizer->supportsNormalization(new Foo(), ItemNormalizer::FORMAT)); } public function testNormalize() { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage(sprintf('%s is a read-only format.', ItemNormalizer::FORMAT)); + $this->expectException(LogicException::class); + $this->expectExceptionMessage(sprintf('%s is a write-only format.', ItemNormalizer::FORMAT)); (new ItemNormalizer($this->prophesize(IdentifierExtractorInterface::class)->reveal()))->normalize(new Foo(), ItemNormalizer::FORMAT); } diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index f9726d07459..a2d2b5d1186 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -324,7 +324,7 @@ public function testDisableGraphQl() $containerBuilderProphecy->setDefinition('api_platform.graphql.executor', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.schema_builder', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.normalizer.item', Argument::type(Definition::class))->shouldNotBeCalled(); - $containerBuilderProphecy->setDefinition('api_platform.graphql.normalizer.item.non_resource', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.normalizer.object', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setParameter('api_platform.graphql.enabled', true)->shouldNotBeCalled(); $containerBuilderProphecy->setParameter('api_platform.graphql.enabled', false)->shouldBeCalled(); $containerBuilderProphecy->setParameter('api_platform.graphql.graphiql.enabled', true)->shouldNotBeCalled(); @@ -842,8 +842,6 @@ private function getPartialContainerBuilderProphecy() 'api_platform.serializer.context_builder.filter', 'api_platform.serializer.group_filter', 'api_platform.serializer.normalizer.item', - 'api_platform.serializer.normalizer.item.non_resource', - 'api_platform.serializer.normalizer.no_op_scalar', 'api_platform.serializer.property_filter', 'api_platform.serializer_locator', 'api_platform.subresource_data_provider', @@ -1019,12 +1017,12 @@ private function getBaseContainerBuilderProphecy() 'api_platform.graphql.resolver.item', 'api_platform.graphql.resolver.resource_field', 'api_platform.graphql.normalizer.item', - 'api_platform.graphql.normalizer.item.non_resource', + 'api_platform.graphql.normalizer.object', 'api_platform.hal.encoder', 'api_platform.hal.normalizer.collection', 'api_platform.hal.normalizer.entrypoint', 'api_platform.hal.normalizer.item', - 'api_platform.hal.normalizer.item.non_resource', + 'api_platform.hal.normalizer.object', 'api_platform.http_cache.listener.response.add_tags', 'api_platform.http_cache.listener.response.configure', 'api_platform.http_cache.purger.varnish_client', @@ -1041,7 +1039,7 @@ private function getBaseContainerBuilderProphecy() 'api_platform.jsonld.context_builder', 'api_platform.jsonld.encoder', 'api_platform.jsonld.normalizer.item', - 'api_platform.jsonld.normalizer.item.non_resource', + 'api_platform.jsonld.normalizer.object', 'api_platform.mercure.listener.response.add_link_header', 'api_platform.messenger.data_persister', 'api_platform.messenger.data_transformer', diff --git a/tests/Fixtures/NotAResource.php b/tests/Fixtures/NotAResource.php index b7323769792..3d14df2d93c 100644 --- a/tests/Fixtures/NotAResource.php +++ b/tests/Fixtures/NotAResource.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Core\Tests\Fixtures; +use Symfony\Component\Serializer\Annotation\Groups; + /** * This class is not mapped as an API resource. * @@ -20,7 +22,14 @@ */ class NotAResource { + /** + * @Groups("contain_non_resource") + */ private $foo; + + /** + * @Groups("contain_non_resource") + */ private $bar; public function __construct($foo, $bar) diff --git a/tests/Fixtures/TestBundle/Document/ContainNonResource.php b/tests/Fixtures/TestBundle/Document/ContainNonResource.php index f9d988a2555..b6fcf3032a1 100644 --- a/tests/Fixtures/TestBundle/Document/ContainNonResource.php +++ b/tests/Fixtures/TestBundle/Document/ContainNonResource.php @@ -16,12 +16,18 @@ use ApiPlatform\Core\Annotation\ApiResource; use ApiPlatform\Core\Tests\Fixtures\NotAResource; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\Serializer\Annotation\Groups; /** * Resource linked to a standard object. * * @ODM\Document - * @ApiResource + * + * @ApiResource( + * normalizationContext={ + * "groups"="contain_non_resource", + * }, + * ) * * @author Kévin Dunglas */ @@ -31,16 +37,22 @@ class ContainNonResource * @var mixed * * @ODM\Id(strategy="INCREMENT", type="integer") + * + * @Groups("contain_non_resource") */ public $id; /** - * @var self + * @var ContainNonResource + * + * @Groups("contain_non_resource") */ public $nested; /** * @var NotAResource + * + * @Groups("contain_non_resource") */ public $notAResource; } diff --git a/tests/Fixtures/TestBundle/Document/RelatedDummy.php b/tests/Fixtures/TestBundle/Document/RelatedDummy.php index 2f62df96cf7..04f0871ea2f 100644 --- a/tests/Fixtures/TestBundle/Document/RelatedDummy.php +++ b/tests/Fixtures/TestBundle/Document/RelatedDummy.php @@ -75,12 +75,6 @@ class RelatedDummy extends ParentDummy */ public $relatedToDummyFriend; - public function __construct() - { - $this->relatedToDummyFriend = new ArrayCollection(); - $this->embeddedDummy = new EmbeddableDummy(); - } - /** * @var bool A dummy bool * @@ -97,6 +91,12 @@ public function __construct() */ public $embeddedDummy; + public function __construct() + { + $this->relatedToDummyFriend = new ArrayCollection(); + $this->embeddedDummy = new EmbeddableDummy(); + } + public function getId() { return $this->id; diff --git a/tests/Fixtures/TestBundle/Entity/ContainNonResource.php b/tests/Fixtures/TestBundle/Entity/ContainNonResource.php index 2771f1ae87b..34fa14b5f1b 100644 --- a/tests/Fixtures/TestBundle/Entity/ContainNonResource.php +++ b/tests/Fixtures/TestBundle/Entity/ContainNonResource.php @@ -16,12 +16,18 @@ use ApiPlatform\Core\Annotation\ApiResource; use ApiPlatform\Core\Tests\Fixtures\NotAResource; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; /** * Resource linked to a standard object. * * @ORM\Entity - * @ApiResource + * + * @ApiResource( + * normalizationContext={ + * "groups"="contain_non_resource", + * }, + * ) * * @author Kévin Dunglas */ @@ -33,16 +39,22 @@ class ContainNonResource * @ORM\Column(type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") + * + * @Groups("contain_non_resource") */ public $id; /** - * @var self + * @var ContainNonResource + * + * @Groups("contain_non_resource") */ public $nested; /** * @var NotAResource + * + * @Groups("contain_non_resource") */ public $notAResource; } diff --git a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php index b3af4e6c637..8802cdfc054 100644 --- a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php +++ b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php @@ -76,11 +76,6 @@ class RelatedDummy extends ParentDummy */ public $relatedToDummyFriend; - public function __construct() - { - $this->relatedToDummyFriend = new ArrayCollection(); - } - /** * @var bool A dummy bool * @@ -97,6 +92,12 @@ public function __construct() */ public $embeddedDummy; + public function __construct() + { + $this->relatedToDummyFriend = new ArrayCollection(); + $this->embeddedDummy = new EmbeddableDummy(); + } + public function getId() { return $this->id; diff --git a/tests/GraphQl/Serializer/ItemNormalizerTest.php b/tests/GraphQl/Serializer/ItemNormalizerTest.php index 6069c25aab3..d8d1c9e9293 100644 --- a/tests/GraphQl/Serializer/ItemNormalizerTest.php +++ b/tests/GraphQl/Serializer/ItemNormalizerTest.php @@ -100,8 +100,7 @@ public function testNormalize() false, null, [], - null, - false + null ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -139,8 +138,7 @@ public function testDenormalize() false, null, [], - null, - false + null ); $normalizer->setSerializer($serializerProphecy->reveal()); diff --git a/tests/Hal/Serializer/ItemNormalizerTest.php b/tests/Hal/Serializer/ItemNormalizerTest.php index 263af28b748..3ac060292f0 100644 --- a/tests/Hal/Serializer/ItemNormalizerTest.php +++ b/tests/Hal/Serializer/ItemNormalizerTest.php @@ -150,8 +150,7 @@ public function testNormalize() false, [], [], - null, - false + null ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -217,8 +216,7 @@ public function testNormalizeWithoutCache() false, [], [], - null, - false + null ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -297,8 +295,7 @@ public function testMaxDepth() false, [], [], - null, - false + null ); $serializer = new Serializer([$normalizer]); $normalizer->setSerializer($serializer); diff --git a/tests/JsonApi/Serializer/ItemNormalizerTest.php b/tests/JsonApi/Serializer/ItemNormalizerTest.php index f04ceb2917c..24fbd80ad3a 100644 --- a/tests/JsonApi/Serializer/ItemNormalizerTest.php +++ b/tests/JsonApi/Serializer/ItemNormalizerTest.php @@ -25,6 +25,7 @@ use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CircularReference; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; use Doctrine\Common\Collections\ArrayCollection; @@ -36,7 +37,6 @@ use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\SerializerInterface; /** @@ -102,33 +102,34 @@ public function testNormalize() $dummy->setName('hello'); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn(new PropertyNameCollection(['id', 'name', 'inherited', '\bad_property']))->shouldBeCalled(); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn(new PropertyNameCollection(['id', 'name', 'inherited', '\bad_property'])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', [])->willReturn(new PropertyMetadata(null, null, true))->shouldBeCalled(); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', [])->willReturn(new PropertyMetadata(null, null, true, null, null, null, null, true))->shouldBeCalled(); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'inherited', [])->willReturn(new PropertyMetadata(null, null, true, null, null, null, null, null, null, 'foo'))->shouldBeCalled(); - $propertyMetadataFactoryProphecy->create(Dummy::class, '\bad_property', [])->willReturn(new PropertyMetadata(null, null, true))->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', [])->willReturn(new PropertyMetadata(null, null, true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', [])->willReturn(new PropertyMetadata(null, null, true, null, null, null, null, true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'inherited', [])->willReturn(new PropertyMetadata(null, null, true, null, null, null, null, null, null, 'foo')); + $propertyMetadataFactoryProphecy->create(Dummy::class, '\bad_property', [])->willReturn(new PropertyMetadata(null, null, true)); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($dummy)->willReturn('/dummies/10'); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass($dummy, null, true)->willReturn(Dummy::class)->shouldBeCalled(); + $resourceClassResolverProphecy->getResourceClass($dummy, null, true)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, Dummy::class, true)->willReturn(Dummy::class); $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); - $propertyAccessorProphecy->getValue($dummy, 'id')->willReturn(10)->shouldBeCalled(); - $propertyAccessorProphecy->getValue($dummy, 'name')->willReturn('hello')->shouldBeCalled(); - $propertyAccessorProphecy->getValue($dummy, 'inherited')->willThrow(new NoSuchPropertyException())->shouldBeCalled(); + $propertyAccessorProphecy->getValue($dummy, 'id')->willReturn(10); + $propertyAccessorProphecy->getValue($dummy, 'name')->willReturn('hello'); + $propertyAccessorProphecy->getValue($dummy, 'inherited')->willThrow(new NoSuchPropertyException()); $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata('Dummy', 'A dummy', '/dummy', null, null, ['id', 'name']))->shouldBeCalled(); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata('Dummy', 'A dummy', '/dummy', null, null, ['id', 'name'])); $serializerProphecy = $this->prophesize(SerializerInterface::class); $serializerProphecy->willImplement(NormalizerInterface::class); - $serializerProphecy->normalize('hello', null, Argument::type('array'))->willReturn('hello')->shouldBeCalled(); - $serializerProphecy->normalize(10, null, Argument::type('array'))->willReturn(10)->shouldBeCalled(); - $serializerProphecy->normalize(null, null, Argument::type('array'))->willReturn(null)->shouldBeCalled(); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getIriFromItem($dummy)->willReturn('/dummies/10')->shouldBeCalled(); + $serializerProphecy->normalize('hello', ItemNormalizer::FORMAT, Argument::type('array'))->willReturn('hello'); + $serializerProphecy->normalize(10, ItemNormalizer::FORMAT, Argument::type('array'))->willReturn(10); + $serializerProphecy->normalize(null, ItemNormalizer::FORMAT, Argument::type('array'))->willReturn(null); $normalizer = new ItemNormalizer( $propertyNameCollectionFactoryProphecy->reveal(), @@ -139,112 +140,114 @@ public function testNormalize() new ReservedAttributeNameConverter(), $resourceMetadataFactoryProphecy->reveal(), [], - [], - false + [] ); $normalizer->setSerializer($serializerProphecy->reveal()); - $this->assertEquals( - [ - 'data' => [ - 'type' => 'Dummy', - 'id' => '/dummies/10', - 'attributes' => [ - '_id' => 10, - 'name' => 'hello', - 'inherited' => null, - ], + $expected = [ + 'data' => [ + 'type' => 'Dummy', + 'id' => '/dummies/10', + 'attributes' => [ + '_id' => 10, + 'name' => 'hello', + 'inherited' => null, ], ], - $normalizer->normalize($dummy) - ); + ]; + + $this->assertEquals($expected, $normalizer->normalize($dummy, ItemNormalizer::FORMAT)); } - public function testNormalizeIsNotAnArray() + public function testNormalizeCircularReference() { - $object = new \stdClass(); - $object->object = $object; + $circularReferenceEntity = new CircularReference(); + $circularReferenceEntity->id = 1; + $circularReferenceEntity->parent = $circularReferenceEntity; + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($circularReferenceEntity)->willReturn('/circular_references/1'); $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); + $resourceClassResolverProphecy->getResourceClass($circularReferenceEntity, null, true)->willReturn(CircularReference::class); + $resourceClassResolverProphecy->getResourceClass($circularReferenceEntity, CircularReference::class, true)->willReturn(CircularReference::class); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(CircularReference::class)->willReturn(new ResourceMetadata('CircularReference')); $normalizer = new ItemNormalizer( $this->prophesize(PropertyNameCollectionFactoryInterface::class)->reveal(), $this->prophesize(PropertyMetadataFactoryInterface::class)->reveal(), - $this->prophesize(IriConverterInterface::class)->reveal(), + $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $this->prophesize(PropertyAccessorInterface::class)->reveal(), new ReservedAttributeNameConverter(), - $resourceMetadataFactory->reveal(), - [], + $resourceMetadataFactoryProphecy->reveal(), [], - false + [] ); - $normalizer->setSerializer(new Serializer([$normalizer])); + $normalizer->setSerializer($this->prophesize(SerializerInterface::class)->reveal()); $circularReferenceLimit = 2; - $circularReferenceHandler = function () { - return 'object'; - }; if (interface_exists(AdvancedNameConverterInterface::class)) { $context = [ 'circular_reference_limit' => $circularReferenceLimit, - 'circular_reference_handler' => $circularReferenceHandler, - 'circular_reference_limit_counters' => [spl_object_hash($object) => 2], + 'circular_reference_limit_counters' => [spl_object_hash($circularReferenceEntity) => 2], 'cache_error' => function () {}, ]; } else { $normalizer->setCircularReferenceLimit($circularReferenceLimit); - $normalizer->setCircularReferenceHandler($circularReferenceHandler); $context = [ - 'circular_reference_limit' => [spl_object_hash($object) => 2], + 'circular_reference_limit' => [spl_object_hash($circularReferenceEntity) => 2], 'cache_error' => function () {}, ]; } - $this->assertEquals('object', $normalizer->normalize($object, ItemNormalizer::FORMAT, $context)); + $this->assertEquals('/circular_references/1', $normalizer->normalize($circularReferenceEntity, ItemNormalizer::FORMAT, $context)); } - public function testNormalizeThrowsNoSuchPropertyException() + public function testNormalizeNonExistentProperty() { $this->expectException(NoSuchPropertyException::class); - $foo = new \stdClass(); + $dummy = new Dummy(); + $dummy->setId(1); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(\stdClass::class, [])->willReturn(new PropertyNameCollection(['bar']))->shouldBeCalled(); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn(new PropertyNameCollection(['bar'])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(\stdClass::class, 'bar', [])->willReturn(new PropertyMetadata(null, null, true))->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'bar', [])->willReturn(new PropertyMetadata(null, null, true)); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($dummy)->willReturn('/dummies/1'); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass($foo, null, true)->willReturn(\stdClass::class)->shouldBeCalled(); + $resourceClassResolverProphecy->getResourceClass($dummy, null, true)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, Dummy::class, true)->willReturn(Dummy::class); $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); - $propertyAccessorProphecy->getValue($foo, 'bar')->willThrow(new NoSuchPropertyException()); + $propertyAccessorProphecy->getValue($dummy, 'bar')->willThrow(new NoSuchPropertyException()); - $resourceMetadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactory->create(\stdClass::class)->willThrow(ResourceClassNotFoundException::class); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata('Dummy')); $normalizer = new ItemNormalizer( $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), - $this->prophesize(IriConverterInterface::class)->reveal(), + $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), new ReservedAttributeNameConverter(), - $resourceMetadataFactory->reveal(), - [], + $resourceMetadataFactoryProphecy->reveal(), [], - false + [] ); - $normalizer->normalize($foo, ItemNormalizer::FORMAT); + $normalizer->normalize($dummy, ItemNormalizer::FORMAT); } public function testDenormalize() @@ -325,8 +328,7 @@ public function testDenormalize() new ReservedAttributeNameConverter(), $resourceMetadataFactory->reveal(), [], - [], - false + [] ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -378,8 +380,7 @@ public function testDenormalizeUpdateOperationNotAllowed() null, $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), [], - [], - false + [] ); $normalizer->denormalize( @@ -435,8 +436,7 @@ public function testDenormalizeCollectionIsNotArray() new ReservedAttributeNameConverter(), $resourceMetadataFactory->reveal(), [], - [], - false + [] ); $normalizer->denormalize( @@ -493,8 +493,7 @@ public function testDenormalizeCollectionWithInvalidKey() new ReservedAttributeNameConverter(), $resourceMetadataFactory->reveal(), [], - [], - false + [] ); $normalizer->denormalize( @@ -553,8 +552,7 @@ public function testDenormalizeRelationIsNotResourceLinkage() new ReservedAttributeNameConverter(), $resourceMetadataFactory->reveal(), [], - [], - false + [] ); $normalizer->denormalize( diff --git a/tests/Hydra/Serializer/ItemNormalizerTest.php b/tests/JsonLd/Serializer/ItemNormalizerTest.php similarity index 98% rename from tests/Hydra/Serializer/ItemNormalizerTest.php rename to tests/JsonLd/Serializer/ItemNormalizerTest.php index e8953638811..fb5b1bf984f 100644 --- a/tests/Hydra/Serializer/ItemNormalizerTest.php +++ b/tests/JsonLd/Serializer/ItemNormalizerTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Core\Tests\Hydra\Serializer; +namespace ApiPlatform\Core\Tests\JsonLd\Serializer; use ApiPlatform\Core\Api\IriConverterInterface; use ApiPlatform\Core\Api\ResourceClassResolverInterface; @@ -127,8 +127,7 @@ public function testNormalize() null, null, [], - [], - false + [] ); $normalizer->setSerializer($serializerProphecy->reveal()); diff --git a/tests/Serializer/AbstractItemNormalizerTest.php b/tests/Serializer/AbstractItemNormalizerTest.php index e38287c9df2..f38d04bdd18 100644 --- a/tests/Serializer/AbstractItemNormalizerTest.php +++ b/tests/Serializer/AbstractItemNormalizerTest.php @@ -388,7 +388,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(), null, null, null, null, false, [], [], null, false) 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) extends AbstractItemNormalizer { }; /** @var DummyForAdditionalFieldsInput $res */ diff --git a/tests/Serializer/ItemNormalizerTest.php b/tests/Serializer/ItemNormalizerTest.php index d46a0712c56..9aa8b4e3efe 100644 --- a/tests/Serializer/ItemNormalizerTest.php +++ b/tests/Serializer/ItemNormalizerTest.php @@ -105,8 +105,7 @@ public function testNormalize() false, null, [], - null, - false + null ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -144,8 +143,7 @@ public function testDenormalize() false, null, [], - null, - false + null ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -184,8 +182,7 @@ public function testDenormalizeWithIri() false, null, [], - null, - false + null ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -222,8 +219,7 @@ public function testDenormalizeWithIdAndUpdateNotAllowed() false, null, [], - null, - false + null ); $normalizer->setSerializer($serializerProphecy->reveal()); $normalizer->denormalize(['id' => '12', 'name' => 'hello'], Dummy::class, null, $context); @@ -261,8 +257,7 @@ public function testDenormalizeWithIdAndNoResourceClass() false, null, [], - null, - true + null ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -307,8 +302,7 @@ public function testNormalizeWithDataTransformers() false, null, [$dataTransformer->reveal()], - null, - false + null ); $normalizer->setSerializer($serializerProphecy->reveal()); From 521fa980613958d587cb4e010a86a144dc6ec79e Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 4 Apr 2019 10:19:22 +0200 Subject: [PATCH 2/2] Add a regression test for api-platform/api-platform#1085 --- features/jsonapi/non_resource.feature | 38 +++++++++++++ features/jsonld/non_resource.feature | 29 ++++++++++ .../TestBundle/Entity/PlainObjectDummy.php | 56 +++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 tests/Fixtures/TestBundle/Entity/PlainObjectDummy.php diff --git a/features/jsonapi/non_resource.feature b/features/jsonapi/non_resource.feature index b4654ac0f0e..493e83efcc0 100644 --- a/features/jsonapi/non_resource.feature +++ b/features/jsonapi/non_resource.feature @@ -86,3 +86,41 @@ Feature: JSON API non-resource handling } } """ + + @!mongodb + @createSchema + Scenario: Create a resource that contains a stdClass object. + When I send a "POST" request to "/plain_object_dummies" with body: + """ + { + "data": { + "type": "PlainObjectDummy", + "attributes": { + "content":"{\"fields\":{\"title\":{\"value\":\"\"},\"images\":[{\"id\":0,\"categoryId\":0,\"uri\":\"/api/pictures\",\"resource\":\"{}\",\"description\":\"\",\"alt\":\"\",\"type\":\"picture\",\"text\":\"\",\"src\":\"\"}],\"alternativeAudio\":{},\"caption\":\"\"},\"showCaption\":false,\"alternativeContent\":false,\"alternativeAudioContent\":false,\"blockLayout\":\"default\"}" + } + } + } + """ + 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/vnd.api+json; charset=utf-8" + And the JSON should be valid according to the JSON API schema + And the JSON should be a superset of: + """ + { + "data": { + "id": "/plain_object_dummies/1", + "type": "PlainObjectDummy", + "attributes": { + "_id": 1, + "data": { + "fields": [], + "showCaption": false, + "alternativeContent": false, + "alternativeAudioContent": false, + "blockLayout": "default" + } + } + } + } + """ diff --git a/features/jsonld/non_resource.feature b/features/jsonld/non_resource.feature index e967e44f3e3..c47d5a2b826 100644 --- a/features/jsonld/non_resource.feature +++ b/features/jsonld/non_resource.feature @@ -62,3 +62,32 @@ Feature: JSON-LD non-resource handling "id": 1 } """ + + @!mongodb + @createSchema + Scenario: Create a resource that contains a stdClass object. + When I send a "POST" request to "/plain_object_dummies" with body: + """ + { + "content": "{\"fields\":{\"title\":{\"value\":\"\"},\"images\":[{\"id\":0,\"categoryId\":0,\"uri\":\"/api/pictures\",\"resource\":\"{}\",\"description\":\"\",\"alt\":\"\",\"type\":\"picture\",\"text\":\"\",\"src\":\"\"}],\"alternativeAudio\":{},\"caption\":\"\"},\"showCaption\":false,\"alternativeContent\":false,\"alternativeAudioContent\":false,\"blockLayout\":\"default\"}" + } + """ + 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/PlainObjectDummy", + "@id": "/plain_object_dummies/1", + "@type": "PlainObjectDummy", + "data": { + "fields": [], + "showCaption": false, + "alternativeContent": false, + "alternativeAudioContent": false, + "blockLayout": "default" + }, + "id": 1 + } + """ diff --git a/tests/Fixtures/TestBundle/Entity/PlainObjectDummy.php b/tests/Fixtures/TestBundle/Entity/PlainObjectDummy.php new file mode 100644 index 00000000000..92c4767b567 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/PlainObjectDummy.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +/** + * Regression test for https://github.com/api-platform/api-platform/issues/1085. + * + * @author Antoine Bluchet + * + * @ApiResource + * @ORM\Entity + */ +class PlainObjectDummy +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue(strategy="AUTO") + */ + private $id; + + /** + * @var string + */ + private $content; + + /** + * @var array + */ + public $data; + + public function setContent($content) + { + $this->content = $content; + $this->data = (array) json_decode($content); + } + + public function getId() + { + return $this->id; + } +}