Skip to content

Commit 143d512

Browse files
soyukamtarld
andauthoredApr 17, 2025
feat(jsonapi): use TypeInfo's Type (#7100)
Co-authored-by: Mathias Arlaud <mathias.arlaud@gmail.com>
1 parent 350d6d7 commit 143d512

File tree

6 files changed

+212
-64
lines changed

6 files changed

+212
-64
lines changed
 

‎src/JsonApi/JsonSchema/SchemaFactory.php

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@
2323
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2424
use ApiPlatform\Metadata\ResourceClassResolverInterface;
2525
use ApiPlatform\State\ApiResource\Error;
26+
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
27+
use Symfony\Component\TypeInfo\Type;
28+
use Symfony\Component\TypeInfo\Type\CollectionType;
29+
use Symfony\Component\TypeInfo\Type\CompositeTypeInterface;
30+
use Symfony\Component\TypeInfo\Type\ObjectType;
31+
use Symfony\Component\TypeInfo\Type\WrappingTypeInterface;
2632

2733
/**
2834
* Decorator factory which adds JSON:API properties to the JSON Schema document.
@@ -286,21 +292,73 @@ private function buildDefinitionPropertiesSchema(string $key, string $className,
286292
private function getRelationship(string $resourceClass, string $property, ?array $serializerContext): ?array
287293
{
288294
$propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, $serializerContext ?? []);
289-
$types = $propertyMetadata->getBuiltinTypes() ?? [];
295+
296+
if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
297+
$types = $propertyMetadata->getBuiltinTypes() ?? [];
298+
$isRelationship = false;
299+
$isOne = $isMany = false;
300+
$relatedClasses = [];
301+
302+
foreach ($types as $type) {
303+
if ($type->isCollection()) {
304+
$collectionValueType = $type->getCollectionValueTypes()[0] ?? null;
305+
$isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
306+
} else {
307+
$isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
308+
}
309+
if (!isset($className) || (!$isOne && !$isMany)) {
310+
continue;
311+
}
312+
$isRelationship = true;
313+
$resourceMetadata = $this->resourceMetadataFactory->create($className);
314+
$operation = $resourceMetadata->getOperation();
315+
// @see https://github.com/api-platform/core/issues/5501
316+
// @see https://github.com/api-platform/core/pull/5722
317+
$relatedClasses[$className] = $operation->canRead();
318+
}
319+
320+
return $isRelationship ? [$isOne, $relatedClasses] : null;
321+
}
322+
323+
if (null === $type = $propertyMetadata->getNativeType()) {
324+
return null;
325+
}
326+
290327
$isRelationship = false;
291328
$isOne = $isMany = false;
292329
$relatedClasses = [];
293330

294-
foreach ($types as $type) {
295-
if ($type->isCollection()) {
296-
$collectionValueType = $type->getCollectionValueTypes()[0] ?? null;
297-
$isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
298-
} else {
299-
$isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
331+
/** @var class-string|null $className */
332+
$className = null;
333+
334+
$typeIsResourceClass = function (Type $type) use (&$typeIsResourceClass, &$className): bool {
335+
return match (true) {
336+
$type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsResourceClass),
337+
$type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsResourceClass),
338+
default => $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()),
339+
};
340+
};
341+
342+
$collectionValueIsResourceClass = function (Type $type) use (&$typeIsResourceClass): bool {
343+
return match (true) {
344+
$type instanceof CollectionType => $type->getCollectionValueType()->isSatisfiedBy($typeIsResourceClass),
345+
$type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsResourceClass),
346+
$type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsResourceClass),
347+
default => false,
348+
};
349+
};
350+
351+
foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) {
352+
if ($t->isSatisfiedBy($collectionValueIsResourceClass)) {
353+
$isMany = true;
354+
} elseif ($t->isSatisfiedBy($typeIsResourceClass)) {
355+
$isOne = true;
300356
}
301-
if (!isset($className) || (!$isOne && !$isMany)) {
357+
358+
if (!$className || (!$isOne && !$isMany)) {
302359
continue;
303360
}
361+
304362
$isRelationship = true;
305363
$resourceMetadata = $this->resourceMetadataFactory->create($className);
306364
$operation = $resourceMetadata->getOperation();

‎src/JsonApi/Serializer/ConstraintViolationListNormalizer.php

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,13 @@
1414
namespace ApiPlatform\JsonApi\Serializer;
1515

1616
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
17+
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
1718
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
1819
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
20+
use Symfony\Component\TypeInfo\Type;
21+
use Symfony\Component\TypeInfo\Type\CompositeTypeInterface;
22+
use Symfony\Component\TypeInfo\Type\ObjectType;
23+
use Symfony\Component\TypeInfo\Type\WrappingTypeInterface;
1924
use Symfony\Component\Validator\ConstraintViolationInterface;
2025
use Symfony\Component\Validator\ConstraintViolationListInterface;
2126

@@ -83,9 +88,23 @@ private function getSourcePointerFromViolation(ConstraintViolationInterface $vio
8388
$fieldName = $this->nameConverter->normalize($fieldName, $class, self::FORMAT);
8489
}
8590

86-
$type = $propertyMetadata->getBuiltinTypes()[0] ?? null;
87-
if ($type && null !== $type->getClassName()) {
88-
return "data/relationships/$fieldName";
91+
if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
92+
$type = $propertyMetadata->getBuiltinTypes()[0] ?? null;
93+
if ($type && null !== $type->getClassName()) {
94+
return "data/relationships/$fieldName";
95+
}
96+
} else {
97+
$typeIsObject = static function (Type $type) use (&$typeIsObject): bool {
98+
return match (true) {
99+
$type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsObject),
100+
$type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsObject),
101+
default => $type instanceof ObjectType,
102+
};
103+
};
104+
105+
if ($propertyMetadata->getNativeType()?->isSatisfiedBy($typeIsObject)) {
106+
return "data/relationships/$fieldName";
107+
}
89108
}
90109

