Skip to content
Merged
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
89 changes: 21 additions & 68 deletions src/JsonApi/JsonSchema/SchemaFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

namespace ApiPlatform\JsonApi\JsonSchema;

use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter;
use ApiPlatform\JsonApi\Util\ResourceLinkageResolver;
use ApiPlatform\JsonSchema\DefinitionNameFactory;
use ApiPlatform\JsonSchema\DefinitionNameFactoryInterface;
use ApiPlatform\JsonSchema\ResourceMetadataTrait;
Expand All @@ -25,12 +27,7 @@
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
use ApiPlatform\Metadata\Util\TypeHelper;
use ApiPlatform\State\ApiResource\Error;
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
use Symfony\Component\TypeInfo\Type;
use Symfony\Component\TypeInfo\Type\CompositeTypeInterface;
use Symfony\Component\TypeInfo\Type\ObjectType;

/**
* Decorator factory which adds JSON:API properties to the JSON Schema document.
Expand Down Expand Up @@ -83,7 +80,9 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI
*/
private $builtSchema = [];

public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null)
private readonly ResourceLinkageResolver $resourceLinkageResolver;

public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null, ?ResourceLinkageResolver $resourceLinkageResolver = null)
{
if (!$definitionNameFactory) {
$this->definitionNameFactory = new DefinitionNameFactory();
Expand All @@ -93,6 +92,7 @@ public function __construct(private readonly SchemaFactoryInterface $schemaFacto
}
$this->resourceClassResolver = $resourceClassResolver;
$this->resourceMetadataFactory = $resourceMetadataFactory;
$this->resourceLinkageResolver = $resourceLinkageResolver ?? new ResourceLinkageResolver($resourceClassResolver);
}

/**
Expand Down Expand Up @@ -326,28 +326,27 @@ private function buildDefinitionPropertiesSchema(string $key, string $className,
foreach (array_keys($refs) as $ref) {
$relatedDefinitions[$ref] = ['$ref' => $ref];
}
// A relationship literally named "relationships"/"included" is prefixed at runtime too.
$relationshipName = ReservedAttributeNameConverter::JSON_API_RESERVED_ATTRIBUTES[$propertyName] ?? $propertyName;
if ($isOne) {
$relationships[$propertyName]['properties']['data'] = [
$relationships[$relationshipName]['properties']['data'] = [
'oneOf' => [
['type' => 'null'],
self::RELATION_PROPS,
],
];
continue;
}
$relationships[$propertyName]['properties']['data'] = [
$relationships[$relationshipName]['properties']['data'] = [
'type' => 'array',
'items' => self::RELATION_PROPS,
];
continue;
}

if ('id' === $propertyName) {
// should probably be renamed "lid" and moved to the above node
$attributes['_id'] = $property;
continue;
}
$attributes[$propertyName] = $property;
// Reserved names (id, type, links, relationships, included) are prefixed by the
// ReservedAttributeNameConverter at runtime; mirror that single source of truth here.
$attributes[ReservedAttributeNameConverter::JSON_API_RESERVED_ATTRIBUTES[$propertyName] ?? $propertyName] = $property;
}

$replacement = self::PROPERTY_PROPS;
Expand Down Expand Up @@ -391,67 +390,21 @@ private function getRelationship(string $resourceClass, string $property, ?array
{
$propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, $serializerContext ?? []);

if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
$types = $propertyMetadata->getBuiltinTypes() ?? [];
$isRelationship = false;
$isOne = $isMany = false;
$relatedClasses = [];

foreach ($types as $type) {
if ($type->isCollection()) {
$collectionValueType = $type->getCollectionValueTypes()[0] ?? null;
$isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
} else {
$isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
}
if (!isset($className) || (!$isOne && !$isMany)) {
continue;
}
$isRelationship = true;
$resourceMetadata = $this->resourceMetadataFactory->create($className);
$operation = $resourceMetadata->getOperation();
// @see https://github.com/api-platform/core/issues/5501
// @see https://github.com/api-platform/core/pull/5722
$relatedClasses[$className] = $operation->canRead();
}

return $isRelationship ? [$isOne, $relatedClasses] : null;
}

if (null === $type = $propertyMetadata->getNativeType()) {
// Share the runtime attributes/relationships split so the generated schema cannot drift from the document.
$relationships = $this->resourceLinkageResolver->getRelationships($propertyMetadata);
if ([] === $relationships) {
return null;
}

$isRelationship = false;
$isOne = $isMany = false;
$isOne = false;
$relatedClasses = [];

/** @var class-string|null $className */
$className = null;

$typeIsResourceClass = function (Type $type) use (&$className): bool {
return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName());
};

foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) {
if (TypeHelper::getCollectionValueType($t)?->isSatisfiedBy($typeIsResourceClass)) {
$isMany = true;
} elseif ($t->isSatisfiedBy($typeIsResourceClass)) {
$isOne = true;
}

if (!$className || (!$isOne && !$isMany)) {
continue;
}

$isRelationship = true;
$resourceMetadata = $this->resourceMetadataFactory->create($className);
$operation = $resourceMetadata->getOperation();
foreach ($relationships as [$className, $isCollection]) {
$isOne = $isOne || !$isCollection;
// @see https://github.com/api-platform/core/issues/5501
// @see https://github.com/api-platform/core/pull/5722
$relatedClasses[$className] = $operation->canRead();
$relatedClasses[$className] = $this->resourceMetadataFactory->create($className)->getOperation()->canRead();
}

return $isRelationship ? [$isOne, $relatedClasses] : null;
return [$isOne, $relatedClasses];
}
}
124 changes: 27 additions & 97 deletions src/JsonApi/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace ApiPlatform\JsonApi\Serializer;

