diff --git a/features/hal/crud.feature b/features/hal/crud.feature new file mode 100644 index 00000000000..91253c9ca26 --- /dev/null +++ b/features/hal/crud.feature @@ -0,0 +1,326 @@ +Feature: Create-Retrieve-Update-Delete + In order to use an hypermedia API + As a client software developer + I need to be able to retrieve, create, update and delete JSON-LD encoded resources. + + @createSchema + Scenario: Create a resource + When I add "Accept" header equal to "application/hal+json" + And I send a "POST" request to "/dummies" with body: + """ + { + "name": "My Dummy", + "dummyDate": "2015-03-01T10:00:00+00:00", + "jsonData": { + "key": [ + "value1", + "value2" + ] + } + } + """ + Then the response status code should be 201 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/hal+json" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "\/dummies\/1" + } + }, + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": "2015-03-01T10:00:00+00:00", + "dummyPrice": null, + "relatedDummy": null, + "relatedDummies": [], + "jsonData": { + "key": [ + "value1", + "value2" + ] + }, + "name_converted": null, + "name": "My Dummy", + "alias": null + } + """ + + Scenario: Get a resource + 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" + } + }, + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": "2015-03-01T10:00:00+00:00", + "dummyPrice": null, + "relatedDummy": null, + "relatedDummies": [], + "jsonData": { + "key": [ + "value1", + "value2" + ] + }, + "name_converted": null, + "name": "My Dummy", + "alias": null + } + """ + + Scenario: Get a not found exception + When I send a "GET" request to "/dummies/42" + Then the response status code should be 404 + + Scenario: Get a collection + When 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/ld+json" + And the JSON should be equal to: + """ + { + "@context": "/contexts/Dummy", + "@id": "/dummies", + "@type": "hydra:Collection", + "hydra:member": [ + { + "@id": "/dummies/1", + "@type": "Dummy", + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": "2015-03-01T10:00:00+00:00", + "dummyPrice": null, + "relatedDummy": null, + "relatedDummies": [], + "jsonData": { + "key": [ + "value1", + "value2" + ] + }, + "name_converted": null, + "name": "My Dummy", + "alias": null + } + ], + "hydra:totalItems": 1, + "hydra:search": { + "@type": "hydra:IriTemplate", + "hydra:template": "/dummies{?id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,order[id],order[name],order[relatedDummy.symfony],dummyDate[before],dummyDate[after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[after],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],dummyBoolean,dummyPrice}", + "hydra:variableRepresentation": "BasicRepresentation", + "hydra:mapping": [ + { + "@type": "IriTemplateMapping", + "variable": "id", + "property": "id", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "id[]", + "property": "id", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "name", + "property": "name", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "alias", + "property": "alias", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "description", + "property": "description", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "relatedDummy.name", + "property": "relatedDummy.name", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "relatedDummy.name[]", + "property": "relatedDummy.name", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "relatedDummies", + "property": "relatedDummies", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "relatedDummies[]", + "property": "relatedDummies", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummy", + "property": "dummy", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "order[id]", + "property": "id", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "order[name]", + "property": "name", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "order[relatedDummy.symfony]", + "property": "relatedDummy.symfony", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyDate[before]", + "property": "dummyDate", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyDate[after]", + "property": "dummyDate", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "relatedDummy.dummyDate[before]", + "property": "relatedDummy.dummyDate", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "relatedDummy.dummyDate[after]", + "property": "relatedDummy.dummyDate", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyPrice[between]", + "property": "dummyPrice", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyPrice[gt]", + "property": "dummyPrice", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyPrice[gte]", + "property": "dummyPrice", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyPrice[lt]", + "property": "dummyPrice", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyPrice[lte]", + "property": "dummyPrice", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyBoolean", + "property": "dummyBoolean", + "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "dummyPrice", + "property": "dummyPrice", + "required": false + } + ] + } + } + """ + + Scenario: Update a resource + When I send a "PUT" request to "/dummies/1" with body: + """ + { + "@id": "/dummies/1", + "name": "A nice dummy", + "jsonData": [{ + "key": "value1" + }, + { + "key": "value2" + } + ] + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json" + And the JSON should be equal to: + """ + { + "@context": "/contexts/Dummy", + "@id": "/dummies/1", + "@type": "Dummy", + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": "2015-03-01T10:00:00+00:00", + "dummyPrice": null, + "relatedDummy": null, + "relatedDummies": [], + "jsonData": [ + { + "key": "value1" + }, + { + "key": "value2" + } + ], + "name_converted": null, + "name": "A nice dummy", + "alias": null + } + """ + + @dropSchema + Scenario: Delete a resource + When I send a "DELETE" request to "/dummies/1" + Then the response status code should be 204 + And the response should be empty 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/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index b0fe5a3ffe8..9cf41c5fe7f 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -40,6 +40,14 @@ + + + + + + + + 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..a357b68717b --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/hal.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + api_platform.hal.action.exception + + + + + + + + + + + + + + + + + %kernel.debug% + + + + + + jsonhal + + + + + + + + + + + + + + + + + + + + + + %api_platform.formats% + + + + + + + + %api_platform.collection.pagination.page_parameter_name% + %api_platform.collection.pagination.enabled_parameter_name% + %api_platform.formats% + + + + + + + + + %api_platform.formats% + + + + + + + + + + + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/hydra.xml b/src/Bridge/Symfony/Bundle/Resources/config/hydra.xml index de30c9b4d9d..c5daa1ef8d8 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/hydra.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/hydra.xml @@ -74,6 +74,8 @@ %api_platform.collection.pagination.page_parameter_name% %api_platform.collection.pagination.enabled_parameter_name% + %api_platform.formats% + @@ -81,11 +83,12 @@ - + %api_platform.formats% + - + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml b/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml index 1819297f81a..3bce669946e 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"> - @@ -25,11 +24,14 @@ + %api_platform.formats% - + + jsonld + 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 d1b5234c788..3f0d566a8d0 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml @@ -11,7 +11,7 @@ - + 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/Bridge/Symfony/Validator/Hal/EventListener/ValidationExceptionListener.php b/src/Bridge/Symfony/Validator/Hal/EventListener/ValidationExceptionListener.php new file mode 100644 index 00000000000..f960d381455 --- /dev/null +++ b/src/Bridge/Symfony/Validator/Hal/EventListener/ValidationExceptionListener.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Bridge\Symfony\Validator\Hal\EventListener; + +use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Handles validation errors. + * + * @author Kévin Dunglas + * @author Armouche Hamza + */ +final class ValidationExceptionListener +{ + private $normalizer; + + public function __construct(NormalizerInterface $normalizer) + { + $this->normalizer = $normalizer; + } + + /** + * Returns a list of violations normalized in the Hydra format. + * + * @param GetResponseForExceptionEvent $event + */ + public function onKernelException(GetResponseForExceptionEvent $event) + { + $exception = $event->getException(); + + if ($exception instanceof ValidationException) { + $event->setResponse(new JsonResponse( + $this->normalizer->normalize($exception->getConstraintViolationList(), 'hal-error'), + JsonResponse::HTTP_BAD_REQUEST, + ['Content-Type' => 'application/hal+json'] + )); + } + } +} diff --git a/src/Bridge/Symfony/Validator/Hal/Serializer/ConstraintViolationListNormalizer.php b/src/Bridge/Symfony/Validator/Hal/Serializer/ConstraintViolationListNormalizer.php new file mode 100644 index 00000000000..049fbe1dbeb --- /dev/null +++ b/src/Bridge/Symfony/Validator/Hal/Serializer/ConstraintViolationListNormalizer.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Bridge\Symfony\Validator\Hal\Serializer; + +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Validator\ConstraintViolationListInterface; + +/** + * Converts {@see \Symfony\Component\Validator\ConstraintViolationListInterface} to a Hal error representation. + * + * @author Kévin Dunglas + * @author Armouche Hamza + */ +final class ConstraintViolationListNormalizer implements NormalizerInterface +{ + const FORMAT = 'hal-error'; + + /** + * @var UrlGeneratorInterface + */ + private $urlGenerator; + + public function __construct(UrlGeneratorInterface $urlGenerator) + { + $this->urlGenerator = $urlGenerator; + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []) + { + $violations = []; + $messages = []; + + foreach ($object as $violation) { + $violations[] = [ + 'propertyPath' => $violation->getPropertyPath(), + 'message' => $violation->getMessage(), + ]; + + $propertyPath = $violation->getPropertyPath(); + $prefix = $propertyPath ? sprintf('%s: ', $propertyPath) : ''; + + $messages [] = $prefix.$violation->getMessage(); + } + + return [ + '@context' => $this->urlGenerator->generate('api_jsonld_context', ['shortName' => 'ConstraintViolationList']), + '@type' => 'ConstraintViolationList', + 'title' => $context['title'] ?? 'An error occurred', + 'description' => $messages ? implode("\n", $messages) : (string) $object, + 'violations' => $violations, + ]; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null) + { + return self::FORMAT === $format && $data instanceof ConstraintViolationListInterface; + } +} diff --git a/src/EventListener/ExceptionListener.php b/src/EventListener/ExceptionListener.php new file mode 100644 index 00000000000..ea569c39e73 --- /dev/null +++ b/src/EventListener/ExceptionListener.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\EventListener; + +use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent; +use Symfony\Component\HttpKernel\EventListener\ExceptionListener as BaseExceptionListener; + +/** + * Handle requests errors. + * + * @author Samuel ROZE + * @author Kévin Dunglas + */ +final class ExceptionListener extends BaseExceptionListener +{ + public function onKernelException(GetResponseForExceptionEvent $event) + { + // Normalize exceptions with hydra errors only for resources + if (!$event->getRequest()->attributes->has('_api_resource_class')) { + return; + } + + parent::onKernelException($event); + } +} diff --git a/src/Hal/Action/ExceptionAction.php b/src/Hal/Action/ExceptionAction.php new file mode 100644 index 00000000000..5c4a8ff4db8 --- /dev/null +++ b/src/Hal/Action/ExceptionAction.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Hal\Action; + +use ApiPlatform\Core\Exception\InvalidArgumentException; +use Symfony\Component\Debug\Exception\FlattenException; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\Serializer\Exception\ExceptionInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Renders a normalized exception for a given {@see \Symfony\Component\Debug\Exception\FlattenException}. + * + * @author Baptiste Meyer + * @author Armouche Hamza + */ +final class ExceptionAction +{ + private $normalizer; + + public function __construct(NormalizerInterface $normalizer) + { + $this->normalizer = $normalizer; + } + + /** + * Converts a an exception to a JSON response. + */ + public function __invoke(FlattenException $exception) : JsonResponse + { + $exceptionClass = $exception->getClass(); + if ( + is_a($exceptionClass, ExceptionInterface::class, true) || + is_a($exceptionClass, InvalidArgumentException::class, true) + ) { + $exception->setStatusCode(JsonResponse::HTTP_BAD_REQUEST); + } + + $headers = $exception->getHeaders(); + $headers['Content-Type'] = 'application/hal+json'; + + return new JsonResponse($this->normalizer->normalize($exception, 'hal-error'), $exception->getStatusCode(), $headers); + } +} diff --git a/src/Hal/ContextBuilder.php b/src/Hal/ContextBuilder.php new file mode 100644 index 00000000000..428c924f186 --- /dev/null +++ b/src/Hal/ContextBuilder.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Hal; + +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use ApiPlatform\Core\Hypermedia\ContextBuilderInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * {@inheritdoc} + * + * @author Kévin Dunglas + */ +final class ContextBuilder implements ContextBuilderInterface +{ + private $resourceNameCollectionFactory; + private $resourceMetadataFactory; + private $propertyNameCollectionFactory; + private $propertyMetadataFactory; + private $urlGenerator; + private $docUri; + + /** + * @var NameConverterInterface + */ + private $nameConverter; + + public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, UrlGeneratorInterface $urlGenerator, string $docUri = '', NameConverterInterface $nameConverter = null) + { + $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; + $this->propertyMetadataFactory = $propertyMetadataFactory; + $this->urlGenerator = $urlGenerator; + $this->nameConverter = $nameConverter; + $this->docUri = $docUri; + } + + /** + * {@inheritdoc} + */ + public function getBaseContext(int $referenceType = UrlGeneratorInterface::ABS_URL, string $linkUrl = '/') : array + { + return [ + '_links' => ['self' => ['href' => $referenceType ? $this->urlGenerator->generate('api_hal_entrypoint') : $linkUrl], + 'curies' => [ + ['name' => 'ap', + 'href' => $this->urlGenerator->generate('api_hal_entrypoint').$this->docUri.'#section-{rel}', + 'templated' => true, + ], + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function getEntrypointContext(int $referenceType = UrlGeneratorInterface::ABS_PATH) : array + { + return []; + } + + /** + * {@inheritdoc} + */ + public function getResourceContext(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH) : array + { + return []; + } + + public function getResourceContextUri(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH) : string + { + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + + return $this->urlGenerator->generate('api_jsonhal_context', ['shortName' => $resourceMetadata->getShortName()]); + } +} diff --git a/src/Hal/EntrypointBuilder.php b/src/Hal/EntrypointBuilder.php new file mode 100644 index 00000000000..8b2629d8811 --- /dev/null +++ b/src/Hal/EntrypointBuilder.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Hal; + +use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use ApiPlatform\Core\Exception\InvalidArgumentException; +use ApiPlatform\Core\Hypermedia\ContextBuilderInterface; +use ApiPlatform\Core\JsonLd\EntrypointBuilderInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; + +/** + * {@inheritdoc} + * + * @author Kévin Dunglas + */ +final class EntrypointBuilder implements EntrypointBuilderInterface +{ + private $resourceNameCollectionFactory; + private $resourceMetadataFactory; + private $iriConverter; + private $urlGenerator; + private $contextBuilder; + + public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, IriConverterInterface $iriConverter, UrlGeneratorInterface $urlGenerator, ContextBuilderInterface $contextBuilder) + { + $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->iriConverter = $iriConverter; + $this->urlGenerator = $urlGenerator; + $this->contextBuilder = $contextBuilder; + } + + /** + * {@inheritdoc} + */ + public function getEntrypoint(string $referenceType = UrlGeneratorInterface::ABS_PATH) : array + { + $entrypoint = $this->contextBuilder->getBaseContext($referenceType); + + foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + + if (empty($resourceMetadata->getCollectionOperations())) { + continue; + } + try { + $entrypoint['_links']['ap:'.$resourceMetadata->getShortName()] = ['href' => $this->iriConverter->getIriFromResourceClass($resourceClass)]; + } catch (InvalidArgumentException $ex) { + // Ignore resources without GET operations + } + } + + return $entrypoint; + } +} diff --git a/src/Hal/Serializer/CollectionFiltersNormalizer.php b/src/Hal/Serializer/CollectionFiltersNormalizer.php new file mode 100644 index 00000000000..a984bd12da5 --- /dev/null +++ b/src/Hal/Serializer/CollectionFiltersNormalizer.php @@ -0,0 +1,143 @@ + + * + * 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\FilterCollection; +use ApiPlatform\Core\Api\FilterInterface; +use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Serializer\ContextTrait; +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 +{ + use ContextTrait; + use SerializerAwareTrait { + setSerializer as baseSetSerializer; + } + + private $collectionNormalizer; + private $resourceMetadataFactory; + private $resourceClassResolver; + private $filters; + private $formats; + + public function __construct(NormalizerInterface $collectionNormalizer, ResourceMetadataFactoryInterface $resourceMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, FilterCollection $filters, array $formats) + { + $this->collectionNormalizer = $collectionNormalizer; + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->resourceClassResolver = $resourceClassResolver; + $this->filters = $filters; + $this->formats = $formats; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null) + { + return $this->collectionNormalizer->supportsNormalization($data, $format); + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []) + { + $data = $this->collectionNormalizer->normalize($object, $format, $context); + $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); + + if (isset($context['jsonld_sub_level'])) { + return $data; + } + + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + + $operationName = $context['collection_operation_name'] ?? null; + + if ($operationName) { + $resourceFilters = $resourceMetadata->getCollectionOperationAttribute($operationName, 'filters', [], true); + } else { + $resourceFilters = $resourceMetadata->getAttribute('filters', []); + } + + if ([] === $resourceFilters) { + return $data; + } + + $requestParts = parse_url($context['request_uri']); + if (!is_array($requestParts)) { + return $data; + } + + $currentFilters = []; + foreach ($this->filters as $filterName => $filter) { + if (in_array($filterName, $resourceFilters)) { + $currentFilters[] = $filter; + } + } + $context = $this->initContext($resourceClass, $context, $format); + + if ([] !== $currentFilters) { + if (isset($context['jsonhal_has_context'])) { + $data['_links']['self'] = array_merge($data['_links']['self'], $this->getSearch($resourceClass, $requestParts, $currentFilters, $context)); + } + } + + return $data; + } + + /** + * {@inheritdoc} + */ + public function setSerializer(SerializerInterface $serializer) + { + $this->baseSetSerializer($serializer); + + if ($this->collectionNormalizer instanceof SerializerAwareInterface) { + $this->collectionNormalizer->setSerializer($serializer); + } + } + + /** + * Returns the content of the Hydra search property. + * + * @param string $resourceClass + * @param array $parts + * @param FilterInterface[] $filters + * + * @return array + */ + private function getSearch(string $resourceClass, array $parts, array $filters, array $context) : array + { + $variables = []; + foreach ($filters as $filter) { + foreach ($filter->getDescription($resourceClass) as $variable => $data) { + $variables[] = $variable; + } + + if (isset($context['jsonhal_has_context'])) { + return [ + 'find' => sprintf('%s{?%s}', $parts['path'], implode(',', $variables)), + ]; + } + } + } +} diff --git a/src/Hal/Serializer/CollectionNormalizer.php b/src/Hal/Serializer/CollectionNormalizer.php new file mode 100644 index 00000000000..ec1479ac0cd --- /dev/null +++ b/src/Hal/Serializer/CollectionNormalizer.php @@ -0,0 +1,96 @@ + + * + * 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\Hypermedia\ContextBuilderInterface; +use ApiPlatform\Core\Serializer\ContextTrait; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerAwareTrait; + +/** + * This normalizer handles collections. + * + * @author Kevin Dunglas + * @author Samuel ROZE + */ +final class CollectionNormalizer implements NormalizerInterface, SerializerAwareInterface +{ + use ContextTrait; + use SerializerAwareTrait; + + private $contextBuilder; + private $resourceClassResolver; + private $iriConverter; + private $formats; + + public function __construct(ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, IriConverterInterface $iriConverter, array $formats = []) + { + $this->contextBuilder = $contextBuilder; + $this->resourceClassResolver = $resourceClassResolver; + $this->iriConverter = $iriConverter; + $this->formats = $formats; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null) + { + if (!isset($this->formats[$format])) { + return false; + } + + return is_array($data) || ($data instanceof \Traversable && $data instanceof \Countable); + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []) + { + if (!$this->serializer instanceof NormalizerInterface) { + throw new RuntimeException('The serializer must implement the NormalizerInterface.'); + } + $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class'] ?? null, true); + + if (isset($context['jsonhal_sub_level'])) { + $data = []; + foreach ($object as $index => $obj) { + $data[$index] = $this->serializer->normalize($obj, $format, $context); + } + + return $data; + } + $context = $this->initContext($resourceClass, $context, $format); + + if (isset($context['jsonhal_has_context'])) { + $data = $this->contextBuilder->getBaseContext(0, $this->iriConverter->getIriFromResourceClass($resourceClass)); + $data['_embedded'] = []; + foreach ($object as $obj) { + $data['_embedded'][] = $this->serializer->normalize($obj, $format, $context); + } + } else { + $data = []; + foreach ($object as $index => $obj) { + $data[$index] = $this->serializer->normalize($obj, $format, $context); + } + + return $data; + } + + return $data; + } +} diff --git a/src/Hal/Serializer/ErrorNormalizer.php b/src/Hal/Serializer/ErrorNormalizer.php new file mode 100644 index 00000000000..6b8549a5222 --- /dev/null +++ b/src/Hal/Serializer/ErrorNormalizer.php @@ -0,0 +1,68 @@ + + * + * 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\UrlGeneratorInterface; +use Symfony\Component\Debug\Exception\FlattenException; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Converts {@see \Exception} or {@see \Symfony\Component\Debug\Exception\FlattenException} + * to a Hydra error representation. + * + * @author Kévin Dunglas + * @author Samuel ROZE + */ +final class ErrorNormalizer implements NormalizerInterface +{ + const FORMAT = 'hal-error'; + + private $urlGenerator; + private $debug; + + public function __construct(UrlGeneratorInterface $urlGenerator, bool $debug) + { + $this->urlGenerator = $urlGenerator; + $this->debug = $debug; + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []) + { + $message = $object->getMessage(); + + if ($this->debug) { + $trace = $object->getTrace(); + } + + $data = [ + 'title' => $context['title'] ?? 'An error occurred', + 'description' => $message ?? (string) $object, + ]; + + if (isset($trace)) { + $data['trace'] = $trace; + } + + return $data; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null) + { + return self::FORMAT === $format && ($data instanceof \Exception || $data instanceof FlattenException); + } +} diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php new file mode 100644 index 00000000000..b958ac56b98 --- /dev/null +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -0,0 +1,90 @@ + + * + * 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\Hypermedia\ContextBuilderInterface; +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 $contextBuilder; + + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ContextBuilderInterface $contextBuilder, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null) + { + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter); + + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->contextBuilder = $contextBuilder; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null) + { + return self::FORMAT === $format && parent::supportsNormalization($data, $format); + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []) + { + $rawData = parent::normalize($object, $format, $context); + if (!is_array($rawData)) { + return $rawData; + } + + $data['_links']['self']['href'] = $this->iriConverter->getIriFromItem($object); + + return array_merge($data, $rawData); + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, $type, $format = null) + { + return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format); + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = null, array $context = []) + { + // Avoid issues with proxies if we populated the object + if (isset($data['_links']['self']['href']) && !isset($context['object_to_populate'])) { + $context['object_to_populate'] = $this->iriConverter->getItemFromIri($data['_links']['self']['href'], true); + } + + return parent::denormalize($data, $class, $format, $context); + } +} diff --git a/src/Hal/Serializer/PartialCollectionViewNormalizer.php b/src/Hal/Serializer/PartialCollectionViewNormalizer.php new file mode 100644 index 00000000000..d423eea51f8 --- /dev/null +++ b/src/Hal/Serializer/PartialCollectionViewNormalizer.php @@ -0,0 +1,141 @@ + + * + * 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\DataProvider\PaginatorInterface; +use ApiPlatform\Core\Exception\InvalidArgumentException; +use ApiPlatform\Core\Serializer\ContextTrait; +use ApiPlatform\Core\Util\RequestParser; +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. + * + * @author Kévin Dunglas + * @author Samuel ROZE + */ +final class PartialCollectionViewNormalizer implements NormalizerInterface, SerializerAwareInterface +{ + use ContextTrait; + + private $collectionNormalizer; + private $pageParameterName; + private $enabledParameterName; + private $formats; + + public function __construct(NormalizerInterface $collectionNormalizer, string $pageParameterName, string $enabledParameterName, array $formats) + { + $this->collectionNormalizer = $collectionNormalizer; + $this->pageParameterName = $pageParameterName; + $this->enabledParameterName = $enabledParameterName; + $this->formats = $formats; + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []) + { + $data = $this->collectionNormalizer->normalize($object, $format, $context); + + if ($paginated = $object instanceof PaginatorInterface) { + $currentPage = $object->getCurrentPage(); + $lastPage = $object->getLastPage(); + + if (1. === $currentPage && 1. === $lastPage) { + // Consider the collection not paginated if there is only one page + $paginated = false; + } + } + + list($parts, $parameters) = $this->parseRequestUri($context['request_uri'] ?? '/'); + $appliedFilters = $parameters; + unset($appliedFilters[$this->enabledParameterName]); + + if ([] === $appliedFilters && !$paginated) { + return $data; + } + + if ('jsonhal' === $format) { + if ($currentPage !== $lastPage) { + $data['_links']['self']['next'] = $this->getId($parts, $parameters, $currentPage + 1.); + } + + return $data; + } + + + 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} + */ + public function supportsNormalization($data, $format = null) + { + return $this->collectionNormalizer->supportsNormalization($data, $format); + } + + /** + * {@inheritdoc} + */ + public function setSerializer(SerializerInterface $serializer) + { + if ($this->collectionNormalizer instanceof SerializerAwareInterface) { + $this->collectionNormalizer->setSerializer($serializer); + } + } +} diff --git a/src/Hydra/ApiDocumentationBuilder.php b/src/Hydra/ApiDocumentationBuilder.php index 636a7912731..fd9f2c6dab1 100644 --- a/src/Hydra/ApiDocumentationBuilder.php +++ b/src/Hydra/ApiDocumentationBuilder.php @@ -15,7 +15,7 @@ use ApiPlatform\Core\Api\ResourceClassResolverInterface; use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\Documentation\ApiDocumentationBuilderInterface; -use ApiPlatform\Core\JsonLd\ContextBuilderInterface; +use ApiPlatform\Core\Hypermedia\ContextBuilderInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; diff --git a/src/Hydra/Serializer/CollectionFiltersNormalizer.php b/src/Hydra/Serializer/CollectionFiltersNormalizer.php index d2cf0696542..a3d1f53ff44 100644 --- a/src/Hydra/Serializer/CollectionFiltersNormalizer.php +++ b/src/Hydra/Serializer/CollectionFiltersNormalizer.php @@ -14,7 +14,7 @@ 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\NormalizerInterface; use Symfony\Component\Serializer\SerializerAwareInterface; @@ -28,7 +28,7 @@ */ final class CollectionFiltersNormalizer implements NormalizerInterface, SerializerAwareInterface { - use ContextTrait; + use JsonLdContextTrait; use SerializerAwareTrait { setSerializer as baseSetSerializer; } @@ -60,7 +60,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; } diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index 17356e36c93..7084b7eac7b 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -15,8 +15,9 @@ 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\Hypermedia\ContextBuilderInterface; +use ApiPlatform\Core\JsonLd\Serializer\JsonLdContextTrait; +use ApiPlatform\Core\Serializer\ContextTrait; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\SerializerAwareInterface; use Symfony\Component\Serializer\SerializerAwareTrait; @@ -30,6 +31,7 @@ final class CollectionNormalizer implements NormalizerInterface, SerializerAwareInterface { use ContextTrait; + use JsonLdContextTrait; use SerializerAwareTrait; const FORMAT = 'jsonld'; @@ -66,7 +68,7 @@ public function normalize($object, $format = null, array $context = []) 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); @@ -77,7 +79,7 @@ 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'; diff --git a/src/Hydra/Serializer/PartialCollectionViewNormalizer.php b/src/Hydra/Serializer/PartialCollectionViewNormalizer.php index ab45dfaaf8f..224db4ff7ce 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\Exception\InvalidArgumentException; -use ApiPlatform\Core\JsonLd\Serializer\ContextTrait; +use ApiPlatform\Core\JsonLd\Serializer\JsonLdContextTrait; use ApiPlatform\Core\Util\RequestParser; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\SerializerAwareInterface; @@ -27,7 +27,7 @@ */ final class PartialCollectionViewNormalizer implements NormalizerInterface, SerializerAwareInterface { - use ContextTrait; + use JsonLdContextTrait; private $collectionNormalizer; private $pageParameterName; @@ -46,7 +46,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; } diff --git a/src/Hypermedia/ContextBuilder.php b/src/Hypermedia/ContextBuilder.php new file mode 100644 index 00000000000..d980e109556 --- /dev/null +++ b/src/Hypermedia/ContextBuilder.php @@ -0,0 +1,141 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Hypermedia; + +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * {@inheritdoc} + * + * @author Kévin Dunglas + */ +final class ContextBuilder implements ContextBuilderInterface +{ + private $resourceNameCollectionFactory; + private $resourceMetadataFactory; + private $propertyNameCollectionFactory; + private $propertyMetadataFactory; + private $urlGenerator; + private $docUri; + + /** + * @var NameConverterInterface + */ + private $nameConverter; + + public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, UrlGeneratorInterface $urlGenerator, string $docUri = '', NameConverterInterface $nameConverter = null) + { + $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; + $this->propertyMetadataFactory = $propertyMetadataFactory; + $this->urlGenerator = $urlGenerator; + $this->nameConverter = $nameConverter; + $this->docUri = $docUri; + } + + /** + * {@inheritdoc} + */ + public function getBaseContext(int $referenceType = UrlGeneratorInterface::ABS_URL) : array + { + return [ + '@vocab' => $this->urlGenerator->generate('api_hydra_doc', [], UrlGeneratorInterface::ABS_URL).'#', + 'hydra' => self::HYDRA_NS, + ]; + } + + /** + * {@inheritdoc} + */ + public function getHalContext(string $selfLink) : array + { + return [ + '_links' => ['self' => ['href' => $selfLink], + 'curies' => [ + ['name' => 'ap', + 'href' => $this->urlGenerator->generate('api_hal_entrypoint').$this->docUri.'#section-{rel}', + 'templated' => true, + ], + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function getEntrypointContext(int $referenceType = UrlGeneratorInterface::ABS_PATH) : array + { + $context = $this->getBaseContext($referenceType); + + foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + + $resourceName = lcfirst($resourceMetadata->getShortName()); + + $context[$resourceName] = [ + '@id' => 'Entrypoint/'.$resourceName, + '@type' => '@id', + ]; + } + + return $context; + } + + /** + * {@inheritdoc} + */ + public function getResourceContext(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH) : array + { + $context = $this->getBaseContext($referenceType, $referenceType); + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + $prefixedShortName = sprintf('#%s', $resourceMetadata->getShortName()); + + foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $propertyName) { + $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName); + + if ($propertyMetadata->isIdentifier() && !$propertyMetadata->isWritable()) { + continue; + } + + $convertedName = $this->nameConverter ? $this->nameConverter->normalize($propertyName) : $propertyName; + + if (!$id = $propertyMetadata->getIri()) { + $id = sprintf('%s/%s', $prefixedShortName, $convertedName); + } + + if (!$propertyMetadata->isReadableLink()) { + $context[$convertedName] = [ + '@id' => $id, + '@type' => '@id', + ]; + } else { + $context[$convertedName] = $id; + } + } + + return $context; + } + + public function getResourceContextUri(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH) : string + { + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + + return $this->urlGenerator->generate('api_jsonld_context', ['shortName' => $resourceMetadata->getShortName()]); + } +} diff --git a/src/JsonLd/ContextBuilderInterface.php b/src/Hypermedia/ContextBuilderInterface.php similarity index 96% rename from src/JsonLd/ContextBuilderInterface.php rename to src/Hypermedia/ContextBuilderInterface.php index d9116e929ea..766ee8b15af 100644 --- a/src/JsonLd/ContextBuilderInterface.php +++ b/src/Hypermedia/ContextBuilderInterface.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace ApiPlatform\Core\JsonLd; +namespace ApiPlatform\Core\Hypermedia; use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\Exception\ResourceClassNotFoundException; @@ -34,7 +34,7 @@ interface ContextBuilderInterface * * @return array */ - public function getBaseContext(int $referenceType = UrlGeneratorInterface::ABS_PATH) : array; + public function getBaseContext(int $referenceType = UrlGeneratorInterface::ABS_PATH): array; /** * Builds the JSON-LD context for the entrypoint. diff --git a/src/JsonLd/Action/ContextAction.php b/src/JsonLd/Action/ContextAction.php index 21318d5d284..74035aa1d9c 100644 --- a/src/JsonLd/Action/ContextAction.php +++ b/src/JsonLd/Action/ContextAction.php @@ -11,7 +11,7 @@ namespace ApiPlatform\Core\JsonLd\Action; -use ApiPlatform\Core\JsonLd\ContextBuilderInterface; +use ApiPlatform\Core\Hypermedia\ContextBuilderInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; diff --git a/src/JsonLd/ContextBuilder.php b/src/JsonLd/ContextBuilder.php index d376a1db358..56c4f1ef6e6 100644 --- a/src/JsonLd/ContextBuilder.php +++ b/src/JsonLd/ContextBuilder.php @@ -12,6 +12,7 @@ namespace ApiPlatform\Core\JsonLd; use ApiPlatform\Core\Api\UrlGeneratorInterface; +use ApiPlatform\Core\Hypermedia\ContextBuilderInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; @@ -30,13 +31,14 @@ final class ContextBuilder implements ContextBuilderInterface private $propertyNameCollectionFactory; private $propertyMetadataFactory; private $urlGenerator; + private $docUri; /** * @var NameConverterInterface */ private $nameConverter; - public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, UrlGeneratorInterface $urlGenerator, NameConverterInterface $nameConverter = null) + public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, UrlGeneratorInterface $urlGenerator, string $docUri = '', NameConverterInterface $nameConverter = null) { $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; $this->resourceMetadataFactory = $resourceMetadataFactory; @@ -44,6 +46,7 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName $this->propertyMetadataFactory = $propertyMetadataFactory; $this->urlGenerator = $urlGenerator; $this->nameConverter = $nameConverter; + $this->docUri = $docUri; } /** diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 5809beeda23..1cb5528fcc3 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\Hypermedia\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 60% rename from src/JsonLd/Serializer/ContextTrait.php rename to src/JsonLd/Serializer/JsonLdContextTrait.php index 0a96acf6bc4..1be134e7ad6 100644 --- a/src/JsonLd/Serializer/ContextTrait.php +++ b/src/JsonLd/Serializer/JsonLdContextTrait.php @@ -11,7 +11,7 @@ namespace ApiPlatform\Core\JsonLd\Serializer; -use ApiPlatform\Core\JsonLd\ContextBuilderInterface; +use ApiPlatform\Core\Hypermedia\ContextBuilderInterface; /** * Creates and manipulates the Serializer context. @@ -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/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php new file mode 100644 index 00000000000..14c0998390d --- /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 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, $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); + } + + /** + * {@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); + } + + /** + * 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); + } + + /** + * 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 + */ + 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/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 5a8000c7712..0d7ef82da8b 100644 --- a/src/Swagger/ApiDocumentationBuilder.php +++ b/src/Swagger/ApiDocumentationBuilder.php @@ -17,7 +17,7 @@ use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\Documentation\ApiDocumentationBuilderInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; -use ApiPlatform\Core\JsonLd\ContextBuilderInterface; +use ApiPlatform\Core\Hypermedia\ContextBuilderInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index d3e9a441f86..68921192db2 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,8 +221,10 @@ private function getContainerBuilderProphecy() $definitions = [ 'api_platform.action.placeholder', + 'api_platform.action.entrypoint', 'api_platform.item_data_provider', 'api_platform.collection_data_provider', + 'api_platform.context_builder', 'api_platform.filters', 'api_platform.resource_class_resolver', 'api_platform.operation_method_resolver', @@ -279,12 +283,28 @@ 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.entrypoint_builder', + 'api_platform.hal.encoder', + 'api_platform.hal.listener.exception.validation', + 'api_platform.hal.listener.exception', + 'api_platform.hal.normalizer.constraint_violation_list', + 'api_platform.hal.normalizer.error', + 'api_platform.hal.action.exception', + 'api_platform.hal.context_builder', + 'api_platform.hal.normalizer.item', + 'api_platform.hal.normalizer.collection', + 'api_platform.hal.normalizer.partial_collection_view', + 'api_platform.hal.normalizer.collection_filters', + '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 +313,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/ContextBuilderTest.php b/tests/Hal/ContextBuilderTest.php new file mode 100644 index 00000000000..d7a2d6289ec --- /dev/null +++ b/tests/Hal/ContextBuilderTest.php @@ -0,0 +1,74 @@ + + * + * 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\UrlGeneratorInterface; +use ApiPlatform\Core\Hal\ContextBuilder; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * @author Amrouche Hamza + */ +class ContextBuilderTest extends \PHPUnit_Framework_TestCase +{ + /** + * @var ContextBuilder + */ + private $contextBuilder; + + public function setUp() + { + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $urlGeneratorProphecy = $this->prophesize(UrlGeneratorInterface::class); + $nameConverter = $this->prophesize(NameConverterInterface::class); + $urlGeneratorProphecy->generate('api_hal_entrypoint')->willReturn('/'); + $this->contextBuilder = new ContextBuilder($resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $urlGeneratorProphecy->reveal(), 'doc', $nameConverter->reveal()); + } + + public function testGetBaseContext() + { + $this->assertEquals( + [ + '_links' => ['self' => ['href' => '/'], + 'curies' => [ + ['name' => 'ap', + 'href' => '/doc#section-{rel}', + 'templated' => true, + ], + ], + ], + ], + $this->contextBuilder->getBaseContext()); + } + + public function testGetEntrypointContext() + { + $this->assertEquals([], $this->contextBuilder->getEntrypointContext()); + } + + public function testGetResourceContext() + { + $this->assertEquals([], $this->contextBuilder->getEntrypointContext()); + } + + public function testGetResourceContextUri() + { + $this->assertEquals([], $this->contextBuilder->getEntrypointContext()); + } +} diff --git a/tests/Hal/EntryPointBuilderTest.php b/tests/Hal/EntryPointBuilderTest.php new file mode 100644 index 00000000000..5fa56bc98ef --- /dev/null +++ b/tests/Hal/EntryPointBuilderTest.php @@ -0,0 +1,80 @@ + + * + * 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\IriConverterInterface; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use ApiPlatform\Core\Hal\EntrypointBuilder; +use ApiPlatform\Core\Hypermedia\ContextBuilderInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Metadata\Resource\ResourceNameCollection; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * @author Amrouche Hamza + */ +class EntryPointBuilderTest extends \PHPUnit_Framework_TestCase +{ + /** + * @var EntrypointBuilder + */ + private $entrypointBuilder; + + public function setUp() + { + $dummyMetadata = new ResourceMetadata('dummy', 'dummy', '#dummy', ['get' => ['method' => 'GET'], 'put' => ['method' => 'PUT']], ['get' => ['method' => 'GET'], 'post' => ['method' => 'POST']], []); + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $urlGeneratorProphecy = $this->prophesize(UrlGeneratorInterface::class); + $nameConverter = $this->prophesize(NameConverterInterface::class); + $urlGeneratorProphecy->generate('api_hal_entrypoint')->willReturn('/'); + $contextBuilder = $this->prophesize(ContextBuilderInterface::class); + $contextBuilder->getBaseContext(1)->willReturn([ + '_links' => ['self' => ['href' => '/'], + 'curies' => [ + ['name' => 'ap', + 'href' => '/doc#section-{rel}', + 'templated' => true, + ], + ], + ], + ]); + + $iriConverter = $this->prophesize(IriConverterInterface::class); + $resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection(['dummy' => 'dummy']))->shouldBeCalled(); + $resourceMetadataFactoryProphecy->create('dummy')->shouldBeCalled()->willReturn($dummyMetadata); + $iriConverter->getIriFromResourceClass('dummy')->shouldBeCalled()->willReturn('/dummies'); + + + + $this->entrypointBuilder = new EntrypointBuilder($resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), $iriConverter->reveal(), $urlGeneratorProphecy->reveal(), $contextBuilder->reveal()); + } + + public function testGetEntrypoint() + { + $this->assertEquals( + [ + '_links' => ['self' => ['href' => '/'], + 'curies' => [ + ['name' => 'ap', + 'href' => '/doc#section-{rel}', + 'templated' => true, + ], + ], + 'ap:dummy' => ['href' => '/dummies'], + ], + ], + $this->entrypointBuilder->getEntrypoint()); + } +} diff --git a/tests/Hal/Serializer/CollectionNormalizerTest.php b/tests/Hal/Serializer/CollectionNormalizerTest.php new file mode 100644 index 00000000000..66e74d15de6 --- /dev/null +++ b/tests/Hal/Serializer/CollectionNormalizerTest.php @@ -0,0 +1,73 @@ + + * + * 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\IriConverterInterface; +use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\Hal\Serializer\CollectionNormalizer; +use ApiPlatform\Core\Hypermedia\ContextBuilderInterface; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @author Amrouche Hamza + */ +class CollectionNormalizerTest extends \PHPUnit_Framework_TestCase +{ + /** + * @var CollectionNormalizer + */ + private $collectionNormalizer; + private $halCollection; + private $resourceClassResolver; + + public function setUp() + { + $dummy1 = new Dummy(); + $dummy1->setName('dummy1'); + + $this->halCollection = ['dummy' => $dummy1]; + $contextBuilder = $this->prophesize(ContextBuilderInterface::class); + + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->willImplement(NormalizerInterface::class); + $this->resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + + $serializer->normalize($dummy1, + 'jsonhal', + ['jsonhal_has_context' => true, 'jsonhal_sub_level' => true, 'resource_class' => 'dummy']) + ->willReturn(['name' => 'dummy1']); + $iriConverter = $this->prophesize(IriConverterInterface::class); + $formats = ['jsonhal' => ['mime_types' => ['application/hal+json']]]; + $iriConverter->getIriFromResourceClass('dummy')->willReturn('/dummies'); + $this->collectionNormalizer = new CollectionNormalizer($contextBuilder->reveal(), $this->resourceClassResolver->reveal(), $iriConverter->reveal(), $formats); + $this->collectionNormalizer->setSerializer($serializer->reveal()); + $contextBuilder->getBaseContext(0, '/dummies')->willReturn([]); + } + + public function testSupportsNormalization() + { + $this->assertEquals(true, $this->collectionNormalizer->supportsNormalization($this->halCollection, 'jsonhal')); + } + + public function testNormalize() + { + $this->resourceClassResolver->getResourceClass($this->halCollection, null, true)->willReturn('dummy')->shouldBeCalled(); + + $expected = [ + '_embedded' => [0 => ['name' => 'dummy1']], + ]; + $this->assertEquals($expected, $this->collectionNormalizer->normalize($this->halCollection, 'jsonhal')); + } +} diff --git a/tests/Hal/Serializer/ItemNormalizerTest.php b/tests/Hal/Serializer/ItemNormalizerTest.php new file mode 100644 index 00000000000..5aa90deb8fc --- /dev/null +++ b/tests/Hal/Serializer/ItemNormalizerTest.php @@ -0,0 +1,65 @@ + + * + * 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\Hal\Serializer\ItemNormalizer; +use ApiPlatform\Core\Hypermedia\ContextBuilderInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * @author Amrouche Hamza + */ +class ItemNormalizerTest extends \PHPUnit_Framework_TestCase +{ + /** + * @var ItemNormalizer + */ + private $itemNormalizer; + private $hal; + + public function setUp() + { + $dummy1 = new Dummy(); + $dummy1->setName('dummy1'); + + $this->hal = $dummy1; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($this->hal)->willReturn('dummy'); + $iriConverter = $this->prophesize(IriConverterInterface::class); + $iriConverter->getIriFromResourceClass('dummy')->willReturn('/dummies'); + $propertyAccess = $this->prophesize(PropertyAccessorInterface::class); + $nameConverter = $this->prophesize(NameConverterInterface::class); + $this->itemNormalizer = new ItemNormalizer($resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverter->reveal(), $resourceClassResolverProphecy->reveal(), $contextBuilderProphecy->reveal(), $propertyAccess->reveal(), $nameConverter->reveal(), ['jsonhal' => ['mime_types' => ['application/hal+json']]]); + } + + public function testSupportsNormalization() + { + $this->assertEquals(true, $this->itemNormalizer->supportsNormalization($this->hal, 'jsonhal')); + } + + public function testNormalize() + { + $this->assertEquals([], $this->itemNormalizer->normalize($this->hal, 'jsonhal', [])); + } +} diff --git a/tests/Hal/Serializer/PartialCollectionViewNormalizerTest.php b/tests/Hal/Serializer/PartialCollectionViewNormalizerTest.php new file mode 100644 index 00000000000..495792c2b74 --- /dev/null +++ b/tests/Hal/Serializer/PartialCollectionViewNormalizerTest.php @@ -0,0 +1,68 @@ + + * + * 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\DataProvider\PaginatorInterface; +use ApiPlatform\Core\Hal\Serializer\CollectionNormalizer; +use ApiPlatform\Core\Hal\Serializer\PartialCollectionViewNormalizer; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Amrouche Hamza + */ +class PartialCollectionViewNormalizerTest extends \PHPUnit_Framework_TestCase +{ + /** + * @var PartialCollectionViewNormalizer + */ + private $partialCollectionView; + /** + * @var CollectionNormalizer + */ + private $collectionNormalizer; + + public function setUp() + { + $this->collectionNormalizer = $this->prophesize(NormalizerInterface::class); + $pageParameterName = 'page'; + $enableParameterName = 'pagination_enabled'; + $formats = ['jsonhal' => ['mime_types' => ['application/hal+json']]]; + $this->partialCollectionView = new PartialCollectionViewNormalizer($this->collectionNormalizer->reveal(), $pageParameterName, $enableParameterName, $formats); + } + + public function testNormalize() + { + $paginatorInteface = $this->prophesize(PaginatorInterface::class); + + $paginatorInteface->getCurrentPage()->willReturn(1); + $paginatorInteface->getLastPage()->willReturn(2); + $paginatorInteface->reveal(); + $this->collectionNormalizer->normalize($paginatorInteface, 'jsonhal', [])->shouldBeCalled(); + $halCollection = [ + '_links' => ['self' => ['href' => '/dummies'], + 'curies' => [ + ['name' => 'ap', + 'href' => '/doc#section-{rel}', + 'templated' => true, + ], + ], + ], + '_embedded' => ['_links' => ['self' => ['href' => '/dummies/1']]], + 'name' => 'dummy', + ]; + $this->collectionNormalizer->normalize($paginatorInteface, 'jsonhal', [])->willReturn($halCollection); + + $halCollection['_links']['self']['next'] = '/?page=2'; + + $this->assertEquals($halCollection, $this->partialCollectionView->normalize($paginatorInteface->reveal(), 'jsonhal', [])); + } +} 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/Hydra/Serializer/PartialCollectionViewNormalizerTest.php b/tests/Serializer/PartialCollectionViewNormalizerTest.php similarity index 98% rename from tests/Hydra/Serializer/PartialCollectionViewNormalizerTest.php rename to tests/Serializer/PartialCollectionViewNormalizerTest.php index 36596ecfed3..ed5fb03d2f8 100644 --- a/tests/Hydra/Serializer/PartialCollectionViewNormalizerTest.php +++ b/tests/Serializer/PartialCollectionViewNormalizerTest.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace ApiPlatform\Core\Tests\Hydra\Serializer; +namespace ApiPlatform\Core\Tests\Serializer; use ApiPlatform\Core\DataProvider\PaginatorInterface; use ApiPlatform\Core\Hydra\Serializer\PartialCollectionViewNormalizer; @@ -66,7 +66,10 @@ public function testNormalizePaginator() 'hydra:last' => '/?_page=20', 'hydra:previous' => '/?_page=2', 'hydra:next' => '/?_page=4', + + ], + ], $normalizer->normalize($paginator) ); diff --git a/tests/Swagger/ApiDocumentationBuilderTest.php b/tests/Swagger/ApiDocumentationBuilderTest.php index 274fa5b7291..a3e2d922ebf 100644 --- a/tests/Swagger/ApiDocumentationBuilderTest.php +++ b/tests/Swagger/ApiDocumentationBuilderTest.php @@ -15,7 +15,7 @@ use ApiPlatform\Core\Api\OperationMethodResolverInterface; use ApiPlatform\Core\Api\ResourceClassResolverInterface; use ApiPlatform\Core\Api\UrlGeneratorInterface; -use ApiPlatform\Core\JsonLd\ContextBuilderInterface; +use ApiPlatform\Core\Hypermedia\ContextBuilderInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata;