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');
+ }
}