Skip to content

Commit 625438c

Browse files
feat(jsonapi): support entity identifiers instead of IRIs as resource id
1 parent b662b41 commit 625438c

14 files changed

Lines changed: 777 additions & 21 deletions

File tree

src/JsonApi/Serializer/ItemNormalizer.php

Lines changed: 119 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
use ApiPlatform\Metadata\ApiProperty;
1717
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
18+
use ApiPlatform\Metadata\HttpOperation;
19+
use ApiPlatform\Metadata\IdentifiersExtractorInterface;
1820
use ApiPlatform\Metadata\IriConverterInterface;
1921
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
2022
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
@@ -23,6 +25,7 @@
2325
use ApiPlatform\Metadata\ResourceClassResolverInterface;
2426
use ApiPlatform\Metadata\UrlGeneratorInterface;
2527
use ApiPlatform\Metadata\Util\ClassInfoTrait;
28+
use ApiPlatform\Metadata\Util\CompositeIdentifierParser;
2629
use ApiPlatform\Metadata\Util\TypeHelper;
2730
use ApiPlatform\Serializer\AbstractItemNormalizer;
2831
use ApiPlatform\Serializer\CacheKeyTrait;
@@ -59,10 +62,26 @@ final class ItemNormalizer extends AbstractItemNormalizer
5962
public const FORMAT = 'jsonapi';
6063

6164
private array $componentsCache = [];
62-
63-
public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null)
64-
{
65+
private bool $useIriAsId;
66+
67+
public function __construct(
68+
PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory,
69+
PropertyMetadataFactoryInterface $propertyMetadataFactory,
70+
IriConverterInterface $iriConverter,
71+
ResourceClassResolverInterface $resourceClassResolver,
72+
?PropertyAccessorInterface $propertyAccessor = null,
73+
?NameConverterInterface $nameConverter = null,
74+
?ClassMetadataFactoryInterface $classMetadataFactory = null,
75+
array $defaultContext = [],
76+
?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null,
77+
?ResourceAccessCheckerInterface $resourceAccessChecker = null,
78+
protected ?TagCollectorInterface $tagCollector = null,
79+
?OperationResourceClassResolverInterface $operationResourceResolver = null,
80+
private readonly ?IdentifiersExtractorInterface $identifiersExtractor = null,
81+
bool $useIriAsId = true,
82+
) {
6583
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver);
84+
$this->useIriAsId = $useIriAsId;
6685
}
6786

