Skip to content

Commit de9e7f5

Browse files
authored
feat(hal): allow to output null links for hal+json
1 parent d06b1a0 commit de9e7f5

File tree

8 files changed

+525
-12
lines changed

8 files changed

+525
-12
lines changed

src/Hal/Serializer/ItemNormalizer.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -305,14 +305,16 @@ private function populateRelation(array $data, object $object, ?string $format,
305305

306306
$attributeValue = $this->getAttributeValue($object, $relation['name'], $format, $childContext);
307307

308-
if (empty($attributeValue)) {
308+
if (empty($attributeValue) && ($context[self::SKIP_NULL_TO_ONE_RELATIONS] ?? $this->defaultContext[self::SKIP_NULL_TO_ONE_RELATIONS] ?? true)) {
309309
continue;
310310
}
311311

312312
if ('one' === $relation['cardinality']) {
313313
if ('links' === $type) {
314-
$data[$key][$relationName]['href'] = $this->getRelationIri($attributeValue);
315-
continue;
314+
if (null !== $attributeValue) {
315+
$data[$key][$relationName]['href'] = $this->getRelationIri($attributeValue);
316+
continue;
317+
}
316318
}
317319

318320
$data[$key][$relationName] = $attributeValue;

src/Serializer/AbstractItemNormalizer.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ abstract class AbstractItemNormalizer extends AbstractObjectNormalizer
6565
use ContextTrait;
6666
use InputOutputMetadataTrait;
6767
use OperationContextTrait;
68+
/**
69+
* Flag to control whether to one relation with the value `null` should be output
70+
* when normalizing or omitted.
71+
*/
72+
public const SKIP_NULL_TO_ONE_RELATIONS = 'skip_null_to_one_relations';
6873

6974
protected PropertyAccessorInterface $propertyAccessor;
7075
protected array $localCache = [];

src/Serializer/SerializerContextBuilder.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ public function createFromRequest(Request $request, bool $normalization, ?array
6060
$context['operation'] = $operation;
6161
$context['resource_class'] = $attributes['resource_class'];
6262
$context['skip_null_values'] ??= true;
63+
$context[AbstractItemNormalizer::SKIP_NULL_TO_ONE_RELATIONS] ??= true;
6364
$context['iri_only'] ??= false;
6465
$context['request_uri'] = $request->getRequestUri();
6566
$context['uri'] = $request->getUri();

src/Serializer/Tests/SerializerContextBuilderTest.php

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,42 +67,42 @@ public function testCreateFromRequest(): void
6767
{
6868
$request = Request::create('/foos/1');
6969
$request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']);
70-
$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']];
70+
$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];
7171
$this->assertEquals($expected, $this->builder->createFromRequest($request, true));
7272

7373
$request = Request::create('/foos');
7474
$request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'get_collection', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']);
75-
$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']];
75+
$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];
7676
$this->assertEquals($expected, $this->builder->createFromRequest($request, true));
7777

7878
$request = Request::create('/foos/1');
7979
$request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']);
80-
$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']];
80+
$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];
8181
$this->assertEquals($expected, $this->builder->createFromRequest($request, false));
8282

8383
$request = Request::create('/foos', 'POST');
8484
$request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'post', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']);
85-
$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']];
85+
$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];
8686
$this->assertEquals($expected, $this->builder->createFromRequest($request, false));
8787

8888
$request = Request::create('/foos', 'PUT');
8989
$request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'put', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']);
90-
$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']];
90+
$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];
9191
$this->assertEquals($expected, $this->builder->createFromRequest($request, false));
9292

9393
$request = Request::create('/bars/1/foos');
9494
$request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']);
95-
$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']];
95+
$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];
9696
$this->assertEquals($expected, $this->builder->createFromRequest($request, false));
9797

9898
$request = Request::create('/foowithpatch/1', 'PATCH');
9999
$request->attributes->replace(['_api_resource_class' => 'FooWithPatch', '_api_operation_name' => 'patch', '_api_format' => 'json', '_api_mime_type' => 'application/json']);
100-
$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']];
100+
$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];
101101
$this->assertEquals($expected, $this->builder->createFromRequest($request, false));
102102

103103
$request = Request::create('/bars/1/foos');
104104
$request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml', 'id' => '1']);
105-
$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']];
105+
$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];
106106
$this->assertEquals($expected, $this->builder->createFromRequest($request, false));
107107
}
108108

@@ -115,7 +115,7 @@ public function testThrowExceptionOnInvalidRequest(): void
115115