use ApiPlatform\JsonApi\Util\ResourceLinkageResolver;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
use ApiPlatform\Metadata\HttpOperation;
Expand All @@ -26,24 +27,19 @@
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\ContextTrait;
use ApiPlatform\Serializer\OperationResourceClassResolverInterface;
use ApiPlatform\Serializer\TagCollectorInterface;
use Symfony\Component\ErrorHandler\Exception\FlattenException;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
use Symfony\Component\Serializer\Exception\LogicException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\Exception\RuntimeException;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\TypeInfo\Type;
use Symfony\Component\TypeInfo\Type\CompositeTypeInterface;
use Symfony\Component\TypeInfo\Type\ObjectType;

/**
* Converts between objects and array.
Expand All @@ -69,6 +65,7 @@ final class ItemNormalizer extends AbstractItemNormalizer

private array $componentsCache = [];
private bool $useIriAsId;
private readonly ResourceLinkageResolver $resourceLinkageResolver;

public function __construct(
PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory,
Expand All @@ -85,9 +82,11 @@ public function __construct(
?OperationResourceClassResolverInterface $operationResourceResolver = null,
private readonly ?IdentifiersExtractorInterface $identifiersExtractor = null,
bool $useIriAsId = true,
?ResourceLinkageResolver $resourceLinkageResolver = null,
) {
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver);
$this->useIriAsId = $useIriAsId;
$this->resourceLinkageResolver = $resourceLinkageResolver ?? new ResourceLinkageResolver($resourceClassResolver);
}

/**
Expand Down Expand Up @@ -409,105 +408,36 @@ private function getComponents(object $object, ?string $format, array $context):
->propertyMetadataFactory
->create($context['resource_class'], $attribute, $options);

// prevent declaring $attribute as attribute if it's already declared as relationship
$isRelationship = false;
// Shared with the JSON Schema SchemaFactory so the documented split cannot drift from this output.
$relationships = $this->resourceLinkageResolver->getRelationships($propertyMetadata);

if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
$types = $propertyMetadata->getBuiltinTypes() ?? [];

foreach ($types as $type) {
$isOne = $isMany = false;

if ($type->isCollection()) {
$collectionValueType = $type->getCollectionValueTypes()[0] ?? null;
$isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
} else {
$isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
}

if (!isset($className) || !$isOne && !$isMany) {
// don't declare it as an attribute too quick: maybe the next type is a valid resource
continue;
}
foreach ($relationships as [$className, $isCollection]) {
$relation = [
'name' => $attribute,
'type' => $this->getResourceShortName($className),
'cardinality' => $isCollection ? 'many' : 'one',
];

$relation = [
'name' => $attribute,
'type' => $this->getResourceShortName($className),
'cardinality' => $isOne ? 'one' : 'many',
];

// if we specify the uriTemplate, generates its value for link definition
// @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content
if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) {
$attributeValue = $this->propertyAccessor->getValue($object, $attribute);
$resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
$childContext = $this->createChildContext($context, $attribute, $format);
unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']);

$operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(
operationName: $itemUriTemplate,
httpOperation: true
);

$components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
}
// if we specify the uriTemplate, generates its value for link definition
// @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content
if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) {
$attributeValue = $this->propertyAccessor->getValue($object, $attribute);
$resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
$childContext = $this->createChildContext($context, $attribute, $format);
unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']);

$operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(
operationName: $itemUriTemplate,
httpOperation: true
);

$components['relationships'][] = $relation;
$isRelationship = true;
$components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
}
} else {
if ($type = $propertyMetadata->getNativeType()) {
/** @var class-string|null $className */
$className = null;

$typeIsResourceClass = function (Type $type) use (&$className): bool {
return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName());
};

foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) {
$isOne = $isMany = false;

if (TypeHelper::getCollectionValueType($t)?->isSatisfiedBy($typeIsResourceClass)) {
$isMany = true;
} elseif ($t->isSatisfiedBy($typeIsResourceClass)) {
$isOne = true;
}

if (!$className || (!$isOne && !$isMany)) {
// don't declare it as an attribute too quick: maybe the next type is a valid resource
continue;
}

$relation = [
'name' => $attribute,
'type' => $this->getResourceShortName($className),
'cardinality' => $isOne ? 'one' : 'many',
];

// if we specify the uriTemplate, generates its value for link definition
// @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content
if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) {
$attributeValue = $this->propertyAccessor->getValue($object, $attribute);
$resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className);
$childContext = $this->createChildContext($context, $attribute, $format);
unset($childContext['iri'], $childContext['uri_variables'], $childContext['resource_class'], $childContext['operation']);

$operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(
operationName: $itemUriTemplate,
httpOperation: true
);

$components['links'][$attribute] = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext);
}

$components['relationships'][] = $relation;
$isRelationship = true;
}
}
$components['relationships'][] = $relation;
}

// if all types are not relationships, declare it as an attribute
if (!$isRelationship) {
if ([] === $relationships) {
$components['attributes'][] = $attribute;
}
}
Expand Down
Loading
Loading