diff --git a/.travis.yml b/.travis.yml index 3a243720bc2..68be1b10add 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,7 +18,9 @@ matrix: before_install: - phpenv config-rm xdebug.ini || echo "xdebug not available" - echo "memory_limit=-1" >> ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini + - rm -rf ~/.nvm && git clone https://github.com/creationix/nvm.git ~/.nvm && (cd ~/.nvm && git checkout `git describe --abbrev=0 --tags`) && source ~/.nvm/nvm.sh && nvm install 6.10.2 - npm install -g swagger-cli + - npm install -g jsonapi-validator - if [[ $coverage = 1 ]]; then mkdir -p build/logs build/cov; fi - if [[ $coverage = 1 ]]; then wget https://phar.phpunit.de/phpcov.phar; fi - if [[ $coverage = 1 ]]; then wget https://github.com/satooshi/php-coveralls/releases/download/v1.0.1/coveralls.phar; fi diff --git a/appveyor.yml b/appveyor.yml index 3696bbe084c..48f2a89597e 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -2,6 +2,9 @@ build: false platform: x86 clone_folder: c:\projects\api-platform\core +environment: + nodejs_version: "6" + cache: - '%LOCALAPPDATA%\Composer\files' @@ -9,6 +12,8 @@ init: - SET PATH=c:\tools\php71;%PATH% install: + - ps: Install-Product node $env:nodejs_version + - npm install -g jsonapi-validator - ps: Set-Service wuauserv -StartupType Manual - cinst -y php - cd c:\tools\php71 diff --git a/behat.yml.dist b/behat.yml.dist index 6f2076fbfb1..eda8834f03f 100644 --- a/behat.yml.dist +++ b/behat.yml.dist @@ -5,6 +5,7 @@ default: - 'FeatureContext': { doctrine: '@doctrine' } - 'HydraContext' - 'SwaggerContext' + - 'JsonApiContext': { doctrine: '@doctrine' } - 'Behat\MinkExtension\Context\MinkContext' - 'Behatch\Context\RestContext' - 'Behatch\Context\JsonContext' diff --git a/features/bootstrap/JsonApiContext.php b/features/bootstrap/JsonApiContext.php new file mode 100644 index 00000000000..514f94e219d --- /dev/null +++ b/features/bootstrap/JsonApiContext.php @@ -0,0 +1,188 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyFriend; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use Behat\Behat\Context\Context; +use Behat\Behat\Context\Environment\InitializedContextEnvironment; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use Behatch\Context\RestContext; +use Behatch\Json\Json; +use Behatch\Json\JsonInspector; +use Doctrine\Common\Persistence\ManagerRegistry; + +final class JsonApiContext implements Context +{ + private $restContext; + + private $inspector; + + private $doctrine; + + /** + * @var \Doctrine\Common\Persistence\ObjectManager + */ + private $manager; + + public function __construct(ManagerRegistry $doctrine) + { + $this->doctrine = $doctrine; + $this->manager = $doctrine->getManager(); + } + + /** + * Gives access to the Behatch context. + * + * @param BeforeScenarioScope $scope + * + * @BeforeScenario + */ + public function gatherContexts(BeforeScenarioScope $scope) + { + /** @var InitializedContextEnvironment $environment */ + $environment = $scope->getEnvironment(); + + $this->restContext = $environment->getContext(RestContext::class); + + $this->inspector = new JsonInspector('javascript'); + } + + /** + * @Then I save the response + */ + public function iSaveTheResponse() + { + $content = $this->getContent(); + + if (null === ($decoded = json_decode($content))) { + throw new \RuntimeException('JSON response seems to be invalid'); + } + + $fileName = __DIR__.'/response.json'; + + file_put_contents($fileName, $content); + + return $fileName; + } + + /** + * @Then I validate it with jsonapi-validator + */ + public function iValidateItWithJsonapiValidator() + { + $fileName = $this->iSaveTheResponse(); + + $validationResponse = exec(sprintf('cd %s && jsonapi-validator -f response.json', __DIR__)); + + $isValidJsonapi = 'response.json is valid JSON API.' === $validationResponse; + + unlink($fileName); + + if (!$isValidJsonapi) { + throw new \RuntimeException('JSON response seems to be invalid JSON API'); + } + } + + /** + * Checks that given JSON node is equal to an empty array. + * + * @Then the JSON node :node should be an empty array + */ + public function theJsonNodeShouldBeAnEmptyArray($node) + { + $actual = $this->getValueOfNode($node); + + if (!is_array($actual) || !empty($actual)) { + throw new \Exception( + sprintf("The node value is '%s'", json_encode($actual)) + ); + } + } + + /** + * Checks that given JSON node is a number. + * + * @Then the JSON node :node should be a number + */ + public function theJsonNodeShouldBeANumber($node) + { + $actual = $this->getValueOfNode($node); + + if (!is_numeric($actual)) { + throw new \Exception( + sprintf('The node value is `%s`', json_encode($actual)) + ); + } + } + + /** + * Checks that given JSON node is not an empty string. + * + * @Then the JSON node :node should not be an empty string + */ + public function theJsonNodeShouldNotBeAnEmptyString($node) + { + $actual = $this->getValueOfNode($node); + + if ($actual === '') { + throw new \Exception( + sprintf('The node value is `%s`', json_encode($actual)) + ); + } + } + + private function getValueOfNode($node) + { + $json = $this->getJson(); + + return $this->inspector->evaluate($json, $node); + } + + private function getJson() + { + return new Json($this->getContent()); + } + + private function getContent() + { + return $this->restContext->getMink()->getSession()->getDriver()->getContent(); + } + + /** + * @Given there is a RelatedDummy + */ + public function thereIsARelatedDummy() + { + $relatedDummy = new RelatedDummy(); + + $relatedDummy->setName('RelatedDummy with friends'); + + $this->manager->persist($relatedDummy); + + $this->manager->flush(); + } + + /** + * @Given there is a DummyFriend + */ + public function thereIsADummyFriend() + { + $friend = new DummyFriend(); + + $friend->setName('DummyFriend'); + + $this->manager->persist($friend); + + $this->manager->flush(); + } +} diff --git a/features/doctrine/date_filter.feature b/features/doctrine/date_filter.feature index 161ad385535..2c797e9a85d 100644 --- a/features/doctrine/date_filter.feature +++ b/features/doctrine/date_filter.feature @@ -407,7 +407,7 @@ Feature: Date filter on collections }, "hydra:search": { "@type": "hydra:IriTemplate", - "hydra:template": "\/dummies{?id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,relatedDummies.name,order[id],order[name],order[relatedDummy.symfony],dummyDate[before],dummyDate[after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[after],dummyFloat[between],dummyFloat[gt],dummyFloat[gte],dummyFloat[lt],dummyFloat[lte],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],dummyBoolean,dummyFloat,dummyPrice,description[exists],relatedDummy.name[exists],dummyBoolean[exists]}", + "hydra:template": "\/dummies{?id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,relatedDummies.name,order[id],order[name],order[description],order[relatedDummy.symfony],dummyDate[before],dummyDate[after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[after],dummyFloat[between],dummyFloat[gt],dummyFloat[gte],dummyFloat[lt],dummyFloat[lte],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],dummyBoolean,dummyFloat,dummyPrice,description[exists],relatedDummy.name[exists],dummyBoolean[exists]}", "hydra:variableRepresentation": "BasicRepresentation", "hydra:mapping": [ { @@ -488,6 +488,12 @@ Feature: Date filter on collections "property": "name", "required": false }, + { + "@type": "IriTemplateMapping", + "variable": "order[description]", + "property": "description", + "required": false + }, { "@type": "IriTemplateMapping", "variable": "order[relatedDummy.symfony]", diff --git a/features/integration/nelmio_api_doc.feature b/features/integration/nelmio_api_doc.feature index d5fb3aa9b29..6ca7080222b 100644 --- a/features/integration/nelmio_api_doc.feature +++ b/features/integration/nelmio_api_doc.feature @@ -5,11 +5,12 @@ Feature: NelmioApiDoc integration Scenario: Create a user When I send a "GET" request to "/nelmioapidoc" - Then the response status code should be 200 + And the response status code should be 200 And I should see text matching "AbstractDummy" And I should see text matching "Dummy" And I should see text matching "User" And I should see text matching "Retrieves the collection of Dummy resources." And I should see text matching "Creates a Dummy resource." And I should see text matching "Deletes the Dummy resource." + And I should see text matching "Updates the Dummy resource." And I should see text matching "Replaces the Dummy resource." diff --git a/features/jsonapi/collections.feature b/features/jsonapi/collections.feature new file mode 100644 index 00000000000..90849c62071 --- /dev/null +++ b/features/jsonapi/collections.feature @@ -0,0 +1,79 @@ +Feature: JSON API collections support + In order to use the JSON API hypermedia format + As a client software developer + I need to be able to retrieve valid JSON API responses for collection attributes on entities. + + @createSchema + @dropSchema + Scenario: Correctly serialize a collection + When I add "Accept" header equal to "application/vnd.api+json" + And I add "Content-Type" header equal to "application/vnd.api+json" + And I send a "POST" request to "/circular_references" with body: + """ + { + "data": {} + } + """ + And I validate it with jsonapi-validator + And I send a "PATCH" request to "/circular_references/1" with body: + """ + { + "data": { + "relationships": { + "parent": { + "data": { + "type": "CircularReference", + "id": "1" + } + } + } + } + } + """ + And I validate it with jsonapi-validator + And I send a "POST" request to "/circular_references" with body: + """ + { + "data": { + "relationships": { + "parent": { + "data": { + "type": "CircularReference", + "id": "1" + } + } + } + } + } + """ + And I validate it with jsonapi-validator + And I send a "GET" request to "/circular_references/1" + And the JSON should be equal to: + """ + { + "data": { + "id": "1", + "type": "CircularReference", + "relationships": { + "parent": { + "data": { + "type": "CircularReference", + "id": "1" + } + }, + "children": { + "data": [ + { + "type": "CircularReference", + "id": "1" + }, + { + "type": "CircularReference", + "id": "2" + } + ] + } + } + } + } + """ diff --git a/features/jsonapi/errors.feature b/features/jsonapi/errors.feature new file mode 100644 index 00000000000..204ca30da29 --- /dev/null +++ b/features/jsonapi/errors.feature @@ -0,0 +1,74 @@ +Feature: JSON API error handling + In order to be able to handle error client side + As a client software developer + I need to retrieve an JSON API serialization of errors + + @createSchema + Scenario: Get a validation error on an attribute + When I add "Content-Type" header equal to "application/vnd.api+json" + And I add "Accept" header equal to "application/vnd.api+json" + And I send a "POST" request to "/dummies" with body: + """ + { + "data": { + "type": "dummy", + "attributes": {} + } + } + """ + Then the response status code should be 400 + And print last JSON response + And I validate it with jsonapi-validator + And the JSON should be equal to: + """ + { + "errors": [ + { + "detail": "This value should not be blank.", + "source": { + "pointer": "data\/attributes\/name" + } + } + ] + } + """ + + @dropSchema + Scenario: Get a validation error on an relationship + Given there is a RelatedDummy + And there is a DummyFriend + When I add "Content-Type" header equal to "application/vnd.api+json" + And I add "Accept" header equal to "application/vnd.api+json" + And I send a "POST" request to "/related_to_dummy_friends" with body: + """ + { + "data": { + "type": "RelatedToDummyFriend", + "attributes": { + "name": "Related to dummy friend" + } + } + } + """ + And print last JSON response + Then the response status code should be 400 + And I validate it with jsonapi-validator + And the JSON should be equal to: + """ + { + "errors": [ + { + "detail": "This value should not be null.", + "source": { + "pointer": "data\/relationships\/dummyFriend" + } + }, + { + "detail": "This value should not be null.", + "source": { + "pointer": "data\/relationships\/relatedDummy" + } + } + ] + } + """ diff --git a/features/jsonapi/filtering.feature b/features/jsonapi/filtering.feature new file mode 100644 index 00000000000..007cd126593 --- /dev/null +++ b/features/jsonapi/filtering.feature @@ -0,0 +1,25 @@ +Feature: JSON API filter handling + In order to be able to handle filtering + As a client software developer + I need to be able to specify filtering parameters according to JSON API recomendation + + @createSchema + Scenario: Apply filters based on the 'filter' query parameter + Given there is "30" dummy objects with dummyDate + And I add "Accept" header equal to "application/vnd.api+json" + When I send a "GET" request to "/dummies?filter[name]=my" + Then the response status code should be 200 + And I validate it with jsonapi-validator + And the JSON node "data" should have 3 elements + When I send a "GET" request to "/dummies?filter[name]=foo" + Then the response status code should be 200 + And I validate it with jsonapi-validator + And the JSON node "data" should have 0 elements + + @dropSchema + Scenario: Apply filters based on the 'filter' query parameter with second level arguments + Given I add "Accept" header equal to "application/vnd.api+json" + When I send a "GET" request to "/dummies?filter[dummyDate][after]=2015-04-28" + Then the response status code should be 200 + And I validate it with jsonapi-validator + And the JSON node "data" should have 2 elements diff --git a/features/jsonapi/jsonapi.feature b/features/jsonapi/jsonapi.feature new file mode 100644 index 00000000000..cf8551e3ce6 --- /dev/null +++ b/features/jsonapi/jsonapi.feature @@ -0,0 +1,187 @@ +Feature: JSON API basic support + In order to use the JSON API hypermedia format + As a client software developer + I need to be able to retrieve valid JSON API responses. + + @createSchema + Scenario: Retrieve the API entrypoint + When I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" + And the JSON node "links.self" should be equal to "/" + And the JSON node "links.dummy" should be equal to "/dummies" + + Scenario: Test empty list against jsonapi-validator + When I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/dummies" + Then the response status code should be 200 + And print last JSON response + And I validate it with jsonapi-validator + And the JSON node "data" should be an empty array + + Scenario: Create a ThirdLevel + When I add "Content-Type" header equal to "application/vnd.api+json" + And I add "Accept" header equal to "application/vnd.api+json" + And I send a "POST" request to "/third_levels" with body: + """ + { + "data": { + "type": "third-level", + "attributes": { + "level": 3 + } + } + } + """ + Then the response status code should be 201 + Then print last response headers + And print last JSON response + And I validate it with jsonapi-validator + And the JSON node "data.id" should not be an empty string + + Scenario: Retrieve the collection + When I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/third_levels" + And I validate it with jsonapi-validator + And print last JSON response + + Scenario: Retrieve the third level + When I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/third_levels/1" + And I validate it with jsonapi-validator + And print last JSON response + + Scenario: Create a related dummy + When I add "Content-Type" header equal to "application/vnd.api+json" + And I add "Accept" header equal to "application/vnd.api+json" + And I send a "POST" request to "/related_dummies" with body: + """ + { + "data": { + "type": "related-dummy", + "attributes": { + "name": "John Doe", + "age": 23 + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "third-level", + "id": "1" + } + } + } + } + } + """ + Then print last JSON response + And I validate it with jsonapi-validator + And the JSON node "data.id" should not be an empty string + And the JSON node "data.attributes.name" should be equal to "John Doe" + And the JSON node "data.attributes.age" should be equal to the number 23 + + Scenario: Create a related dummy with en empty relationship + When I add "Content-Type" header equal to "application/vnd.api+json" + And I add "Accept" header equal to "application/vnd.api+json" + And I send a "POST" request to "/related_dummies" with body: + """ + { + "data": { + "type": "related-dummy", + "attributes": { + "name": "John Doe" + }, + "relationships": { + "thirdLevel": { + "data": null + } + } + } + } + """ + Then print last JSON response + And I validate it with jsonapi-validator + + Scenario: Retrieve a collection with relationships + When I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/related_dummies" + And I validate it with jsonapi-validator + And the JSON node "data[0].relationships.thirdLevel.data.id" should be equal to "1" + + Scenario: Retrieve the related dummy + When I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/related_dummies/1" + Then print last JSON response + And I validate it with jsonapi-validator + And the JSON should be equal to: + """ + { + "data": { + "id": "1", + "type": "RelatedDummy", + "attributes": { + "id": 1, + "name": "John Doe", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "age": 23 + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "1" + } + } + } + } + } + """ + + Scenario: Update a resource via PATCH + When I add "Accept" header equal to "application/vnd.api+json" + When I add "Content-Type" header equal to "application/vnd.api+json" + And I send a "PATCH" request to "/related_dummies/1" with body: + """ + { + "data": { + "type": "related-dummy", + "attributes": { + "name": "Jane Doe" + } + } + } + """ + Then print last JSON response + And I validate it with jsonapi-validator + And the JSON node "data.id" should not be an empty string + And the JSON node "data.attributes.name" should be equal to "Jane Doe" + And the JSON node "data.attributes.age" should be equal to the number 23 + + @dropSchema + Scenario: Embed a relation in a parent object + When I add "Accept" header equal to "application/vnd.api+json" + When I add "Content-Type" header equal to "application/vnd.api+json" + And I send a "POST" request to "/relation_embedders" with body: + """ + { + "data": { + "relationships": { + "related": { + "data": { + "type": "related-dummy", + "id": "1" + } + } + } + } + } + """ + Then the response status code should be 201 + And I validate it with jsonapi-validator + And the JSON node "data.id" should not be an empty string + And the JSON node "data.attributes.krondstadt" should be equal to "Krondstadt" + And the JSON node "data.relationships.related.data.id" should be equal to "1" diff --git a/features/jsonapi/ordering.feature b/features/jsonapi/ordering.feature new file mode 100644 index 00000000000..8244efa9b2c --- /dev/null +++ b/features/jsonapi/ordering.feature @@ -0,0 +1,141 @@ +Feature: JSON API order handling + In order to be able to handle ordering + As a client software developer + I need to be able to specify ordering parameters according to JSON API recomendation + + @createSchema + Scenario: Get collection ordered in ascending or descending order on an integer property and on which order filter has been enabled in whitelist mode + Given there is "30" dummy objects + And I add "Accept" header equal to "application/vnd.api+json" + When I send a "GET" request to "/dummies?order=id" + Then the response status code should be 200 + And I validate it with jsonapi-validator + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^1$" + } + } + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^2$" + } + } + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^3$" + } + } + } + ] + } + } + } + """ + And I send a "GET" request to "/dummies?order=-id" + Then the response status code should be 200 + And I validate it with jsonapi-validator + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^30$" + } + } + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^29$" + } + } + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^28$" + } + } + } + ] + } + } + } + """ + + @dropSchema + Scenario: Get collection ordered on two properties previously whitelisted + Given I add "Accept" header equal to "application/vnd.api+json" + When I send a "GET" request to "/dummies?order=description,-id" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^30$" + } + } + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^28$" + } + } + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^26$" + } + } + } + ] + } + } + } + """ + diff --git a/features/jsonapi/pagination.feature b/features/jsonapi/pagination.feature new file mode 100644 index 00000000000..5d288c0240f --- /dev/null +++ b/features/jsonapi/pagination.feature @@ -0,0 +1,31 @@ +Feature: JSON API pagination handling + In order to be able to handle pagination + As a client software developer + I need to retrieve an JSON API pagination information as metadata and links + + @createSchema + Scenario: Get a paginated collection according to basic config + Given there is "10" dummy objects + And I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/dummies" + Then the response status code should be 200 + And I validate it with jsonapi-validator + And the JSON node "data" should have 3 elements + And the JSON node "meta.totalItems" should be equal to the number 10 + And the JSON node "meta.itemsPerPage" should be equal to the number 3 + And the JSON node "meta.currentPage" should be equal to the number 1 + And I send a "GET" request to "/dummies?page=4" + And I validate it with jsonapi-validator + And the JSON node "data" should have 1 elements + And the JSON node "meta.currentPage" should be equal to the number 4 + + @dropSchema + Scenario: Get a paginated collection according to custom items per page in request + And I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/dummies?itemsPerPage=15" + Then the response status code should be 200 + And I validate it with jsonapi-validator + And the JSON node "data" should have 10 elements + And the JSON node "meta.totalItems" should be equal to the number 10 + And the JSON node "meta.itemsPerPage" should be equal to the number 15 + And the JSON node "meta.currentPage" should be equal to the number 1 diff --git a/features/main/crud.feature b/features/main/crud.feature index bacbfbcca9f..1f16461429e 100644 --- a/features/main/crud.feature +++ b/features/main/crud.feature @@ -123,7 +123,7 @@ Feature: Create-Retrieve-Update-Delete "hydra:totalItems": 1, "hydra:search": { "@type": "hydra:IriTemplate", - "hydra:template": "/dummies{?id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,relatedDummies.name,order[id],order[name],order[relatedDummy.symfony],dummyDate[before],dummyDate[after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[after],dummyFloat[between],dummyFloat[gt],dummyFloat[gte],dummyFloat[lt],dummyFloat[lte],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],dummyBoolean,dummyFloat,dummyPrice,description[exists],relatedDummy.name[exists],dummyBoolean[exists]}", + "hydra:template": "/dummies{?id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,relatedDummies.name,order[id],order[name],order[description],order[relatedDummy.symfony],dummyDate[before],dummyDate[after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[after],dummyFloat[between],dummyFloat[gt],dummyFloat[gte],dummyFloat[lt],dummyFloat[lte],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],dummyBoolean,dummyFloat,dummyPrice,description[exists],relatedDummy.name[exists],dummyBoolean[exists]}", "hydra:variableRepresentation": "BasicRepresentation", "hydra:mapping": [ { @@ -204,6 +204,12 @@ Feature: Create-Retrieve-Update-Delete "property": "name", "required": false }, + { + "@type": "IriTemplateMapping", + "variable": "order[description]", + "property": "description", + "required": false + }, { "@type": "IriTemplateMapping", "variable": "order[relatedDummy.symfony]", diff --git a/features/main/custom_identifier.feature b/features/main/custom_identifier.feature index 116a1206af0..faf42991fcd 100644 --- a/features/main/custom_identifier.feature +++ b/features/main/custom_identifier.feature @@ -92,7 +92,7 @@ Feature: Using custom identifier on resource Then the response status code should be 200 And the response should be in JSON And the hydra class "CustomIdentifierDummy" exist - And 3 operations are available for hydra class "CustomIdentifierDummy" + And 4 operations are available for hydra class "CustomIdentifierDummy" And 1 properties are available for hydra class "CustomIdentifierDummy" And "name" property is readable for hydra class "CustomIdentifierDummy" And "name" property is writable for hydra class "CustomIdentifierDummy" diff --git a/features/main/custom_normalized.feature b/features/main/custom_normalized.feature index 8c40cd451f0..22b654dba2b 100644 --- a/features/main/custom_normalized.feature +++ b/features/main/custom_normalized.feature @@ -151,7 +151,7 @@ Feature: Using custom normalized entity Then the response status code should be 200 And the response should be in JSON And the hydra class "CustomNormalizedDummy" exist - And 3 operations are available for hydra class "CustomNormalizedDummy" + And 4 operations are available for hydra class "CustomNormalizedDummy" And 2 properties are available for hydra class "CustomNormalizedDummy" And "name" property is readable for hydra class "CustomNormalizedDummy" And "name" property is writable for hydra class "CustomNormalizedDummy" diff --git a/features/main/custom_writable_identifier.feature b/features/main/custom_writable_identifier.feature index e76b0e463f5..4e31c29ca01 100644 --- a/features/main/custom_writable_identifier.feature +++ b/features/main/custom_writable_identifier.feature @@ -94,7 +94,7 @@ Feature: Using custom writable identifier on resource Then the response status code should be 200 And the response should be in JSON And the hydra class "CustomWritableIdentifierDummy" exist - And 3 operations are available for hydra class "CustomWritableIdentifierDummy" + And 4 operations are available for hydra class "CustomWritableIdentifierDummy" And 2 properties are available for hydra class "CustomWritableIdentifierDummy" And "name" property is readable for hydra class "CustomWritableIdentifierDummy" And "name" property is writable for hydra class "CustomWritableIdentifierDummy" diff --git a/features/security/validate_incoming_content-types.feature b/features/security/validate_incoming_content-types.feature index d1ca21fd7c5..71d981c089f 100644 --- a/features/security/validate_incoming_content-types.feature +++ b/features/security/validate_incoming_content-types.feature @@ -13,4 +13,4 @@ Feature: Validate incoming content type """ Then the response status code should be 406 And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:description" should be equal to 'The content-type "text/plain" is not supported. Supported MIME types are "application/ld+json", "application/hal+json", "application/xml", "text/xml", "application/json", "text/html".' + And the JSON node "hydra:description" should be equal to 'The content-type "text/plain" is not supported. Supported MIME types are "application/ld+json", "application/hal+json", "application/vnd.api+json", "application/xml", "text/xml", "application/json", "text/html".' diff --git a/features/security/validate_response_types.feature b/features/security/validate_response_types.feature index 9f5573baa6b..2b858045d28 100644 --- a/features/security/validate_response_types.feature +++ b/features/security/validate_response_types.feature @@ -8,7 +8,7 @@ Feature: Validate response types And I send a "GET" request to "/dummies" Then the response status code should be 406 And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "detail" should be equal to 'Requested format "text/plain" is not supported. Supported MIME types are "application/ld+json", "application/hal+json", "application/xml", "text/xml", "application/json", "text/html".' + And the JSON node "detail" should be equal to 'Requested format "text/plain" is not supported. Supported MIME types are "application/ld+json", "application/hal+json", "application/vnd.api+json", "application/xml", "text/xml", "application/json", "text/html".' Scenario: Requesting a different format in the Accept header and in the URL should error When I add "Accept" header equal to "text/xml" @@ -22,7 +22,7 @@ Feature: Validate response types And I send a "GET" request to "/dummies/1" Then the response status code should be 406 And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "detail" should be equal to 'Requested format "invalid" is not supported. Supported MIME types are "application/ld+json", "application/hal+json", "application/xml", "text/xml", "application/json", "text/html".' + And the JSON node "detail" should be equal to 'Requested format "invalid" is not supported. Supported MIME types are "application/ld+json", "application/hal+json", "application/vnd.api+json", "application/xml", "text/xml", "application/json", "text/html".' Scenario: Requesting an invalid format in the URL should throw an error And I send a "GET" request to "/dummies/1.invalid" diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 43538246b6b..e5fad286cd3 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -89,6 +89,7 @@ public function load(array $configs, ContainerBuilder $container) $this->registerMetadataConfiguration($container, $loader, $bundles, $config['loader_paths']); $this->registerOAuthConfiguration($container, $config, $loader); $this->registerSwaggerConfiguration($container, $config, $loader); + $this->registerJsonApiConfiguration($formats, $loader); $this->registerJsonLdConfiguration($formats, $loader); $this->registerJsonHalConfiguration($formats, $loader); $this->registerJsonProblemConfiguration($errorFormats, $loader); @@ -283,6 +284,21 @@ private function registerJsonHalConfiguration(array $formats, XmlFileLoader $loa $loader->load('hal.xml'); } + /** + * Registers the JsonApi configuration. + * + * @param array $formats + * @param XmlFileLoader $loader + */ + private function registerJsonApiConfiguration(array $formats, XmlFileLoader $loader) + { + if (!isset($formats['jsonapi'])) { + return; + } + + $loader->load('jsonapi.xml'); + } + /** * Registers the JSON Problem configuration. * diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index 6ba8243a4d7..f978d9547f0 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -146,6 +146,7 @@ + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml b/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml new file mode 100644 index 00000000000..672dc368a06 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml @@ -0,0 +1,76 @@ + + + + + + + jsonapi + + + + + + + + + + + + + + + + + + %api_platform.collection.pagination.page_parameter_name% + + + + + + + + + + + + + + + + + + + + + + + + + + + %kernel.debug% + + + + + + + + + + + + %api_platform.collection.order_parameter_name% + + + + + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/public/init-swagger-ui.js b/src/Bridge/Symfony/Bundle/Resources/public/init-swagger-ui.js index bfd33e54fb4..809c7ddbab1 100644 --- a/src/Bridge/Symfony/Bundle/Resources/public/init-swagger-ui.js +++ b/src/Bridge/Symfony/Bundle/Resources/public/init-swagger-ui.js @@ -4,7 +4,7 @@ $(function () { url: data.url, spec: data.spec, dom_id: 'swagger-ui-container', - supportedSubmitMethods: ['get', 'post', 'put', 'delete'], + supportedSubmitMethods: ['get', 'post', 'put', 'patch', 'delete'], onComplete: function() { if (data.oauth.enabled && 'function' === typeof initOAuth) { initOAuth({ diff --git a/src/Hydra/Serializer/DocumentationNormalizer.php b/src/Hydra/Serializer/DocumentationNormalizer.php index 523fc5ad1ac..469052af2f3 100644 --- a/src/Hydra/Serializer/DocumentationNormalizer.php +++ b/src/Hydra/Serializer/DocumentationNormalizer.php @@ -255,6 +255,13 @@ private function getHydraOperation(string $resourceClass, ResourceMetadata $reso 'returns' => $prefixedShortName, 'expects' => $prefixedShortName, ] + $hydraOperation; + } elseif ('PATCH' === $method) { + $hydraOperation = [ + '@type' => 'hydra:Operation', + 'hydra:title' => "Updates the $shortName resource.", + 'returns' => $prefixedShortName, + 'expects' => $prefixedShortName, + ] + $hydraOperation; } elseif ('DELETE' === $method) { $hydraOperation = [ 'hydra:title' => "Deletes the $shortName resource.", diff --git a/src/JsonApi/EventListener/FlattenPaginationParametersListener.php b/src/JsonApi/EventListener/FlattenPaginationParametersListener.php new file mode 100644 index 00000000000..9ecb305b862 --- /dev/null +++ b/src/JsonApi/EventListener/FlattenPaginationParametersListener.php @@ -0,0 +1,63 @@ + + * + * 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\EventListener; + +use Symfony\Component\HttpKernel\Event\GetResponseEvent; + +/** + * Flattens possible 'page' array query parameter into dot-separated values to avoid + * conflicts with Doctrine\Orm\Extension\PaginationExtension. + * + * @see http://jsonapi.org/format/#fetching-pagination + * + * @author Héctor Hurtarte + */ +final class FlattenPaginationParametersListener +{ + /** + * Flatens possible 'page' array query parameter. + * + * @param GetResponseEvent $event + * + * @throws NotFoundHttpException + */ + public function onKernelRequest(GetResponseEvent $event) + { + $request = $event->getRequest(); + + // This applies only to jsonapi request format + if ('jsonapi' !== $request->getRequestFormat()) { + return; + } + + // If 'page' query parameter is not defined or is not an array, never mind + $page = $request->query->get('page'); + + if (null === $page || !is_array($page)) { + return; + } + + // Otherwise, flatten into dot-separated values + $pageParameters = $request->query->get('page'); + + foreach ($pageParameters as $pageParameterName => $pageParameterValue) { + $request->query->set( + sprintf('page.%s', $pageParameterName), + $pageParameterValue + ); + } + + $request->query->remove('page'); + } +} diff --git a/src/JsonApi/EventListener/TransformFilteringParametersListener.php b/src/JsonApi/EventListener/TransformFilteringParametersListener.php new file mode 100644 index 00000000000..0a44121632e --- /dev/null +++ b/src/JsonApi/EventListener/TransformFilteringParametersListener.php @@ -0,0 +1,63 @@ + + * + * 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\EventListener; + +use Symfony\Component\HttpKernel\Event\GetResponseEvent; + +/** + * Flattens possible 'filter' array query parameter into first-level query parameters + * to be processed by api-platform. + * + * @see http://jsonapi.org/format/#fetching-filtering and http://jsonapi.org/recommendations/#filtering + * + * @author Héctor Hurtarte + */ +final class TransformFilteringParametersListener +{ + /** + * Flatens possible 'page' array query parameter. + * + * @param GetResponseEvent $event + * + * @throws NotFoundHttpException + */ + public function onKernelRequest(GetResponseEvent $event) + { + $request = $event->getRequest(); + + // This applies only to jsonapi request format + if ('jsonapi' !== $request->getRequestFormat()) { + return; + } + + // If filter query parameter is not defined or is not an array, never mind + $filter = $request->query->get('filter'); + + if (null === $filter || !is_array($filter)) { + return; + } + + // Otherwise, flatten into dot-separated values + $pageParameters = $filter; + + foreach ($pageParameters as $pageParameterName => $pageParameterValue) { + $request->query->set( + $pageParameterName, + $pageParameterValue + ); + } + + $request->query->remove('filter'); + } +} diff --git a/src/JsonApi/EventListener/TransformSortingParametersListener.php b/src/JsonApi/EventListener/TransformSortingParametersListener.php new file mode 100644 index 00000000000..db0e9a3b1f3 --- /dev/null +++ b/src/JsonApi/EventListener/TransformSortingParametersListener.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\JsonApi\EventListener; + +use Symfony\Component\HttpKernel\Event\GetResponseEvent; + +/** + * Converts pagination parameters from JSON API recommended convention to + * api-platform convention. + * + * @see http://jsonapi.org/format/#fetching-sorting and + * https://api-platform.com/docs/core/filters#order-filter + * + * @author Héctor Hurtarte + */ +final class TransformSortingParametersListener +{ + private $orderParameterName; + + public function __construct(string $orderParameterName) + { + $this->orderParameterName = $orderParameterName; + } + + /** + * @param GetResponseEvent $event + * + * @throws NotFoundHttpException + */ + public function onKernelRequest(GetResponseEvent $event) + { + $request = $event->getRequest(); + + // This applies only to jsonapi request format + if ('jsonapi' !== $request->getRequestFormat()) { + return; + } + + // If order query parameter is not defined or is already an array, never mind + $orderParameter = $request->query->get($this->orderParameterName); + if (null === $orderParameter || is_array($orderParameter) + ) { + return; + } + + $orderParametersArray = explode(',', $orderParameter); + + $transformedOrderParametersArray = []; + foreach ($orderParametersArray as $orderParameter) { + $sorting = 'asc'; + + if ('-' === substr($orderParameter, 0, 1)) { + $sorting = 'desc'; + $orderParameter = substr($orderParameter, 1); + } + + $transformedOrderParametersArray[$orderParameter] = $sorting; + } + + $request->query->set($this->orderParameterName, $transformedOrderParametersArray); + } +} diff --git a/src/JsonApi/Serializer/CollectionNormalizer.php b/src/JsonApi/Serializer/CollectionNormalizer.php new file mode 100644 index 00000000000..0eef1b552dd --- /dev/null +++ b/src/JsonApi/Serializer/CollectionNormalizer.php @@ -0,0 +1,169 @@ + + * + * 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\ResourceClassResolverInterface; +use ApiPlatform\Core\DataProvider\PaginatorInterface; +use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Serializer\ContextTrait; +use ApiPlatform\Core\Util\IriHelper; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Normalizes collections in the JSON API format. + * + * @author Kevin Dunglas + * @author Hamza Amrouche + */ +final class CollectionNormalizer implements NormalizerInterface, NormalizerAwareInterface +{ + use ContextTrait; + use NormalizerAwareTrait; + + const FORMAT = 'jsonapi'; + + private $resourceClassResolver; + private $pageParameterName; + private $resourceMetadataFactory; + private $propertyMetadataFactory; + + public function __construct(ResourceClassResolverInterface $resourceClassResolver, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, string $pageParameterName) + { + $this->resourceClassResolver = $resourceClassResolver; + $this->pageParameterName = $pageParameterName; + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->propertyMetadataFactory = $propertyMetadataFactory; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null) + { + return self::FORMAT === $format && (is_array($data) || ($data instanceof \Traversable)); + } + + /** + * {@inheritdoc} + */ + public function normalize($data, $format = null, array $context = []) + { + $currentPage = $lastPage = $itemsPerPage = 1; + + // If we are normalizing stuff one level down (i.e., an attribute which + // could be already an array) + $returnDataArray = []; + if (isset($context['api_sub_level'])) { + foreach ($data as $index => $obj) { + $returnDataArray['data'][][$index] = $this->normalizer->normalize($obj, $format, $context); + } + + return $data; + } + + $resourceClass = $this->resourceClassResolver->getResourceClass( + $data, + $context['resource_class'] ?? null, + true + ); + + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + + $context = $this->initContext($resourceClass, $context); + + $parsed = IriHelper::parseIri($context['request_uri'] ?? '/', $this->pageParameterName); + $paginated = $isPaginator = $data instanceof PaginatorInterface; + + if ($isPaginator) { + $currentPage = $data->getCurrentPage(); + $lastPage = $data->getLastPage(); + $itemsPerPage = $data->getItemsPerPage(); + + $paginated = 1. !== $lastPage; + } + + $returnDataArray = [ + 'data' => [], + 'links' => [ + 'self' => IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null), + ], + ]; + + if ($paginated) { + $returnDataArray['links']['first'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1.); + + $returnDataArray['links']['last'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage); + + if (1. !== $currentPage) { + $returnDataArray['links']['prev'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1.); + } + + if ($currentPage !== $lastPage) { + $returnDataArray['links']['next'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1.); + } + } + + $identifier = null; + foreach ($data as $item) { + $normalizedItem = $this->normalizer->normalize($item, $format, $context); + + if (!isset($normalizedItem['data'])) { + throw new RuntimeException( + 'The JSON API document must contain a "data" key.' + ); + } + + $normalizedItemData = $normalizedItem['data']; + + foreach ($normalizedItemData['attributes'] as $property => $value) { + $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property); + + if ($propertyMetadata->isIdentifier()) { + $identifier = $normalizedItemData['attributes'][$property]; + } + } + + $items = [ + 'type' => $resourceMetadata->getShortName(), + // The id attribute must be a string + // http://jsonapi.org/format/#document-resource-object-identification + 'id' => (string) $identifier ?? '', + 'attributes' => $normalizedItemData['attributes'], + ]; + + if (isset($normalizedItemData['relationships'])) { + $items['relationships'] = $normalizedItemData['relationships']; + } + + $returnDataArray['data'][] = $items; + } + + if (is_array($data) || $data instanceof \Countable) { + $returnDataArray['meta']['totalItems'] = $data instanceof PaginatorInterface ? + $data->getTotalItems() : + count($data); + } + + if ($isPaginator) { + $returnDataArray['meta']['itemsPerPage'] = (int) $itemsPerPage; + $returnDataArray['meta']['currentPage'] = (int) $currentPage; + } + + return $returnDataArray; + } +} diff --git a/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php b/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php new file mode 100644 index 00000000000..7d657d4844f --- /dev/null +++ b/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php @@ -0,0 +1,83 @@ + + * + * 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\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Validator\ConstraintViolationInterface; +use Symfony\Component\Validator\ConstraintViolationListInterface; + +final class ConstraintViolationListNormalizer implements NormalizerInterface +{ + const FORMAT = 'jsonapi'; + + private $nameConverter; + + public function __construct(PropertyMetadataFactoryInterface $propertyMetadataFactory, NameConverterInterface $nameConverter = null) + { + $this->propertyMetadataFactory = $propertyMetadataFactory; + $this->nameConverter = $nameConverter; + } + + public function normalize($object, $format = null, array $context = []) + { + $violations = []; + foreach ($object as $violation) { + $violations[] = [ + 'detail' => $violation->getMessage(), + 'source' => [ + 'pointer' => $this->getSourcePointerFromViolation($violation), + ], + ]; + } + + return ['errors' => $violations]; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null) + { + return self::FORMAT === $format && $data instanceof ConstraintViolationListInterface; + } + + private function getSourcePointerFromViolation(ConstraintViolationInterface $violation) + { + $fieldName = $violation->getPropertyPath(); + + if (!$fieldName) { + return 'data'; + } + + $propertyMetadata = $this->propertyMetadataFactory + ->create( + // Im quite sure this requires some thought in case of validations + // over relationships + get_class($violation->getRoot()), + $fieldName + ); + + if (null !== $this->nameConverter) { + $fieldName = $this->nameConverter->normalize($fieldName); + } + + if (null !== $propertyMetadata->getType()->getClassName()) { + return sprintf('data/relationships/%s', $fieldName); + } + + return sprintf('data/attributes/%s', $fieldName); + } +} diff --git a/src/JsonApi/Serializer/EntrypointNormalizer.php b/src/JsonApi/Serializer/EntrypointNormalizer.php new file mode 100644 index 00000000000..2d9872382d1 --- /dev/null +++ b/src/JsonApi/Serializer/EntrypointNormalizer.php @@ -0,0 +1,74 @@ + + * + * 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\Entrypoint; +use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use ApiPlatform\Core\Exception\InvalidArgumentException; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Normalizes the API entrypoint. + * + * @author Amrouche Hamza + * @author Kévin Dunglas + */ +final class EntrypointNormalizer implements NormalizerInterface +{ + const FORMAT = 'jsonapi'; + + private $resourceMetadataFactory; + private $iriConverter; + private $urlGenerator; + + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, IriConverterInterface $iriConverter, UrlGeneratorInterface $urlGenerator) + { + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->iriConverter = $iriConverter; + $this->urlGenerator = $urlGenerator; + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []) + { + $entrypoint = ['links' => ['self' => $this->urlGenerator->generate('api_entrypoint')]]; + + foreach ($object->getResourceNameCollection() as $resourceClass) { + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + + if (!$resourceMetadata->getCollectionOperations()) { + continue; + } + try { + $entrypoint['links'][lcfirst($resourceMetadata->getShortName())] = $this->iriConverter->getIriFromResourceClass($resourceClass); + } catch (InvalidArgumentException $ex) { + // Ignore resources without GET operations + } + } + + return $entrypoint; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null) + { + return self::FORMAT === $format && $data instanceof Entrypoint; + } +} diff --git a/src/JsonApi/Serializer/ErrorNormalizer.php b/src/JsonApi/Serializer/ErrorNormalizer.php new file mode 100644 index 00000000000..464ad535701 --- /dev/null +++ b/src/JsonApi/Serializer/ErrorNormalizer.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\JsonApi\Serializer; + +use ApiPlatform\Core\Problem\Serializer\ErrorNormalizerTrait; +use Symfony\Component\Debug\Exception\FlattenException; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +final class ErrorNormalizer implements NormalizerInterface +{ + const FORMAT = 'jsonapi'; + + use ErrorNormalizerTrait; + + private $debug; + + public function __construct(bool $debug = false) + { + $this->debug = $debug; + } + + public function normalize($object, $format = null, array $context = []) + { + if ($this->debug) { + $trace = $object->getTrace(); + } + + $message = $object->getErrorMessage($object, $context, $this->debug); + + $data = [ + 'title' => $context['title'] ?? 'An error occurred', + 'description' => $message ?? (string) $object, + ]; + + if (isset($trace)) { + $data['trace'] = $trace; + } + + return $data; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null) + { + return self::FORMAT === $format && ($data instanceof \Exception || $data instanceof FlattenException); + } +} diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php new file mode 100644 index 00000000000..ef684babfdd --- /dev/null +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -0,0 +1,660 @@ + + * + * 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\DataProvider\ItemDataProviderInterface; +use ApiPlatform\Core\Exception\InvalidArgumentException; +use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Serializer\AbstractItemNormalizer; +use ApiPlatform\Core\Serializer\ContextTrait; +use ApiPlatform\Core\Util\ClassInfoTrait; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Converts between objects and array. + * + * @author Kévin Dunglas + * @author Amrouche Hamza + */ +final class ItemNormalizer extends AbstractItemNormalizer +{ + use ContextTrait; + use ClassInfoTrait; + + const FORMAT = 'jsonapi'; + + private $componentsCache = []; + private $resourceMetadataFactory; + private $itemDataProvider; + + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ResourceMetadataFactoryInterface $resourceMetadataFactory, ItemDataProviderInterface $itemDataProvider) + { + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter); + + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->itemDataProvider = $itemDataProvider; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null) + { + return self::FORMAT === $format && parent::supportsNormalization($data, $format); + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []) + { + $context['cache_key'] = $this->getCacheKey($format, $context); + + // Get and populate attributes data + $objectAttributesData = parent::normalize($object, $format, $context); + + if (!is_array($objectAttributesData)) { + return $objectAttributesData; + } + + // Get and populate identifier if existent + $identifier = $this->getIdentifierFromItem($object); + + // Get and populate item type + $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + + // Get and populate relations + $components = $this->getComponents($object, $format, $context); + $objectRelationshipsData = $this->getPopulatedRelations($object, $format, $context, $components); + + $item = [ + // The id attribute must be a string + // See: http://jsonapi.org/format/#document-resource-object-identification + 'id' => (string) $identifier, + 'type' => $resourceMetadata->getShortName(), + ]; + + if ($objectAttributesData) { + $item['attributes'] = $objectAttributesData; + } + + if ($objectRelationshipsData) { + $item['relationships'] = $objectRelationshipsData; + } + + return ['data' => $item]; + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, $type, $format = null) + { + return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format); + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = null, array $context = []) + { + // Avoid issues with proxies if we populated the object + if (isset($data['data']['id']) && !isset($context['object_to_populate'])) { + if (isset($context['api_allow_update']) && true !== $context['api_allow_update']) { + throw new InvalidArgumentException('Update is not allowed for this operation.'); + } + + $context['object_to_populate'] = $this->iriConverter->getItemFromIri( + $data['data']['id'], + $context + ['fetch_data' => false] + ); + } + + // Merge attributes and relations previous to apply parents denormalizing + $dataToDenormalize = array_merge( + $data['data']['attributes'] ?? [], + $data['data']['relationships'] ?? [] + ); + + return parent::denormalize( + $dataToDenormalize, + $class, + $format, + $context + ); + } + + /** + * {@inheritdoc} + */ + protected function getAttributes($object, $format, array $context) + { + return $this->getComponents($object, $format, $context)['attributes']; + } + + /** + * Sets a value of the object using the PropertyAccess component. + * + * @param object $object + * @param string $attributeName + * @param mixed $value + */ + private function setValue($object, string $attributeName, $value) + { + try { + $this->propertyAccessor->setValue($object, $attributeName, $value); + } catch (NoSuchPropertyException $exception) { + // Properties not found are ignored + } + } + + /** + * {@inheritdoc} + */ + protected function setAttributeValue($object, $attribute, $value, $format = null, array $context = []) + { + $propertyMetadata = $this->propertyMetadataFactory->create( + $context['resource_class'], + $attribute, + $this->getFactoryOptions($context) + ); + $type = $propertyMetadata->getType(); + + if (null === $type) { + // No type provided, blindly set the value + $this->setValue($object, $attribute, $value); + + return; + } + + if (null === $value && $type->isNullable()) { + $this->setValue($object, $attribute, $value); + + return; + } + + if ( + $type->isCollection() && + null !== ($collectionValueType = $type->getCollectionValueType()) && + null !== $className = $collectionValueType->getClassName() + ) { + $this->setValue( + $object, + $attribute, + $this->denormalizeCollectionFromArray($attribute, $propertyMetadata, $type, $className, $value, $format, $context) + ); + + return; + } + + if (null !== $className = $type->getClassName()) { + $this->setValue( + $object, + $attribute, + $this->denormalizeRelationFromArray($attribute, $propertyMetadata, $className, $value, $format, $context) + ); + + return; + } + + $this->validateType($attribute, $type, $value, $format); + $this->setValue($object, $attribute, $value); + } + + /** + * Denormalizes a collection of objects. + * + * @param string $attribute + * @param PropertyMetadata $propertyMetadata + * @param Type $type + * @param string $className + * @param mixed $value + * @param string|null $format + * @param array $context + * + * @throws InvalidArgumentException + * + * @return array + */ + private function denormalizeCollectionFromArray(string $attribute, PropertyMetadata $propertyMetadata, Type $type, string $className, $value, string $format = null, array $context): array + { + if (!is_array($value)) { + throw new InvalidArgumentException(sprintf( + 'The type of the "%s" attribute must be "array", "%s" given.', $attribute, gettype($value) + )); + } + + $collectionKeyType = $type->getCollectionKeyType(); + $collectionKeyBuiltinType = null === $collectionKeyType ? null : $collectionKeyType->getBuiltinType(); + + $values = []; + foreach ($value as $index => $obj) { + if (null !== $collectionKeyBuiltinType && !call_user_func('is_'.$collectionKeyBuiltinType, $index)) { + throw new InvalidArgumentException(sprintf( + 'The type of the key "%s" must be "%s", "%s" given.', + $index, $collectionKeyBuiltinType, gettype($index)) + ); + } + + $values[$index] = $this->denormalizeRelationFromArray($attribute, $propertyMetadata, $className, $obj, $format, $context); + } + + return $values; + } + + /** + * {@inheritdoc} + * + * @throws NoSuchPropertyException + */ + protected function getAttributeValue($object, $attribute, $format = null, array $context = []) + { + $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context)); + + try { + $attributeValue = $this->propertyAccessor->getValue($object, $attribute); + } catch (NoSuchPropertyException $e) { + if (null === $propertyMetadata->isChildInherited()) { + throw $e; + } + + $attributeValue = null; + } + + $type = $propertyMetadata->getType(); + + if ( + (is_array($attributeValue) || $attributeValue instanceof \Traversable) && + $type && + $type->isCollection() && + ($collectionValueType = $type->getCollectionValueType()) && + ($className = $collectionValueType->getClassName()) && + $this->resourceClassResolver->isResourceClass($className) + ) { + $value = []; + foreach ($attributeValue as $index => $obj) { + $value[$index] = $this->normalizeRelationToArray($propertyMetadata, $obj, $className, $format, $context); + } + + return $value; + } + + if ( + $attributeValue && + $type && + ($className = $type->getClassName()) && + $this->resourceClassResolver->isResourceClass($className) + ) { + return $this->normalizeRelationToArray($propertyMetadata, $attributeValue, $className, $format, $context); + } + + return $this->serializer->normalize($attributeValue, $format, $context); + } + + /** + * Gets JSON API components of the resource: attributes, relationships, meta and links. + * + * @param object $object + * @param string|null $format + * @param array $context + * + * @return array + */ + private function getComponents($object, string $format = null, array $context) + { + if (isset($this->componentsCache[$context['cache_key']])) { + return $this->componentsCache[$context['cache_key']]; + } + + $attributes = parent::getAttributes($object, $format, $context); + + $options = $this->getFactoryOptions($context); + + $typeShortName = $className = ''; + + $components = [ + 'links' => [], + 'relationships' => [], + 'attributes' => [], + 'meta' => [], + ]; + + foreach ($attributes as $attribute) { + $propertyMetadata = $this + ->propertyMetadataFactory + ->create($context['resource_class'], $attribute, $options); + + $type = $propertyMetadata->getType(); + $isOne = $isMany = false; + + if (null !== $type) { + if ($type->isCollection()) { + $valueType = $type->getCollectionValueType(); + + $isMany = null !== $valueType && ($className = $valueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); + } else { + $className = $type->getClassName(); + + $isOne = null !== $className && $this->resourceClassResolver->isResourceClass($className); + } + + $typeShortName = ''; + + if ($className && $this->resourceClassResolver->isResourceClass($className)) { + $typeShortName = $this->resourceMetadataFactory->create($className)->getShortName(); + } + } + + if (!$isOne && !$isMany) { + $components['attributes'][] = $attribute; + + continue; + } + + $relation = [ + 'name' => $attribute, + 'type' => $typeShortName, + 'cardinality' => $isOne ? 'one' : 'many', + ]; + + $components['relationships'][] = $relation; + } + + return $this->componentsCache[$context['cache_key']] = $components; + } + + /** + * Populates links and relationships keys. + * + * @param array $data + * @param object $object + * @param string|null $format + * @param array $context + * @param array $components + * @param string $type + * + * @return array + */ + private function getPopulatedRelations($object, string $format = null, array $context, array $components, string $type = 'relationships'): array + { + $data = []; + + $identifier = ''; + foreach ($components[$type] as $relationshipDataArray) { + $relationshipName = $relationshipDataArray['name']; + + $attributeValue = $this->getAttributeValue( + $object, + $relationshipName, + $format, + $context + ); + + if ($this->nameConverter) { + $relationshipName = $this->nameConverter->normalize($relationshipName); + } + + if (!$attributeValue) { + continue; + } + + $data[$relationshipName] = [ + 'data' => [], + ]; + + // Many to one relationship + if ('one' === $relationshipDataArray['cardinality']) { + $data[$relationshipName] = $attributeValue; + + continue; + } + + // Many to many relationship + foreach ($attributeValue as $attributeValueElement) { + if (!isset($attributeValueElement['data'])) { + throw new RuntimeException(sprintf( + 'The JSON API attribute \'%s\' must contain a "data" key.', + $relationshipName + )); + } + + $data[$relationshipName]['data'][] = $attributeValueElement['data']; + } + } + + return $data; + } + + /** + * Gets the IRI of the given relation. + * + * @param array|string $rel + * + * @return string + */ + private function getRelationIri($rel): string + { + return $rel['links']['self'] ?? $rel; + } + + /** + * Gets the cache key to use. + * + * @param string|null $format + * @param array $context + * + * @return bool|string + */ + private function getCacheKey(string $format = null, array $context) + { + try { + return md5($format.serialize($context)); + } catch (\Exception $exception) { + // The context cannot be serialized, skip the cache + return false; + } + } + + /** + * Denormalizes a resource linkage relation. + * + * @see http://jsonapi.org/format/#document-resource-object-linkage + * + * @param string $attributeName + * @param PropertyMetadata $propertyMetadata + * @param string $className + * @param mixed $data + * @param string|null $format + * @param array $context + * + * @return object|null + */ + private function denormalizeRelationFromArray(string $attributeName, PropertyMetadata $propertyMetadata, string $className, $data, string $format = null, array $context) + { + // Null is allowed for empty to-one relationships, see + // http://jsonapi.org/format/#document-resource-object-linkage + if (null === $data['data']) { + return; + } + + // An empty array is allowed for empty to-many relationships, see + // http://jsonapi.org/format/#document-resource-object-linkage + if ([] === $data['data']) { + return; + } + + if (!isset($data['data'])) { + throw new InvalidArgumentException( + 'Key \'data\' expected. Only resource linkage currently supported, see: http://jsonapi.org/format/#document-resource-object-linkage' + ); + } + + $data = $data['data']; + + if (!is_array($data) || 2 !== count($data)) { + throw new InvalidArgumentException( + 'Only resource linkage supported currently supported, see: http://jsonapi.org/format/#document-resource-object-linkage' + ); + } + + if (!isset($data['id'])) { + throw new InvalidArgumentException( + 'Only resource linkage supported currently supported, see: http://jsonapi.org/format/#document-resource-object-linkage' + ); + } + + return $this->itemDataProvider->getItem( + $this->resourceClassResolver->getResourceClass(null, $className), + $data['id'] + ); + } + + /** + * Normalizes a relation as resource linkage relation. + * + * @see http://jsonapi.org/format/#document-resource-object-linkage + * + * For example, it may return the following array: + * + * [ + * 'data' => [ + * 'type' => 'dummy', + * 'id' => '1' + * ] + * ] + * + * @param PropertyMetadata $propertyMetadata + * @param mixed $relatedObject + * @param string $resourceClass + * @param string|null $format + * @param array $context + * + * @return string|array + */ + private function normalizeRelationToArray(PropertyMetadata $propertyMetadata, $relatedObject, string $resourceClass, string $format = null, array $context) + { + $resourceClass = $this->resourceClassResolver->getResourceClass( + $relatedObject, + null, + true + ); + + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + + $identifier = $this->getIdentifierFromItem($relatedObject); + + return ['data' => [ + 'type' => $resourceMetadata->getShortName(), + 'id' => (string) $identifier, + ]]; + } + + private function getIdentifierFromItem($item) + { + $identifiers = $this->getIdentifiersFromItem($item); + + if (count($identifiers) > 1) { + throw new RuntimeException(sprintf( + 'Multiple identifiers are not supported during serialization of relationships (Entity: \'%s\')', + $resourceClass + )); + } + + return reset($identifiers); + } + + /** + * Find identifiers from an Item (Object). + * + * Taken from ApiPlatform\Core\Bridge\Symfony\Routing\IriConverter + * + * @param object $item + * + * @throws RuntimeException + * + * @return array + */ + private function getIdentifiersFromItem($item): array + { + $identifiers = []; + $resourceClass = $this->getObjectClass($item); + + foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $propertyName) { + $propertyMetadata = $this + ->propertyMetadataFactory + ->create($resourceClass, $propertyName); + + $identifier = $propertyMetadata->isIdentifier(); + if (null === $identifier || false === $identifier) { + continue; + } + + $identifiers[$propertyName] = $this + ->propertyAccessor + ->getValue($item, $propertyName); + + if (!is_object($identifiers[$propertyName])) { + continue; + } + + $relatedResourceClass = $this->getObjectClass($identifiers[$propertyName]); + $relatedItem = $identifiers[$propertyName]; + + unset($identifiers[$propertyName]); + + foreach ($this->propertyNameCollectionFactory->create($relatedResourceClass) as $relatedPropertyName) { + $propertyMetadata = $this + ->propertyMetadataFactory + ->create($relatedResourceClass, $relatedPropertyName); + + if ($propertyMetadata->isIdentifier()) { + if (isset($identifiers[$propertyName])) { + throw new RuntimeException(sprintf( + 'Composite identifiers not supported in "%s" through relation "%s" of "%s" used as identifier', + $relatedResourceClass, + $propertyName, + $resourceClass + )); + } + + $identifiers[$propertyName] = $this + ->propertyAccessor + ->getValue( + $relatedItem, + $relatedPropertyName + ); + } + } + + if (!isset($identifiers[$propertyName])) { + throw new RuntimeException(sprintf( + 'No identifier found in "%s" through relation "%s" of "%s" used as identifier', + $relatedResourceClass, + $propertyName, + $resourceClass + )); + } + } + + return $identifiers; + } +} diff --git a/src/Metadata/Resource/Factory/OperationResourceMetadataFactory.php b/src/Metadata/Resource/Factory/OperationResourceMetadataFactory.php index 3f27155bc8c..6e5eb10879c 100644 --- a/src/Metadata/Resource/Factory/OperationResourceMetadataFactory.php +++ b/src/Metadata/Resource/Factory/OperationResourceMetadataFactory.php @@ -46,7 +46,7 @@ public function create(string $resourceClass): ResourceMetadata if (null === $resourceMetadata->getItemOperations()) { $resourceMetadata = $resourceMetadata->withItemOperations($this->createOperations( - $isAbstract ? ['GET', 'DELETE'] : ['GET', 'PUT', 'DELETE'] + $isAbstract ? ['GET', 'DELETE'] : ['GET', 'PUT', 'DELETE', 'PATCH'] )); } diff --git a/src/Serializer/NameConverter/CamelCaseToDashedCaseNameConverter.php b/src/Serializer/NameConverter/CamelCaseToDashedCaseNameConverter.php new file mode 100644 index 00000000000..3d5daddfd13 --- /dev/null +++ b/src/Serializer/NameConverter/CamelCaseToDashedCaseNameConverter.php @@ -0,0 +1,83 @@ + + * + * 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\NameConverter; + +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * CamelCase to dashed name converter. + * + * Based on Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter. + * + * @author Kévin Dunglas + */ +class CamelCaseToDashedCaseNameConverter implements NameConverterInterface +{ + private $attributes; + private $lowerCamelCase; + + /** + * @param null|array $attributes The list of attributes to rename or null for all attributes + * @param bool $lowerCamelCase Use lowerCamelCase style + */ + public function __construct(array $attributes = null, bool $lowerCamelCase = true) + { + $this->attributes = $attributes; + $this->lowerCamelCase = $lowerCamelCase; + } + + /** + * {@inheritdoc} + */ + public function normalize($propertyName) + { + if (null === $this->attributes || in_array($propertyName, $this->attributes, true)) { + $lcPropertyName = lcfirst($propertyName); + $snakeCasedName = ''; + + $len = strlen($lcPropertyName); + for ($i = 0; $i < $len; ++$i) { + if (ctype_upper($lcPropertyName[$i])) { + $snakeCasedName .= '-'.strtolower($lcPropertyName[$i]); + } else { + $snakeCasedName .= strtolower($lcPropertyName[$i]); + } + } + + return $snakeCasedName; + } + + return $propertyName; + } + + /** + * {@inheritdoc} + */ + public function denormalize($propertyName) + { + $camelCasedName = preg_replace_callback('/(^|-|\.)+(.)/', function ($match) { + return ('.' === $match[1] ? '-' : '').strtoupper($match[2]); + }, $propertyName); + + if ($this->lowerCamelCase) { + $camelCasedName = lcfirst($camelCasedName); + } + + if (null === $this->attributes || in_array($camelCasedName, $this->attributes, true)) { + return $camelCasedName; + } + + return $propertyName; + } +} diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index 2cc69d2d475..367406b97a3 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -53,7 +53,7 @@ public function createFromRequest(Request $request, bool $normalization, array $ } if (!$normalization && !isset($context['api_allow_update'])) { - $context['api_allow_update'] = Request::METHOD_PUT === $request->getMethod(); + $context['api_allow_update'] = in_array($request->getMethod(), [Request::METHOD_PUT, Request::METHOD_PATCH], true); } $context['resource_class'] = $attributes['resource_class']; diff --git a/src/Swagger/Serializer/DocumentationNormalizer.php b/src/Swagger/Serializer/DocumentationNormalizer.php index 1cb6bf470a5..1941e485e4d 100644 --- a/src/Swagger/Serializer/DocumentationNormalizer.php +++ b/src/Swagger/Serializer/DocumentationNormalizer.php @@ -179,6 +179,10 @@ private function getPathOperation(string $operationName, array $operation, strin case 'POST': return $this->updatePostOperation($pathOperation, $mimeTypes, $collection, $resourceMetadata, $resourceClass, $resourceShortName, $operationName, $definitions); case 'PUT': + return $this->updatePutOperation($pathOperation, $mimeTypes, $collection, $resourceMetadata, $resourceClass, $resourceShortName, $operationName, $definitions); + case 'PATCH': + $pathOperation['summary'] ?? $pathOperation['summary'] = sprintf('Updates the %s resource.', $resourceShortName); + return $this->updatePutOperation($pathOperation, $mimeTypes, $collection, $resourceMetadata, $resourceClass, $resourceShortName, $operationName, $definitions); case 'DELETE': return $this->updateDeleteOperation($pathOperation, $resourceShortName); diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index c519766868a..878b136be23 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -395,6 +395,7 @@ private function getContainerBuilderProphecy() 'api_platform.action.get_item' => 'api_platform.action.placeholder', 'api_platform.action.post_collection' => 'api_platform.action.placeholder', 'api_platform.action.put_item' => 'api_platform.action.placeholder', + 'api_platform.action.patch_item' => 'api_platform.action.placeholder', 'api_platform.metadata.property.metadata_factory' => 'api_platform.metadata.property.metadata_factory.xml', 'api_platform.metadata.property.name_collection_factory' => 'api_platform.metadata.property.name_collection_factory.property_info', 'api_platform.metadata.resource.metadata_factory' => 'api_platform.metadata.resource.metadata_factory.xml', diff --git a/tests/Fixtures/TestBundle/Entity/Dummy.php b/tests/Fixtures/TestBundle/Entity/Dummy.php index 25170b52f68..42d711b841b 100644 --- a/tests/Fixtures/TestBundle/Entity/Dummy.php +++ b/tests/Fixtures/TestBundle/Entity/Dummy.php @@ -272,4 +272,9 @@ public function getDummy() { return $this->dummy; } + + public function getRelatedDummies() + { + return $this->relatedDummies; + } } diff --git a/tests/Fixtures/TestBundle/Entity/ParentDummy.php b/tests/Fixtures/TestBundle/Entity/ParentDummy.php index 6ebf70eca51..84fb2bb971f 100644 --- a/tests/Fixtures/TestBundle/Entity/ParentDummy.php +++ b/tests/Fixtures/TestBundle/Entity/ParentDummy.php @@ -37,4 +37,9 @@ public function getAge() { return $this->age; } + + public function setAge($age) + { + return $this->age = $age; + } } diff --git a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php index fb024a4fdbb..3704e394c69 100644 --- a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php +++ b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php @@ -24,7 +24,13 @@ * * @author Kévin Dunglas * - * @ApiResource(iri="https://schema.org/Product", attributes={"normalization_context"={"groups"={"friends"}}, "filters"={"related_dummy.friends"}}) + * @ApiResource( + * iri="https://schema.org/Product", + * attributes={ + * "normalization_context"={"groups"={"friends"}}, + * "filters"={"related_dummy.friends"} + * } + * ) * @ORM\Entity */ class RelatedDummy extends ParentDummy diff --git a/tests/Fixtures/TestBundle/Entity/RelatedToDummyFriend.php b/tests/Fixtures/TestBundle/Entity/RelatedToDummyFriend.php index 364ab7974af..4abdcf8086f 100644 --- a/tests/Fixtures/TestBundle/Entity/RelatedToDummyFriend.php +++ b/tests/Fixtures/TestBundle/Entity/RelatedToDummyFriend.php @@ -42,6 +42,7 @@ class RelatedToDummyFriend * @ORM\ManyToOne(targetEntity="DummyFriend") * @ORM\JoinColumn(name="dummyfriend_id", referencedColumnName="id", nullable=false) * @Groups({"fakemanytomany", "friends"}) + * @Assert\NotNull */ private $dummyFriend; @@ -49,6 +50,7 @@ class RelatedToDummyFriend * @ORM\Id * @ORM\ManyToOne(targetEntity="RelatedDummy", inversedBy="relatedToDummyFriend") * @ORM\JoinColumn(name="relateddummy_id", referencedColumnName="id", nullable=false, onDelete="CASCADE") + * @Assert\NotNull */ private $relatedDummy; diff --git a/tests/Fixtures/TestBundle/Entity/RelationEmbedder.php b/tests/Fixtures/TestBundle/Entity/RelationEmbedder.php index 452742e55f9..6fead20108a 100644 --- a/tests/Fixtures/TestBundle/Entity/RelationEmbedder.php +++ b/tests/Fixtures/TestBundle/Entity/RelationEmbedder.php @@ -22,17 +22,20 @@ * * @author Kévin Dunglas * - * @ApiResource(attributes={ - * "normalization_context"={"groups"={"barcelona"}}, - * "denormalization_context"={"groups"={"chicago"}}, - * "hydra_context"={"@type"="hydra:Operation", "hydra:title"="A custom operation", "returns"="xmls:string"} - * }, itemOperations={ - * "get"={"method"="GET"}, - * "put"={"method"="PUT"}, - * "custom_get"={"route_name"="relation_embedded.custom_get"}, - * "custom1"={"path"="/api/custom-call/{id}", "method"="GET"}, - * "custom2"={"path"="/api/custom-call/{id}", "method"="PUT"}, - * }) + * @ApiResource( + * attributes={ + * "normalization_context"={"groups"={"barcelona"}}, + * "denormalization_context"={"groups"={"chicago"}}, + * "hydra_context"={"@type"="hydra:Operation", "hydra:title"="A custom operation", "returns"="xmls:string"} + * }, + * itemOperations={ + * "get"={"method"="GET"}, + * "put"={"method"="PUT"}, + * "custom_get"={"route_name"="relation_embedded.custom_get"}, + * "custom1"={"path"="/api/custom-call/{id}", "method"="GET"}, + * "custom2"={"path"="/api/custom-call/{id}", "method"="PUT"}, + * } + * ) * @ORM\Entity */ class RelationEmbedder diff --git a/tests/Fixtures/app/config/config.yml b/tests/Fixtures/app/config/config.yml index d36217c01bb..93c9b92f1a8 100644 --- a/tests/Fixtures/app/config/config.yml +++ b/tests/Fixtures/app/config/config.yml @@ -34,9 +34,14 @@ api_platform: formats: jsonld: ['application/ld+json'] jsonhal: ['application/hal+json'] + jsonapi: ['application/vnd.api+json'] xml: ['application/xml', 'text/xml'] json: ['application/json'] html: ['text/html'] + error_formats: + jsonproblem: ['application/problem+json'] + jsonld: ['application/ld+json'] + jsonapi: ['application/vnd.api+json'] name_converter: 'app.name_converter' enable_fos_user: true collection: @@ -100,7 +105,7 @@ services: # Tests if the id default to the service name, do not add id attributes here my_dummy.order: parent: 'api_platform.doctrine.orm.order_filter' - arguments: [ { 'id': ~, 'name': 'desc', 'relatedDummy.symfony': ~ } ] + arguments: [ { 'id': ~, 'name': 'desc', 'description': ~, 'relatedDummy.symfony': ~ } ] tags: [ { name: 'api_platform.filter' } ] app.my_dummy_resource.date_filter: diff --git a/tests/Hydra/Serializer/ItemNormalizerTest.php b/tests/Hydra/Serializer/ItemNormalizerTest.php index 14fa9e73162..e283bf73f46 100644 --- a/tests/Hydra/Serializer/ItemNormalizerTest.php +++ b/tests/Hydra/Serializer/ItemNormalizerTest.php @@ -44,12 +44,10 @@ public function testDontSupportDenormalization() $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); $resourceClassResolverProphecy->getResourceClass(['dummy'], 'Dummy')->willReturn(Dummy::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn(new PropertyNameCollection(['name' => 'name'])); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', [])->willReturn(new PropertyMetadata())->shouldBeCalled(1); $normalizer = new ItemNormalizer($resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $contextBuilderProphecy->reveal()); $this->assertFalse($normalizer->supportsDenormalization('foo', ItemNormalizer::FORMAT)); - $normalizer->denormalize(['foo'], Dummy::class, 'jsonld', ['jsonld_has_context' => true, 'jsonld_sub_level' => true, 'resource_class' => Dummy::class]); } public function testSupportNormalization() diff --git a/tests/JsonApi/Serializer/CollectionNormalizerTest.php b/tests/JsonApi/Serializer/CollectionNormalizerTest.php new file mode 100644 index 00000000000..b389f13d59c --- /dev/null +++ b/tests/JsonApi/Serializer/CollectionNormalizerTest.php @@ -0,0 +1,181 @@ + + * + * 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\JsonApi\Serializer; + +use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\DataProvider\PaginatorInterface; +use ApiPlatform\Core\JsonApi\Serializer\CollectionNormalizer; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Amrouche Hamza + */ +class CollectionNormalizerTest extends \PHPUnit_Framework_TestCase +{ + public function testSupportsNormalize() + { + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceMetadataProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $propertyMetadataProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + $normalizer = new CollectionNormalizer( + $resourceClassResolverProphecy->reveal(), + $resourceMetadataProphecy->reveal(), + $propertyMetadataProphecy->reveal(), + 'page' + ); + + $this->assertTrue($normalizer->supportsNormalization( + [], + CollectionNormalizer::FORMAT + )); + + $this->assertTrue($normalizer->supportsNormalization( + new \ArrayObject(), + CollectionNormalizer::FORMAT + )); + + $this->assertFalse($normalizer->supportsNormalization([], 'xml')); + $this->assertFalse($normalizer->supportsNormalization( + new \ArrayObject(), + 'xml' + )); + } + + public function testNormalizePaginator() + { + $paginatorProphecy = $this->prophesize(PaginatorInterface::class); + $paginatorProphecy->getCurrentPage()->willReturn(3); + $paginatorProphecy->getLastPage()->willReturn(7); + $paginatorProphecy->getItemsPerPage()->willReturn(12); + $paginatorProphecy->getTotalItems()->willReturn(1312); + + $paginatorProphecy->rewind()->shouldBeCalled(); + $paginatorProphecy->next()->willReturn()->shouldBeCalled(); + $paginatorProphecy->current()->willReturn('foo')->shouldBeCalled(); + $paginatorProphecy->valid()->willReturn(true, false)->shouldBeCalled(); + + $paginator = $paginatorProphecy->reveal(); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy + ->getResourceClass($paginator, null, true) + ->willReturn('Foo') + ->shouldBeCalled(); + + $resourceMetadataProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataProphecy + ->create('Foo') + ->willReturn( + new ResourceMetadata('Foo', 'A foo', '/foos', null, null, ['id', 'name']) + ); + + $propertyMetadataProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataProphecy + ->create('Foo', 'id') + ->willReturn(new PropertyMetadata( + new Type(Type::BUILTIN_TYPE_INT), + 'id', + true, + true, + true, + true, + false, + true, + null, + null, + [] + )) + ->shouldBeCalled(1); + + $propertyMetadataProphecy + ->create('Foo', 'name') + ->willReturn(new PropertyMetadata( + new Type(Type::BUILTIN_TYPE_STRING), + 'name', + true, + true, + true, + true, + false, + false, + null, + null, + [] + )) + ->shouldBeCalled(1); + + $normalizer = new CollectionNormalizer( + $resourceClassResolverProphecy->reveal(), + $resourceMetadataProphecy->reveal(), + $propertyMetadataProphecy->reveal(), + 'page' + ); + + $itemNormalizer = $this->prophesize(NormalizerInterface::class); + $itemNormalizer + ->normalize( + 'foo', + null, + [ + 'api_sub_level' => true, + 'resource_class' => 'Foo', + ] + ) + ->willReturn([ + 'data' => [ + 'type' => 'Foo', + 'id' => 1, + 'attributes' => [ + 'id' => 1, + 'name' => 'Kévin', + ], + ], + ]); + + $normalizer->setNormalizer($itemNormalizer->reveal()); + + $expected = [ + 'links' => [ + 'self' => '/?page=3', + 'first' => '/?page=1', + 'last' => '/?page=7', + 'prev' => '/?page=2', + 'next' => '/?page=4', + ], + 'data' => [ + [ + 'type' => 'Foo', + 'id' => 1, + 'attributes' => [ + 'id' => 1, + 'name' => 'Kévin', + ], + ], + ], + 'meta' => [ + 'totalItems' => 1312, + 'itemsPerPage' => 12, + 'currentPage' => 3, + ], + ]; + + $this->assertEquals($expected, $normalizer->normalize($paginator)); + } +} diff --git a/tests/JsonApi/Serializer/EntrypointNormalizerTest.php b/tests/JsonApi/Serializer/EntrypointNormalizerTest.php new file mode 100644 index 00000000000..8b92b5ec36c --- /dev/null +++ b/tests/JsonApi/Serializer/EntrypointNormalizerTest.php @@ -0,0 +1,69 @@ + + * + * 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\JsonApi\Serializer; + +use ApiPlatform\Core\Api\Entrypoint; +use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use ApiPlatform\Core\JsonApi\Serializer\EntrypointNormalizer; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; + +/** + * @author Amrouche Hamza + */ +class EntrypointNormalizerTest extends \PHPUnit_Framework_TestCase +{ + public function testSupportNormalization() + { + $collection = new ResourceNameCollection(); + $entrypoint = new Entrypoint($collection); + + $factoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $urlGeneratorProphecy = $this->prophesize(UrlGeneratorInterface::class); + + $normalizer = new EntrypointNormalizer($factoryProphecy->reveal(), $iriConverterProphecy->reveal(), $urlGeneratorProphecy->reveal()); + + $this->assertTrue($normalizer->supportsNormalization($entrypoint, EntrypointNormalizer::FORMAT)); + $this->assertFalse($normalizer->supportsNormalization($entrypoint, 'json')); + $this->assertFalse($normalizer->supportsNormalization(new \stdClass(), EntrypointNormalizer::FORMAT)); + } + + public function testNormalize() + { + $collection = new ResourceNameCollection([Dummy::class]); + $entrypoint = new Entrypoint($collection); + $factoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $factoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata('Dummy', null, null, null, ['get']))->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResourceClass(Dummy::class)->willReturn('/api/dummies')->shouldBeCalled(); + + $urlGeneratorProphecy = $this->prophesize(UrlGeneratorInterface::class); + $urlGeneratorProphecy->generate('api_entrypoint')->willReturn('/api')->shouldBeCalled(); + + $normalizer = new EntrypointNormalizer($factoryProphecy->reveal(), $iriConverterProphecy->reveal(), $urlGeneratorProphecy->reveal()); + + $expected = [ + 'links' => [ + 'self' => '/api', + 'dummy' => '/api/dummies', + ], + ]; + $this->assertEquals($expected, $normalizer->normalize($entrypoint, EntrypointNormalizer::FORMAT)); + } +} diff --git a/tests/JsonApi/Serializer/ItemNormalizerTest.php b/tests/JsonApi/Serializer/ItemNormalizerTest.php new file mode 100644 index 00000000000..be2682b0f49 --- /dev/null +++ b/tests/JsonApi/Serializer/ItemNormalizerTest.php @@ -0,0 +1,239 @@ + + * + * 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\JsonApi\Serializer; + +use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; +use ApiPlatform\Core\Exception\InvalidArgumentException; +use ApiPlatform\Core\JsonApi\Serializer\ItemNormalizer; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use Prophecy\Argument; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @author Amrouche Hamza + */ +class ItemNormalizerTest extends \PHPUnit_Framework_TestCase +{ + public function testSupportDenormalization() + { + $propertyNameCollectionFactoryProphecy = $this + ->prophesize(PropertyNameCollectionFactoryInterface::class); + + $propertyMetadataFactoryProphecy = $this + ->prophesize(PropertyMetadataFactoryInterface::class); + + $iriConverterProphecy = $this + ->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this + ->prophesize(ResourceClassResolverInterface::class); + + $resourceClassResolverProphecy + ->isResourceClass(Dummy::class) + ->willReturn(true) + ->shouldBeCalled(); + + $resourceClassResolverProphecy + ->isResourceClass(\stdClass::class) + ->willReturn(false) + ->shouldBeCalled(); + + $resourceMetadataFactoryProphecy = $this + ->prophesize(ResourceMetadataFactoryInterface::class); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + null, + null, + $resourceMetadataFactoryProphecy->reveal(), + $this->prophesize(ItemDataProviderInterface::class)->reveal() + ); + + $this->assertTrue($normalizer->supportsDenormalization(null, Dummy::class, ItemNormalizer::FORMAT)); + $this->assertFalse($normalizer->supportsDenormalization(null, \stdClass::class, ItemNormalizer::FORMAT)); + } + + public function testSupportNormalization() + { + $std = new \stdClass(); + $dummy = new Dummy(); + + $propertyNameCollectionFactoryProphecy = $this + ->prophesize(PropertyNameCollectionFactoryInterface::class); + + $propertyMetadataFactoryProphecy = $this + ->prophesize(PropertyMetadataFactoryInterface::class); + + $iriConverterProphecy = $this + ->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this + ->prophesize(ResourceClassResolverInterface::class); + + $resourceClassResolverProphecy + ->getResourceClass($dummy) + ->willReturn(Dummy::class) + ->shouldBeCalled(); + + $resourceClassResolverProphecy + ->getResourceClass($std) + ->willThrow(new InvalidArgumentException()) + ->shouldBeCalled(); + + $resourceMetadataFactoryProphecy = $this + ->prophesize(ResourceMetadataFactoryInterface::class); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + null, + null, + $resourceMetadataFactoryProphecy->reveal(), + $this->prophesize(ItemDataProviderInterface::class)->reveal() + ); + + $this->assertTrue($normalizer->supportsNormalization($dummy, ItemNormalizer::FORMAT)); + $this->assertFalse($normalizer->supportsNormalization($dummy, 'xml')); + $this->assertFalse($normalizer->supportsNormalization($std, ItemNormalizer::FORMAT)); + } + + public function testNormalize() + { + $dummy = new Dummy(); + $dummy->setName('hello'); + + $propertyNameCollectionFactoryProphecy = $this + ->prophesize(PropertyNameCollectionFactoryInterface::class); + + $propertyNameCollectionFactoryProphecy + ->create(Dummy::class, []) + ->willReturn(new PropertyNameCollection(['id', 'name'])) + ->shouldBeCalled(); + + $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(); + + $resourceClassResolverProphecy = $this + ->prophesize(ResourceClassResolverInterface::class); + + $resourceClassResolverProphecy + ->getResourceClass($dummy, null, true) + ->willReturn(Dummy::class) + ->shouldBeCalled(); + + // We're also gonna fake this to test normalization of ids + $propertyAccessorProphecy = $this + ->prophesize(PropertyAccessorInterface::class); + + $propertyAccessorProphecy + ->getValue($dummy, 'id') + ->willReturn(10) + ->shouldBeCalled(); + + $propertyAccessorProphecy + ->getValue($dummy, 'name') + ->willReturn('hello') + ->shouldBeCalled(); + + $resourceMetadataFactoryProphecy = $this + ->prophesize(ResourceMetadataFactoryInterface::class); + + $resourceMetadataFactoryProphecy + ->create(Dummy::class) + ->willReturn(new ResourceMetadata( + 'Dummy', 'A dummy', '/dummy', null, null, ['id', 'name'] + )) + ->shouldBeCalled(); + + $serializerProphecy = $this + ->prophesize(SerializerInterface::class); + + $serializerProphecy->willImplement(NormalizerInterface::class); + + $serializerProphecy + ->normalize('hello', null, Argument::type('array')) + ->willReturn('hello') + ->shouldBeCalled(); + + // Normalization of the fake id property + $serializerProphecy + ->normalize(10, null, Argument::type('array')) + ->willReturn(10) + ->shouldBeCalled(); + + // Generation of the fake id + $propertyNameCollectionFactoryProphecy + ->create(Dummy::class) + ->willReturn(new PropertyNameCollection(['id'])) + ->shouldBeCalled(); + + $propertyMetadataFactoryProphecy + ->create(Dummy::class, 'id') + ->willReturn(new PropertyMetadata(null, null, true, null, null, null, null, true)) + ->shouldBeCalled(); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $this->prophesize(IriConverterInterface::class)->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + null, + $resourceMetadataFactoryProphecy->reveal(), + $this->prophesize(ItemDataProviderInterface::class)->reveal() + ); + + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + 'data' => [ + 'type' => 'Dummy', + 'id' => '10', + 'attributes' => [ + 'id' => 10, + 'name' => 'hello', + ], + ], + ]; + + $this->assertEquals($expected, $normalizer->normalize($dummy)); + } + + // TODO: Add metho to testDenormalize +} diff --git a/tests/Serializer/ItemNormalizerTest.php b/tests/Serializer/ItemNormalizerTest.php index 5a1110443f9..6524670c942 100644 --- a/tests/Serializer/ItemNormalizerTest.php +++ b/tests/Serializer/ItemNormalizerTest.php @@ -105,9 +105,12 @@ public function testDenormalize() $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn($propertyNameCollection)->shouldBeCalled(); - $propertyMetadataFactory = new PropertyMetadata(null, null, true); + $propertyMetadataProphecy = new PropertyMetadata(null, null, true, true); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', [])->willReturn($propertyMetadataFactory)->shouldBeCalled(); + $propertyMetadataFactoryProphecy + ->create(Dummy::class, 'name', []) + ->willReturn($propertyMetadataProphecy) + ->shouldBeCalled(); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -135,9 +138,12 @@ public function testDenormalizeWithIri() $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn($propertyNameCollection)->shouldBeCalled(); - $propertyMetadataFactory = new PropertyMetadata(null, null, true); + $propertyMetadata = new PropertyMetadata(null, null, true, true); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', [])->willReturn($propertyMetadataFactory)->shouldBeCalled(); + $propertyMetadataFactoryProphecy + ->create(Dummy::class, 'name', []) + ->willReturn($propertyMetadata) + ->shouldBeCalled(); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getItemFromIri('/dummies/12', ['resource_class' => Dummy::class, 'api_allow_update' => true, 'fetch_data' => false])->shouldBeCalled();