From 589c304a4352fc8f74f715716bc5e90f30eeb40c Mon Sep 17 00:00:00 2001 From: meyerbaptiste Date: Wed, 16 Dec 2020 17:53:25 +0100 Subject: [PATCH 1/4] Revert "Serializer: Support ignore annotation (#3820)" This reverts commit 347a0952982e053bb864548c757a22b6bb28fc24. --- src/Serializer/AbstractItemNormalizer.php | 11 --- .../Serializer/AbstractItemNormalizerTest.php | 67 ------------------- 2 files changed, 78 deletions(-) diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index bafdba3a86a..72655d7be8a 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -356,19 +356,8 @@ protected function getAllowedAttributes($classOrObject, array $context, $attribu $options = $this->getFactoryOptions($context); $propertyNames = $this->propertyNameCollectionFactory->create($context['resource_class'], $options); - $attributesMetadata = $this->classMetadataFactory ? - $this->classMetadataFactory->getMetadataFor($context['resource_class'])->getAttributesMetadata() : - null; - $allowedAttributes = []; foreach ($propertyNames as $propertyName) { - if ( - null != $attributesMetadata && \array_key_exists($propertyName, $attributesMetadata) && - method_exists($attributesMetadata[$propertyName], 'isIgnored') && - $attributesMetadata[$propertyName]->isIgnored()) { - continue; - } - $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $propertyName, $options); if ( diff --git a/tests/Serializer/AbstractItemNormalizerTest.php b/tests/Serializer/AbstractItemNormalizerTest.php index e4a6c491474..7f81ac608df 100644 --- a/tests/Serializer/AbstractItemNormalizerTest.php +++ b/tests/Serializer/AbstractItemNormalizerTest.php @@ -44,9 +44,6 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Exception\UnexpectedValueException; -use Symfony\Component\Serializer\Mapping\AttributeMetadata; -use Symfony\Component\Serializer\Mapping\ClassMetadataInterface; -use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; @@ -1205,68 +1202,4 @@ public function testNormalizationWithDataTransformer() $propertyAccessorProphecy->setValue($actualDummy, 'name', 'Dummy')->shouldHaveBeenCalled(); } - - public function testNormalizationWithIgnoreMetadata() - { - if (!method_exists(AttributeMetadata::class, 'setIgnore')) { - $this->markTestSkipped(); - } - - $dummy = new Dummy(); - - $dummyAttributeMetadata = new AttributeMetadata('dummy'); - $dummyAttributeMetadata->setIgnore(true); - - $classMetadataProphecy = $this->prophesize(ClassMetadataInterface::class); - $classMetadataProphecy->getAttributesMetadata()->willReturn(['dummy' => $dummyAttributeMetadata]); - - $classMetadataFactoryProphecy = $this->prophesize(ClassMetadataFactoryInterface::class); - $classMetadataFactoryProphecy->getMetadataFor(Dummy::class)->willReturn($classMetadataProphecy->reveal()); - - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn(new PropertyNameCollection(['name', 'dummy'])); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummy', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', true)); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getIriFromItem($dummy)->willReturn('/dummies/1'); - - $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); - $propertyAccessorProphecy->getValue($dummy, 'name')->willReturn('foo'); - $propertyAccessorProphecy->getValue($dummy, 'dummy')->willReturn('bar'); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); - - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(NormalizerInterface::class); - $serializerProphecy->normalize('foo', null, Argument::type('array'))->willReturn('foo'); - $serializerProphecy->normalize('bar', null, Argument::type('array'))->willReturn('bar'); - - $normalizer = $this->getMockForAbstractClass(AbstractItemNormalizer::class, [ - $propertyNameCollectionFactoryProphecy->reveal(), - $propertyMetadataFactoryProphecy->reveal(), - $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal(), - $propertyAccessorProphecy->reveal(), - null, - $classMetadataFactoryProphecy->reveal(), - null, - false, - [], - [], - null, - null, - ]); - $normalizer->setSerializer($serializerProphecy->reveal()); - - $expected = [ - 'name' => 'foo', - ]; - $this->assertEquals($expected, $normalizer->normalize($dummy, null, [ - 'resources' => [], - ])); - } } From 796518b3aebcd148ef0707d68de24b3bc47ee29a Mon Sep 17 00:00:00 2001 From: meyerbaptiste Date: Wed, 16 Dec 2020 17:57:53 +0100 Subject: [PATCH 2/4] =?UTF-8?q?Add=20the=20=EF=BC=A0Ignore=20annotation=20?= =?UTF-8?q?support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composer.json | 2 +- ...pertyInfoPropertyNameCollectionFactory.php | 2 +- .../SerializerPropertyMetadataFactory.php | 26 +++++----- ...yInfoPropertyNameCollectionFactoryTest.php | 38 ++++++++++++++ tests/Fixtures/DummyIgnoreProperty.php | 33 ++++++++++++ .../SerializerPropertyMetadataFactoryTest.php | 50 +++++++++++++++++-- 6 files changed, 132 insertions(+), 19 deletions(-) create mode 100644 tests/Fixtures/DummyIgnoreProperty.php diff --git a/composer.json b/composer.json index 00990f8314f..bd1ac719224 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "symfony/http-foundation": "^4.4 || ^5.1", "symfony/http-kernel": "^4.4 || ^5.1", "symfony/property-access": "^3.4.19 || ^4.4 || ^5.1", - "symfony/property-info": "^3.4 || ^4.4 || ^5.1", + "symfony/property-info": "^3.4 || ^4.4 || ^5.2.x-dev#516cbda5788a37f393df6f0dfd19c25eb926784b", "symfony/serializer": "^4.4 || ^5.1", "symfony/web-link": "^4.4 || ^5.1", "willdurand/negotiation": "^2.0.3 || ^3.0" diff --git a/src/Bridge/Symfony/PropertyInfo/Metadata/Property/PropertyInfoPropertyNameCollectionFactory.php b/src/Bridge/Symfony/PropertyInfo/Metadata/Property/PropertyInfoPropertyNameCollectionFactory.php index f603e08f3a1..50f9970acb2 100644 --- a/src/Bridge/Symfony/PropertyInfo/Metadata/Property/PropertyInfoPropertyNameCollectionFactory.php +++ b/src/Bridge/Symfony/PropertyInfo/Metadata/Property/PropertyInfoPropertyNameCollectionFactory.php @@ -38,7 +38,7 @@ public function __construct(PropertyInfoExtractorInterface $propertyInfo) */ public function create(string $resourceClass, array $options = []): PropertyNameCollection { - $properties = $this->propertyInfo->getProperties($resourceClass, $options); + $properties = $this->propertyInfo->getProperties($resourceClass, $options + ['serializer_groups' => null]); return new PropertyNameCollection($properties ?? []); } diff --git a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php index 226e84c95a7..c02050cd5a0 100644 --- a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php @@ -18,6 +18,7 @@ use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Util\ResourceClassInfoTrait; +use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface as SerializerClassMetadataFactoryInterface; /** @@ -66,24 +67,26 @@ public function create(string $resourceClass, string $property, array $options = } /** - * Sets readable/writable based on matching normalization/denormalization groups. + * Sets readable/writable based on matching normalization/denormalization groups and property's ignorance. * * A false value is never reset as it could be unreadable/unwritable for other reasons. - * If normalization/denormalization groups are not specified, the property is implicitly readable/writable. + * If normalization/denormalization groups are not specified and the property is not ignored, the property is implicitly readable/writable. * * @param string[]|null $normalizationGroups * @param string[]|null $denormalizationGroups */ private function transformReadWrite(PropertyMetadata $propertyMetadata, string $resourceClass, string $propertyName, array $normalizationGroups = null, array $denormalizationGroups = null): PropertyMetadata { - $groups = $this->getPropertySerializerGroups($resourceClass, $propertyName); + $serializerAttributeMetadata = $this->getSerializerAttributeMetadata($resourceClass, $propertyName); + $groups = $serializerAttributeMetadata ? $serializerAttributeMetadata->getGroups() : []; + $ignored = $serializerAttributeMetadata && method_exists($serializerAttributeMetadata, 'isIgnored') ? $serializerAttributeMetadata->isIgnored() : false; if (false !== $propertyMetadata->isReadable()) { - $propertyMetadata = $propertyMetadata->withReadable(null === $normalizationGroups || !empty(array_intersect($normalizationGroups, $groups))); + $propertyMetadata = $propertyMetadata->withReadable(!$ignored && (null === $normalizationGroups || array_intersect($normalizationGroups, $groups))); } if (false !== $propertyMetadata->isWritable()) { - $propertyMetadata = $propertyMetadata->withWritable(null === $denormalizationGroups || !empty(array_intersect($denormalizationGroups, $groups))); + $propertyMetadata = $propertyMetadata->withWritable(!$ignored && (null === $denormalizationGroups || array_intersect($denormalizationGroups, $groups))); } return $propertyMetadata; @@ -178,22 +181,17 @@ private function getEffectiveSerializerGroups(array $options, string $resourceCl ]; } - /** - * Gets the serializer groups defined on a property. - * - * @return string[] - */ - private function getPropertySerializerGroups(string $class, string $property): array + private function getSerializerAttributeMetadata(string $class, string $attribute): ?AttributeMetadataInterface { $serializerClassMetadata = $this->serializerClassMetadataFactory->getMetadataFor($class); foreach ($serializerClassMetadata->getAttributesMetadata() as $serializerAttributeMetadata) { - if ($property === $serializerAttributeMetadata->getName()) { - return $serializerAttributeMetadata->getGroups(); + if ($attribute === $serializerAttributeMetadata->getName()) { + return $serializerAttributeMetadata; } } - return []; + return null; } /** diff --git a/tests/Bridge/Symfony/PropertyInfo/Metadata/Property/PropertyInfoPropertyNameCollectionFactoryTest.php b/tests/Bridge/Symfony/PropertyInfo/Metadata/Property/PropertyInfoPropertyNameCollectionFactoryTest.php index 27384cccb64..cbcc1458f27 100644 --- a/tests/Bridge/Symfony/PropertyInfo/Metadata/Property/PropertyInfoPropertyNameCollectionFactoryTest.php +++ b/tests/Bridge/Symfony/PropertyInfo/Metadata/Property/PropertyInfoPropertyNameCollectionFactoryTest.php @@ -14,13 +14,19 @@ namespace ApiPlatform\Core\Tests\Bridge\Symfony\PropertyInfo\Metadata\Property; use ApiPlatform\Core\Bridge\Symfony\PropertyInfo\Metadata\Property\PropertyInfoPropertyNameCollectionFactory; +use ApiPlatform\Core\Tests\Fixtures\DummyIgnoreProperty; use ApiPlatform\Core\Tests\Fixtures\DummyObjectWithOnlyPrivateProperty; use ApiPlatform\Core\Tests\Fixtures\DummyObjectWithOnlyPublicProperty; use ApiPlatform\Core\Tests\Fixtures\DummyObjectWithoutProperty; use ApiPlatform\Core\Tests\Fixtures\DummyObjectWithPublicAndPrivateProperty; +use Doctrine\Common\Annotations\AnnotationReader; use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\DependencyInjection\PropertyInfoConstructorPass; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\Extractor\SerializerExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; +use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; /** * @author Oskar Stark @@ -70,4 +76,36 @@ public function testCreateMethodReturnsProperPropertyNameCollectionForObjectWith self::assertCount(1, $collection->getIterator()); } + + public function testCreateMethodReturnsProperPropertyNameCollectionForObjectWithIgnoredProperties(): void + { + // symfony/property-info < 5.2.1 + if (!class_exists(PropertyInfoConstructorPass::class)) { + self::markTestSkipped(); + } + + $factory = new PropertyInfoPropertyNameCollectionFactory( + new PropertyInfoExtractor([ + new SerializerExtractor( + new ClassMetadataFactory( + new AnnotationLoader( + new AnnotationReader() + ) + ) + ), + ]) + ); + + self::assertObjectHasAttribute('ignored', new DummyIgnoreProperty()); + + $collection = $factory->create(DummyIgnoreProperty::class, ['serializer_groups' => ['dummy']]); + + self::assertCount(1, $collection); + self::assertNotContains('ignored', $collection); + + $collection = $factory->create(DummyIgnoreProperty::class); + + self::assertCount(2, $collection); + self::assertNotContains('ignored', $collection); + } } diff --git a/tests/Fixtures/DummyIgnoreProperty.php b/tests/Fixtures/DummyIgnoreProperty.php new file mode 100644 index 00000000000..ff267d757f3 --- /dev/null +++ b/tests/Fixtures/DummyIgnoreProperty.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures; + +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Annotation\Ignore; + +class DummyIgnoreProperty +{ + public $visibleWithoutGroup; + + /** + * @Groups({"dummy"}) + */ + public $visibleWithGroup; + + /** + * @Groups({"dummy"}) + * @Ignore + */ + public $ignored; +} diff --git a/tests/Metadata/Property/Factory/SerializerPropertyMetadataFactoryTest.php b/tests/Metadata/Property/Factory/SerializerPropertyMetadataFactoryTest.php index 0d571730e1f..f068623bef3 100644 --- a/tests/Metadata/Property/Factory/SerializerPropertyMetadataFactoryTest.php +++ b/tests/Metadata/Property/Factory/SerializerPropertyMetadataFactoryTest.php @@ -19,6 +19,7 @@ use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Tests\Fixtures\DummyIgnoreProperty; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyTableInheritance; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceChild; @@ -26,6 +27,7 @@ use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\Annotation\Ignore; use Symfony\Component\Serializer\Mapping\AttributeMetadata as SerializerAttributeMetadata; use Symfony\Component\Serializer\Mapping\ClassMetadata as SerializerClassMetadata; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface as SerializerClassMetadataFactoryInterface; @@ -82,9 +84,6 @@ public function testCreate($readGroups, $writeGroups) $dummySerializerClassMetadata->addAttributeMetadata($nameConvertedSerializerAttributeMetadata); $serializerClassMetadataFactoryProphecy->getMetadataFor(Dummy::class)->willReturn($dummySerializerClassMetadata); $relatedDummySerializerClassMetadata = new SerializerClassMetadata(RelatedDummy::class); - $idSerializerAttributeMetadata = new SerializerAttributeMetadata('id'); - $idSerializerAttributeMetadata->addGroup('dummy_read'); - $relatedDummySerializerClassMetadata->addAttributeMetadata($idSerializerAttributeMetadata); $nameSerializerAttributeMetadata = new SerializerAttributeMetadata('name'); $nameSerializerAttributeMetadata->addGroup('dummy_read'); $relatedDummySerializerClassMetadata->addAttributeMetadata($nameSerializerAttributeMetadata); @@ -161,4 +160,49 @@ public function testCreateInherited(): void $this->assertEquals($actual->getChildInherited(), DummyTableInheritanceChild::class); } + + public function testCreateWithIgnoredProperty(): void + { + // symfony/serializer < 5.1 + if (!class_exists(Ignore::class)) { + self::markTestSkipped(); + } + + $dummyIgnorePropertyResourceMetadata = (new ResourceMetadata()) + ->withAttributes([ + 'normalization_context' => [AbstractNormalizer::GROUPS => ['dummy']], + 'denormalization_context' => [AbstractNormalizer::GROUPS => ['dummy']], + ]); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(DummyIgnoreProperty::class)->willReturn($dummyIgnorePropertyResourceMetadata); + + $ignoredSerializerAttributeMetadata = new SerializerAttributeMetadata('ignored'); + $ignoredSerializerAttributeMetadata->addGroup('dummy'); + $ignoredSerializerAttributeMetadata->addGroup('dummy'); + $ignoredSerializerAttributeMetadata->setIgnore(true); + + $dummyIgnorePropertySerializerClassMetadata = new SerializerClassMetadata(DummyIgnoreProperty::class); + $dummyIgnorePropertySerializerClassMetadata->addAttributeMetadata($ignoredSerializerAttributeMetadata); + + $serializerClassMetadataFactoryProphecy = $this->prophesize(SerializerClassMetadataFactoryInterface::class); + $serializerClassMetadataFactoryProphecy->getMetadataFor(DummyIgnoreProperty::class)->willReturn($dummyIgnorePropertySerializerClassMetadata); + + $ignoredPropertyMetadata = (new PropertyMetadata())->withType(new Type(Type::BUILTIN_TYPE_STRING, true)); + + $decoratedProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $decoratedProphecy->create(DummyIgnoreProperty::class, 'ignored', [])->willReturn($ignoredPropertyMetadata); + + $serializerPropertyMetadataFactory = new SerializerPropertyMetadataFactory( + $resourceMetadataFactoryProphecy->reveal(), + $serializerClassMetadataFactoryProphecy->reveal(), + $decoratedProphecy->reveal(), + $this->prophesize(ResourceClassResolverInterface::class)->reveal() + ); + + $result = $serializerPropertyMetadataFactory->create(DummyIgnoreProperty::class, 'ignored'); + + self::assertFalse($result->isReadable()); + self::assertFalse($result->isWritable()); + } } From b6c338a843304404c0ad6dc592a372615852cccf Mon Sep 17 00:00:00 2001 From: meyerbaptiste Date: Thu, 17 Dec 2020 15:18:32 +0100 Subject: [PATCH 3/4] Try to fix tests --- behat.yml.dist | 5 + composer.json | 2 +- features/doctrine/search_filter.feature | 45 ++++--- features/graphql/introspection.feature | 124 +++++++++++++++--- .../related-resouces-inclusion.feature | 20 +-- features/main/composite.feature | 2 +- features/main/content_negotiation.feature | 15 ++- features/main/subresource.feature | 84 ++++++------ tests/Behat/JsonContext.php | 87 ++++++++---- tests/Behat/XmlContext.php | 43 ++++++ .../TestBundle/Entity/RelatedDummy.php | 1 - 11 files changed, 300 insertions(+), 128 deletions(-) create mode 100644 tests/Behat/XmlContext.php diff --git a/behat.yml.dist b/behat.yml.dist index 75db6117f57..c3af2501185 100644 --- a/behat.yml.dist +++ b/behat.yml.dist @@ -11,6 +11,7 @@ default: - 'ApiPlatform\Core\Tests\Behat\HttpCacheContext' - 'ApiPlatform\Core\Tests\Behat\JsonApiContext' - 'ApiPlatform\Core\Tests\Behat\JsonHalContext' + - 'ApiPlatform\Core\Tests\Behat\XmlContext' - 'Behat\MinkExtension\Context\MinkContext' - 'behatch:context:rest' filters: @@ -45,6 +46,7 @@ postgres: - 'ApiPlatform\Core\Tests\Behat\HttpCacheContext' - 'ApiPlatform\Core\Tests\Behat\JsonApiContext' - 'ApiPlatform\Core\Tests\Behat\JsonHalContext' + - 'ApiPlatform\Core\Tests\Behat\XmlContext' - 'Behat\MinkExtension\Context\MinkContext' - 'behatch:context:rest' filters: @@ -64,6 +66,7 @@ mongodb: - 'ApiPlatform\Core\Tests\Behat\HttpCacheContext' - 'ApiPlatform\Core\Tests\Behat\JsonApiContext' - 'ApiPlatform\Core\Tests\Behat\JsonHalContext' + - 'ApiPlatform\Core\Tests\Behat\XmlContext' - 'Behat\MinkExtension\Context\MinkContext' - 'behatch:context:rest' filters: @@ -98,6 +101,7 @@ default-coverage: - 'ApiPlatform\Core\Tests\Behat\JsonApiContext' - 'ApiPlatform\Core\Tests\Behat\JsonHalContext' - 'ApiPlatform\Core\Tests\Behat\CoverageContext' + - 'ApiPlatform\Core\Tests\Behat\XmlContext' - 'Behat\MinkExtension\Context\MinkContext' - 'behatch:context:rest' @@ -117,6 +121,7 @@ mongodb-coverage: - 'ApiPlatform\Core\Tests\Behat\JsonApiContext' - 'ApiPlatform\Core\Tests\Behat\JsonHalContext' - 'ApiPlatform\Core\Tests\Behat\CoverageContext' + - 'ApiPlatform\Core\Tests\Behat\XmlContext' - 'Behat\MinkExtension\Context\MinkContext' - 'behatch:context:rest' diff --git a/composer.json b/composer.json index bd1ac719224..26a255698cd 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "symfony/http-foundation": "^4.4 || ^5.1", "symfony/http-kernel": "^4.4 || ^5.1", "symfony/property-access": "^3.4.19 || ^4.4 || ^5.1", - "symfony/property-info": "^3.4 || ^4.4 || ^5.2.x-dev#516cbda5788a37f393df6f0dfd19c25eb926784b", + "symfony/property-info": "^3.4 || ^4.4 || ^5.2.1", "symfony/serializer": "^4.4 || ^5.1", "symfony/web-link": "^4.4 || ^5.1", "willdurand/negotiation": "^2.0.3 || ^3.0" diff --git a/features/doctrine/search_filter.feature b/features/doctrine/search_filter.feature index 0ed91ebf66d..c396159bf77 100644 --- a/features/doctrine/search_filter.feature +++ b/features/doctrine/search_filter.feature @@ -19,7 +19,7 @@ Feature: Search filter on collections Given there is a DummyCar entity with related colors When I send a "GET" request to "/dummy_cars?colors.prop=red" Then the response status code should be 200 - And the JSON should be deep equal to: + And the JSON should be equal to: """ { "@context": "/contexts/DummyCar", @@ -81,25 +81,25 @@ Feature: Search filter on collections "hydra:mapping": [ { "@type": "IriTemplateMapping", - "variable": "availableAt[after]", + "variable": "availableAt[before]", "property": "availableAt", "required": false }, { "@type": "IriTemplateMapping", - "variable": "availableAt[before]", + "variable": "availableAt[strictly_before]", "property": "availableAt", "required": false }, { "@type": "IriTemplateMapping", - "variable": "availableAt[strictly_after]", + "variable": "availableAt[after]", "property": "availableAt", "required": false }, { "@type": "IriTemplateMapping", - "variable": "availableAt[strictly_before]", + "variable": "availableAt[strictly_after]", "property": "availableAt", "required": false }, @@ -111,44 +111,38 @@ Feature: Search filter on collections }, { "@type": "IriTemplateMapping", - "variable": "colors", - "property": "colors", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "colors.prop", - "property": "colors.prop", + "variable": "foobar[]", + "property": null, "required": false }, { "@type": "IriTemplateMapping", - "variable": "colors[]", - "property": "colors", + "variable": "foobargroups[]", + "property": null, "required": false }, { "@type": "IriTemplateMapping", - "variable": "foobar[]", + "variable": "foobargroups_override[]", "property": null, "required": false }, { "@type": "IriTemplateMapping", - "variable": "foobargroups[]", - "property": null, + "variable": "colors.prop", + "property": "colors.prop", "required": false }, { "@type": "IriTemplateMapping", - "variable": "foobargroups_override[]", - "property": null, + "variable": "colors", + "property": "colors", "required": false }, { "@type": "IriTemplateMapping", - "variable": "name", - "property": "name", + "variable": "colors[]", + "property": "colors", "required": false }, { @@ -186,6 +180,12 @@ Feature: Search filter on collections "variable": "uuid[]", "property": "uuid", "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "name", + "property": "name", + "required": false } ] } @@ -278,7 +278,6 @@ Feature: Search filter on collections 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; charset=utf-8" - And print last JSON response And the JSON should be valid according to this schema: """ { diff --git a/features/graphql/introspection.feature b/features/graphql/introspection.feature index 1867b588c7c..d1923c738c2 100644 --- a/features/graphql/introspection.feature +++ b/features/graphql/introspection.feature @@ -71,12 +71,56 @@ Feature: GraphQL introspection support And the response should be in JSON And the header "Content-Type" should be equal to "application/json" And the JSON node "data.type1.description" should be equal to "Dummy Product." - And the JSON node "data.type1.fields[1].type.name" should be equal to "DummyAggregateOfferConnection" - And the JSON node "data.type2.fields[0].name" should be equal to "edges" - And the JSON node "data.type2.fields[0].type.ofType.name" should be equal to "DummyAggregateOfferEdge" - And the JSON node "data.type3.fields[0].name" should be equal to "node" - And the JSON node "data.type3.fields[1].name" should be equal to "cursor" - And the JSON node "data.type3.fields[0].type.name" should be equal to "DummyAggregateOffer" + And the JSON node "data.type1.fields" should contain: + """ + { + "name":"offers", + "type":{ + "name":"DummyAggregateOfferConnection", + "kind":"OBJECT", + "ofType":null + } + } + """ + And the JSON node "data.type2.fields" should contain: + """ + { + "name":"edges", + "type":{ + "name":null, + "kind":"LIST", + "ofType":{ + "name":"DummyAggregateOfferEdge", + "kind":"OBJECT" + } + } + } + """ + And the JSON node "data.type3.fields" should contain: + """ + { + "name":"node", + "type":{ + "name":"DummyAggregateOffer", + "kind":"OBJECT", + "ofType":null + } + } + """ + And the JSON node "data.type3.fields" should contain: + """ + { + "name":"cursor", + "type":{ + "name":null, + "kind":"NON_NULL", + "ofType":{ + "name":"String", + "kind":"SCALAR" + } + } + } + """ Scenario: Introspect types with different serialization groups for item_query and collection_query When I send the following GraphQL request: @@ -201,7 +245,7 @@ Feature: GraphQL introspection support 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/json" - And the JSON should be deep equal to: + And the JSON should be equal to: """ { "data": { @@ -286,8 +330,17 @@ Feature: GraphQL introspection support 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/json" - And the JSON node "data.__type.fields[9].name" should be equal to "jsonData" - And the JSON node "data.__type.fields[9].type.name" should be equal to "Iterable" + And the JSON node "data.__type.fields" should contain: + """ + { + "name":"jsonData", + "type":{ + "name":"Iterable", + "kind":"SCALAR", + "ofType":null + } + } + """ Scenario: Retrieve entity - using serialization groups - fields When I send the following GraphQL request: @@ -420,13 +473,52 @@ Feature: GraphQL introspection support 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/json" - And the JSON node "data.typeCreatePayload.fields" should have 2 elements - And the JSON node "data.typeCreatePayload.fields[0].name" should be equal to "dummyProperty" - And the JSON node "data.typeCreatePayload.fields[0].type.name" should be equal to "createDummyPropertyPayloadData" - And the JSON node "data.typeCreatePayload.fields[1].name" should be equal to "clientMutationId" - And the JSON node "data.typeCreatePayloadData.fields[3].name" should be equal to "group" - And the JSON node "data.typeCreatePayloadData.fields[3].type.name" should be equal to "createDummyGroupNestedPayload" - And the JSON node "data.typeCreateNestedPayload.fields[0].name" should be equal to "id" + And the JSON node "data.typeCreatePayload.fields" should be equal to: + """ + [ + { + "name":"dummyProperty", + "type":{ + "name":"createDummyPropertyPayloadData", + "kind":"OBJECT", + "ofType":null + } + }, + { + "name":"clientMutationId", + "type":{ + "name":"String", + "kind":"SCALAR", + "ofType":null + } + } + ] + """ + And the JSON node "data.typeCreatePayloadData.fields" should contain: + """ + { + "name":"group", + "type":{ + "name":"createDummyGroupNestedPayload", + "kind":"OBJECT", + "ofType":null + } + } + """ + And the JSON node "data.typeCreateNestedPayload.fields" should contain: + """ + { + "name":"id", + "type":{ + "name":null, + "kind":"NON_NULL", + "ofType":{ + "name":"ID", + "kind":"SCALAR" + } + } + } + """ Scenario: Retrieve a type name through a GraphQL query Given there are 4 dummy objects with relatedDummy diff --git a/features/jsonapi/related-resouces-inclusion.feature b/features/jsonapi/related-resouces-inclusion.feature index 5857c5a1f5b..df339df10db 100644 --- a/features/jsonapi/related-resouces-inclusion.feature +++ b/features/jsonapi/related-resouces-inclusion.feature @@ -14,7 +14,7 @@ Feature: JSON API Inclusion of Related Resources Then the response status code should be 200 And the response should be in JSON And the JSON should be valid according to the JSON API schema - And the JSON should be deep equal to: + And the JSON should be equal to: """ { "data": { @@ -56,7 +56,7 @@ Feature: JSON API Inclusion of Related Resources Then the response status code should be 200 And the response should be in JSON And the JSON should be valid according to the JSON API schema - And the JSON should be deep equal to: + And the JSON should be equal to: """ { "data": { @@ -86,7 +86,7 @@ Feature: JSON API Inclusion of Related Resources Then the response status code should be 200 And the response should be in JSON And the JSON should be valid according to the JSON API schema - And the JSON should be deep equal to: + And the JSON should be equal to: """ { "data": { @@ -123,7 +123,7 @@ Feature: JSON API Inclusion of Related Resources Then the response status code should be 200 And the response should be in JSON And the JSON should be valid according to the JSON API schema - And the JSON should be deep equal to: + And the JSON should be equal to: """ { "data": { @@ -158,7 +158,7 @@ Feature: JSON API Inclusion of Related Resources Then the response status code should be 200 And the response should be in JSON And the JSON should be valid according to the JSON API schema - And the JSON should be deep equal to: + And the JSON should be equal to: """ { "data": { @@ -238,7 +238,7 @@ Feature: JSON API Inclusion of Related Resources Then the response status code should be 200 And the response should be in JSON And the JSON should be valid according to the JSON API schema - And the JSON should be deep equal to: + And the JSON should be equal to: """ { "data": { @@ -328,7 +328,7 @@ Feature: JSON API Inclusion of Related Resources Then the response status code should be 200 And the response should be in JSON And the JSON should be valid according to the JSON API schema - And the JSON should be deep equal to: + And the JSON should be equal to: """ { "data": { @@ -398,7 +398,7 @@ Feature: JSON API Inclusion of Related Resources Then the response status code should be 200 And the response should be in JSON And the JSON should be valid according to the JSON API schema - And the JSON should be deep equal to: + And the JSON should be equal to: """ { "links": { @@ -510,7 +510,7 @@ Feature: JSON API Inclusion of Related Resources Then the response status code should be 200 And the response should be in JSON And the JSON should be valid according to the JSON API schema - And the JSON should be deep equal to: + And the JSON should be equal to: """ { "links": { @@ -602,7 +602,7 @@ Feature: JSON API Inclusion of Related Resources Then the response status code should be 200 And the response should be in JSON And the JSON should be valid according to the JSON API schema - And the JSON should be deep equal to: + And the JSON should be equal to: """ { "links": { diff --git a/features/main/composite.feature b/features/main/composite.feature index 6fd4e9721ce..3997855e8b5 100644 --- a/features/main/composite.feature +++ b/features/main/composite.feature @@ -11,7 +11,7 @@ Feature: Retrieve data with Composite identifiers 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; charset=utf-8" - And the JSON should be deep equal to: + And the JSON should be equal to: """ { "@context": "/contexts/CompositeItem", diff --git a/features/main/content_negotiation.feature b/features/main/content_negotiation.feature index 71033af1f74..f6f6593c448 100644 --- a/features/main/content_negotiation.feature +++ b/features/main/content_negotiation.feature @@ -15,7 +15,8 @@ Feature: Content Negotiation support """ Then the response status code should be 201 And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - And the response should be equal to + And the response should be in XML + And the XML should be equal to: """ 1XML! @@ -26,7 +27,8 @@ Feature: Content Negotiation support And I send a "GET" request to "/dummies" Then the response status code should be 200 And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - And the response should be equal to + And the response should be in XML + And the XML should be equal to: """ 1XML! @@ -36,7 +38,8 @@ Feature: Content Negotiation support When I send a "GET" request to "/dummies.xml" Then the response status code should be 200 And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - And the response should be equal to + And the response should be in XML + And the XML should be equal to: """ 1XML! @@ -82,7 +85,8 @@ Feature: Content Negotiation support """ Then the response status code should be 201 And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - And the response should be equal to + And the response should be in XML + And the XML should be equal to: """ 2Sent in JSON @@ -134,7 +138,8 @@ Feature: Content Negotiation support """ Then the response status code should be 201 And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - And the response should be equal to + And the response should be in XML + And the XML should be equal to: """ 1Kevin diff --git a/features/main/subresource.feature b/features/main/subresource.feature index 909fd5398ac..5c99830688c 100644 --- a/features/main/subresource.feature +++ b/features/main/subresource.feature @@ -193,45 +193,45 @@ Feature: Subresource support } """ - Scenario: Get the subresource relation item - When I send a "GET" request to "/dummies/1/related_dummies/2" - 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; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelatedDummy", - "@id": "/related_dummies/2", - "@type": "https://schema.org/Product", - "id": 2, - "name": null, - "symfony": "symfony", - "dummyDate": null, - "thirdLevel": { - "@id": "/third_levels/1", - "@type": "ThirdLevel", - "fourthLevel": "/fourth_levels/1" - }, - "relatedToDummyFriend": [], - "dummyBoolean": null, - "embeddedDummy": [], - "age": null - } - """ +# Scenario: Get the subresource relation item +# When I send a "GET" request to "/dummies/1/related_dummies/2" +# 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; charset=utf-8" +# And the JSON should be equal to: +# """ +# { +# "@context": "/contexts/RelatedDummy", +# "@id": "/related_dummies/2", +# "@type": "https://schema.org/Product", +# "id": 2, +# "name": null, +# "symfony": "symfony", +# "dummyDate": null, +# "thirdLevel": { +# "@id": "/third_levels/1", +# "@type": "ThirdLevel", +# "fourthLevel": "/fourth_levels/1" +# }, +# "relatedToDummyFriend": [], +# "dummyBoolean": null, +# "embeddedDummy": [], +# "age": null +# } +# """ - Scenario: Create a dummy with a relation that is a subresource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Dummy with relations", - "relatedDummy": "/dummies/1/related_dummies/2" - } - """ - 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" +# Scenario: Create a dummy with a relation that is a subresource +# When I add "Content-Type" header equal to "application/ld+json" +# And I send a "POST" request to "/dummies" with body: +# """ +# { +# "name": "Dummy with relations", +# "relatedDummy": "/dummies/1/related_dummies/2" +# } +# """ +# 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" Scenario: Get the embedded relation subresource item at the third level When I send a "GET" request to "/dummies/1/related_dummies/1/third_level" @@ -355,7 +355,7 @@ Feature: Subresource support """ { "@context": "/contexts/Dummy", - "@id": "/dummies/3", + "@id": "/dummies/2", "@type": "Dummy", "description": null, "dummy": null, @@ -370,7 +370,7 @@ Feature: Subresource support "name_converted": null, "relatedOwnedDummy": "/related_owned_dummies/1", "relatedOwningDummy": null, - "id": 3, + "id": 2, "name": "plop", "alias": null, "foo": null @@ -387,7 +387,7 @@ Feature: Subresource support """ { "@context": "/contexts/Dummy", - "@id": "/dummies/4", + "@id": "/dummies/3", "@type": "Dummy", "description": null, "dummy": null, @@ -402,7 +402,7 @@ Feature: Subresource support "name_converted": null, "relatedOwnedDummy": null, "relatedOwningDummy": "/related_owning_dummies/1", - "id": 4, + "id": 3, "name": "plop", "alias": null, "foo": null diff --git a/tests/Behat/JsonContext.php b/tests/Behat/JsonContext.php index 5cb109b7b6c..e24c4a6d02f 100644 --- a/tests/Behat/JsonContext.php +++ b/tests/Behat/JsonContext.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\ApiTestCase; use Behat\Gherkin\Node\PyStringNode; +use Behat\Mink\Exception\ExpectationException; use Behatch\Context\JsonContext as BaseJsonContext; use Behatch\HttpCall\HttpCallResultPool; use Behatch\Json\Json; @@ -28,56 +29,84 @@ public function __construct(HttpCallResultPool $httpCallResultPool) } /** - * @Then /^the JSON should be deep equal to:$/ + * @Then the JSON node :node should contain: */ - public function theJsonShouldBeDeepEqualTo(PyStringNode $content) + public function theJsonNodeShouldContainContent(string $node, PyStringNode $content): void { $actual = $this->getJson(); + try { $expected = new Json($content); } catch (\Exception $e) { - throw new \Exception('The expected JSON is not a valid'); + throw new ExpectationException('The expected JSON is not valid.', $this->getSession()->getDriver(), $e); } - $actual = new Json(json_encode($this->sortArrays($actual->getContent()))); - $expected = new Json(json_encode($this->sortArrays($expected->getContent()))); + $actualContent = $this->inspector->evaluate($actual, $node); - $this->assertSame( - (string) $expected, - (string) $actual, - "The json is equal to:\n".$actual->encode() - ); + if (!is_iterable($actualContent)) { + throw new ExpectationException(sprintf("The JSON is equal to:\n%s", json_encode($actualContent, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)), $this->getSession()->getDriver()); + } + + foreach ($actualContent as $itemContent) { + try { + $this->assertEquals($expected->getContent(), $itemContent, ' '); + } catch (ExpectationException $e) { + continue; + } + + return; + } + + throw new ExpectationException("The JSON node \"{$node}\" does not contain the expected content.", $this->getSession()->getDriver()); } /** - * @Then /^the JSON should be a superset of:$/ + * @Then the JSON node :node should be equal to: */ - public function theJsonIsASupersetOf(PyStringNode $content) + public function theJsonNodeShouldBeEqualToContent(string $node, PyStringNode $content): void { - $array = json_decode($this->httpCallResultPool->getResult()->getValue(), true); - $subset = json_decode($content->getRaw(), true); + $actual = $this->getJson(); - method_exists(Assert::class, 'assertArraySubset') ? Assert::assertArraySubset($subset, $array) : ApiTestCase::assertArraySubset($subset, $array); // @phpstan-ignore-line Compatibility with PHPUnit 7 + try { + $expected = new Json($content); + } catch (\Exception $e) { + throw new ExpectationException('The expected JSON is not valid.', $this->getSession()->getDriver(), $e); + } + + $actualContent = $this->inspector->evaluate($actual, $node); + + $this->assertEquals( + $expected->getContent(), + $actualContent, + sprintf("The JSON node \"%s\" is equal to:\n%s", $node, json_encode($actualContent, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) + ); } - private function sortArrays($obj) + public function theJsonShouldBeEqualTo(PyStringNode $content): void { - $isObject = \is_object($obj); - - foreach ($obj as $key => $value) { - if (null === $value || is_scalar($value)) { - continue; - } + $actual = $this->getJson(); - if (\is_array($value)) { - sort($value); - } + try { + $expected = new Json($content); + } catch (\Exception $e) { + throw new ExpectationException('The expected JSON is not valid.', $this->getSession()->getDriver()); + } - $value = $this->sortArrays($value); + $this->assertEquals( + $expected->getContent(), + $actual->getContent(), + "The JSON is equal to:\n{$actual->encode()}" + ); + } - $isObject ? $obj->{$key} = $value : $obj[$key] = $value; - } + /** + * @Then /^the JSON should be a superset of:$/ + */ + public function theJsonIsASupersetOf(PyStringNode $content) + { + $array = json_decode($this->httpCallResultPool->getResult()->getValue(), true); + $subset = json_decode($content->getRaw(), true); - return $obj; + method_exists(Assert::class, 'assertArraySubset') ? Assert::assertArraySubset($subset, $array) : ApiTestCase::assertArraySubset($subset, $array); // @phpstan-ignore-line Compatibility with PHPUnit 7 } } diff --git a/tests/Behat/XmlContext.php b/tests/Behat/XmlContext.php new file mode 100644 index 00000000000..8e18ffd9408 --- /dev/null +++ b/tests/Behat/XmlContext.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Behat; + +use Behat\Gherkin\Node\PyStringNode; +use Behatch\Context\XmlContext as BaseXmlContext; +use Symfony\Component\Serializer\Encoder\XmlEncoder; + +final class XmlContext extends BaseXmlContext +{ + private $xmlEncoder; + + public function __construct() + { + $this->xmlEncoder = new XmlEncoder(); + } + + /** + * @Then the XML should be equal to: + */ + public function theXmlShouldBeEqualTo(PyStringNode $content): void + { + $expected = $this->xmlEncoder->decode((string) $content, 'xml'); + $actual = $this->xmlEncoder->decode($actualXml = $this->getSession()->getPage()->getContent(), 'xml'); + + $this->assertEquals( + $expected, + $actual, + "The XML is equal to:\n{$actualXml}" + ); + } +} diff --git a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php index 02f13cf929e..cb1fbc8f721 100644 --- a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php +++ b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php @@ -33,7 +33,6 @@ class RelatedDummy extends ParentDummy { /** * @ApiProperty(writable=false) - * @ApiSubresource * @ORM\Column(type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") From b3130df5bc8d223c018b4d4f01db2f7a3f0df7a1 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 18 Dec 2020 13:21:31 +0100 Subject: [PATCH 4/4] Fix subresources --- features/main/subresource.feature | 131 +++++++++--------- .../Factory/SubresourceOperationFactory.php | 5 +- .../TestBundle/Entity/RelatedDummy.php | 1 + 3 files changed, 71 insertions(+), 66 deletions(-) diff --git a/features/main/subresource.feature b/features/main/subresource.feature index 5c99830688c..c2f0341c600 100644 --- a/features/main/subresource.feature +++ b/features/main/subresource.feature @@ -193,45 +193,45 @@ Feature: Subresource support } """ -# Scenario: Get the subresource relation item -# When I send a "GET" request to "/dummies/1/related_dummies/2" -# 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; charset=utf-8" -# And the JSON should be equal to: -# """ -# { -# "@context": "/contexts/RelatedDummy", -# "@id": "/related_dummies/2", -# "@type": "https://schema.org/Product", -# "id": 2, -# "name": null, -# "symfony": "symfony", -# "dummyDate": null, -# "thirdLevel": { -# "@id": "/third_levels/1", -# "@type": "ThirdLevel", -# "fourthLevel": "/fourth_levels/1" -# }, -# "relatedToDummyFriend": [], -# "dummyBoolean": null, -# "embeddedDummy": [], -# "age": null -# } -# """ + Scenario: Get the subresource relation item + When I send a "GET" request to "/dummies/1/related_dummies/2" + 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; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/RelatedDummy", + "@id": "/related_dummies/2", + "@type": "https://schema.org/Product", + "id": 2, + "name": null, + "symfony": "symfony", + "dummyDate": null, + "thirdLevel": { + "@id": "/third_levels/1", + "@type": "ThirdLevel", + "fourthLevel": "/fourth_levels/1" + }, + "relatedToDummyFriend": [], + "dummyBoolean": null, + "embeddedDummy": [], + "age": null + } + """ -# Scenario: Create a dummy with a relation that is a subresource -# When I add "Content-Type" header equal to "application/ld+json" -# And I send a "POST" request to "/dummies" with body: -# """ -# { -# "name": "Dummy with relations", -# "relatedDummy": "/dummies/1/related_dummies/2" -# } -# """ -# 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" + Scenario: Create a dummy with a relation that is a subresource + When I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/dummies" with body: + """ + { + "name": "Dummy with relations", + "relatedDummy": "/dummies/1/related_dummies/2" + } + """ + 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" Scenario: Get the embedded relation subresource item at the third level When I send a "GET" request to "/dummies/1/related_dummies/1/third_level" @@ -344,7 +344,30 @@ Feature: Subresource support } """ + Scenario: Recursive resource + When I send a "GET" request to "/dummy_products/2" + 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; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/DummyProduct", + "@id": "/dummy_products/2", + "@type": "DummyProduct", + "offers": [ + "/dummy_aggregate_offers/1" + ], + "id": 2, + "name": "Dummy product", + "relatedProducts": [ + "/dummy_products/1" + ], + "parent": null + } + """ + @createSchema Scenario: The OneToOne subresource should be accessible from owned side Given there is a RelatedOwnedDummy object with OneToOne relation When I send a "GET" request to "/related_owned_dummies/1/owning_dummy" @@ -355,7 +378,7 @@ Feature: Subresource support """ { "@context": "/contexts/Dummy", - "@id": "/dummies/2", + "@id": "/dummies/1", "@type": "Dummy", "description": null, "dummy": null, @@ -370,13 +393,14 @@ Feature: Subresource support "name_converted": null, "relatedOwnedDummy": "/related_owned_dummies/1", "relatedOwningDummy": null, - "id": 2, + "id": 1, "name": "plop", "alias": null, "foo": null } """ + @createSchema Scenario: The OneToOne subresource should be accessible from owning side Given there is a RelatedOwningDummy object with OneToOne relation When I send a "GET" request to "/related_owning_dummies/1/owned_dummy" @@ -387,7 +411,7 @@ Feature: Subresource support """ { "@context": "/contexts/Dummy", - "@id": "/dummies/3", + "@id": "/dummies/1", "@type": "Dummy", "description": null, "dummy": null, @@ -402,32 +426,9 @@ Feature: Subresource support "name_converted": null, "relatedOwnedDummy": null, "relatedOwningDummy": "/related_owning_dummies/1", - "id": 3, + "id": 1, "name": "plop", "alias": null, "foo": null } """ - - Scenario: Recursive resource - When I send a "GET" request to "/dummy_products/2" - 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; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/DummyProduct", - "@id": "/dummy_products/2", - "@type": "DummyProduct", - "offers": [ - "/dummy_aggregate_offers/1" - ], - "id": 2, - "name": "Dummy product", - "relatedProducts": [ - "/dummy_products/1" - ], - "parent": null - } - """ diff --git a/src/Operation/Factory/SubresourceOperationFactory.php b/src/Operation/Factory/SubresourceOperationFactory.php index 0986b3c75f6..e5a19bedeaf 100644 --- a/src/Operation/Factory/SubresourceOperationFactory.php +++ b/src/Operation/Factory/SubresourceOperationFactory.php @@ -149,7 +149,10 @@ private function computeSubresourceOperations(string $resourceClass, array &$tre $identifiers = (array) $resourceMetadata->getAttribute('identifiers', null === $this->identifiersExtractor ? ['id'] : $this->identifiersExtractor->getIdentifiersFromResourceClass($resourceClass)); $identifier = \is_string($key = array_key_first($identifiers)) ? $key : $identifiers[0]; $operation['identifiers'] = $parentOperation['identifiers']; - $operation['identifiers'][$parentOperation['property']] = [$resourceClass, $identifiers[$identifier][1] ?? $identifier, $isLastItem ? true : $parentOperation['collection']]; + + if (!isset($operation['identifiers'][$parentOperation['property']])) { + $operation['identifiers'][$parentOperation['property']] = [$resourceClass, $identifiers[$identifier][1] ?? $identifier, $isLastItem ? true : $parentOperation['collection']]; + } $operation['operation_name'] = str_replace( 'get'.self::SUBRESOURCE_SUFFIX, diff --git a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php index cb1fbc8f721..02f13cf929e 100644 --- a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php +++ b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php @@ -33,6 +33,7 @@ class RelatedDummy extends ParentDummy { /** * @ApiProperty(writable=false) + * @ApiSubresource * @ORM\Column(type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO")