From 808b5d3efb529e49adc68b0099c259c62ace7c19 Mon Sep 17 00:00:00 2001 From: abluchet Date: Tue, 12 Sep 2017 15:34:12 +0200 Subject: [PATCH] Allow plain IDs with `allow_plain_identifiers` --- features/main/relation.feature | 28 ++++++ .../ApiPlatformExtension.php | 1 + .../DependencyInjection/Configuration.php | 1 + .../Symfony/Bundle/Resources/config/api.xml | 2 + src/Serializer/AbstractItemNormalizer.php | 18 +++- .../ApiPlatformExtensionTest.php | 1 + .../DependencyInjection/ConfigurationTest.php | 1 + tests/Fixtures/app/config/config.yml | 1 + .../Serializer/AbstractItemNormalizerTest.php | 99 +++++++++++++++++++ 9 files changed, 151 insertions(+), 1 deletion(-) diff --git a/features/main/relation.feature b/features/main/relation.feature index 6db478edb7a..c46428b3e1a 100644 --- a/features/main/relation.feature +++ b/features/main/relation.feature @@ -443,6 +443,34 @@ Feature: Relations support } """ + Scenario: Create a related dummy with a relation (json) + When I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/related_dummies" with body: + """ + {"thirdLevel": "1"} + """ + Then the response status code should be 201 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/RelatedDummy", + "@id": "/related_dummies/6", + "@type": "https://schema.org/Product", + "id": 6, + "name": null, + "symfony": "symfony", + "dummyDate": null, + "thirdLevel": "/third_levels/1", + "relatedToDummyFriend": [], + "dummyBoolean": null, + "embeddedDummy": null, + "symfony": "symfony", + "age": null + } + """ + @dropSchema Scenario: Issue #1222 Given there are people having pets diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index b85a202797c..4d56cb96fbb 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -140,6 +140,7 @@ private function handleConfig(ContainerBuilder $container, array $config, array $container->setParameter('api_platform.exception_to_status', $config['exception_to_status']); $container->setParameter('api_platform.formats', $formats); $container->setParameter('api_platform.error_formats', $errorFormats); + $container->setParameter('api_platform.allow_plain_identifiers', $config['allow_plain_identifiers']); $container->setParameter('api_platform.eager_loading.enabled', $config['eager_loading']['enabled']); $container->setParameter('api_platform.eager_loading.max_joins', $config['eager_loading']['max_joins']); $container->setParameter('api_platform.eager_loading.fetch_partial', $config['eager_loading']['fetch_partial']); diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index b55c3ea1312..59a4c1a373d 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -56,6 +56,7 @@ public function getConfigTreeBuilder() ->end() ->scalarNode('name_converter')->defaultNull()->info('Specify a name converter to use.')->end() ->scalarNode('path_segment_name_generator')->defaultValue('api_platform.path_segment_name_generator.underscore')->info('Specify a path name generator to use.')->end() + ->booleanNode('allow_plain_identifiers')->defaultFalse()->info('Allow plain identifiers, for example "id" instead of "@id" when denormalizing a relation.')->end() ->arrayNode('validator') ->addDefaultsIfNotSet() ->children() diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index 58c946e3a1a..eaf3d6cc371 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -80,6 +80,8 @@ + + %api_platform.allow_plain_identifiers% diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 0e12ee065b7..b804dea1e85 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Api\IriConverterInterface; use ApiPlatform\Core\Api\OperationType; use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Exception\ItemNotFoundException; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; @@ -44,8 +45,10 @@ abstract class AbstractItemNormalizer extends AbstractObjectNormalizer protected $resourceClassResolver; protected $propertyAccessor; protected $localCache = []; + protected $itemDataProvider; + protected $allowPlainIdentifiers; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, ItemDataProviderInterface $itemDataProvider = null, bool $allowPlainIdentifiers = false) { parent::__construct($classMetadataFactory, $nameConverter); @@ -54,6 +57,8 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName $this->iriConverter = $iriConverter; $this->resourceClassResolver = $resourceClassResolver; $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); + $this->itemDataProvider = $itemDataProvider; + $this->allowPlainIdentifiers = $allowPlainIdentifiers; $this->setCircularReferenceHandler(function ($object) { return $this->iriConverter->getIriFromItem($object); @@ -299,6 +304,17 @@ private function denormalizeRelation(string $attributeName, PropertyMetadata $pr } if (!is_array($value)) { + // repeat the code so that IRIs keep working with the json format + if (true === $this->allowPlainIdentifiers && $this->itemDataProvider) { + try { + return $this->itemDataProvider->getItem($className, $value, null, $context + ['fetch_data' => true]); + } catch (ItemNotFoundException $e) { + throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e); + } catch (InvalidArgumentException $e) { + // Give a chance to other normalizers (e.g.: DateTimeNormalizer) + } + } + throw new InvalidArgumentException(sprintf( 'Expected IRI or nested document for attribute "%s", "%s" given.', $attributeName, gettype($value) )); diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index ce18c1b5156..e0952b9818c 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -359,6 +359,7 @@ private function getPartialContainerBuilderProphecy() 'api_platform.exception_to_status' => [ExceptionInterface::class => Response::HTTP_BAD_REQUEST, InvalidArgumentException::class => Response::HTTP_BAD_REQUEST], 'api_platform.title' => 'title', 'api_platform.version' => 'version', + 'api_platform.allow_plain_identifiers' => false, 'api_platform.eager_loading.enabled' => Argument::type('bool'), 'api_platform.eager_loading.max_joins' => 30, 'api_platform.eager_loading.force_eager' => true, diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index ce2a4a36599..150f2d01658 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -130,6 +130,7 @@ public function testDefaultConfig() 'vary' => ['Accept'], 'public' => null, ], + 'allow_plain_identifiers' => false, ], $config); } diff --git a/tests/Fixtures/app/config/config.yml b/tests/Fixtures/app/config/config.yml index f78094697b5..6309df53594 100644 --- a/tests/Fixtures/app/config/config.yml +++ b/tests/Fixtures/app/config/config.yml @@ -30,6 +30,7 @@ doctrine: api_platform: title: 'My Dummy API' description: 'This is a test API.' + allow_plain_identifiers: true formats: jsonld: ['application/ld+json'] jsonhal: ['application/hal+json'] diff --git a/tests/Serializer/AbstractItemNormalizerTest.php b/tests/Serializer/AbstractItemNormalizerTest.php index ab23083538e..e2c4fc24ec3 100644 --- a/tests/Serializer/AbstractItemNormalizerTest.php +++ b/tests/Serializer/AbstractItemNormalizerTest.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\Api\IriConverterInterface; use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -644,4 +645,102 @@ public function testChildInheritedProperty() 'nickname' => null, ], $normalizer->normalize($dummy, null, ['resource_class' => DummyTableInheritance::class, 'resources' => []])); } + + public function testDenormalizeRelationWithPlainId() + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn( + new PropertyNameCollection(['relatedDummy']) + )->shouldBeCalled(); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', [])->willReturn( + new PropertyMetadata( + new Type(Type::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class), + '', + false, + true, + false, + false + ) + )->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true)->shouldBeCalled(); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $itemDataProviderProphecy = $this->prophesize(ItemDataProviderInterface::class); + $itemDataProviderProphecy->getItem(RelatedDummy::class, 1, null, Argument::type('array'))->shouldBeCalled(); + + $normalizer = $this->getMockForAbstractClass(AbstractItemNormalizer::class, [ + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + null, + null, + $itemDataProviderProphecy->reveal(), + true, + ]); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $normalizer->denormalize(['relatedDummy' => 1], Dummy::class, 'jsonld'); + } + + /** + * @expectedException \ApiPlatform\Core\Exception\InvalidArgumentException + * @expectedExceptionMessage Expected IRI or nested document for attribute "relatedDummy", "integer" given. + */ + public function testDoNotDenormalizeRelationWithPlainIdWhenPlainIdentifiersAreNotAllowed() + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn( + new PropertyNameCollection(['relatedDummy']) + )->shouldBeCalled(); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', [])->willReturn( + new PropertyMetadata( + new Type(Type::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class), + '', + false, + true, + false, + false + ) + )->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true)->shouldBeCalled(); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $itemDataProviderProphecy = $this->prophesize(ItemDataProviderInterface::class); + $itemDataProviderProphecy->getItem(RelatedDummy::class, 1, null, Argument::type('array'))->shouldNotBeCalled(); + + $normalizer = $this->getMockForAbstractClass(AbstractItemNormalizer::class, [ + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + null, + null, + $itemDataProviderProphecy->reveal(), + false, + ]); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $normalizer->denormalize(['relatedDummy' => 1], Dummy::class, 'jsonld'); + } }