91110
return "data/attributes/$fieldName";

‎src/JsonApi/Serializer/ItemNormalizer.php

Lines changed: 107 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,19 @@
2929
use ApiPlatform\Serializer\TagCollectorInterface;
3030
use Symfony\Component\ErrorHandler\Exception\FlattenException;
3131
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
32+
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
3233
use Symfony\Component\Serializer\Exception\LogicException;
3334
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
3435
use Symfony\Component\Serializer\Exception\RuntimeException;
3536
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
3637
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
3738
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
3839
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
40+
use Symfony\Component\TypeInfo\Type;
41+
use Symfony\Component\TypeInfo\Type\CollectionType;
42+
use Symfony\Component\TypeInfo\Type\CompositeTypeInterface;
43+
use Symfony\Component\TypeInfo\Type\ObjectType;
44+
use Symfony\Component\TypeInfo\Type\WrappingTypeInterface;
3945

4046
/**
4147
* Converts between objects and array.
@@ -319,50 +325,115 @@ private function getComponents(object $object, ?string $format, array $context):
319325
->propertyMetadataFactory
320326
->create($context['resource_class'], $attribute, $options);
321327

322-
$types = $propertyMetadata->getBuiltinTypes() ?? [];
323-
324328
// prevent declaring $attribute as attribute if it's already declared as relationship
325329
$isRelationship = false;
326330

327-
foreach ($types as $type) {
328-
$isOne = $isMany = false;
331+
if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
332+
$types = $propertyMetadata->getBuiltinTypes() ?? [];
329333

330-
if ($type->isCollection()) {
331-
$collectionValueType = $type->getCollectionValueTypes()[0] ?? null;
332-
$isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
333-
} else {
334-
$isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
335-
}
334+
foreach ($types as $type) {
335+
$isOne = $isMany = false;
336336

337-
if (!isset($className) || !$isOne && !$isMany) {
338-
// don't declare it as an attribute too quick: maybe the next type is a valid resource
339-
continue;
340-
}
337+
if ($type->isCollection()) {
338+
$collectionValueType = $type->getCollectionValueTypes()[0] ?? null;
339+
$isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
340+
} else {
341+
$isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
342+
}
343+
344+
if (!isset($className) || !$isOne && !$isMany) {
345+
// don't declare it as an attribute too quick: maybe the next type is a valid resource
346+
continue;
347+
}
348+
349+
$relation = [
350+
'name' => $attribute,
351+
'type' => $this->getResourceShortName($className),
352+
'cardinality' => $isOne ? 'one' : 'many',
353+
];
354+
355+
// if we specify the uriTemplate, generates its value for link definition
356+
// @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content
357+
if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) {
358+
$attributeValue = $this->propertyAccessor->getValue($object, $attribute);
359+
$resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
360+
$childContext = $this->createChildContext($context, $attribute, $format);
361+
unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']);
362+
363+
$operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(
364+
operationName: $itemUriTemplate,
365+
httpOperation: true
366+
);
367+
368+
$components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
369+
}
341370

342-
$relation = [
343-
'name' => $attribute,
344-
'type' => $this->getResourceShortName($className),
345-
'cardinality' => $isOne ? 'one' : 'many',
346-
];
347-
348-
// if we specify the uriTemplate, generates its value for link definition
349-
// @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content
350-
if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) {
351-
$attributeValue = $this->propertyAccessor->getValue($object, $attribute);
352-
$resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
353-
$childContext = $this->createChildContext($context, $attribute, $format);
354-
unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']);
355-
356-
$operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(
357-
operationName: $itemUriTemplate,
358-
httpOperation: true
359-
);
360-
361-
$components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
371+
$components['relationships'][] = $relation;
372+
$isRelationship = true;
362373
}
374+
} else {
375+
if ($type = $propertyMetadata->getNativeType()) {
376+
/** @var class-string|null $className */
377+
$className = null;
378+
379+
$typeIsResourceClass = function (Type $type) use (&$typeIsResourceClass, &$className): bool {
380+
return match (true) {
381+
$type instanceof ObjectType => $this->resourceClassResolver->isResourceClass($className = $type->getClassName()),
382+
$type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsResourceClass),
383+
$type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsResourceClass),
384+
default => false,
385+
};
386+
};
387+
388+
$collectionValueIsResourceClass = function (Type $type) use ($typeIsResourceClass, &$collectionValueIsResourceClass): bool {
389+
return match (true) {
390+
$type instanceof CollectionType => $type->getCollectionValueType()->isSatisfiedBy($typeIsResourceClass),
391+
$type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($collectionValueIsResourceClass),
392+
$type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($collectionValueIsResourceClass),
393+
default => false,
394+
};
395+
};
396+
397+
foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) {
398+
$isOne = $isMany = false;
399+
400+
if ($t->isSatisfiedBy($collectionValueIsResourceClass)) {
401+
$isMany = true;
402+
} elseif ($t->isSatisfiedBy($typeIsResourceClass)) {
403+
$isOne = true;
404+
}
405+
406+
if (!$className || (!$isOne && !$isMany)) {
407+
// don't declare it as an attribute too quick: maybe the next type is a valid resource
408+
continue;
409+
}
363410

364-
$components['relationships'][] = $relation;
365-
$isRelationship = true;
411+
$relation = [
412+
'name' => $attribute,
413+
'type' => $this->getResourceShortName($className),
414+
'cardinality' => $isOne ? 'one' : 'many',
415+
];
416+
417+
// if we specify the uriTemplate, generates its value for link definition
418+
// @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content
419+
if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) {
420+
$attributeValue = $this->propertyAccessor->getValue($object, $attribute);
421+
$resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
422+
$childContext = $this->createChildContext($context, $attribute, $format);
423+
unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']);
424+
425+
$operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(
426+
operationName: $itemUriTemplate,
427+
httpOperation: true
428+
);
429+
430+
$components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
431+
}
432+
433+
$components['relationships'][] = $relation;
434+
$isRelationship = true;
435+
}
436+
}
366437
}
367438

