skip to Main Content

I use Doctrine objects in database that are mapped to DTOs by services with a getComponentDtoFromEntity($component) method to expose them on API Platform 3.2 on Symfony 7.1 and PHP 8.3. Each DTO has a route with needed HTTP verbs which allows me to work only with data I need, have multiple endpoints using the same entity (with different DTO), having composite DTOs (a mix of multiples entities) and never expose directly my entities.
I created some DTO directly linked to API Platform, for example :

#[ApiResource]
#[Get(
    uriTemplate: 'stock/components/{id}',
    read: true,
    provider: ComponentDtoProviderService::class,
)]
#[GetCollection(
    uriTemplate: 'stock/components/',
    provider: ComponentDtoProviderService::class
)]
#[Post(
    uriTemplate: 'stock/components/',
    validationContext: ['groups' => ['Default', 'postValidation']],
    processor: ComponentDtoProcessorService::class,
)]
#[Delete(
    uriTemplate: 'stock/components/{id}',
    provider: ComponentDtoProviderService::class,
    processor: ComponentDtoProcessorService::class
)]
#[Put(
    uriTemplate: 'stock/components/',
    validationContext: ['groups' => ['Default']],
    processor: ComponentDtoProcessorService::class
)]
readonly class ComponentDto
{
    public function __construct(
        public ?string $id,
        #[AssertNotBlank(groups: ['postValidation'])]
        public string $name,
        public ?string $ean13,
        public ?int $quantity,
        public ?int $quantityThreshold,
    ) {
    }
}

When I use the built-in API test at /api address, everything works fine with every operation (GET, POST, PUT, DELETE).

I use PHPunit to test my API, so I created tests for get, getAll, post, delete and put/patch operations using previously added fixtures (that I get from name).

    public function testComponentsPut(): void
    {
        $componentBlueRose = $this->componentRepository->findByName(ComponentFixtures::COMPONENT_BLUE_ROSE);
        self::assertNotNull($componentBlueRose);

        // test new EAN13 and quantity and quantityThreshold
        $componentBlueRoseDtoModified = new ComponentDto(
            $componentBlueRose->getId()->toString(),
            $componentBlueRose->getName(),
            '9234569990123', // data are modified here
            400, // data are modified here
            8, // data are modified here
        );

        $response = parent::createClient()->request('PUT', 'api/stock/components/',
            ['json' => $componentBlueRoseDtoModified]);

        self::assertResponseIsSuccessful();
        $componentBlueRoseUpdated = $this->componentRepository->findByName(ComponentFixtures::COMPONENT_BLUE_ROSE);

        // todo : its not working here
        self::assertSame($componentBlueRoseDtoModified->ean13, $componentBlueRoseUpdated->getEan13());
        self::assertSame($componentBlueRoseDtoModified->quantity, $componentBlueRoseUpdated->getQuantity());
        self::assertSame($componentBlueRoseDtoModified->quantityThreshold, $componentBlueRoseUpdated->getQuantityThreshold());
}

Previous tests are made with Get, Post, Delete and GetAll and everything works fine, but not in the case of PUT and PATCH : when I get the saved data in database, the data is never modified. To test, I take an object in database, I map it in a DTO, then I modify the DTO and send it in API with PUT verb (I also did the same with PATCH). Then, I get the object with same ID in database and I compare both objects properties to check that modifications have been saved. But they are not.

This made me discovering a strange behavior of API Platform :
When PATCH or PUT operations are called, the Provider is always called even if it’s not specified, and it’s called BEFORE the processor witch, in fact does :

  • The DTO with new modifications is passed to the HTTPRequest like this :

          $response = $client->request('PUT', 'api/stock/components/',
          ['json' => $componentBlueRoseDtoModified]);
    
  • This DTO is intercepted by the Provider class which takes the ID, load a new object from the database and map it as DTO, which is the correct behavior of GET operation. The DTO returned by the Provider is from database so it’s not the modified DTO given in HTTP request.

  • This DTO is given to the Processor which saves it.

The problem is that the Processor saves the DTO taken from the database and not the one given in the first place, and modifications are never saved.

Since I know that when /{id} is specified, a Provider is required for Patch and Put operations (in recent Api Platform versions), I tried both paths :

  • /api/component/{id} with specified Provider and Processor and
  • /api/component/ with no Provider
    It just works the same way : testing directly with /api works but not in unit tests.

Here is the Provider code :

public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|ComponentDto
    {
        // get all components
        if ($operation instanceof CollectionOperationInterface) {
            $components = $this->componentRepository->getAll();
            $componentDtos = [];
            foreach ($components as $component) {
                $componentDtos[] = $this->getComponentDtoFromEntity($component);
            }

            return $componentDtos;
        }

        // get one component
        if (!isset($uriVariables['id'])) {
            throw new Exception('Id is required');
        }
        $component = $this->componentRepository->findByIdAsString($uriVariables['id']);

        if (null === $component) {
            throw new Exception('Component not found');
        }

        return $this->getComponentDtoFromEntity($component);
    }

    public function getComponentDtoFromEntity(Component $component): ComponentDto
    {
        return new ComponentDto(
            $component->getId() === null ? null : (string) $component->getId(),
            $component->getName(),
            $component->getEan13(),
            $component->getQuantity(),
            $component->getQuantityThreshold(),
        );
    }

Well the questions are :

  • Why does PATCH and PUT works for real but not in unit tests ? How to make it work ?
  • Why is the Provider called even if ONLY the Processor is specified in Api Platform attributes ?

2

Answers


  1. When dealing with issues in PUT and PATCH requests in API Platform, especially when custom DTOs and components are involved, there are several factors to consider. Below is a solution based on common pitfalls:

    Solution
    Ensure DTO and Entity Alignment

    Make sure your DTO (Data Transfer Object) aligns with your entity structure. A mismatch here can cause issues with PUT and PATCH operations.

    // src/Dto/UserInput.php
    namespace AppDto;
    
    use SymfonyComponentValidatorConstraints as Assert;
    
    class UserInput
    {
        /**
         * @AssertNotBlank
         * @AssertLength(max=100)
         */
        public $name;
    
        /**
         * @AssertNotBlank
         * @AssertEmail
         * @AssertLength(max=100)
         */
        public $email;
    }
    
    Login or Signup to reply.
  2. add next

    Implement a Data Transformer
    Data transformers are crucial for converting between DTOs and entities. Ensure your transformer is correctly implemented.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search