diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php index cde2efac85f..d1d495dbb39 100644 --- a/src/Hal/Serializer/ItemNormalizer.php +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -305,14 +305,16 @@ private function populateRelation(array $data, object $object, ?string $format, $attributeValue = $this->getAttributeValue($object, $relation['name'], $format, $childContext); - if (empty($attributeValue)) { + if (empty($attributeValue) && ($context[self::SKIP_NULL_TO_ONE_RELATIONS] ?? $this->defaultContext[self::SKIP_NULL_TO_ONE_RELATIONS] ?? true)) { continue; } if ('one' === $relation['cardinality']) { if ('links' === $type) { - $data[$key][$relationName]['href'] = $this->getRelationIri($attributeValue); - continue; + if (null !== $attributeValue) { + $data[$key][$relationName]['href'] = $this->getRelationIri($attributeValue); + continue; + } } $data[$key][$relationName] = $attributeValue; diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index cd1f08ddc49..9c51de1767f 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -65,6 +65,11 @@ abstract class AbstractItemNormalizer extends AbstractObjectNormalizer use ContextTrait; use InputOutputMetadataTrait; use OperationContextTrait; + /** + * Flag to control whether to one relation with the value `null` should be output + * when normalizing or omitted. + */ + public const SKIP_NULL_TO_ONE_RELATIONS = 'skip_null_to_one_relations'; protected PropertyAccessorInterface $propertyAccessor; protected array $localCache = []; diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index ecc58b333b8..040cce17489 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -60,6 +60,7 @@ public function createFromRequest(Request $request, bool $normalization, ?array $context['operation'] = $operation; $context['resource_class'] = $attributes['resource_class']; $context['skip_null_values'] ??= true; + $context[AbstractItemNormalizer::SKIP_NULL_TO_ONE_RELATIONS] ??= true; $context['iri_only'] ??= false; $context['request_uri'] = $request->getRequestUri(); $context['uri'] = $request->getUri(); diff --git a/src/Serializer/Tests/SerializerContextBuilderTest.php b/src/Serializer/Tests/SerializerContextBuilderTest.php index 294f255b383..59447307ec3 100644 --- a/src/Serializer/Tests/SerializerContextBuilderTest.php +++ b/src/Serializer/Tests/SerializerContextBuilderTest.php @@ -67,42 +67,42 @@ public function testCreateFromRequest(): void { $request = Request::create('/foos/1'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['foo' => 'bar', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation', 'object', 'data', 'property_metadata', 'circular_reference_limit_counters', 'debug_trace_id']]; + $expected = ['foo' => 'bar', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation', 'object', 'data', 'property_metadata', 'circular_reference_limit_counters', 'debug_trace_id'], 'skip_null_to_one_relations' => true]; $this->assertEquals($expected, $this->builder->createFromRequest($request, true)); $request = Request::create('/foos'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'get_collection', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['foo' => 'bar', 'operation_name' => 'get_collection', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation->withName('get_collection'), 'exclude_from_cache_key' => ['root_operation', 'operation', 'object', 'data', 'property_metadata', 'circular_reference_limit_counters', 'debug_trace_id']]; + $expected = ['foo' => 'bar', 'operation_name' => 'get_collection', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation->withName('get_collection'), 'exclude_from_cache_key' => ['root_operation', 'operation', 'object', 'data', 'property_metadata', 'circular_reference_limit_counters', 'debug_trace_id'], 'skip_null_to_one_relations' => true]; $this->assertEquals($expected, $this->builder->createFromRequest($request, true)); $request = Request::create('/foos/1'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation', 'object', 'data', 'property_metadata', 'circular_reference_limit_counters', 'debug_trace_id']]; + $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation', 'object', 'data', 'property_metadata', 'circular_reference_limit_counters', 'debug_trace_id'], 'skip_null_to_one_relations' => true]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/foos', 'POST'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'post', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'operation_name' => 'post', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => false, 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation->withName('post'), 'exclude_from_cache_key' => ['root_operation', 'operation', 'object', 'data', 'property_metadata', 'circular_reference_limit_counters', 'debug_trace_id']]; + $expected = ['bar' => 'baz', 'operation_name' => 'post', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => false, 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation->withName('post'), 'exclude_from_cache_key' => ['root_operation', 'operation', 'object', 'data', 'property_metadata', 'circular_reference_limit_counters', 'debug_trace_id'], 'skip_null_to_one_relations' => true]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/foos', 'PUT'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'put', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'operation_name' => 'put', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => true, 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => (new Put(name: 'put'))->withOperation($this->operation), 'exclude_from_cache_key' => ['root_operation', 'operation', 'object', 'data', 'property_metadata', 'circular_reference_limit_counters', 'debug_trace_id']]; + $expected = ['bar' => 'baz', 'operation_name' => 'put', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => true, 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => (new Put(name: 'put'))->withOperation($this->operation), 'exclude_from_cache_key' => ['root_operation', 'operation', 'object', 'data', 'property_metadata', 'circular_reference_limit_counters', 'debug_trace_id'], 'skip_null_to_one_relations' => true]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/bars/1/foos'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation', 'object', 'data', 'property_metadata', 'circular_reference_limit_counters', 'debug_trace_id']]; + $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation', 'object', 'data', 'property_metadata', 'circular_reference_limit_counters', 'debug_trace_id'], 'skip_null_to_one_relations' => true]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/foowithpatch/1', 'PATCH'); $request->attributes->replace(['_api_resource_class' => 'FooWithPatch', '_api_operation_name' => 'patch', '_api_format' => 'json', '_api_mime_type' => 'application/json']); - $expected = ['operation_name' => 'patch', 'resource_class' => 'FooWithPatch', 'request_uri' => '/foowithpatch/1', 'api_allow_update' => true, 'uri' => 'http://localhost/foowithpatch/1', 'output' => null, 'input' => null, 'deep_object_to_populate' => true, 'skip_null_values' => true, 'iri_only' => false, 'operation' => $this->patchOperation, 'exclude_from_cache_key' => ['root_operation', 'operation', 'object', 'data', 'property_metadata', 'circular_reference_limit_counters', 'debug_trace_id']]; + $expected = ['operation_name' => 'patch', 'resource_class' => 'FooWithPatch', 'request_uri' => '/foowithpatch/1', 'api_allow_update' => true, 'uri' => 'http://localhost/foowithpatch/1', 'output' => null, 'input' => null, 'deep_object_to_populate' => true, 'skip_null_values' => true, 'iri_only' => false, 'operation' => $this->patchOperation, 'exclude_from_cache_key' => ['root_operation', 'operation', 'object', 'data', 'property_metadata', 'circular_reference_limit_counters', 'debug_trace_id'], 'skip_null_to_one_relations' => true]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/bars/1/foos'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml', 'id' => '1']); - $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'operation' => $this->operation, 'skip_null_values' => true, 'exclude_from_cache_key' => ['root_operation', 'operation', 'object', 'data', 'property_metadata', 'circular_reference_limit_counters', 'debug_trace_id']]; + $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'operation' => $this->operation, 'skip_null_values' => true, 'exclude_from_cache_key' => ['root_operation', 'operation', 'object', 'data', 'property_metadata', 'circular_reference_limit_counters', 'debug_trace_id'], 'skip_null_to_one_relations' => true]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); } @@ -115,7 +115,7 @@ public function testThrowExceptionOnInvalidRequest(): void public function testReuseExistingAttributes(): void { - $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation', 'object', 'data', 'property_metadata', 'circular_reference_limit_counters', 'debug_trace_id']]; + $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation', 'object', 'data', 'property_metadata', 'circular_reference_limit_counters', 'debug_trace_id'], 'skip_null_to_one_relations' => true]; $this->assertEquals($expected, $this->builder->createFromRequest(Request::create('/foos/1'), false, ['resource_class' => 'Foo', 'operation_name' => 'get'])); } diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue4372/RelatedEntity.php b/tests/Fixtures/TestBundle/ApiResource/Issue4372/RelatedEntity.php new file mode 100644 index 00000000000..821a1151376 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue4372/RelatedEntity.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\Tests\Fixtures\TestBundle\ApiResource\Issue4372; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ApiResource( + operations: [ + new GetCollection(), + new Get(), + ] +)] +class RelatedEntity +{ + #[ApiProperty(identifier: true)] + #[Groups(['read'])] + public ?int $id = null; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue4372/ToOneRelationPropertyMayBeNull.php b/tests/Fixtures/TestBundle/ApiResource/Issue4372/ToOneRelationPropertyMayBeNull.php new file mode 100644 index 00000000000..d3747133573 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue4372/ToOneRelationPropertyMayBeNull.php @@ -0,0 +1,98 @@ + + * + * 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\Tests\Fixtures\TestBundle\ApiResource\Issue4372; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Serializer\AbstractItemNormalizer; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ApiResource( + operations: [ + new GetCollection( + uriTemplate: self::ROUTE.'{._format}', + ), + new Get( + uriTemplate: self::ITEM_ROUTE.'{._format}', + normalizationContext: [ + AbstractItemNormalizer::SKIP_NULL_TO_ONE_RELATIONS => false, + 'groups' => ['default', 'read'], + ], + provider: [self::class, 'provide'] + ), + new Get( + uriTemplate: self::ITEM_SKIP_NULL_TO_ONE_RELATION_ROUTE.'{._format}', + normalizationContext: [ + 'groups' => ['default', 'read'], + ], + provider: [self::class, 'provide'] + ), + ], +)] +class ToOneRelationPropertyMayBeNull +{ + public const ROUTE = '/my-route'; + public const ITEM_ROUTE = self::ROUTE.'/{id}'; + public const SKIP_NULL_TO_ONE_RELATION_ROUTE = '/skip-null-relation-route'; + public const ITEM_SKIP_NULL_TO_ONE_RELATION_ROUTE = self::SKIP_NULL_TO_ONE_RELATION_ROUTE.'/{id}'; + public const ENTITY_ID = 1; + + #[ApiProperty(identifier: true)] + #[Groups(['read'])] + public ?int $id = null; + + #[ApiProperty] + public ?RelatedEntity $relatedEntity = null; + + #[ApiProperty(readableLink: true)] + #[Groups(['read'])] + public ?RelatedEntity $relatedEmbeddedEntity = null; + + #[ApiProperty] + public ?RelatedEntity $relatedEntity2 = null; + + #[ApiProperty(readableLink: true)] + #[Groups(['read'])] + public ?RelatedEntity $relatedEmbeddedEntity2 = null; + + #[ApiProperty] + #[Groups(['read'])] + public Collection $collection; + + public function __construct() + { + $this->collection = new ArrayCollection(); + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): ?self + { + $relatedEntity1 = new RelatedEntity(); + $relatedEntity1->id = 1; + $relatedEntity2 = new RelatedEntity(); + $relatedEntity2->id = 2; + + $toOneRelationPropertyMayBeNull = new self(); + $toOneRelationPropertyMayBeNull->id = self::ENTITY_ID; + $toOneRelationPropertyMayBeNull->relatedEntity2 = $relatedEntity1; + $toOneRelationPropertyMayBeNull->relatedEmbeddedEntity2 = $relatedEntity1; + $toOneRelationPropertyMayBeNull->collection = new ArrayCollection([$relatedEntity1, $relatedEntity2]); + + return $toOneRelationPropertyMayBeNull; + } +} diff --git a/tests/Functional/NotSkipNullToOneRelationTest.php b/tests/Functional/NotSkipNullToOneRelationTest.php new file mode 100644 index 00000000000..9a0b0db26ef --- /dev/null +++ b/tests/Functional/NotSkipNullToOneRelationTest.php @@ -0,0 +1,229 @@ + + * + * 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\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue4372\RelatedEntity; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue4372\ToOneRelationPropertyMayBeNull; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; + +class NotSkipNullToOneRelationTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ToOneRelationPropertyMayBeNull::class, RelatedEntity::class]; + } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws \JsonException + */ + public function testNullRelationsAreNotSkippedWhenConfigured(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Not tested with mongodb.'); + } + $itemIri = str_replace( + '{id}', + ToOneRelationPropertyMayBeNull::ENTITY_ID.'', + ToOneRelationPropertyMayBeNull::ITEM_ROUTE + ); + $this->checkRoutesAreCorrectlySetUp(); + + self::createClient()->request( + 'GET', + $itemIri, + [ + 'headers' => [ + 'accept' => 'application/hal+json', + ], + ] + ); + + $this->assertResponseStatusCodeSame(200); + + $this->assertJsonEquals( + [ + '_embedded' => [ + 'relatedEmbeddedEntity' => null, + 'relatedEmbeddedEntity2' => [ + '_links' => [ + 'self' => [ + 'href' => '/related_entities/1', + ], + ], + 'id' => 1, + ], + ], + '_links' => [ + 'self' => [ + 'href' => '/my-route/1', + ], + 'relatedEmbeddedEntity' => null, + 'relatedEmbeddedEntity2' => [ + 'href' => '/related_entities/1', + ], + ], + 'collection' => [ + [ + '_links' => [ + 'self' => [ + 'href' => '/related_entities/1', + ], + ], + 'id' => 1, + ], + [ + '_links' => [ + 'self' => [ + 'href' => '/related_entities/2', + ], + ], + 'id' => 2, + ], + ], + 'id' => 1, + ] + ); + } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws \JsonException + */ + public function testNullRelationsAreSkippedByDefault(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Not tested with mongodb.'); + } + $itemIri = str_replace( + '{id}', + ToOneRelationPropertyMayBeNull::ENTITY_ID.'', + ToOneRelationPropertyMayBeNull::ITEM_SKIP_NULL_TO_ONE_RELATION_ROUTE + ); + $this->checkRoutesAreCorrectlySetUp(); + + self::createClient()->request( + 'GET', + $itemIri, + [ + 'headers' => [ + 'accept' => 'application/hal+json', + ], + ] + ); + + $this->assertResponseStatusCodeSame(200); + + $this->assertJsonEquals( + [ + '_embedded' => [ + 'relatedEmbeddedEntity2' => [ + '_links' => [ + 'self' => [ + 'href' => '/related_entities/1', + ], + ], + 'id' => 1, + ], + ], + '_links' => [ + 'self' => [ + 'href' => '/skip-null-relation-route/1', + ], + 'relatedEmbeddedEntity2' => [ + 'href' => '/related_entities/1', + ], + ], + 'collection' => [ + [ + '_links' => [ + 'self' => [ + 'href' => '/related_entities/1', + ], + ], + 'id' => 1, + ], + [ + '_links' => [ + 'self' => [ + 'href' => '/related_entities/2', + ], + ], + 'id' => 2, + ], + ], + 'id' => 1, + ] + ); + } + + /** + * @throws ClientExceptionInterface + * @throws DecodingExceptionInterface + * @throws RedirectionExceptionInterface + * @throws ServerExceptionInterface + * @throws TransportExceptionInterface + */ + private function checkRoutesAreCorrectlySetUp(): void + { + self::createClient()->request( + 'GET', + '/', + [ + 'headers' => [ + 'accept' => 'application/hal+json', + ], + ] + ); + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains( + [ + '_links' => [ + 'relatedEntity' => [ + 'href' => '/related_entities', + ], + 'toOneRelationPropertyMayBeNull' => [ + 'href' => ToOneRelationPropertyMayBeNull::ROUTE, + ], + ], + ] + ); + } + + private function isMongoDB(): bool + { + return 'mongodb' === static::getContainer()->getParameter('kernel.environment'); + } +} diff --git a/tests/Hal/Serializer/ItemNormalizerTest.php b/tests/Hal/Serializer/ItemNormalizerTest.php index cf3a0c282af..200da7bf333 100644 --- a/tests/Hal/Serializer/ItemNormalizerTest.php +++ b/tests/Hal/Serializer/ItemNormalizerTest.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue4372\RelatedEntity; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\ActivableInterface; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Author; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Book; @@ -28,6 +29,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MaxDepthDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -420,4 +422,147 @@ public function testMaxDepth(): void $this->assertEquals($expected, $normalizer->normalize($level1, ItemNormalizer::FORMAT, [ObjectNormalizer::ENABLE_MAX_DEPTH => true])); } + + #[DataProvider('getSkipNullToOneRelationCases')] + public function testSkipNullToOneRelation($context, $expected) + { + $dummy = new Dummy(); + $dummy->setAlias(null); + $dummy->relatedDummy = null; + + $propertyNameCollection = new PropertyNameCollection(['alias', 'relatedDummy']); + $propertyNameCollectionFactory = $this->createMock(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->method('create')->willReturn($propertyNameCollection); + + $propertyMetadataFactory = $this->createMock(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->method('create')->willReturnCallback(function ($resourceClass, $propertyName, $groups) { + if ('alias' == $propertyName) { + return (new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true); + } + if ('relatedDummy' == $propertyName) { + return (new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withDescription('')->withReadable(true)->withWritable(false); + } + }); + + $iriConverter = $this->createMock(IriConverterInterface::class); + $iriConverter->method('getIriFromResource')->willReturn('/dummies/1'); + + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $resourceClassResolver->method('getResourceClass')->willReturnCallback(function ($resource) { + if ($resource instanceof Dummy) { + return Dummy::class; + } + if (null == $resource) { + return RelatedDummy::class; + } + }); + $resourceClassResolver->method('isResourceClass')->willReturn(true); + + /** + * @var SerializerInterface&NormalizerInterface $serializer + */ + $serializer = $this->createMockForIntersectionOfInterfaces([SerializerInterface::class, NormalizerInterface::class]); + $serializer->method('normalize')->with(null, null, self::anything())->willReturn(null); + + $nameConverter = self::createMock(NameConverterInterface::class); + $nameConverter->method('normalize')->willReturnCallback(function ($propertyName) { + if ('alias' == $propertyName) { + return 'alias'; + } + if ('relatedDummy' == $propertyName) { + return 'related_dummy'; + } + }); + + $normalizer = new ItemNormalizer( + propertyNameCollectionFactory: $propertyNameCollectionFactory, + propertyMetadataFactory: $propertyMetadataFactory, + iriConverter: $iriConverter, + resourceClassResolver: $resourceClassResolver, + propertyAccessor: null, + nameConverter: $nameConverter, + classMetadataFactory: null, + defaultContext: [], + resourceMetadataCollectionFactory: null, + resourceAccessChecker: null, + tagCollector: null, + ); + $normalizer->setSerializer($serializer); + + self::assertThat($expected, self::equalTo($normalizer->normalize($dummy, null, $context))); + } + + public static function getSkipNullToOneRelationCases() + { + yield [ + ['skip_null_to_one_relations' => true], + [ + '_links' => [ + 'self' => [ + 'href' => '/dummies/1', + ], + ], + 'alias' => null, + ], + ]; + + yield [ + ['skip_null_to_one_relations' => false], + [ + '_links' => [ + 'self' => [ + 'href' => '/dummies/1', + ], + 'related_dummy' => null, + ], + 'alias' => null, + ]]; + } + + /** + * @return string|void + */ + private static function convertPropertyNames($propertyName) + { + if ('relatedEntity' == $propertyName) { + return 'related_entity'; + } + if ('relatedEmbeddedEntity' == $propertyName) { + return 'related_embedded_entity'; + } + if ('collection' == $propertyName) { + return 'collection'; + } + if ('relatedEntity2' == $propertyName) { + return 'related_entity2'; + } + if ('relatedEmbeddedEntity2' == $propertyName) { + return 'related_embedded_entity2'; + } + + return $propertyName; + } + + /** + * @return ApiProperty|void + */ + private static function createPropertyMetadata($propertyName) + { + $relatedPropertyMeta = (new ApiProperty())->withNativeType(Type::object(RelatedEntity::class))->withDescription('')->withReadable(true)->withWritable(false)->withReadableLink(true); + if ('relatedEntity' == $propertyName) { + return $relatedPropertyMeta; + } + if ('relatedEmbeddedEntity' == $propertyName) { + return $relatedPropertyMeta; + } + if ('relatedEntity2' == $propertyName) { + return $relatedPropertyMeta; + } + + if ('relatedEmbeddedEntity2' == $propertyName) { + return $relatedPropertyMeta; + } + + return (new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true); + } }