From 4a25bfe987df19c2befcc2f68e8a7c0825b84de4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Sat, 9 Jul 2016 16:34:07 +0200 Subject: [PATCH 1/2] Add HAL support --- features/hal.feature | 210 ++++++++++ features/relation.feature | 5 +- src/{Hydra => }/Action/EntrypointAction.php | 4 +- .../ApiPlatformExtension.php | 21 +- .../Symfony/Bundle/Resources/config/hal.xml | 35 ++ .../Symfony/Bundle/Resources/config/hydra.xml | 3 +- .../Bundle/Resources/config/jsonld.xml | 6 +- .../Bundle/Resources/config/routing/hal.xml | 15 + .../Bundle/Resources/config/routing/hydra.xml | 2 +- .../Resources/config/routing/jsonld.xml | 9 + .../Bundle/Resources/config/swagger.xml | 1 - src/Bridge/Symfony/Routing/ApiLoader.php | 2 + src/Hal/Serializer/CollectionNormalizer.php | 115 ++++++ src/Hal/Serializer/ItemNormalizer.php | 229 +++++++++++ .../CollectionFiltersNormalizer.php | 23 +- src/Hydra/Serializer/CollectionNormalizer.php | 35 +- .../PartialCollectionViewNormalizer.php | 74 +--- src/JsonLd/Serializer/ItemNormalizer.php | 303 +-------------- ...ontextTrait.php => JsonLdContextTrait.php} | 28 +- src/Routing/CollectionRoutingHelper.php | 67 ++++ src/Serializer/AbstractItemNormalizer.php | 358 ++++++++++++++++++ src/Serializer/ContextTrait.php | 40 ++ .../JsonEncoder.php} | 19 +- src/Swagger/ApiDocumentationBuilder.php | 5 +- .../ApiPlatformExtensionTest.php | 17 +- tests/Fixtures/app/config/config.yml | 1 + .../Serializer/CollectionNormalizerTest.php | 97 +++++ tests/Hal/Serializer/ItemNormalizerTest.php | 128 +++++++ .../PartialCollectionViewNormalizerTest.php | 34 +- .../JsonEncoderTest.php} | 18 +- tests/Swagger/ApiDocumentationBuilderTest.php | 5 +- 31 files changed, 1419 insertions(+), 490 deletions(-) create mode 100644 features/hal.feature rename src/{Hydra => }/Action/EntrypointAction.php (89%) create mode 100644 src/Bridge/Symfony/Bundle/Resources/config/hal.xml create mode 100644 src/Bridge/Symfony/Bundle/Resources/config/routing/hal.xml create mode 100644 src/Hal/Serializer/CollectionNormalizer.php create mode 100644 src/Hal/Serializer/ItemNormalizer.php rename src/JsonLd/Serializer/{ContextTrait.php => JsonLdContextTrait.php} (63%) create mode 100644 src/Routing/CollectionRoutingHelper.php create mode 100644 src/Serializer/AbstractItemNormalizer.php create mode 100644 src/Serializer/ContextTrait.php rename src/{JsonLd/Serializer/JsonLdEncoder.php => Serializer/JsonEncoder.php} (74%) create mode 100644 tests/Hal/Serializer/CollectionNormalizerTest.php create mode 100644 tests/Hal/Serializer/ItemNormalizerTest.php rename tests/{JsonLd/Serializer/JsonLdEncoderTest.php => Serializer/JsonEncoderTest.php} (64%) diff --git a/features/hal.feature b/features/hal.feature new file mode 100644 index 00000000000..86d348ec931 --- /dev/null +++ b/features/hal.feature @@ -0,0 +1,210 @@ +Feature: HAL support + In order to use the HAL hypermedia format + As a client software developer + I need to be able to retrieve valid HAL responses. + + @createSchema + Scenario: Create a third level + When I send a "POST" request to "/third_levels" with body: + """ + {"level": 3} + """ + Then the response status code should be 201 + + Scenario: Create a related dummy + When I send a "POST" request to "/related_dummies" with body: + """ + {"thirdLevel": "/third_levels/1"} + """ + Then the response status code should be 201 + + Scenario: Create a dummy with relations + When I send a "POST" request to "/dummies" with body: + """ + { + "name": "Dummy with relations", + "dummyDate": "2015-03-01T10:00:00+00:00", + "relatedDummy": "http://example.com/related_dummies/1", + "relatedDummies": [ + "/related_dummies/1" + ] + } + """ + Then the response status code should be 201 + + Scenario: Get a resource with relations + When I add "Accept" header equal to "application/hal+json" + And I send a "GET" request to "/dummies/1" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/hal+json" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "/dummies/1" + }, + "relatedDummy": { + "href": "/related_dummies/1" + }, + "relatedDummies": [ + { + "href": "/related_dummies/1" + } + ] + }, + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": "2015-03-01T10:00:00+00:00", + "dummyPrice": null, + "jsonData": [], + "name_converted": null, + "name": "Dummy with relations", + "alias": null + } + """ + + Scenario: Update a resource + When I add "Accept" header equal to "application/hal+json" + And I send a "PUT" request to "/dummies/1" with body: + """ + { + "name": "A nice dummy" + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/hal+json" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "/dummies/1" + }, + "relatedDummy": { + "href": "/related_dummies/1" + }, + "relatedDummies": [ + { + "href": "/related_dummies/1" + } + ] + }, + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": "2015-03-01T10:00:00+00:00", + "dummyPrice": null, + "jsonData": [], + "name_converted": null, + "name": "A nice dummy", + "alias": null + } + """ + + Scenario: Embed a relation in a parent object + When I send a "POST" request to "/relation_embedders" with body: + """ + { + "related": "/related_dummies/1" + } + """ + Then the response status code should be 201 + + Scenario: Get the object with the embedded relation + When I add "Accept" header equal to "application/hal+json" + And I send a "GET" request to "/relation_embedders/1" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/hal+json" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "/relation_embedders/1" + }, + "related": { + "href": "/related_dummies/1" + } + }, + "_embedded": { + "related": { + "_links": { + "self": { + "href": "/related_dummies/1" + }, + "thirdLevel": { + "href": "/third_levels/1" + } + }, + "_embedded": { + "thirdLevel": { + "_links": { + "self": { + "href": "/third_levels/1" + } + }, + "level": 3 + } + }, + "symfony": "symfony" + } + }, + "krondstadt": "Krondstadt" + } + """ + + @dropSchema + Scenario: Get a collection + When I add "Accept" header equal to "application/hal+json" + And I send a "GET" request to "/dummies" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/hal+json" + And the JSON should be equal to: + """ + { + "_links": { + "self": "/dummies", + "item": [ + { + "href": "/dummies/1" + } + ] + }, + "_embedded": { + "item": [ + { + "_links": { + "self": { + "href": "/dummies/1" + }, + "relatedDummy": { + "href": "/related_dummies/1" + }, + "relatedDummies": [ + { + "href": "/related_dummies/1" + } + ] + }, + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": "2015-03-01T10:00:00+00:00", + "dummyPrice": null, + "jsonData": [], + "name_converted": null, + "name": "A nice dummy", + "alias": null + } + ] + }, + "totalItems": 1, + "itemsPerPage": 3 + } + """ diff --git a/features/relation.feature b/features/relation.feature index f52ad0c964c..38b70ef9962 100644 --- a/features/relation.feature +++ b/features/relation.feature @@ -44,9 +44,7 @@ Feature: Relations support Scenario: Create a related dummy When I send a "POST" request to "/related_dummies" with body: """ - { - "thirdLevel": "/third_levels/1" - } + {"thirdLevel": "/third_levels/1"} """ Then the response status code should be 201 And the response should be in JSON @@ -114,7 +112,6 @@ Feature: Relations support } """ - Scenario: Create a dummy with relations When I send a "POST" request to "/dummies" with body: """ diff --git a/src/Hydra/Action/EntrypointAction.php b/src/Action/EntrypointAction.php similarity index 89% rename from src/Hydra/Action/EntrypointAction.php rename to src/Action/EntrypointAction.php index 7c453cb7922..202f164e43f 100644 --- a/src/Hydra/Action/EntrypointAction.php +++ b/src/Action/EntrypointAction.php @@ -9,12 +9,12 @@ * file that was distributed with this source code. */ -namespace ApiPlatform\Core\Hydra\Action; +namespace ApiPlatform\Core\Action; use ApiPlatform\Core\JsonLd\EntrypointBuilderInterface; /** - * Generates the JSON-LD API entrypoint. + * Generates the API entrypoint. * * @author Kévin Dunglas */ diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 807b89a3caa..6b290714fac 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -90,7 +90,15 @@ public function load(array $configs, ContainerBuilder $container) $container->setParameter('api_platform.enable_swagger', (string) $config['enable_swagger']); } - $this->enableJsonLd($loader); + if (isset($formats['jsonld'])) { + $loader->load('jsonld.xml'); + $loader->load('hydra.xml'); + } + + if (isset($formats['jsonhal'])) { + $loader->load('hal.xml'); + } + $this->registerAnnotationLoaders($container); $this->registerFileLoaders($container); @@ -116,17 +124,6 @@ public function load(array $configs, ContainerBuilder $container) } } - /** - * Enables JSON-LD and Hydra support. - * - * @param XmlFileLoader $loader - */ - private function enableJsonLd(XmlFileLoader $loader) - { - $loader->load('jsonld.xml'); - $loader->load('hydra.xml'); - } - /** * Registers annotations loaders. * diff --git a/src/Bridge/Symfony/Bundle/Resources/config/hal.xml b/src/Bridge/Symfony/Bundle/Resources/config/hal.xml new file mode 100644 index 00000000000..f5fcd603d7a --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/hal.xml @@ -0,0 +1,35 @@ + + + + + + + + jsonhal + + + + + + + + + + + + + + + + + + + %api_platform.collection.pagination.page_parameter_name% + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/hydra.xml b/src/Bridge/Symfony/Bundle/Resources/config/hydra.xml index de30c9b4d9d..995b0966e6b 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/hydra.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/hydra.xml @@ -82,10 +82,9 @@ - - + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml b/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml index 1819297f81a..2fc020aa646 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml @@ -5,7 +5,6 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> - @@ -29,7 +28,9 @@ - + + jsonld + @@ -40,7 +41,6 @@ - diff --git a/src/Bridge/Symfony/Bundle/Resources/config/routing/hal.xml b/src/Bridge/Symfony/Bundle/Resources/config/routing/hal.xml new file mode 100644 index 00000000000..8615a58eb6f --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/routing/hal.xml @@ -0,0 +1,15 @@ + + + + + + api_platform.action.entrypoint + 1 + index + index + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/routing/hydra.xml b/src/Bridge/Symfony/Bundle/Resources/config/routing/hydra.xml index 88d53fd5adf..2a0c5902210 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/routing/hydra.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/routing/hydra.xml @@ -6,7 +6,7 @@ http://symfony.com/schema/routing/routing-1.0.xsd"> - api_platform.hydra.action.entrypoint + api_platform.action.entrypoint 1 jsonld index diff --git a/src/Bridge/Symfony/Bundle/Resources/config/routing/jsonld.xml b/src/Bridge/Symfony/Bundle/Resources/config/routing/jsonld.xml index 158b2a675aa..b2814ac45d6 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/routing/jsonld.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/routing/jsonld.xml @@ -5,6 +5,14 @@ xsi:schemaLocation="http://symfony.com/schema/routing http://symfony.com/schema/routing/routing-1.0.xsd"> + + api_platform.action.entrypoint + 1 + jsonld + index + index + + api_platform.jsonld.action.context 1 @@ -13,4 +21,5 @@ .+ + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml b/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml index c48e42a0814..e1d04616284 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml @@ -11,7 +11,6 @@ - diff --git a/src/Bridge/Symfony/Routing/ApiLoader.php b/src/Bridge/Symfony/Routing/ApiLoader.php index 4a99227049b..4c066ec0559 100644 --- a/src/Bridge/Symfony/Routing/ApiLoader.php +++ b/src/Bridge/Symfony/Routing/ApiLoader.php @@ -57,8 +57,10 @@ public function load($data, $type = null) { $routeCollection = new RouteCollection(); + $routeCollection->addCollection($this->fileLoader->load('hal.xml')); $routeCollection->addCollection($this->fileLoader->load('jsonld.xml')); $routeCollection->addCollection($this->fileLoader->load('hydra.xml')); + if ($this->container->getParameter('api_platform.enable_swagger')) { $routeCollection->addCollection($this->fileLoader->load('swagger.xml')); } diff --git a/src/Hal/Serializer/CollectionNormalizer.php b/src/Hal/Serializer/CollectionNormalizer.php new file mode 100644 index 00000000000..9dd5edd9685 --- /dev/null +++ b/src/Hal/Serializer/CollectionNormalizer.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Hal\Serializer; + +use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\DataProvider\PaginatorInterface; +use ApiPlatform\Core\Routing\CollectionRoutingHelper; +use ApiPlatform\Core\Serializer\ContextTrait; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Normalizes collections in the HAL format. + * + * @author Kevin Dunglas + * @author Hamza Amrouche + */ +final class CollectionNormalizer implements NormalizerInterface, NormalizerAwareInterface +{ + use ContextTrait; + use NormalizerAwareTrait; + + const FORMAT = 'jsonhal'; + + private $resourceClassResolver; + private $pageParameterName; + + public function __construct(ResourceClassResolverInterface $resourceClassResolver, string $pageParameterName) + { + $this->resourceClassResolver = $resourceClassResolver; + $this->pageParameterName = $pageParameterName; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null) + { + return self::FORMAT === $format && (is_array($data) || ($data instanceof \Traversable)); + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []) + { + $data = []; + if (isset($context['api_sub_level'])) { + foreach ($object as $index => $obj) { + $data[$index] = $this->normalizer->normalize($obj, $format, $context); + } + + return $data; + } + + $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); + $context = $this->initContext($resourceClass, $context, $format); + list($parts, $parameters) = CollectionRoutingHelper::parseRequestUri($context['request_uri'] ?? '/', $this->pageParameterName); + $paginated = $isPaginator = $object instanceof PaginatorInterface; + + if ($isPaginator) { + $currentPage = $object->getCurrentPage(); + $lastPage = $object->getLastPage(); + $itemsPerPage = $object->getItemsPerPage(); + + $paginated = 1. !== $lastPage; + } + + $data = [ + '_links' => [ + 'self' => CollectionRoutingHelper::generateUrl($parts, $parameters, $this->pageParameterName, $paginated ? $currentPage : null), + ], + ]; + + if ($paginated) { + $data['_links']['first'] = CollectionRoutingHelper::generateUrl($parts, $parameters, $this->pageParameterName, 1.); + $data['_links']['last'] = CollectionRoutingHelper::generateUrl($parts, $parameters, $this->pageParameterName, $lastPage); + + if (1. !== $currentPage) { + $data['_links']['prev'] = CollectionRoutingHelper::generateUrl($parts, $parameters, $this->pageParameterName, $currentPage - 1.); + } + + if ($currentPage !== $lastPage) { + $data['_links']['next'] = CollectionRoutingHelper::generateUrl($parts, $parameters, $this->pageParameterName, $currentPage + 1.); + } + } + + foreach ($object as $obj) { + $item = $this->normalizer->normalize($obj, $format, $context); + + $data['_embedded']['item'][] = $item; + $data['_links']['item'][] = $item['_links']['self']; + } + + if (is_array($object) || $object instanceof \Countable) { + $data['totalItems'] = $object instanceof PaginatorInterface ? (int) $object->getTotalItems() : count($object); + } + + if ($isPaginator) { + $data['itemsPerPage'] = (int) $itemsPerPage; + } + + return $data; + } +} diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php new file mode 100644 index 00000000000..1bff738960e --- /dev/null +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -0,0 +1,229 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Hal\Serializer; + +use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Serializer\AbstractItemNormalizer; +use ApiPlatform\Core\Serializer\ContextTrait; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Converts between objects and array including HAL metadata. + * + * @author Kévin Dunglas + */ +final class ItemNormalizer extends AbstractItemNormalizer +{ + use ContextTrait; + + const FORMAT = 'jsonhal'; + + private $resourceMetadataFactory; + private $componentsCache = []; + + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null) + { + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter); + + $this->resourceMetadataFactory = $resourceMetadataFactory; + } + + /** + * {@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); + + $rawData = parent::normalize($object, $format, $context); + if (!is_array($rawData)) { + return $rawData; + } + + $data['_links']['self']['href'] = $this->iriConverter->getIriFromItem($object); + + $components = $this->getComponents($object, $format, $context); + $data = $this->populateRelation($data, $object, $format, $context, $components, 'links'); + $data = $this->populateRelation($data, $object, $format, $context, $components, 'embedded'); + + return array_merge($data, $rawData); + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, $type, $format = null) + { + return false; + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = null, array $context = []) + { + throw new RuntimeException(sprintf('%s is a read-only format.', self::FORMAT)); + } + + /** + * {@inheritdoc} + */ + protected function getAttributes($object, $format, array $context) + { + return $this->getComponents($object, $format, $context)['states']; + } + + /** + * Gets HAL components of the resource: states, links and embedded. + * + * @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); + + $components = [ + 'states' => [], + 'links' => [], + 'embedded' => [], + ]; + + 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 = $className && $this->resourceClassResolver->isResourceClass($className); + } + } + + if (!$isOne && !$isMany) { + $components['states'][] = $attribute; + continue; + } + + $relation = ['name' => $attribute, 'cardinality' => $isOne ? 'one' : 'many']; + if ($propertyMetadata->isReadableLink()) { + $components['embedded'][] = $relation; + } + + $components['links'][] = $relation; + } + + return $this->componentsCache[$context['cache_key']] = $components; + } + + /** + * Populates _links and _embedded keys. + * + * @param array $data + * @param object $object + * @param string|null $format + * @param array $context + * @param array $components + * @param string $type + * + * @return array + */ + private function populateRelation(array $data, $object, string $format = null, array $context, array $components, string $type) : array + { + $key = '_'.$type; + foreach ($components[$type] as $relation) { + $attributeValue = $this->getAttributeValue($object, $relation['name'], $format, $context); + if (empty($attributeValue)) { + continue; + } + + if ('one' === $relation['cardinality']) { + if ('links' === $type) { + $data[$key][$relation['name']]['href'] = $this->getRelationIri($attributeValue); + continue; + } + + $data[$key][$relation['name']] = $attributeValue; + continue; + } + + // many + $data[$key][$relation['name']] = []; + foreach ($attributeValue as $rel) { + if ('links' === $type) { + $rel = ['href' => $this->getRelationIri($rel)]; + } + + $data[$key][$relation['name']][] = $rel; + } + } + + return $data; + } + + /** + * Gets the IRI of the given relation. + * + * @param array|string $rel + * + * @return string + */ + private function getRelationIri($rel) : string + { + return isset($rel['_links']['self']['href']) ? $rel['_links']['self']['href'] : $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; + } + } +} diff --git a/src/Hydra/Serializer/CollectionFiltersNormalizer.php b/src/Hydra/Serializer/CollectionFiltersNormalizer.php index d2cf0696542..c454533a0bd 100644 --- a/src/Hydra/Serializer/CollectionFiltersNormalizer.php +++ b/src/Hydra/Serializer/CollectionFiltersNormalizer.php @@ -14,24 +14,19 @@ use ApiPlatform\Core\Api\FilterCollection; use ApiPlatform\Core\Api\FilterInterface; use ApiPlatform\Core\Api\ResourceClassResolverInterface; -use ApiPlatform\Core\JsonLd\Serializer\ContextTrait; +use ApiPlatform\Core\JsonLd\Serializer\JsonLdContextTrait; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\Serializer\SerializerAwareInterface; -use Symfony\Component\Serializer\SerializerAwareTrait; -use Symfony\Component\Serializer\SerializerInterface; /** * Enhance the result of collection by adding the filters applied on collection. * * @author Samuel ROZE */ -final class CollectionFiltersNormalizer implements NormalizerInterface, SerializerAwareInterface +final class CollectionFiltersNormalizer implements NormalizerInterface, NormalizerAwareInterface { - use ContextTrait; - use SerializerAwareTrait { - setSerializer as baseSetSerializer; - } + use JsonLdContextTrait; private $collectionNormalizer; private $resourceMetadataFactory; @@ -60,7 +55,7 @@ public function supportsNormalization($data, $format = null) public function normalize($object, $format = null, array $context = []) { $data = $this->collectionNormalizer->normalize($object, $format, $context); - if (isset($context['jsonld_sub_level'])) { + if (isset($context['api_sub_level'])) { return $data; } @@ -101,12 +96,10 @@ public function normalize($object, $format = null, array $context = []) /** * {@inheritdoc} */ - public function setSerializer(SerializerInterface $serializer) + public function setNormalizer(NormalizerInterface $normalizer) { - $this->baseSetSerializer($serializer); - - if ($this->collectionNormalizer instanceof SerializerAwareInterface) { - $this->collectionNormalizer->setSerializer($serializer); + if ($this->collectionNormalizer instanceof NormalizerAwareInterface) { + $this->collectionNormalizer->setNormalizer($normalizer); } } diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index 17356e36c93..6eeb687a7b0 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -14,12 +14,12 @@ use ApiPlatform\Core\Api\IriConverterInterface; use ApiPlatform\Core\Api\ResourceClassResolverInterface; use ApiPlatform\Core\DataProvider\PaginatorInterface; -use ApiPlatform\Core\Exception\RuntimeException; use ApiPlatform\Core\JsonLd\ContextBuilderInterface; -use ApiPlatform\Core\JsonLd\Serializer\ContextTrait; +use ApiPlatform\Core\JsonLd\Serializer\JsonLdContextTrait; +use ApiPlatform\Core\Serializer\ContextTrait; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\Serializer\SerializerAwareInterface; -use Symfony\Component\Serializer\SerializerAwareTrait; /** * This normalizer handles collections. @@ -27,10 +27,11 @@ * @author Kevin Dunglas * @author Samuel ROZE */ -final class CollectionNormalizer implements NormalizerInterface, SerializerAwareInterface +final class CollectionNormalizer implements NormalizerInterface, NormalizerAwareInterface { use ContextTrait; - use SerializerAwareTrait; + use JsonLdContextTrait; + use NormalizerAwareTrait; const FORMAT = 'jsonld'; @@ -50,11 +51,7 @@ public function __construct(ContextBuilderInterface $contextBuilder, ResourceCla */ public function supportsNormalization($data, $format = null) { - if (self::FORMAT !== $format) { - return false; - } - - return is_array($data) || ($data instanceof \Traversable && $data instanceof \Countable); + return self::FORMAT === $format && (is_array($data) || ($data instanceof \Traversable)); } /** @@ -62,14 +59,10 @@ public function supportsNormalization($data, $format = null) */ public function normalize($object, $format = null, array $context = []) { - if (!$this->serializer instanceof NormalizerInterface) { - throw new RuntimeException('The serializer must implement the NormalizerInterface.'); - } - - if (isset($context['jsonld_sub_level'])) { + if (isset($context['api_sub_level'])) { $data = []; foreach ($object as $index => $obj) { - $data[$index] = $this->serializer->normalize($obj, $format, $context); + $data[$index] = $this->normalizer->normalize($obj, $format, $context); } return $data; @@ -77,17 +70,19 @@ public function normalize($object, $format = null, array $context = []) $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); $data = $this->addJsonLdContext($this->contextBuilder, $resourceClass, $context); - $context = $this->createContext($resourceClass, $context); + $context = $this->initContext($resourceClass, $context); $data['@id'] = $this->iriConverter->getIriFromResourceClass($resourceClass); $data['@type'] = 'hydra:Collection'; $data['hydra:member'] = []; foreach ($object as $obj) { - $data['hydra:member'][] = $this->serializer->normalize($obj, $format, $context); + $data['hydra:member'][] = $this->normalizer->normalize($obj, $format, $context); } - $data['hydra:totalItems'] = $object instanceof PaginatorInterface ? $object->getTotalItems() : count($object); + if (is_array($object) || $object instanceof \Countable) { + $data['hydra:totalItems'] = $object instanceof PaginatorInterface ? $object->getTotalItems() : count($object); + } return $data; } diff --git a/src/Hydra/Serializer/PartialCollectionViewNormalizer.php b/src/Hydra/Serializer/PartialCollectionViewNormalizer.php index ab45dfaaf8f..e12458ec980 100644 --- a/src/Hydra/Serializer/PartialCollectionViewNormalizer.php +++ b/src/Hydra/Serializer/PartialCollectionViewNormalizer.php @@ -12,12 +12,10 @@ namespace ApiPlatform\Core\Hydra\Serializer; use ApiPlatform\Core\DataProvider\PaginatorInterface; -use ApiPlatform\Core\Exception\InvalidArgumentException; -use ApiPlatform\Core\JsonLd\Serializer\ContextTrait; -use ApiPlatform\Core\Util\RequestParser; +use ApiPlatform\Core\JsonLd\Serializer\JsonLdContextTrait; +use ApiPlatform\Core\Routing\CollectionRoutingHelper; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\Serializer\SerializerAwareInterface; -use Symfony\Component\Serializer\SerializerInterface; /** * Adds a view key to the result of a paginated Hydra collection. @@ -25,9 +23,9 @@ * @author Kévin Dunglas * @author Samuel ROZE */ -final class PartialCollectionViewNormalizer implements NormalizerInterface, SerializerAwareInterface +final class PartialCollectionViewNormalizer implements NormalizerInterface, NormalizerAwareInterface { - use ContextTrait; + use JsonLdContextTrait; private $collectionNormalizer; private $pageParameterName; @@ -46,7 +44,7 @@ public function __construct(NormalizerInterface $collectionNormalizer, string $p public function normalize($object, $format = null, array $context = []) { $data = $this->collectionNormalizer->normalize($object, $format, $context); - if (isset($context['jsonld_sub_level'])) { + if (isset($context['api_sub_level'])) { return $data; } @@ -60,7 +58,7 @@ public function normalize($object, $format = null, array $context = []) } } - list($parts, $parameters) = $this->parseRequestUri($context['request_uri'] ?? '/'); + list($parts, $parameters) = CollectionRoutingHelper::parseRequestUri($context['request_uri'] ?? '/', $this->pageParameterName); $appliedFilters = $parameters; unset($appliedFilters[$this->enabledParameterName]); @@ -69,68 +67,26 @@ public function normalize($object, $format = null, array $context = []) } $data['hydra:view'] = [ - '@id' => $this->getId($parts, $parameters, $paginated ? $currentPage : null), + '@id' => CollectionRoutingHelper::generateUrl($parts, $parameters, $this->pageParameterName, $paginated ? $currentPage : null), '@type' => 'hydra:PartialCollectionView', ]; if ($paginated) { - $data['hydra:view']['hydra:first'] = $this->getId($parts, $parameters, 1.); - $data['hydra:view']['hydra:last'] = $this->getId($parts, $parameters, $lastPage); + $data['hydra:view']['hydra:first'] = CollectionRoutingHelper::generateUrl($parts, $parameters, $this->pageParameterName, 1.); + $data['hydra:view']['hydra:last'] = CollectionRoutingHelper::generateUrl($parts, $parameters, $this->pageParameterName, $lastPage); if (1. !== $currentPage) { - $data['hydra:view']['hydra:previous'] = $this->getId($parts, $parameters, $currentPage - 1.); + $data['hydra:view']['hydra:previous'] = CollectionRoutingHelper::generateUrl($parts, $parameters, $this->pageParameterName, $currentPage - 1.); } if ($currentPage !== $lastPage) { - $data['hydra:view']['hydra:next'] = $this->getId($parts, $parameters, $currentPage + 1.); + $data['hydra:view']['hydra:next'] = CollectionRoutingHelper::generateUrl($parts, $parameters, $this->pageParameterName, $currentPage + 1.); } } return $data; } - /** - * Parses and standardizes the request URI. - */ - private function parseRequestUri(string $requestUri) : array - { - $parts = parse_url($requestUri); - if (false === $parts) { - throw new InvalidArgumentException(sprintf('The request URI "%s" is malformed.', $requestUri)); - } - - $parameters = []; - if (isset($parts['query'])) { - $parameters = RequestParser::parseRequestParams($parts['query']); - - // Remove existing page parameter - unset($parameters[$this->pageParameterName]); - } - - return [$parts, $parameters]; - } - - /** - * Gets a collection @id for the given parameters. - */ - private function getId(array $parts, array $parameters, float $page = null) : string - { - if (null !== $page) { - $parameters[$this->pageParameterName] = $page; - } - - $query = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986); - $parts['query'] = preg_replace('/%5B[0-9]+%5D/', '%5B%5D', $query); - - $url = $parts['path']; - - if ('' !== $parts['query']) { - $url .= '?'.$parts['query']; - } - - return $url; - } - /** * {@inheritdoc} */ @@ -142,10 +98,10 @@ public function supportsNormalization($data, $format = null) /** * {@inheritdoc} */ - public function setSerializer(SerializerInterface $serializer) + public function setNormalizer(NormalizerInterface $normalizer) { - if ($this->collectionNormalizer instanceof SerializerAwareInterface) { - $this->collectionNormalizer->setSerializer($serializer); + if ($this->collectionNormalizer instanceof NormalizerAwareInterface) { + $this->collectionNormalizer->setNormalizer($normalizer); } } } diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 5809beeda23..02507be12fe 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -13,52 +13,36 @@ use ApiPlatform\Core\Api\IriConverterInterface; use ApiPlatform\Core\Api\ResourceClassResolverInterface; -use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\JsonLd\ContextBuilderInterface; 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 Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; -use Symfony\Component\PropertyAccess\PropertyAccess; +use ApiPlatform\Core\Serializer\AbstractItemNormalizer; +use ApiPlatform\Core\Serializer\ContextTrait; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; -use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; /** * Converts between objects and array including JSON-LD and Hydra metadata. * * @author Kévin Dunglas */ -final class ItemNormalizer extends AbstractObjectNormalizer +final class ItemNormalizer extends AbstractItemNormalizer { use ContextTrait; + use JsonLdContextTrait; const FORMAT = 'jsonld'; private $resourceMetadataFactory; - private $propertyNameCollectionFactory; - private $propertyMetadataFactory; - private $iriConverter; - private $resourceClassResolver; private $contextBuilder; - private $propertyAccessor; public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ContextBuilderInterface $contextBuilder, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null) { - parent::__construct(null, $nameConverter); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter); $this->resourceMetadataFactory = $resourceMetadataFactory; - $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; - $this->propertyMetadataFactory = $propertyMetadataFactory; - $this->iriConverter = $iriConverter; - $this->resourceClassResolver = $resourceClassResolver; $this->contextBuilder = $contextBuilder; - $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); - - $this->setCircularReferenceHandler(function ($object) { - return $this->iriConverter->getIriFromItem($object); - }); } /** @@ -66,17 +50,7 @@ public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFa */ public function supportsNormalization($data, $format = null) { - if (self::FORMAT !== $format || !is_object($data)) { - return false; - } - - try { - $this->resourceClassResolver->getResourceClass($data); - } catch (InvalidArgumentException $e) { - return false; - } - - return true; + return self::FORMAT === $format && parent::supportsNormalization($data, $format); } /** @@ -86,12 +60,8 @@ public function normalize($object, $format = null, array $context = []) { $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); - $data = $this->addJsonLdContext($this->contextBuilder, $resourceClass, $context); - $context['jsonld_normalize'] = true; - $context = $this->createContext($resourceClass, $context); - $rawData = parent::normalize($object, $format, $context); if (!is_array($rawData)) { return $rawData; @@ -108,7 +78,7 @@ public function normalize($object, $format = null, array $context = []) */ public function supportsDenormalization($data, $type, $format = null) { - return self::FORMAT === $format && $this->resourceClassResolver->isResourceClass($type); + return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format); } /** @@ -116,268 +86,11 @@ public function supportsDenormalization($data, $type, $format = null) */ public function denormalize($data, $class, $format = null, array $context = []) { - $resourceClass = $this->resourceClassResolver->getResourceClass($data, $context['resource_class']); - - $context['jsonld_denormalize'] = true; - $context = $this->createContext($resourceClass, $context); - // Avoid issues with proxies if we populated the object - $overrideClass = isset($data['@id']) && !isset($context['object_to_populate']); - - if ($overrideClass) { + if (isset($data['@id']) && !isset($context['object_to_populate'])) { $context['object_to_populate'] = $this->iriConverter->getItemFromIri($data['@id'], true); } return parent::denormalize($data, $class, $format, $context); } - - /** - * {@inheritdoc} - * - * Unused in this context. - */ - protected function extractAttributes($object, $format = null, array $context = []) - { - return []; - } - - /** - * {@inheritdoc} - */ - protected function getAttributeValue($object, $attribute, $format = null, array $context = []) - { - $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context)); - - $attributeValue = $this->propertyAccessor->getValue($object, $attribute); - $type = $propertyMetadata->getType(); - - if ( - $attributeValue && - $type && - $type->isCollection() && - ($collectionValueType = $type->getCollectionValueType()) && - ($className = $collectionValueType->getClassName()) && - $this->resourceClassResolver->isResourceClass($className) - ) { - $value = []; - foreach ($attributeValue as $index => $obj) { - $value[$index] = $this->normalizeRelation($propertyMetadata, $obj, $className, $context); - } - - return $value; - } - - if ( - $attributeValue && - $type && - ($className = $type->getClassName()) && - $this->resourceClassResolver->isResourceClass($className) - ) { - return $this->normalizeRelation($propertyMetadata, $attributeValue, $className, $context); - } - - return $this->serializer->normalize($attributeValue, self::FORMAT, $context); - } - - /** - * {@inheritdoc} - */ - protected function getAllowedAttributes($classOrObject, array $context, $attributesAsString = false) - { - $options = $this->getFactoryOptions($context); - $propertyNames = $this->propertyNameCollectionFactory->create($context['resource_class'], $options); - - $allowedAttributes = []; - foreach ($propertyNames as $propertyName) { - $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $propertyName, $options); - - if ( - (isset($context['jsonld_normalize']) && $propertyMetadata->isReadable()) || - (isset($context['jsonld_denormalize']) && $propertyMetadata->isWritable()) - ) { - $allowedAttributes[] = $propertyName; - } - } - - return $allowedAttributes; - } - - /** - * {@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 ($type && $value) { - if ( - $type->isCollection() && - ($collectionType = $type->getCollectionValueType()) && - ($className = $collectionType->getClassName()) - ) { - if (!is_array($value)) { - return; - } - - $values = []; - foreach ($value as $index => $obj) { - $values[$index] = $this->denormalizeRelation( - $context['resource_class'], - $attribute, - $propertyMetadata, - $className, - $obj, - $context - ); - } - - $this->setValue($object, $attribute, $values); - - return; - } - - if ($className = $type->getClassName()) { - $this->setValue( - $object, - $attribute, - $this->denormalizeRelation( - $context['resource_class'], - $attribute, - $propertyMetadata, - $className, - $value, - $context - ) - ); - - return; - } - } - - $this->setValue($object, $attribute, $value); - } - - /** - * Normalizes a relation as an URI if is a Link or as a JSON-LD object. - * - * @param PropertyMetadata $propertyMetadata - * @param mixed $relatedObject - * @param string $resourceClass - * @param array $context - * - * @return string|array - */ - private function normalizeRelation(PropertyMetadata $propertyMetadata, $relatedObject, string $resourceClass, array $context) - { - if ($propertyMetadata->isReadableLink()) { - return $this->serializer->normalize($relatedObject, self::FORMAT, $this->createRelationSerializationContext($resourceClass, $context)); - } - - return $this->iriConverter->getIriFromItem($relatedObject); - } - - /** - * Denormalizes a relation. - * - * @param string $resourceClass - * @param string $attributeName - * @param PropertyMetadata $propertyMetadata - * @param string $className - * @param mixed $value - * @param array $context - * - * @throws InvalidArgumentException - * - * @return object|null - */ - private function denormalizeRelation(string $resourceClass, string $attributeName, PropertyMetadata $propertyMetadata, string $className, $value, array $context) - { - if (is_string($value)) { - try { - return $this->iriConverter->getItemFromIri($value, true); - } catch (InvalidArgumentException $e) { - // Give a chance to other normalizers (e.g.: DateTimeNormalizer) - } - } - - if (!$this->resourceClassResolver->isResourceClass($className) || $propertyMetadata->isWritableLink()) { - return $this->serializer->denormalize($value, $className, self::FORMAT, $this->createRelationSerializationContext($className, $context)); - } - - if (!is_array($value)) { - throw new InvalidArgumentException(sprintf( - 'Expected IRI or nested object for attribute "%s" of "%s", "%s" given.', - $attributeName, - $resourceClass, - is_object($value) ? get_class($value) : gettype($value) - )); - } - throw new InvalidArgumentException(sprintf( - 'Nested objects for attribute "%s" of "%s" are not enabled. Use serialization groups to change that behavior.', - $attributeName, - $resourceClass - )); - } - - /** - * 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 - } - } - - /** - * Gets a valid context for property metadata factories. - * - * @see https://github.com/symfony/symfony/blob/master/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php - * - * @param array $context - * - * @return array - */ - private function getFactoryOptions(array $context) : array - { - $options = []; - - if (isset($context['groups'])) { - $options['serializer_groups'] = $context['groups']; - } - - if (isset($context['collection_operation_name'])) { - $options['collection_operation_name'] = $context['collection_operation_name']; - } - - if (isset($context['item_operation_name'])) { - $options['item_operation_name'] = $context['item_operation_name']; - } - - return $options; - } - - /** - * Creates the context to use when serializing a relation. - * - * @param string $resourceClass - * @param array $context - * - * @return array - */ - private function createRelationSerializationContext(string $resourceClass, array $context) : array - { - $context['resource_class'] = $resourceClass; - unset($context['item_operation_name']); - unset($context['collection_operation_name']); - - return $context; - } } diff --git a/src/JsonLd/Serializer/ContextTrait.php b/src/JsonLd/Serializer/JsonLdContextTrait.php similarity index 63% rename from src/JsonLd/Serializer/ContextTrait.php rename to src/JsonLd/Serializer/JsonLdContextTrait.php index 0a96acf6bc4..1e8c4b2267a 100644 --- a/src/JsonLd/Serializer/ContextTrait.php +++ b/src/JsonLd/Serializer/JsonLdContextTrait.php @@ -20,30 +20,8 @@ * * @internal */ -trait ContextTrait +trait JsonLdContextTrait { - /** - * Import the context defined in metadata and set some default values. - * - * @param string $resourceClass - * @param array $context - * - * @return array - */ - private function createContext(string $resourceClass, array $context) : array - { - if (isset($context['jsonld_has_context'])) { - return $context; - } - - return array_merge($context, [ - 'jsonld_has_context' => true, - // Don't use hydra:Collection in sub levels - 'jsonld_sub_level' => true, - 'resource_class' => $resourceClass, - ]); - } - /** * Updates the given JSON-LD document to add its @context key. * @@ -54,12 +32,14 @@ private function createContext(string $resourceClass, array $context) : array * * @return array */ - private function addJsonLdContext(ContextBuilderInterface $contextBuilder, string $resourceClass, array $context, array $data = []) : array + private function addJsonLdContext(ContextBuilderInterface $contextBuilder, string $resourceClass, array &$context, array $data = []) : array { if (isset($context['jsonld_has_context'])) { return $data; } + $context['jsonld_has_context'] = true; + if (isset($context['jsonld_embed_context'])) { $data['@context'] = $contextBuilder->getResourceContext($resourceClass); diff --git a/src/Routing/CollectionRoutingHelper.php b/src/Routing/CollectionRoutingHelper.php new file mode 100644 index 00000000000..b479354db64 --- /dev/null +++ b/src/Routing/CollectionRoutingHelper.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Routing; + +use ApiPlatform\Core\Exception\InvalidArgumentException; +use ApiPlatform\Core\Util\RequestParser; + +/** + * URL generator for collections. + * + * @author Kévin Dunglas + */ +final class CollectionRoutingHelper +{ + /** + * Parses and standardizes the request URI. + * + * @throws InvalidArgumentException + */ + public static function parseRequestUri(string $requestUri, string $pageParameterName) : array + { + $parts = parse_url($requestUri); + if (false === $parts) { + throw new InvalidArgumentException(sprintf('The request URI "%s" is malformed.', $requestUri)); + } + + $parameters = []; + if (isset($parts['query'])) { + $parameters = RequestParser::parseRequestParams($parts['query']); + + // Remove existing page parameter + unset($parameters[$pageParameterName]); + } + + return [$parts, $parameters]; + } + + /** + * Gets a collection IRI for the given parameters. + */ + public static function generateUrl(array $parts, array $parameters, string $pageParameterName, float $page = null) : string + { + if (null !== $page) { + $parameters[$pageParameterName] = $page; + } + + $query = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986); + $parts['query'] = preg_replace('/%5B[0-9]+%5D/', '%5B%5D', $query); + + $url = $parts['path']; + + if ('' !== $parts['query']) { + $url .= '?'.$parts['query']; + } + + return $url; + } +} diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php new file mode 100644 index 00000000000..4250592d653 --- /dev/null +++ b/src/Serializer/AbstractItemNormalizer.php @@ -0,0 +1,358 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Serializer; + +use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\Exception\InvalidArgumentException; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; + +/** + * Base item normalizer. + * + * @author Kévin Dunglas + */ +abstract class AbstractItemNormalizer extends AbstractObjectNormalizer +{ + use ContextTrait; + + protected $propertyNameCollectionFactory; + protected $propertyMetadataFactory; + protected $iriConverter; + protected $resourceClassResolver; + protected $propertyAccessor; + + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null) + { + parent::__construct(null, $nameConverter); + + $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; + $this->propertyMetadataFactory = $propertyMetadataFactory; + $this->iriConverter = $iriConverter; + $this->resourceClassResolver = $resourceClassResolver; + $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); + + $this->setCircularReferenceHandler(function ($object) { + return $this->iriConverter->getIriFromItem($object); + }); + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null) + { + if (!is_object($data)) { + return false; + } + + try { + $this->resourceClassResolver->getResourceClass($data); + } catch (InvalidArgumentException $e) { + return false; + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []) + { + $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); + $context = $this->initContext($resourceClass, $context); + $context['api_normalize'] = true; + + return parent::normalize($object, $format, $context); + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, $type, $format = null) + { + return $this->resourceClassResolver->isResourceClass($type); + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = null, array $context = []) + { + $context['api_denormalize'] = true; + + return parent::denormalize($data, $class, $format, $context); + } + + /** + * {@inheritdoc} + * + * Unused in this context. + */ + protected function extractAttributes($object, $format = null, array $context = []) + { + return []; + } + + /** + * {@inheritdoc} + */ + protected function getAllowedAttributes($classOrObject, array $context, $attributesAsString = false) + { + $options = $this->getFactoryOptions($context); + $propertyNames = $this->propertyNameCollectionFactory->create($context['resource_class'], $options); + + $allowedAttributes = []; + foreach ($propertyNames as $propertyName) { + $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $propertyName, $options); + + if ( + (isset($context['api_normalize']) && $propertyMetadata->isReadable()) || + (isset($context['api_denormalize']) && $propertyMetadata->isWritable()) + ) { + $allowedAttributes[] = $propertyName; + } + } + + return $allowedAttributes; + } + + /** + * {@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 ($type && $value) { + if ( + $type->isCollection() && + ($collectionType = $type->getCollectionValueType()) && + ($className = $collectionType->getClassName()) + ) { + if (!is_array($value)) { + return; + } + + $values = []; + foreach ($value as $index => $obj) { + $values[$index] = $this->denormalizeRelation( + $context['resource_class'], + $attribute, + $propertyMetadata, + $className, + $obj, + $format, + $context + ); + } + + $this->setValue($object, $attribute, $values); + + return; + } + + if ($className = $type->getClassName()) { + $this->setValue( + $object, + $attribute, + $this->denormalizeRelation( + $context['resource_class'], + $attribute, + $propertyMetadata, + $className, + $value, + $format, + $context + ) + ); + + return; + } + } + + $this->setValue($object, $attribute, $value); + } + + /** + * Denormalizes a relation. + * + * @param string $resourceClass + * @param string $attributeName + * @param PropertyMetadata $propertyMetadata + * @param string $className + * @param mixed $value + * @param string|null $format + * @param array $context + * + * @throws InvalidArgumentException + * + * @return object|null + */ + private function denormalizeRelation(string $resourceClass, string $attributeName, PropertyMetadata $propertyMetadata, string $className, $value, string $format = null, array $context) + { + if (is_string($value)) { + try { + return $this->iriConverter->getItemFromIri($value, true); + } catch (InvalidArgumentException $e) { + // Give a chance to other normalizers (e.g.: DateTimeNormalizer) + } + } + + if (!$this->resourceClassResolver->isResourceClass($className) || $propertyMetadata->isWritableLink()) { + return $this->serializer->denormalize($value, $className, $format, $this->createRelationSerializationContext($className, $context)); + } + + if (!is_array($value)) { + throw new InvalidArgumentException(sprintf( + 'Expected IRI or nested object for attribute "%s" of "%s", "%s" given.', + $attributeName, + $resourceClass, + is_object($value) ? get_class($value) : gettype($value) + )); + } + + throw new InvalidArgumentException(sprintf( + 'Nested objects for attribute "%s" of "%s" are not enabled. Use serialization groups to change that behavior.', + $attributeName, + $resourceClass + )); + } + + /** + * 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 + } + } + + /** + * Gets a valid context for property metadata factories. + * + * @see https://github.com/symfony/symfony/blob/master/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php + * + * @param array $context + * + * @return array + */ + protected function getFactoryOptions(array $context) : array + { + $options = []; + + if (isset($context['groups'])) { + $options['serializer_groups'] = $context['groups']; + } + + if (isset($context['collection_operation_name'])) { + $options['collection_operation_name'] = $context['collection_operation_name']; + } + + if (isset($context['item_operation_name'])) { + $options['item_operation_name'] = $context['item_operation_name']; + } + + return $options; + } + + /** + * Creates the context to use when serializing a relation. + * + * @param string $resourceClass + * @param array $context + * + * @return array + */ + protected function createRelationSerializationContext(string $resourceClass, array $context) : array + { + $context['resource_class'] = $resourceClass; + unset($context['item_operation_name']); + unset($context['collection_operation_name']); + + return $context; + } + + /** + * {@inheritdoc} + */ + protected function getAttributeValue($object, $attribute, $format = null, array $context = []) + { + $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context)); + + $attributeValue = $this->propertyAccessor->getValue($object, $attribute); + $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->normalizeRelation($propertyMetadata, $obj, $className, $format, $context); + } + + return $value; + } + + if ( + $attributeValue && + $type && + ($className = $type->getClassName()) && + $this->resourceClassResolver->isResourceClass($className) + ) { + return $this->normalizeRelation($propertyMetadata, $attributeValue, $className, $format, $context); + } + + return $this->serializer->normalize($attributeValue, $format, $context); + } + + /** + * Normalizes a relation as an URI if is a Link or as a JSON-LD object. + * + * @param PropertyMetadata $propertyMetadata + * @param mixed $relatedObject + * @param string $resourceClass + * @param string|null $format + * @param array $context + * + * @return string|array + */ + private function normalizeRelation(PropertyMetadata $propertyMetadata, $relatedObject, string $resourceClass, string $format = null, array $context) + { + if ($propertyMetadata->isReadableLink()) { + return $this->serializer->normalize($relatedObject, $format, $this->createRelationSerializationContext($resourceClass, $context)); + } + + return $this->iriConverter->getIriFromItem($relatedObject); + } +} diff --git a/src/Serializer/ContextTrait.php b/src/Serializer/ContextTrait.php new file mode 100644 index 00000000000..c7057c176b9 --- /dev/null +++ b/src/Serializer/ContextTrait.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Serializer; + +/** + * Creates and manipulates the Serializer context. + * + * @author Kévin Dunglas + */ +trait ContextTrait +{ + /** + * Initializes the context. + * + * @param string $resourceClass + * @param array $context + * + * @return array + */ + private function initContext(string $resourceClass, array $context) : array + { + if (isset($context['api_sub_level'])) { + return $context; + } + + return array_merge($context, [ + 'api_sub_level' => true, + 'resource_class' => $resourceClass, + ]); + } +} diff --git a/src/JsonLd/Serializer/JsonLdEncoder.php b/src/Serializer/JsonEncoder.php similarity index 74% rename from src/JsonLd/Serializer/JsonLdEncoder.php rename to src/Serializer/JsonEncoder.php index ba33f495bf9..53a474e0529 100644 --- a/src/JsonLd/Serializer/JsonLdEncoder.php +++ b/src/Serializer/JsonEncoder.php @@ -9,30 +9,31 @@ * file that was distributed with this source code. */ -namespace ApiPlatform\Core\JsonLd\Serializer; +namespace ApiPlatform\Core\Serializer; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Serializer\Encoder\DecoderInterface; use Symfony\Component\Serializer\Encoder\EncoderInterface; use Symfony\Component\Serializer\Encoder\JsonDecode; use Symfony\Component\Serializer\Encoder\JsonEncode; -use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Encoder\JsonEncoder as BaseJsonEncoder; /** * JSON-LD Encoder. * * @author Kévin Dunglas */ -final class JsonLdEncoder implements EncoderInterface, DecoderInterface +final class JsonEncoder implements EncoderInterface, DecoderInterface { - const FORMAT = 'jsonld'; - + private $format; private $jsonEncoder; - public function __construct(JsonEncoder $jsonEncoder = null) + public function __construct(string $format, BaseJsonEncoder $jsonEncoder = null) { + $this->format = $format; + // Encode <, >, ', &, and " for RFC4627-compliant JSON, which may also be embedded into HTML. - $this->jsonEncoder = $jsonEncoder ?: new JsonEncoder( + $this->jsonEncoder = $jsonEncoder ?: new BaseJsonEncoder( new JsonEncode(JsonResponse::DEFAULT_ENCODING_OPTIONS), new JsonDecode(true) ); } @@ -42,7 +43,7 @@ public function __construct(JsonEncoder $jsonEncoder = null) */ public function supportsEncoding($format) { - return self::FORMAT === $format; + return $this->format === $format; } /** @@ -58,7 +59,7 @@ public function encode($data, $format, array $context = []) */ public function supportsDecoding($format) { - return self::FORMAT === $format; + return $this->format === $format; } /** diff --git a/src/Swagger/ApiDocumentationBuilder.php b/src/Swagger/ApiDocumentationBuilder.php index 8cd6c3ba141..550d6721d6f 100644 --- a/src/Swagger/ApiDocumentationBuilder.php +++ b/src/Swagger/ApiDocumentationBuilder.php @@ -16,7 +16,6 @@ use ApiPlatform\Core\Api\ResourceClassResolverInterface; use ApiPlatform\Core\Documentation\ApiDocumentationBuilderInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; -use ApiPlatform\Core\JsonLd\ContextBuilderInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; @@ -39,7 +38,6 @@ final class ApiDocumentationBuilder implements ApiDocumentationBuilderInterface private $resourceMetadataFactory; private $propertyNameCollectionFactory; private $propertyMetadataFactory; - private $contextBuilder; private $resourceClassResolver; private $operationMethodResolver; private $title; @@ -48,13 +46,12 @@ final class ApiDocumentationBuilder implements ApiDocumentationBuilderInterface private $version; private $mimeTypes = []; - public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, OperationMethodResolverInterface $operationMethodResolver, IriConverterInterface $iriConverter, array $formats, string $title, string $description, string $version = null) + public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, OperationMethodResolverInterface $operationMethodResolver, IriConverterInterface $iriConverter, array $formats, string $title, string $description, string $version = null) { $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; $this->resourceMetadataFactory = $resourceMetadataFactory; $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; $this->propertyMetadataFactory = $propertyMetadataFactory; - $this->contextBuilder = $contextBuilder; $this->resourceClassResolver = $resourceClassResolver; $this->operationMethodResolver = $operationMethodResolver; $this->title = $title; diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index d3e9a441f86..58a70ef0bbe 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -33,6 +33,8 @@ class ApiPlatformExtensionTest extends \PHPUnit_Framework_TestCase 'title' => 'title', 'description' => 'description', 'version' => 'version', + 'formats' => ['jsonld' => ['mime_types' => ['application/ld+json']], 'jsonhal' => ['mime_types' => ['application/hal+json']]], + ], ]; @@ -174,7 +176,7 @@ private function getContainerBuilderProphecy() 'api_platform.title' => 'title', 'api_platform.description' => 'description', 'api_platform.version' => 'version', - 'api_platform.formats' => ['jsonld' => ['application/ld+json']], + 'api_platform.formats' => ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']], 'api_platform.collection.order' => null, 'api_platform.collection.order_parameter_name' => 'order', 'api_platform.collection.pagination.enabled' => true, @@ -219,6 +221,7 @@ private function getContainerBuilderProphecy() $definitions = [ 'api_platform.action.placeholder', + 'api_platform.action.entrypoint', 'api_platform.item_data_provider', 'api_platform.collection_data_provider', 'api_platform.filters', @@ -279,12 +282,19 @@ private function getContainerBuilderProphecy() 'api_platform.jsonld.normalizer.item', 'api_platform.jsonld.encoder', 'api_platform.jsonld.action.context', + 'api_platform.jsonld.context_builder', + 'api_platform.jsonld.normalizer.item', 'api_platform.swagger.documentation_builder', 'api_platform.swagger.command.swagger_command', 'api_platform.swagger.action.documentation', 'api_platform.swagger.action.ui', - 'api_platform.hydra.entrypoint_builder', + 'api_platform.hal.encoder', + 'api_platform.hal.normalizer.item', + 'api_platform.hal.normalizer.collection', + 'api_platform.hydra.action.documentation', + 'api_platform.hydra.action.exception', 'api_platform.hydra.documentation_builder', + 'api_platform.hydra.entrypoint_builder', 'api_platform.hydra.listener.response.add_link_header', 'api_platform.hydra.listener.exception.validation', 'api_platform.hydra.listener.exception', @@ -293,9 +303,6 @@ private function getContainerBuilderProphecy() 'api_platform.hydra.normalizer.collection_filters', 'api_platform.hydra.normalizer.constraint_violation_list', 'api_platform.hydra.normalizer.error', - 'api_platform.hydra.action.entrypoint', - 'api_platform.hydra.action.documentation', - 'api_platform.hydra.action.exception', ]; diff --git a/tests/Fixtures/app/config/config.yml b/tests/Fixtures/app/config/config.yml index 173bf393ef4..abe02849eef 100644 --- a/tests/Fixtures/app/config/config.yml +++ b/tests/Fixtures/app/config/config.yml @@ -31,6 +31,7 @@ api_platform: description: 'This is a test API.' formats: jsonld: ['application/ld+json'] + jsonhal: ['application/hal+json'] xml: ['application/xml', 'text/xml'] json: ['application/json'] name_converter: 'app.name_converter' diff --git a/tests/Hal/Serializer/CollectionNormalizerTest.php b/tests/Hal/Serializer/CollectionNormalizerTest.php new file mode 100644 index 00000000000..3469c9dfe2a --- /dev/null +++ b/tests/Hal/Serializer/CollectionNormalizerTest.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Tests\Hal\Serializer; + +use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\DataProvider\PaginatorInterface; +use ApiPlatform\Core\Hal\Serializer\CollectionNormalizer; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Kévin Dunglas + */ +class CollectionNormalizerTest extends \PHPUnit_Framework_TestCase +{ + public function testSupportsNormalize() + { + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->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 testNormalizeApiSubLevel() + { + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass()->shouldNotBeCalled(); + + $itemNormalizer = $this->prophesize(NormalizerInterface::class); + $itemNormalizer->normalize('bar', null, ['api_sub_level' => true])->willReturn(22); + + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page'); + $normalizer->setNormalizer($itemNormalizer->reveal()); + + $this->assertEquals(['foo' => 22], $normalizer->normalize(['foo' => 'bar'], null, ['api_sub_level' => true])); + } + + 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->valid()->willReturn(true, false)->shouldBeCalled(); + $paginatorProphecy->current()->willReturn('foo')->shouldBeCalled(); + $paginatorProphecy->next()->willReturn()->shouldBeCalled(); + $paginator = $paginatorProphecy->reveal(); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($paginator, null, true)->willReturn('Foo')->shouldBeCalled(); + + $itemNormalizer = $this->prophesize(NormalizerInterface::class); + $itemNormalizer->normalize('foo', null, ['api_sub_level' => true, 'resource_class' => 'Foo'])->willReturn(['_links' => ['self' => '/me'], 'name' => 'Kévin']); + + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page'); + $normalizer->setNormalizer($itemNormalizer->reveal()); + + $expected = [ + '_links' => [ + 'self' => '/?page=3', + 'first' => '/?page=1', + 'last' => '/?page=7', + 'prev' => '/?page=2', + 'next' => '/?page=4', + 'item' => [ + '/me', + ], + ], + '_embedded' => [ + 'item' => [ + [ + '_links' => [ + 'self' => '/me', + ], + 'name' => 'Kévin', + ], + ], + ], + 'totalItems' => 1312, + 'itemsPerPage' => 12, + ]; + $this->assertEquals($expected, $normalizer->normalize($paginator)); + } +} diff --git a/tests/Hal/Serializer/ItemNormalizerTest.php b/tests/Hal/Serializer/ItemNormalizerTest.php new file mode 100644 index 00000000000..4e12ac1b9aa --- /dev/null +++ b/tests/Hal/Serializer/ItemNormalizerTest.php @@ -0,0 +1,128 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\tests\Hal; + +use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\Exception\InvalidArgumentException; +use ApiPlatform\Core\Hal\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\Tests\Fixtures\TestBundle\Entity\Dummy; +use Prophecy\Argument; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @author Kévin Dunglas + */ +class ItemNormalizerTest extends \PHPUnit_Framework_TestCase +{ + /** + * @expectedException \ApiPlatform\Core\Exception\RuntimeException + */ + public function testDonTSupportDenormalization() + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + + $normalizer = new ItemNormalizer( + $resourceMetadataFactoryProphecy->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + + $this->assertFalse($normalizer->supportsDenormalization('foo', ItemNormalizer::FORMAT)); + $normalizer->denormalize(['foo'], 'Foo'); + } + + public function testSupportNormalization() + { + $std = new \stdClass(); + $dummy = new Dummy(); + $dummy->setDescription('hello'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $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(); + + $normalizer = new ItemNormalizer( + $resourceMetadataFactoryProphecy->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + + $this->assertTrue($normalizer->supportsNormalization($dummy, 'jsonhal')); + $this->assertFalse($normalizer->supportsNormalization($dummy, 'xml')); + $this->assertFalse($normalizer->supportsNormalization($std, 'jsonhal')); + } + + public function testNormalize() + { + $dummy = new Dummy(); + $dummy->setName('hello'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + + $propertyNameCollection = new PropertyNameCollection(['name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn($propertyNameCollection)->shouldBeCalled(); + + $propertyMetadataFactory = new PropertyMetadata(null, null, true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', [])->willReturn($propertyMetadataFactory)->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($dummy)->willReturn('/dummies/1988')->shouldBeCalled(); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null, true)->willReturn(Dummy::class)->shouldBeCalled(); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('hello', null, Argument::type('array'))->willReturn('hello')->shouldBeCalled(); + + $normalizer = new ItemNormalizer( + $resourceMetadataFactoryProphecy->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + '_links' => [ + 'self' => [ + 'href' => '/dummies/1988', + ], + ], + 'name' => 'hello', + ]; + $this->assertEquals($expected, $normalizer->normalize($dummy)); + } +} diff --git a/tests/Hydra/Serializer/PartialCollectionViewNormalizerTest.php b/tests/Hydra/Serializer/PartialCollectionViewNormalizerTest.php index 36596ecfed3..8005ce01546 100644 --- a/tests/Hydra/Serializer/PartialCollectionViewNormalizerTest.php +++ b/tests/Hydra/Serializer/PartialCollectionViewNormalizerTest.php @@ -14,9 +14,8 @@ use ApiPlatform\Core\DataProvider\PaginatorInterface; use ApiPlatform\Core\Hydra\Serializer\PartialCollectionViewNormalizer; use Prophecy\Argument; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\Serializer\SerializerAwareInterface; -use Symfony\Component\Serializer\SerializerInterface; /** * @author Kévin Dunglas @@ -27,9 +26,8 @@ public function testNormalizeDoesNotChangeSubLevel() { $decoratedNormalizerProphecy = $this->prophesize(NormalizerInterface::class); $decoratedNormalizerProphecy->normalize(Argument::any(), null, ['jsonld_sub_level' => true])->willReturn(['foo' => 'bar'])->shouldBeCalled(); - $decoratedNormalizer = $decoratedNormalizerProphecy->reveal(); - $normalizer = new PartialCollectionViewNormalizer($decoratedNormalizer); + $normalizer = new PartialCollectionViewNormalizer($decoratedNormalizerProphecy->reveal()); $this->assertEquals(['foo' => 'bar'], $normalizer->normalize(new \stdClass(), null, ['jsonld_sub_level' => true])); } @@ -37,9 +35,8 @@ public function testNormalizeDoesNotChangeWhenNoFilterNorPagination() { $decoratedNormalizerProphecy = $this->prophesize(NormalizerInterface::class); $decoratedNormalizerProphecy->normalize(Argument::any(), null, Argument::type('array'))->willReturn(['foo' => 'bar'])->shouldBeCalled(); - $decoratedNormalizer = $decoratedNormalizerProphecy->reveal(); - $normalizer = new PartialCollectionViewNormalizer($decoratedNormalizer); + $normalizer = new PartialCollectionViewNormalizer($decoratedNormalizerProphecy->reveal()); $this->assertEquals(['foo' => 'bar'], $normalizer->normalize(new \stdClass(), null, ['request_uri' => '/?page=1&pagination=1'])); } @@ -48,13 +45,11 @@ public function testNormalizePaginator() $paginatorProphecy = $this->prophesize(PaginatorInterface::class); $paginatorProphecy->getCurrentPage()->willReturn(3)->shouldBeCalled(); $paginatorProphecy->getLastPage()->willReturn(20)->shouldBeCalled(); - $paginator = $paginatorProphecy->reveal(); $decoratedNormalizerProphecy = $this->prophesize(NormalizerInterface::class); $decoratedNormalizerProphecy->normalize(Argument::type(PaginatorInterface::class), null, Argument::type('array'))->willReturn(['hydra:totalItems' => 40, 'foo' => 'bar'])->shouldBeCalled(); - $decoratedNormalizer = $decoratedNormalizerProphecy->reveal(); - $normalizer = new PartialCollectionViewNormalizer($decoratedNormalizer, '_page'); + $normalizer = new PartialCollectionViewNormalizer($decoratedNormalizerProphecy->reveal(), '_page'); $this->assertEquals( [ 'hydra:totalItems' => 40, @@ -68,7 +63,7 @@ public function testNormalizePaginator() 'hydra:next' => '/?_page=4', ], ], - $normalizer->normalize($paginator) + $normalizer->normalize($paginatorProphecy->reveal()) ); } @@ -76,23 +71,20 @@ public function testSupportsNormalization() { $decoratedNormalizerProphecy = $this->prophesize(NormalizerInterface::class); $decoratedNormalizerProphecy->supportsNormalization(Argument::any(), null)->willReturn(true)->shouldBeCalled(); - $decoratedNormalizer = $decoratedNormalizerProphecy->reveal(); - $normalizer = new PartialCollectionViewNormalizer($decoratedNormalizer); + $normalizer = new PartialCollectionViewNormalizer($decoratedNormalizerProphecy->reveal()); $this->assertTrue($normalizer->supportsNormalization(new \stdClass())); } - public function testSetSerializer() + public function testSetNormalizer() { - $serializer = $this->prophesize(SerializerInterface::class)->reveal(); + $injectedNormalizer = $this->prophesize(NormalizerInterface::class)->reveal(); - $decoratedNormalizerProphecy = $this - ->prophesize(NormalizerInterface::class) - ->willImplement(SerializerAwareInterface::class); - $decoratedNormalizerProphecy->setSerializer(Argument::type(SerializerInterface::class))->shouldBeCalled(); - $decoratedNormalizer = $decoratedNormalizerProphecy->reveal(); + $decoratedNormalizerProphecy = $this->prophesize(NormalizerInterface::class); + $decoratedNormalizerProphecy->willImplement(NormalizerAwareInterface::class); + $decoratedNormalizerProphecy->setNormalizer(Argument::type(NormalizerInterface::class))->shouldBeCalled(); - $normalizer = new PartialCollectionViewNormalizer($decoratedNormalizer); - $normalizer->setSerializer($serializer); + $normalizer = new PartialCollectionViewNormalizer($decoratedNormalizerProphecy->reveal()); + $normalizer->setNormalizer($injectedNormalizer); } } diff --git a/tests/JsonLd/Serializer/JsonLdEncoderTest.php b/tests/Serializer/JsonEncoderTest.php similarity index 64% rename from tests/JsonLd/Serializer/JsonLdEncoderTest.php rename to tests/Serializer/JsonEncoderTest.php index bd667a19215..04a2fb7e02e 100644 --- a/tests/JsonLd/Serializer/JsonLdEncoderTest.php +++ b/tests/Serializer/JsonEncoderTest.php @@ -9,28 +9,28 @@ * file that was distributed with this source code. */ -namespace ApiPlatform\Core\Tests\JsonLd\Serializer; +namespace ApiPlatform\Core\Tests\Serializer; -use ApiPlatform\Core\JsonLd\Serializer\JsonLdEncoder; +use ApiPlatform\Core\Serializer\JsonEncoder; /** * @author Kévin Dunglas */ -class JsonLdEncoderTest extends \PHPUnit_Framework_TestCase +class JsonEncoderTest extends \PHPUnit_Framework_TestCase { /** - * @var JsonLdEncoder + * @var JsonEncoder */ private $encoder; public function setUp() { - $this->encoder = new JsonLdEncoder(); + $this->encoder = new JsonEncoder('json'); } public function testSupportEncoding() { - $this->assertTrue($this->encoder->supportsEncoding(JsonLdEncoder::FORMAT)); + $this->assertTrue($this->encoder->supportsEncoding('json')); $this->assertFalse($this->encoder->supportsEncoding('csv')); } @@ -38,17 +38,17 @@ public function testEncode() { $data = ['foo' => 'bar']; - $this->assertEquals('{"foo":"bar"}', $this->encoder->encode($data, JsonLdEncoder::FORMAT)); + $this->assertEquals('{"foo":"bar"}', $this->encoder->encode($data, 'json')); } public function testSupportDecoding() { - $this->assertTrue($this->encoder->supportsDecoding(JsonLdEncoder::FORMAT)); + $this->assertTrue($this->encoder->supportsDecoding('json')); $this->assertFalse($this->encoder->supportsDecoding('csv')); } public function testDecode() { - $this->assertEquals(['foo' => 'bar'], $this->encoder->decode('{"foo":"bar"}', JsonLdEncoder::FORMAT)); + $this->assertEquals(['foo' => 'bar'], $this->encoder->decode('{"foo":"bar"}', 'json')); } } diff --git a/tests/Swagger/ApiDocumentationBuilderTest.php b/tests/Swagger/ApiDocumentationBuilderTest.php index 77621834ab3..6cebc019f56 100644 --- a/tests/Swagger/ApiDocumentationBuilderTest.php +++ b/tests/Swagger/ApiDocumentationBuilderTest.php @@ -14,7 +14,6 @@ use ApiPlatform\Core\Api\IriConverterInterface; use ApiPlatform\Core\Api\OperationMethodResolverInterface; use ApiPlatform\Core\Api\ResourceClassResolverInterface; -use ApiPlatform\Core\JsonLd\ContextBuilderInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; @@ -51,8 +50,6 @@ public function testGetApiDocumention() $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create('dummy', 'name')->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'name', true, true, true, true, false, false, null, [])); - $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(true); @@ -65,7 +62,7 @@ public function testGetApiDocumention() $iriConverter = $this->prophesize(IriConverterInterface::class); $iriConverter->getIriFromResourceClass('dummy')->shouldBeCalled()->willReturn('/dummies'); - $apiDocumentationBuilder = new ApiDocumentationBuilder($resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $contextBuilderProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $operationMethodResolverProphecy->reveal(), $iriConverter->reveal(), $formats, $title, $desc); + $apiDocumentationBuilder = new ApiDocumentationBuilder($resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $operationMethodResolverProphecy->reveal(), $iriConverter->reveal(), $formats, $title, $desc); $expected = [ 'swagger' => '2.0', From 67bf83085a6e40ca6172d71f463881ce2ac77c83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Thu, 14 Jul 2016 15:16:39 +0200 Subject: [PATCH 2/2] Refactor and test IriHelper --- .../EventListener/ValidateListener.php | 2 +- src/EventListener/DeserializeListener.php | 2 +- src/EventListener/ReadListener.php | 2 +- src/EventListener/SerializeListener.php | 2 +- src/Hal/Serializer/CollectionNormalizer.php | 14 +++---- .../PartialCollectionViewNormalizer.php | 16 ++++---- src/Serializer/SerializerContextBuilder.php | 2 +- .../IriHelper.php} | 33 +++++++++++------ .../RequestAttributesExtractor.php | 4 +- .../Controller/ConfigCustomController.php | 2 +- tests/Util/IriHelperTest.php | 37 +++++++++++++++++++ .../RequestAttributesExtractorTest.php | 18 +++------ 12 files changed, 88 insertions(+), 46 deletions(-) rename src/{Routing/CollectionRoutingHelper.php => Util/IriHelper.php} (61%) rename src/{Api => Util}/RequestAttributesExtractor.php (95%) create mode 100644 tests/Util/IriHelperTest.php rename tests/{Api => Util}/RequestAttributesExtractorTest.php (69%) diff --git a/src/Bridge/Symfony/Validator/EventListener/ValidateListener.php b/src/Bridge/Symfony/Validator/EventListener/ValidateListener.php index 59e5c86a50a..5e8a3fed2dd 100644 --- a/src/Bridge/Symfony/Validator/EventListener/ValidateListener.php +++ b/src/Bridge/Symfony/Validator/EventListener/ValidateListener.php @@ -11,10 +11,10 @@ namespace ApiPlatform\Core\Bridge\Symfony\Validator\EventListener; -use ApiPlatform\Core\Api\RequestAttributesExtractor; use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException; use ApiPlatform\Core\Exception\RuntimeException; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Util\RequestAttributesExtractor; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent; use Symfony\Component\Validator\Validator\ValidatorInterface; diff --git a/src/EventListener/DeserializeListener.php b/src/EventListener/DeserializeListener.php index 8ef976d3125..65c882b0960 100644 --- a/src/EventListener/DeserializeListener.php +++ b/src/EventListener/DeserializeListener.php @@ -11,9 +11,9 @@ namespace ApiPlatform\Core\EventListener; -use ApiPlatform\Core\Api\RequestAttributesExtractor; use ApiPlatform\Core\Exception\RuntimeException; use ApiPlatform\Core\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\Core\Util\RequestAttributesExtractor; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\GetResponseEvent; use Symfony\Component\Serializer\SerializerInterface; diff --git a/src/EventListener/ReadListener.php b/src/EventListener/ReadListener.php index a762548294c..8c2a287373b 100644 --- a/src/EventListener/ReadListener.php +++ b/src/EventListener/ReadListener.php @@ -11,11 +11,11 @@ namespace ApiPlatform\Core\EventListener; -use ApiPlatform\Core\Api\RequestAttributesExtractor; use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; use ApiPlatform\Core\DataProvider\PaginatorInterface; use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\Util\RequestAttributesExtractor; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\GetResponseEvent; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; diff --git a/src/EventListener/SerializeListener.php b/src/EventListener/SerializeListener.php index cddbd291c17..6f013e9bad5 100644 --- a/src/EventListener/SerializeListener.php +++ b/src/EventListener/SerializeListener.php @@ -11,9 +11,9 @@ namespace ApiPlatform\Core\EventListener; -use ApiPlatform\Core\Api\RequestAttributesExtractor; use ApiPlatform\Core\Exception\RuntimeException; use ApiPlatform\Core\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\Core\Util\RequestAttributesExtractor; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent; use Symfony\Component\Serializer\Encoder\EncoderInterface; diff --git a/src/Hal/Serializer/CollectionNormalizer.php b/src/Hal/Serializer/CollectionNormalizer.php index 9dd5edd9685..2f79f2327f7 100644 --- a/src/Hal/Serializer/CollectionNormalizer.php +++ b/src/Hal/Serializer/CollectionNormalizer.php @@ -13,8 +13,8 @@ use ApiPlatform\Core\Api\ResourceClassResolverInterface; use ApiPlatform\Core\DataProvider\PaginatorInterface; -use ApiPlatform\Core\Routing\CollectionRoutingHelper; 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; @@ -65,7 +65,7 @@ public function normalize($object, $format = null, array $context = []) $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); $context = $this->initContext($resourceClass, $context, $format); - list($parts, $parameters) = CollectionRoutingHelper::parseRequestUri($context['request_uri'] ?? '/', $this->pageParameterName); + $parsed = IriHelper::parseIri($context['request_uri'] ?? '/', $this->pageParameterName); $paginated = $isPaginator = $object instanceof PaginatorInterface; if ($isPaginator) { @@ -78,20 +78,20 @@ public function normalize($object, $format = null, array $context = []) $data = [ '_links' => [ - 'self' => CollectionRoutingHelper::generateUrl($parts, $parameters, $this->pageParameterName, $paginated ? $currentPage : null), + 'self' => IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null), ], ]; if ($paginated) { - $data['_links']['first'] = CollectionRoutingHelper::generateUrl($parts, $parameters, $this->pageParameterName, 1.); - $data['_links']['last'] = CollectionRoutingHelper::generateUrl($parts, $parameters, $this->pageParameterName, $lastPage); + $data['_links']['first'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1.); + $data['_links']['last'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage); if (1. !== $currentPage) { - $data['_links']['prev'] = CollectionRoutingHelper::generateUrl($parts, $parameters, $this->pageParameterName, $currentPage - 1.); + $data['_links']['prev'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1.); } if ($currentPage !== $lastPage) { - $data['_links']['next'] = CollectionRoutingHelper::generateUrl($parts, $parameters, $this->pageParameterName, $currentPage + 1.); + $data['_links']['next'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1.); } } diff --git a/src/Hydra/Serializer/PartialCollectionViewNormalizer.php b/src/Hydra/Serializer/PartialCollectionViewNormalizer.php index e12458ec980..ad2a7bdd15c 100644 --- a/src/Hydra/Serializer/PartialCollectionViewNormalizer.php +++ b/src/Hydra/Serializer/PartialCollectionViewNormalizer.php @@ -13,7 +13,7 @@ use ApiPlatform\Core\DataProvider\PaginatorInterface; use ApiPlatform\Core\JsonLd\Serializer\JsonLdContextTrait; -use ApiPlatform\Core\Routing\CollectionRoutingHelper; +use ApiPlatform\Core\Util\IriHelper; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -58,8 +58,8 @@ public function normalize($object, $format = null, array $context = []) } } - list($parts, $parameters) = CollectionRoutingHelper::parseRequestUri($context['request_uri'] ?? '/', $this->pageParameterName); - $appliedFilters = $parameters; + $parsed = IriHelper::parseIri($context['request_uri'] ?? '/', $this->pageParameterName); + $appliedFilters = $parsed['parameters']; unset($appliedFilters[$this->enabledParameterName]); if ([] === $appliedFilters && !$paginated) { @@ -67,20 +67,20 @@ public function normalize($object, $format = null, array $context = []) } $data['hydra:view'] = [ - '@id' => CollectionRoutingHelper::generateUrl($parts, $parameters, $this->pageParameterName, $paginated ? $currentPage : null), + '@id' => IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null), '@type' => 'hydra:PartialCollectionView', ]; if ($paginated) { - $data['hydra:view']['hydra:first'] = CollectionRoutingHelper::generateUrl($parts, $parameters, $this->pageParameterName, 1.); - $data['hydra:view']['hydra:last'] = CollectionRoutingHelper::generateUrl($parts, $parameters, $this->pageParameterName, $lastPage); + $data['hydra:view']['hydra:first'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1.); + $data['hydra:view']['hydra:last'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage); if (1. !== $currentPage) { - $data['hydra:view']['hydra:previous'] = CollectionRoutingHelper::generateUrl($parts, $parameters, $this->pageParameterName, $currentPage - 1.); + $data['hydra:view']['hydra:previous'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1.); } if ($currentPage !== $lastPage) { - $data['hydra:view']['hydra:next'] = CollectionRoutingHelper::generateUrl($parts, $parameters, $this->pageParameterName, $currentPage + 1.); + $data['hydra:view']['hydra:next'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1.); } } diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index 36feb767d88..f86ec4207d9 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -11,8 +11,8 @@ namespace ApiPlatform\Core\Serializer; -use ApiPlatform\Core\Api\RequestAttributesExtractor; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Util\RequestAttributesExtractor; use Symfony\Component\HttpFoundation\Request; /** diff --git a/src/Routing/CollectionRoutingHelper.php b/src/Util/IriHelper.php similarity index 61% rename from src/Routing/CollectionRoutingHelper.php rename to src/Util/IriHelper.php index b479354db64..a9c8e6c515e 100644 --- a/src/Routing/CollectionRoutingHelper.php +++ b/src/Util/IriHelper.php @@ -9,28 +9,32 @@ * file that was distributed with this source code. */ -namespace ApiPlatform\Core\Routing; +namespace ApiPlatform\Core\Util; use ApiPlatform\Core\Exception\InvalidArgumentException; -use ApiPlatform\Core\Util\RequestParser; /** - * URL generator for collections. + * Parses and creates IRIs. * * @author Kévin Dunglas + * + * @internal */ -final class CollectionRoutingHelper +abstract class IriHelper { /** - * Parses and standardizes the request URI. + * Parses and standardizes the request IRI. + * + * @param string $iri + * @param string $pageParameterName * - * @throws InvalidArgumentException + * @return array */ - public static function parseRequestUri(string $requestUri, string $pageParameterName) : array + public static function parseIri(string $iri, string $pageParameterName) : array { - $parts = parse_url($requestUri); + $parts = parse_url($iri); if (false === $parts) { - throw new InvalidArgumentException(sprintf('The request URI "%s" is malformed.', $requestUri)); + throw new InvalidArgumentException(sprintf('The request URI "%s" is malformed.', $iri)); } $parameters = []; @@ -41,13 +45,20 @@ public static function parseRequestUri(string $requestUri, string $pageParameter unset($parameters[$pageParameterName]); } - return [$parts, $parameters]; + return ['parts' => $parts, 'parameters' => $parameters]; } /** * Gets a collection IRI for the given parameters. + * + * @param array $parts + * @param array $parameters + * @param string $pageParameterName + * @param float $page + * + * @return string */ - public static function generateUrl(array $parts, array $parameters, string $pageParameterName, float $page = null) : string + public static function createIri(array $parts, array $parameters, string $pageParameterName, float $page = null) : string { if (null !== $page) { $parameters[$pageParameterName] = $page; diff --git a/src/Api/RequestAttributesExtractor.php b/src/Util/RequestAttributesExtractor.php similarity index 95% rename from src/Api/RequestAttributesExtractor.php rename to src/Util/RequestAttributesExtractor.php index c887fcd9229..a78ab755ee2 100644 --- a/src/Api/RequestAttributesExtractor.php +++ b/src/Util/RequestAttributesExtractor.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace ApiPlatform\Core\Api; +namespace ApiPlatform\Core\Util; use ApiPlatform\Core\Exception\RuntimeException; use Symfony\Component\HttpFoundation\Request; @@ -21,7 +21,7 @@ * * @internal */ -final class RequestAttributesExtractor +abstract class RequestAttributesExtractor { /** * Extracts resource class, operation name and format request attributes. Throws an exception if the request does not diff --git a/tests/Fixtures/TestBundle/Controller/ConfigCustomController.php b/tests/Fixtures/TestBundle/Controller/ConfigCustomController.php index 430058a7450..b7e2771a1d1 100644 --- a/tests/Fixtures/TestBundle/Controller/ConfigCustomController.php +++ b/tests/Fixtures/TestBundle/Controller/ConfigCustomController.php @@ -11,8 +11,8 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Controller; -use ApiPlatform\Core\Api\RequestAttributesExtractor; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; +use ApiPlatform\Core\Util\RequestAttributesExtractor; use Symfony\Component\HttpFoundation\Request; /** diff --git a/tests/Util/IriHelperTest.php b/tests/Util/IriHelperTest.php new file mode 100644 index 00000000000..ca0067e3c62 --- /dev/null +++ b/tests/Util/IriHelperTest.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Tests\Util; + +use ApiPlatform\Core\Util\IriHelper; + +/** + * @author Kévin Dunglas + */ +class IriHelperTest extends \PHPUnit_Framework_TestCase +{ + public function testHelpers() + { + $parsed = [ + 'parts' => [ + 'path' => '/hello.json', + 'query' => 'foo=bar&page=2&bar=3', + ], + 'parameters' => [ + 'foo' => 'bar', + 'bar' => '3', + ], + ]; + + $this->assertEquals($parsed, IriHelper::parseIri('/hello.json?foo=bar&page=2&bar=3', 'page')); + $this->assertEquals('/hello.json?foo=bar&bar=3&page=2', IriHelper::createIri($parsed['parts'], $parsed['parameters'], 'page', 2)); + } +} diff --git a/tests/Api/RequestAttributesExtractorTest.php b/tests/Util/RequestAttributesExtractorTest.php similarity index 69% rename from tests/Api/RequestAttributesExtractorTest.php rename to tests/Util/RequestAttributesExtractorTest.php index 9a0c33b26de..43303d7ac28 100644 --- a/tests/Api/RequestAttributesExtractorTest.php +++ b/tests/Util/RequestAttributesExtractorTest.php @@ -9,9 +9,9 @@ * file that was distributed with this source code. */ -namespace ApiPlatform\Core\Tests\Api; +namespace ApiPlatform\Core\Tests\Util; -use ApiPlatform\Core\Api\RequestAttributesExtractor; +use ApiPlatform\Core\Util\RequestAttributesExtractor; use Symfony\Component\HttpFoundation\Request; /** @@ -22,22 +22,20 @@ class RequestAttributesExtractorTest extends \PHPUnit_Framework_TestCase public function testExtractCollectionAttributes() { $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'post']); - $extactor = new RequestAttributesExtractor(); $this->assertEquals( ['resource_class' => 'Foo', 'collection_operation_name' => 'post'], - $extactor->extractAttributes($request) + RequestAttributesExtractor::extractAttributes($request) ); } public function testExtractItemAttributes() { $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get']); - $extactor = new RequestAttributesExtractor(); $this->assertEquals( ['resource_class' => 'Foo', 'item_operation_name' => 'get'], - $extactor->extractAttributes($request) + RequestAttributesExtractor::extractAttributes($request) ); } @@ -47,9 +45,7 @@ public function testExtractItemAttributes() */ public function testResourceClassNotSet() { - $request = new Request([], [], ['_api_item_operation_name' => 'get']); - $extactor = new RequestAttributesExtractor(); - $extactor->extractAttributes($request); + RequestAttributesExtractor::extractAttributes(new Request([], [], ['_api_item_operation_name' => 'get'])); } /** @@ -58,8 +54,6 @@ public function testResourceClassNotSet() */ public function testOperationNotSet() { - $request = new Request([], [], ['_api_resource_class' => 'Foo']); - $extactor = new RequestAttributesExtractor(); - $extactor->extractAttributes($request); + RequestAttributesExtractor::extractAttributes(new Request([], [], ['_api_resource_class' => 'Foo'])); } }