Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 125 additions & 19 deletions src/JsonApi/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\IdentifiersExtractorInterface;
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
Expand All @@ -23,6 +25,7 @@
use ApiPlatform\Metadata\ResourceClassResolverInterface;
use ApiPlatform\Metadata\UrlGeneratorInterface;
use ApiPlatform\Metadata\Util\ClassInfoTrait;
use ApiPlatform\Metadata\Util\CompositeIdentifierParser;
use ApiPlatform\Metadata\Util\TypeHelper;
use ApiPlatform\Serializer\AbstractItemNormalizer;
use ApiPlatform\Serializer\CacheKeyTrait;
Expand Down Expand Up @@ -59,10 +62,26 @@ final class ItemNormalizer extends AbstractItemNormalizer
public const FORMAT = 'jsonapi';

private array $componentsCache = [];

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)
{
private bool $useIriAsId;

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,
private readonly ?IdentifiersExtractorInterface $identifiersExtractor = null,
bool $useIriAsId = true,
) {
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver);
$this->useIriAsId = $useIriAsId;
}

/**
Expand Down Expand Up @@ -121,16 +140,31 @@ public function normalize(mixed $data, ?string $format = null, array $context =
$populatedRelationContext = $context;
$relationshipsData = $this->getPopulatedRelations($data, $format, $populatedRelationContext, $allRelationshipsData);

// Do not include primary resources
$context['api_included_resources'] = [$context['iri']];
$id = $iri;
if (!$this->useIriAsId) {
$identifiers = $this->identifiersExtractor->getIdentifiersFromItem($data, context: $context);
$id = $this->getIdStringFromIdentifiers($identifiers);
// Use the resource's own item operation for links.self (not the subresource operation)
$iri = $this->iriConverter->getIriFromResource($data);
}

$resourceShortName = $this->getResourceShortName($resourceClass);

// Do not include primary resources — use type:id composite key to avoid cross-type collisions
$context['api_included_resources'] = [$resourceShortName.':'.$id => true];

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

$resourceData = [
'id' => $context['iri'],
'type' => $this->getResourceShortName($resourceClass),
'id' => $id,
'type' => $resourceShortName,
];

// TODO: consider always adding links.self — it's valid per the JSON:API spec even when id is the IRI
if (!$this->useIriAsId) {
$resourceData['links'] = ['self' => $iri];
}

if ($normalizedData) {
$resourceData['attributes'] = $normalizedData;
}
Expand Down Expand Up @@ -175,10 +209,19 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a
throw new NotNormalizableValueException('Update is not allowed for this operation.');
}

$context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri(
$data['data']['id'],
$context + ['fetch_data' => false]
);
$context += ['fetch_data' => false];
if ($this->useIriAsId) {
$context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri(
$data['data']['id'],
$context
);
} else {
$operation = $context['operation'] ?? null;
if ($operation instanceof HttpOperation) {
$iri = $this->reconstructIri($type, (string) $data['data']['id'], $operation);
$context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context);
}
}
}

// Merge attributes and relationships, into format expected by the parent normalizer
Expand Down Expand Up @@ -226,7 +269,29 @@ protected function denormalizeRelation(string $attributeName, ApiProperty $prope
}

try {
return $this->iriConverter->getResourceFromIri($value['id'], $context + ['fetch_data' => true]);
$context += ['fetch_data' => true];
if ($this->useIriAsId) {
return $this->iriConverter->getResourceFromIri($value['id'], $context);
}

$targetClass = null;
$nativeType = $propertyMetadata->getNativeType();

if ($nativeType) {
$nativeType->isSatisfiedBy(function (Type $type) use (&$targetClass): bool {
return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($targetClass = $type->getClassName());
});
}

if (null === $targetClass) {
throw new ItemNotFoundException(\sprintf('Cannot determine target class for property "%s".', $attributeName));
}

/** @var HttpOperation $getOperation */
$getOperation = $this->resourceMetadataCollectionFactory->create($targetClass)->getOperation(httpOperation: true);
$iri = $this->reconstructIri($targetClass, (string) $value['id'], $getOperation);

return $this->iriConverter->getResourceFromIri($iri, $context);
} catch (ItemNotFoundException $e) {
if (!isset($context['not_normalizable_value_exceptions'])) {
throw new RuntimeException($e->getMessage(), $e->getCode(), $e);
Expand Down Expand Up @@ -274,11 +339,23 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel
return $normalizedRelatedObject;
}

$id = $iri;
if (!$this->useIriAsId) {
$identifiers = $this->identifiersExtractor->getIdentifiersFromItem($relatedObject);
$id = $this->getIdStringFromIdentifiers($identifiers);
}

$relationData = [
'type' => $this->getResourceShortName($resourceClass),
'id' => $id,
];

if (!$this->useIriAsId) {
$relationData['links'] = ['self' => $iri];
}

$context['data'] = [
'data' => [
'type' => $this->getResourceShortName($resourceClass),
'id' => $iri,
],
'data' => $relationData,
];

$context['iri'] = $iri;
Expand Down Expand Up @@ -551,10 +628,10 @@ private function getRelatedResources(object $object, ?string $format, array $con
*/
private function addIncluded(array $data, array &$included, array &$context): void
{
if (isset($data['id']) && !\in_array($data['id'], $context['api_included_resources'], true)) {
$trackingKey = ($data['type'] ?? '').':'.($data['id'] ?? '');
if (isset($data['id']) && !isset($context['api_included_resources'][$trackingKey])) {
$included[] = $data;
// Track already included resources
$context['api_included_resources'][] = $data['id'];
$context['api_included_resources'][$trackingKey] = true;
}
}

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

private function getIdStringFromIdentifiers(array $identifiers): string
{
if (1 === \count($identifiers)) {
return (string) array_values($identifiers)[0];
}

return CompositeIdentifierParser::stringify($identifiers);
}

/**
* Reconstructs an IRI from a resource class and a raw JSON:API id string.
*
* Maps the id to the operation's single URI variable parameter name and generates
* the IRI via IriConverter. Composite identifiers on a single Link work naturally
* since the composite string (e.g. "field1=val1;field2=val2") is passed as-is.
*/
private function reconstructIri(string $resourceClass, string $id, HttpOperation $operation): string
{
$uriVariables = $operation->getUriVariables() ?? [];

if (\count($uriVariables) > 1) {
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)));
}

$parameterName = array_key_first($uriVariables) ?? 'id';

return $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => [$parameterName => $id]]);
}

// TODO: this code is similar to the one used in JsonLd
private function getResourceShortName(string $resourceClass): string
{
Expand Down
Loading
Loading