6887
/**
@@ -121,16 +140,29 @@ public function normalize(mixed $data, ?string $format = null, array $context =
121140
$populatedRelationContext = $context;
122141
$relationshipsData = $this->getPopulatedRelations($data, $format, $populatedRelationContext, $allRelationshipsData);
123142

124-
// Do not include primary resources
125-
$context['api_included_resources'] = [$context['iri']];
143+
$id = $iri;
144+
if (!$this->useIriAsId) {
145+
$identifiers = $this->identifiersExtractor->getIdentifiersFromItem($data, context: $context);
146+
$id = $this->getIdStringFromIdentifiers($identifiers);
147+
}
148+
149+
$resourceShortName = $this->getResourceShortName($resourceClass);
150+
151+
// Do not include primary resources — use type:id composite key to avoid cross-type collisions
152+
$context['api_included_resources'] = [$resourceShortName.':'.$id => true];
126153

127154
$includedResourcesData = $this->getRelatedResources($data, $format, $context, $allRelationshipsData);
128155

129156
$resourceData = [
130-
'id' => $context['iri'],
131-
'type' => $this->getResourceShortName($resourceClass),
157+
'id' => $id,
158+
'type' => $resourceShortName,
132159
];
133160

161+
// TODO: consider always adding links.self — it's valid per the JSON:API spec even when id is the IRI
162+
if (!$this->useIriAsId) {
163+
$resourceData['links'] = ['self' => $iri];
164+
}
165+
134166
if ($normalizedData) {
135167
$resourceData['attributes'] = $normalizedData;
136168
}
@@ -175,10 +207,19 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a
175207
throw new NotNormalizableValueException('Update is not allowed for this operation.');
176208
}
177209

178-
$context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri(
179-
$data['data']['id'],
180-
$context + ['fetch_data' => false]
181-
);
210+
$context += ['fetch_data' => false];
211+
if ($this->useIriAsId) {
212+
$context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri(
213+
$data['data']['id'],
214+
$context
215+
);
216+
} else {
217+
$operation = $context['operation'] ?? null;
218+
if ($operation instanceof HttpOperation) {
219+
$iri = $this->reconstructIri($type, (string) $data['data']['id'], $operation);
220+
$context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context);
221+
}
222+
}
182223
}
183224

184225
// Merge attributes and relationships, into format expected by the parent normalizer
@@ -226,7 +267,29 @@ protected function denormalizeRelation(string $attributeName, ApiProperty $prope
226267
}
227268

228269
try {
229-
return $this->iriConverter->getResourceFromIri($value['id'], $context + ['fetch_data' => true]);
270+
$context += ['fetch_data' => true];
271+
if ($this->useIriAsId) {
272+
return $this->iriConverter->getResourceFromIri($value['id'], $context);
273+
}
274+
275+
$targetClass = null;
276+
$nativeType = $propertyMetadata->getNativeType();
277+
278+
if ($nativeType) {
279+
$nativeType->isSatisfiedBy(function (Type $type) use (&$targetClass): bool {
280+
return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($targetClass = $type->getClassName());
281+
});
282+
}
283+
284+
if (null === $targetClass) {
285+
throw new ItemNotFoundException(\sprintf('Cannot determine target class for property "%s".', $attributeName));
286+
}
287+
288+
/** @var HttpOperation $getOperation */
289+
$getOperation = $this->resourceMetadataCollectionFactory->create($targetClass)->getOperation(httpOperation: true);
290+
$iri = $this->reconstructIri($targetClass, (string) $value['id'], $getOperation);
291+
292+
return $this->iriConverter->getResourceFromIri($iri, $context);
230293
} catch (ItemNotFoundException $e) {
231294
if (!isset($context['not_normalizable_value_exceptions'])) {
232295
throw new RuntimeException($e->getMessage(), $e->getCode(), $e);
@@ -274,11 +337,19 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel
274337
return $normalizedRelatedObject;
275338
}
276339

340+
$id = $iri;
341+
if (!$this->useIriAsId) {
342+
$identifiers = $this->identifiersExtractor->getIdentifiersFromItem($relatedObject);
343+
$id = $this->getIdStringFromIdentifiers($identifiers);
344+
}
345+
346+
$relationData = [
347+
'type' => $this->getResourceShortName($resourceClass),
348+
'id' => $id,
349+
];
350+
277351
$context['data'] = [
278-
'data' => [
279-
'type' => $this->getResourceShortName($resourceClass),
280-
'id' => $iri,
281-
],
352+
'data' => $relationData,
282353
];
283354

284355
$context['iri'] = $iri;
@@ -551,10 +622,10 @@ private function getRelatedResources(object $object, ?string $format, array $con
551622
*/
552623
private function addIncluded(array $data, array &$included, array &$context): void
553624
{
554-
if (isset($data['id']) && !\in_array($data['id'], $context['api_included_resources'], true)) {
625+
$trackingKey = ($data['type'] ?? '').':'.($data['id'] ?? '');
626+
if (isset($data['id']) && !isset($context['api_included_resources'][$trackingKey])) {
555627
$included[] = $data;
556-
// Track already included resources
557-
$context['api_included_resources'][] = $data['id'];
628+
$context['api_included_resources'][$trackingKey] = true;
558629
}
559630
}
560631

@@ -580,6 +651,35 @@ private function getIncludedNestedResources(string $relationshipName, array $con
580651
return array_map(static fn (string $nested): string => substr($nested, strpos($nested, '.') + 1), $filtered);
581652
}
582653

654+
private function getIdStringFromIdentifiers(array $identifiers): string
655+
{
656+
if (1 === \count($identifiers)) {
657+
return (string) array_values($identifiers)[0];
658+
}
659+
660+
return CompositeIdentifierParser::stringify($identifiers);
661+
}
662+
663+
/**
664+
* Reconstructs an IRI from a resource class and a raw JSON:API id string.
665+
*
666+
* Maps the id to the operation's single URI variable parameter name and generates
667+
* the IRI via IriConverter. Composite identifiers on a single Link work naturally
668+
* since the composite string (e.g. "field1=val1;field2=val2") is passed as-is.
669+
*/
670+
private function reconstructIri(string $resourceClass, string $id, HttpOperation $operation): string
671+
{
672+
$uriVariables = $operation->getUriVariables() ?? [];
673+
674+
if (\count($uriVariables) > 1) {
675+
throw new UnexpectedValueException(\sprintf('JSON:API entity identifier mode requires operations with a single URI variable, operation "%s" has %d. Consider adding a NotExposed Get operation on the resource.', $operation->getName() ?? $operation->getUriTemplate(), \count($uriVariables)));
676+
}
677+
678+
$parameterName = array_key_first($uriVariables) ?? 'id';
679+
680+
return $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => [$parameterName => $id]]);
681+
}
682+
583683
// TODO: this code is similar to the one used in JsonLd
584684
private function getResourceShortName(string $resourceClass): string
585685
{

0 commit comments

Comments
 (0)