368439
// if all types are not relationships, declare it as an attribute

‎src/JsonApi/Tests/Serializer/ConstraintViolationNormalizerTest.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
2121
use PHPUnit\Framework\TestCase;
2222
use Prophecy\PhpUnit\ProphecyTrait;
23-
use Symfony\Component\PropertyInfo\Type;
2423
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
24+
use Symfony\Component\TypeInfo\Type;
2525
use Symfony\Component\Validator\ConstraintViolation;
2626
use Symfony\Component\Validator\ConstraintViolationList;
2727
use Symfony\Component\Validator\ConstraintViolationListInterface;
@@ -50,8 +50,8 @@ public function testSupportNormalization(): void
5050
public function testNormalize(): void
5151
{
5252
$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
53-
$propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy')->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class)]))->shouldBeCalledTimes(1);
54-
$propertyMetadataFactoryProphecy->create(Dummy::class, 'name')->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]))->shouldBeCalledTimes(1);
53+
$propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy')->willReturn((new ApiProperty())->withNativeType(Type::object(RelatedDummy::class)))->shouldBeCalledTimes(1);
54+
$propertyMetadataFactoryProphecy->create(Dummy::class, 'name')->willReturn((new ApiProperty())->withNativeType(Type::string()))->shouldBeCalledTimes(1);
5555

5656
$nameConverterProphecy = $this->prophesize(NameConverterInterface::class);
5757
$nameConverterProphecy->normalize('relatedDummy', Dummy::class, 'jsonapi')->willReturn('relatedDummy')->shouldBeCalledTimes(1);

0 commit comments

Comments
 (0)