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
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Doctrine\Odm\Serializer;

use ApiPlatform\Doctrine\Odm\State\Options;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Util\ClassInfoTrait;
use ApiPlatform\Serializer\OperationResourceClassResolver;

/**
* Doctrine ODM operation resource resolver.
*
* Handles document-to-resource mappings from Doctrine ODM's stateOptions.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
final class DoctrineOdmOperationResourceClassResolver extends OperationResourceClassResolver
{
use ClassInfoTrait;

public function resolve(object|string $resource, Operation $operation): string
{
if (\is_string($resource)) {
return $resource;
}

$objectClass = $this->getObjectClass($resource);
$stateOptions = $operation->getStateOptions();

// Doctrine ODM: Check for document class in stateOptions
if ($stateOptions instanceof Options) {
$documentClass = $stateOptions->getDocumentClass();

// Validate object matches the backing document class
if ($documentClass && is_a($objectClass, $documentClass, true)) {
return $operation->getClass();
}
}

// Fallback to core behavior
return parent::resolve($resource, $operation);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Doctrine\Orm\Serializer;

use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Util\ClassInfoTrait;
use ApiPlatform\Serializer\OperationResourceClassResolver;

/**
* Doctrine ORM operation resource resolver.
*
* Handles entity-to-resource mappings from Doctrine ORM's stateOptions.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
final class DoctrineOrmOperationResourceClassResolver extends OperationResourceClassResolver
{
use ClassInfoTrait;

public function resolve(object|string $resource, Operation $operation): string
{
if (\is_string($resource)) {
return $resource;
}

$objectClass = $this->getObjectClass($resource);
$stateOptions = $operation->getStateOptions();

// Doctrine ORM: Check for entity class in stateOptions
if ($stateOptions instanceof Options) {
$entityClass = $stateOptions->getEntityClass();

// Validate object matches the backing entity class
if ($entityClass && is_a($objectClass, $entityClass, true)) {
return $operation->getClass();
}
}

// Fallback to core behavior
return parent::resolve($resource, $operation);
}
}
5 changes: 3 additions & 2 deletions src/Hal/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use ApiPlatform\Serializer\AbstractItemNormalizer;
use ApiPlatform\Serializer\CacheKeyTrait;
use ApiPlatform\Serializer\ContextTrait;
use ApiPlatform\Serializer\OperationResourceClassResolverInterface;
use ApiPlatform\Serializer\TagCollectorInterface;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
Expand Down Expand Up @@ -59,7 +60,7 @@ final class ItemNormalizer extends AbstractItemNormalizer
private array $componentsCache = [];
private array $attributesMetadataCache = [];

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, ?TagCollectorInterface $tagCollector = null)
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, ?TagCollectorInterface $tagCollector = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null)
{
$defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER] = function ($object): ?array {
$iri = $this->iriConverter->getIriFromResource($object);
Expand All @@ -70,7 +71,7 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName
return ['_links' => ['self' => ['href' => $iri]]];
};

parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector);
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver);
}

/**
Expand Down
5 changes: 3 additions & 2 deletions src/JsonApi/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use ApiPlatform\Serializer\AbstractItemNormalizer;
use ApiPlatform\Serializer\CacheKeyTrait;
use ApiPlatform\Serializer\ContextTrait;
use ApiPlatform\Serializer\OperationResourceClassResolverInterface;
use ApiPlatform\Serializer\TagCollectorInterface;
use Symfony\Component\ErrorHandler\Exception\FlattenException;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
Expand Down Expand Up @@ -59,9 +60,9 @@ final class ItemNormalizer extends AbstractItemNormalizer

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)
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)
{
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector);
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver);
}

/**
Expand Down
82 changes: 63 additions & 19 deletions src/JsonLd/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
Expand All @@ -27,6 +28,7 @@
use ApiPlatform\Metadata\Util\ClassInfoTrait;
use ApiPlatform\Serializer\AbstractItemNormalizer;
use ApiPlatform\Serializer\ContextTrait;
use ApiPlatform\Serializer\OperationResourceClassResolverInterface;
use ApiPlatform\Serializer\TagCollectorInterface;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Serializer\Exception\LogicException;
Expand Down Expand Up @@ -70,9 +72,9 @@ final class ItemNormalizer extends AbstractItemNormalizer
'@vocab',
];

public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, private readonly ContextBuilderInterface $contextBuilder, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null)
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, private readonly ContextBuilderInterface $contextBuilder, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, private ?OperationMetadataFactoryInterface $operationMetadataFactory = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null)
{
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector);
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver);
}

/**
Expand All @@ -84,9 +86,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array
}

/**
* @param string|null $format
* {@inheritdoc}
*/
public function getSupportedTypes($format): array
public function getSupportedTypes(?string $format): array
{
return self::FORMAT === $format ? parent::getSupportedTypes($format) : [];
}
Expand All @@ -96,20 +98,39 @@ public function getSupportedTypes($format): array
*
* @throws LogicException
*/
public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
{
$resourceClass = $this->getObjectClass($object);
$resourceClass = $this->getObjectClass($data);
$outputClass = $this->getOutputClass($context);

if ($outputClass && !($context['item_uri_template'] ?? null)) {
return parent::normalize($object, $format, $context);
if ($outputClass) {
if ($context['item_uri_template'] ?? null) {
// When both output and item_uri_template are present, temporarily remove
// item_uri_template so the output re-dispatch produces the correct @type
// from the output class (not from the item_uri_template operation).
$itemUriTemplate = $context['item_uri_template'];
unset($context['item_uri_template']);
$originalData = $data;
$data = parent::normalize($data, $format, $context);
if (\is_array($data)) {
try {
$context['item_uri_template'] = $itemUriTemplate;
$data['@id'] = $this->iriConverter->getIriFromResource($originalData, UrlGeneratorInterface::ABS_PATH, null, $context);
} catch (\Exception) {
}
}

return $data;
}

return parent::normalize($data, $format, $context);
}

// TODO: we should not remove the resource_class in the normalizeRawCollection as we would find out anyway that it's not the same as the requested one
$previousResourceClass = $context['resource_class'] ?? null;
$metadata = [];
if ($isResourceClass = $this->resourceClassResolver->isResourceClass($resourceClass) && (null === $previousResourceClass || $this->resourceClassResolver->isResourceClass($previousResourceClass))) {
$resourceClass = $this->resourceClassResolver->getResourceClass($object, $previousResourceClass);
$resourceClass = $this->resourceClassResolver->getResourceClass($data, $previousResourceClass);
$context = $this->initContext($resourceClass, $context);
$metadata = $this->addJsonLdContext($this->contextBuilder, $resourceClass, $context);
} elseif ($this->contextBuilder instanceof AnonymousContextBuilderInterface) {
Expand All @@ -119,45 +140,68 @@ public function normalize(mixed $object, ?string $format = null, array $context
$context['output']['iri'] = null;
}

if ($this->resourceClassResolver->isResourceClass($resourceClass)) {
if (isset($context['item_uri_template']) && $this->operationMetadataFactory) {
$itemOp = $this->operationMetadataFactory->create($context['item_uri_template']);
// Use resource-level shortName for @type, not operation-specific shortName
try {
$itemResourceShortName = $this->resourceMetadataCollectionFactory->create($itemOp->getClass())[0]->getShortName();
$context['output']['operation'] = $itemOp->withShortName($itemResourceShortName);
} catch (\Exception) {
$context['output']['operation'] = $itemOp;
}
} elseif ($this->resourceClassResolver->isResourceClass($resourceClass)) {
$context['output']['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation();
}

// We should improve what's behind the context creation, its probably more complicated then it should
$metadata = $this->createJsonLdContext($this->contextBuilder, $object, $context);
$metadata = $this->createJsonLdContext($this->contextBuilder, $data, $context);
}

// Special case: non-resource got serialized and contains a resource therefore we need to reset part of the context
if ($previousResourceClass !== $resourceClass && $resourceClass !== $outputClass) {
unset($context['operation'], $context['operation_name'], $context['output']);
}

if (true === ($context['output']['gen_id'] ?? true) && true === ($context['force_iri_generation'] ?? true) && $iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context)) {
if (true === ($context['output']['gen_id'] ?? true) && true === ($context['force_iri_generation'] ?? true) && $iri = $this->iriConverter->getIriFromResource($data, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context)) {
$context['iri'] = $iri;
$metadata['@id'] = $iri;
}

$context['api_normalize'] = true;

$data = parent::normalize($object, $format, $context);
if (!\is_array($data)) {
return $data;
$normalizedData = parent::normalize($data, $format, $context);
if (!\is_array($normalizedData)) {
return $normalizedData;
}

$operation = $context['operation'] ?? null;

if ($this->operationMetadataFactory && isset($context['item_uri_template']) && !$operation) {
$operation = $this->operationMetadataFactory->create($context['item_uri_template']);
}

if ($isResourceClass && !$operation) {
$operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation();
}

if (!isset($metadata['@type']) && $operation) {
$types = $operation instanceof HttpOperation ? $operation->getTypes() : null;
if (null === $types) {
$types = [$operation->getShortName()];
// TODO: 5.x break on this as this looks wrong, CollectionReferencingItem returns an IRI that point through
// ItemReferencedInCollection but it returns a CollectionReferencingItem therefore we should use the current
// object's class Type and not rely on operation ?
// Use resource-level shortName to avoid operation-specific overrides
$typeClass = $isResourceClass ? $resourceClass : ($operation->getClass() ?? $resourceClass);
try {
$types = [$this->resourceMetadataCollectionFactory->create($typeClass)[0]->getShortName()];
} catch (\Exception) {
$types = [$operation->getShortName()];
}
}
$metadata['@type'] = 1 === \count($types) ? $types[0] : $types;
}

return $metadata + $data;
return $metadata + $normalizedData;
}

/**
Expand All @@ -173,7 +217,7 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form
*
* @throws NotNormalizableValueException
*/
public function denormalize(mixed $data, string $class, ?string $format = null, array $context = []): mixed
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
{
// Avoid issues with proxies if we populated the object
if (isset($data['@id']) && !isset($context[self::OBJECT_TO_POPULATE])) {
Expand All @@ -192,7 +236,7 @@ public function denormalize(mixed $data, string $class, ?string $format = null,
}
}

return parent::denormalize($data, $class, $format, $context);
return parent::denormalize($data, $type, $format, $context);
}

protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool
Expand Down
Loading
Loading