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..c0fcec01efc
--- /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 no 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..6bbff304dd8
--- /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"
+ Then 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..2788f60332a
--- /dev/null
+++ b/features/jsonapi/jsonapi.feature
@@ -0,0 +1,258 @@
+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 dummy with relations
+ Given there is a RelatedDummy
+ 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": {
+ "name": "Dummy with relations",
+ "dummyDate": "2015-03-01T10:00:00+00:00"
+ },
+ "relationships": {
+ "relatedDummy": {
+ "data": {
+ "type": "related-dummy",
+ "id": "2"
+ }
+ },
+ "relatedDummies": {
+ "data": [
+ {
+ "type": "related-dummy",
+ "id": "1"
+ },
+ {
+ "type": "related-dummy",
+ "id": "2"
+ }
+ ]
+ }
+ }
+ }
+ }
+ """
+ And the response status code should be 201
+ And the JSON node "data.relationships.relatedDummies.data" should have 2 elements
+ And the JSON node "data.relationships.relatedDummy.data.id" should be equal to "2"
+
+ Scenario: Update a resource with a many-to-many relationship via PATCH
+ 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 "PATCH" request to "/dummies/1" with body:
+ """
+ {
+ "data": {
+ "type": "dummy",
+ "relationships": {
+ "relatedDummy": {
+ "data": {
+ "type": "related-dummy",
+ "id": "1"
+ }
+ },
+ "relatedDummies": {
+ "data": [
+ {
+ "type": "related-dummy",
+ "id": "2"
+ }
+ ]
+ }
+ }
+ }
+ }
+ """
+ And the response status code should be 200
+ And the JSON node "data.relationships.relatedDummies.data" should have 1 elements
+ And the JSON node "data.relationships.relatedDummy.data.id" should be equal to "1"
+
+ Scenario: Create a related dummy with an 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
+ qWhen 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..3939cee6b8c
--- /dev/null
+++ b/src/JsonApi/EventListener/TransformFilteringParametersListener.php
@@ -0,0 +1,61 @@
+
+ *
+ * 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 'filter' 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
+ $filterParameters = $request->query->get('filter');
+
+ if (null === $filterParameters || !is_array($filterParameters)) {
+ return;
+ }
+
+ // Otherwise, flatten one level to comply with api-platform filter expectations
+ foreach ($filterParameters as $filterParameterName => $filterParameterValue) {
+ $request->query->set(
+ $filterParameterName,
+ $filterParameterValue
+ );
+ }
+
+ $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..d7b19766385
--- /dev/null
+++ b/src/JsonApi/Serializer/ErrorNormalizer.php
@@ -0,0 +1,58 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+namespace ApiPlatform\Core\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();
+ }
+
+ $data = [
+ 'title' => $context['title'] ?? 'An error occurred',
+ 'description' => $this->getErrorMessage($object, $context, $this->debug),
+ ];
+
+ 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..f42d589b5df
--- /dev/null
+++ b/src/JsonApi/Serializer/ItemNormalizer.php
@@ -0,0 +1,676 @@
+
+ *
+ * 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\PropertyInfo\Type;
+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 $rawData
+ * @param string|null $format
+ * @param array $context
+ *
+ * @throws InvalidArgumentException
+ *
+ * @return array
+ */
+ private function denormalizeCollectionFromArray(string $attributeName, PropertyMetadata $propertyMetadata, Type $type, string $className, $rawData, string $format = null, array $context): array
+ {
+ // A 'data' key is expected as the first level of the array
+ $data = $rawData['data'];
+
+ if (!is_array($data)) {
+ throw new InvalidArgumentException(sprintf(
+ 'The type of the "%s" attribute must be "array", "%s" given.', $attributeName, gettype($data)
+ ));
+ }
+
+ $collectionKeyType = $type->getCollectionKeyType();
+ $collectionKeyBuiltinType = null === $collectionKeyType ? null : $collectionKeyType->getBuiltinType();
+
+ $values = [];
+ foreach ($data as $rawIndex => $obj) {
+ $index = $rawIndex;
+
+ // Given JSON API forces ids to be strings, we might need to cast stuff
+ if (null !== $collectionKeyBuiltinType && 'int' === $collectionKeyBuiltinType) {
+ $index = (int) $index;
+ } elseif (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($attributeName, $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 $rawData
+ * @param string|null $format
+ * @param array $context
+ *
+ * @return object|null
+ */
+ private function denormalizeRelationFromArray(string $attributeName, PropertyMetadata $propertyMetadata, string $className, $rawData, string $format = null, array $context)
+ {
+ // Give a chance to other normalizers (e.g.: DateTimeNormalizer)
+ if (!is_array($rawData) || !$this->resourceClassResolver->isResourceClass($className)) {
+ return $this->serializer->denormalize($rawData, $className, $format, $this->createRelationSerializationContext($className, $context));
+ }
+
+ $dataToDenormalize = $rawData;
+
+ if (array_key_exists('data', $rawData)) {
+ $dataToDenormalize = $rawData['data'];
+ }
+
+ // Null is allowed for empty to-one relationships, see
+ // http://jsonapi.org/format/#document-resource-object-linkage
+ if (null === $dataToDenormalize) {
+ return;
+ }
+
+ // An empty array is allowed for empty to-many relationships, see
+ // http://jsonapi.org/format/#document-resource-object-linkage
+ if ([] === $dataToDenormalize) {
+ return;
+ }
+
+ if (!is_array($dataToDenormalize) || 2 !== count($dataToDenormalize)) {
+ throw new InvalidArgumentException(
+ 'Only resource linkage supported currently supported, see: http://jsonapi.org/format/#document-resource-object-linkage'
+ );
+ }
+
+ if (!isset($dataToDenormalize['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),
+ $dataToDenormalize['id'],
+ null,
+ $context + ['fetch_data' => true]
+ );
+ }
+
+ /**
+ * 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();