api-platform / core

The server component of API Platform: hypermedia and GraphQL APIs in minutes
https://api-platform.com
MIT License
2.4k stars 856 forks source link

Error during PUT operation when using DTO and stateOptions #6466

Open popadko opened 1 month ago

popadko commented 1 month ago

Hello! Hope I find you in a good mood!

API Platform version(s) affected: 3.3.7

Description
I followed the next guides to create multiple ApiResources over one entity using DTO and stateOptions. https://www.youtube.com/watch?v=IVJjADhU7WM https://symfonycasts.com/screencast/api-platform-extending

Everything works like a charm except for the PUT operation. I get the 500 error because Doctrine can not retrieve the entity identifier. Please see the details below.

How to reproduce

  1. Configure an ApiResource over DTO with stateOptions settings based on a Doctrine Entity.
  2. Perform the PUT request on the ApiResource

ER: PUT works as expected. AR:

{
  "title": "An error occurred",
  "detail": "Given object is not an instance of the class this property was declared in",
  "status": 500,
  "type": "/errors/500",
  "trace": [
    {
      "file": "/home/www/vendor/doctrine/persistence/src/Persistence/Reflection/TypedNoDefaultReflectionPropertyBase.php",
      "line": 33,
      "function": "isInitialized",
      "class": "ReflectionProperty",
      "type": "->"
    },
    {
      "file": "/home/www/vendor/doctrine/orm/src/Mapping/ClassMetadata.php",
      "line": 616,
      "function": "getValue",
      "class": "Doctrine\\Persistence\\Reflection\\TypedNoDefaultReflectionProperty",
      "type": "->"
    },
    {
      "file": "/home/www/vendor/api-platform/core/src/Doctrine/Common/State/PersistProcessor.php",
      "line": 61,
      "function": "getIdentifierValues",
      "class": "Doctrine\\ORM\\Mapping\\ClassMetadata",
      "type": "->"
    },
    {
      "file": "/home/www/src/ApiPlatform/State/Processor/Doctrine/EntityClassDtoStateProcessor.php",
      "line": 32,
      "function": "process",
      "class": "ApiPlatform\\Doctrine\\Common\\State\\PersistProcessor",
      "type": "->"
    },

Possible Solution
I've debugged it a little and it seems Doctrine tries to fetch an identifier from $context['previous_data'] which is a DTO and not an entity, so it fails.

Screenshot 2024-07-12 at 15 35 32

My understanding is that the case with stateOptions configuration should be handled inside the \ApiPlatform\Doctrine\Common\State\PersistProcessor.

Please assist with a correct solution here, or point me to my mistake. Thank you!

Additional Context

My ApiResource


#[ApiFilter(SearchFilter::class, properties: [
    'id' => 'exact',
    'type' => 'exact',
    'title' => 'partial',
    'description' => 'partial',
    'keywords' => 'partial'
])]
#[ApiFilter(JsonPropertySearchFilter::class, properties: ['metadata.zodiac'])]
#[ApiFilter(OrderFilter::class, properties: ['createdAt' => 'DESC', 'id' => 'DESC'])]
#[ApiFilter(RangeFilter::class, properties: ['id'])]
#[ApiResource(
    shortName: 'Article Admin',
    operations: [
        new Get('/articles/{id}'),
        new GetCollection('/articles'),
        new Put(uriTemplate: '/articles/{id}', processor: EntityClassDtoStateProcessor::class),
        new Patch(uriTemplate: '/articles/{id}', processor: EntityClassDtoStateProcessor::class),
        new Post(uriTemplate: '/articles', processor: EntityClassDtoStateProcessor::class),
        new Delete('/articles/{id}')
    ],
    routePrefix: '/admin',
    paginationViaCursor: [
        ['field' => 'id', 'direction' => 'DESC']
    ],
    processor: EntityClassDtoStateProcessor::class,
    provider: EntityToDtoApiStateProvider::class,
    stateOptions: new Options(entityClass: Article::class)
)]
class AdminArticleApi
{
    #[ApiProperty(readable: true, writable: false, identifier: true)]
    private ?string $id;
// ...

My Processor

namespace App\ApiPlatform\State\Processor\Doctrine;

use ApiPlatform\Doctrine\Common\State\PersistProcessor;
use ApiPlatform\Doctrine\Common\State\RemoveProcessor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfonycasts\MicroMapper\MicroMapperInterface;

class EntityClassDtoStateProcessor implements ProcessorInterface
{
    public function __construct(
        #[Autowire(service: PersistProcessor::class)] private ProcessorInterface $persistProcessor,
        #[Autowire(service: RemoveProcessor::class)] private ProcessorInterface $removeProcessor,
        private MicroMapperInterface $microMapper
    ) {
    }

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
    {
        $stateOptions = $operation->getStateOptions();
        $entityClass = $stateOptions->getEntityClass();

        $entity = $this->microMapper->map($data, $entityClass);
        if ($operation instanceof DeleteOperationInterface) {
            $this->removeProcessor->process($entity, $operation, $uriVariables, $context);
            return null;
        }
        $this->persistProcessor->process($entity, $operation, $uriVariables, $context);
        $data->setId($entity->getId());
        return $data;
    }
}
soyuka commented 1 month ago

The mapper should transform your dto into an entity, you may need to set $context['previous_data'] = $entity = $this->microMapper->map($context['previous_data'], $entityClass);?