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
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.
add next
Implement a Data Transformer
Data transformers are crucial for converting between DTOs and entities. Ensure your transformer is correctly implemented.