1515
1616use ApiPlatform \Metadata \ApiProperty ;
1717use ApiPlatform \Metadata \Exception \ItemNotFoundException ;
18+ use ApiPlatform \Metadata \HttpOperation ;
19+ use ApiPlatform \Metadata \IdentifiersExtractorInterface ;
1820use ApiPlatform \Metadata \IriConverterInterface ;
1921use ApiPlatform \Metadata \Property \Factory \PropertyMetadataFactoryInterface ;
2022use ApiPlatform \Metadata \Property \Factory \PropertyNameCollectionFactoryInterface ;
2325use ApiPlatform \Metadata \ResourceClassResolverInterface ;
2426use ApiPlatform \Metadata \UrlGeneratorInterface ;
2527use ApiPlatform \Metadata \Util \ClassInfoTrait ;
28+ use ApiPlatform \Metadata \Util \CompositeIdentifierParser ;
2629use ApiPlatform \Metadata \Util \TypeHelper ;
2730use ApiPlatform \Serializer \AbstractItemNormalizer ;
2831use 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