116116
public function testReuseExistingAttributes(): void
117117
{
118-
$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']];
118+
$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];
119119
$this->assertEquals($expected, $this->builder->createFromRequest(Request::create('/foos/1'), false, ['resource_class' => 'Foo', 'operation_name' => 'get']));
120120
}
121121

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue4372;
15+
16+
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\Get;
19+
use ApiPlatform\Metadata\GetCollection;
20+
use Symfony\Component\Serializer\Annotation\Groups;
21+
22+
#[ApiResource(
23+
operations: [
24+
new GetCollection(),
25+
new Get(),
26+
]
27+
)]
28+
class RelatedEntity
29+
{
30+
#[ApiProperty(identifier: true)]
31+
#[Groups(['read'])]
32+
public ?int $id = null;
33+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue4372;
15+
16+
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\Get;
19+
use ApiPlatform\Metadata\GetCollection;
20+
use ApiPlatform\Metadata\Operation;
21+
use ApiPlatform\Serializer\AbstractItemNormalizer;
22+
use Doctrine\Common\Collections\ArrayCollection;
23+
use Doctrine\Common\Collections\Collection;
24+
use Symfony\Component\Serializer\Annotation\Groups;
25+
26+
#[ApiResource(
27+
operations: [
28+
new GetCollection(
29+
uriTemplate: self::ROUTE.'{._format}',
30+
),
31+
new Get(
32+
uriTemplate: self::ITEM_ROUTE.'{._format}',
33+
normalizationContext: [
34+
AbstractItemNormalizer::SKIP_NULL_TO_ONE_RELATIONS => false,
35+
'groups' => ['default', 'read'],
36+
],
37+
provider: [self::class, 'provide']
38+
),
39+
new Get(
40+
uriTemplate: self::ITEM_SKIP_NULL_TO_ONE_RELATION_ROUTE.'{._format}',
41+
normalizationContext: [
42+
'groups' => ['default', 'read'],
43+
],
44+
provider: [self::class, 'provide']
45+
),
46+
],
47+
)]
48+
class ToOneRelationPropertyMayBeNull
49+
{
50+
public const ROUTE = '/my-route';
51+
public const ITEM_ROUTE = self::ROUTE.'/{id}';
52+
public const SKIP_NULL_TO_ONE_RELATION_ROUTE = '/skip-null-relation-route';
53+
public const ITEM_SKIP_NULL_TO_ONE_RELATION_ROUTE = self::SKIP_NULL_TO_ONE_RELATION_ROUTE.'/{id}';
54+
public const ENTITY_ID = 1;
55+
56+
#[ApiProperty(identifier: true)]
57+
#[Groups(['read'])]
58+
public ?int $id = null;
59+
60+
#[ApiProperty]
61+
public ?RelatedEntity $relatedEntity = null;
62+
63+
#[ApiProperty(readableLink: true)]
64+
#[Groups(['read'])]
65+
public ?RelatedEntity $relatedEmbeddedEntity = null;
66+
67+
#[ApiProperty]
68+
public ?RelatedEntity $relatedEntity2 = null;
69+
70+
#[ApiProperty(readableLink: true)]
71+
#[Groups(['read'])]
72+
public ?RelatedEntity $relatedEmbeddedEntity2 = null;
73+
74+
#[ApiProperty]
75+
#[Groups(['read'])]
76+
public Collection $collection;
77+
78+
public function __construct()
79+
{
80+
$this->collection = new ArrayCollection();
81+
}
82+
83+
public static function provide(Operation $operation, array $uriVariables = [], array $context = []): ?self
84+
{
85+
$relatedEntity1 = new RelatedEntity();
86+
$relatedEntity1->id = 1;
87+
$relatedEntity2 = new RelatedEntity();
88+
$relatedEntity2->id = 2;
89+
90+
$toOneRelationPropertyMayBeNull = new self();
91+
$toOneRelationPropertyMayBeNull->id = self::ENTITY_ID;
92+
$toOneRelationPropertyMayBeNull->relatedEntity2 = $relatedEntity1;
93+
$toOneRelationPropertyMayBeNull->relatedEmbeddedEntity2 = $relatedEntity1;
94+
$toOneRelationPropertyMayBeNull->collection = new ArrayCollection([$relatedEntity1, $relatedEntity2]);
95+
96+
return $toOneRelationPropertyMayBeNull;
97+
}
98+
}

0 commit comments

Comments
 (0)