Leveraging the Core Processor
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeLook at us go! In our state processor, we have successfully transformed the UserApi
into a User
entity. So let's save it! We could inject the entity manager, persist and flush... and call it a day. But I'd rather offload that work to the core PersistProcessor
. Search for that file and open it.
It does the simple persisting and flushing... but it also has some pretty complex logic for PUT
operations. We're not really using those, but the point is: better to reuse this class than try to roll our own logic.
Calling the Core PersistProcessor
How we do that should be familiar by this point. Add a private ProcessorInterface $persistProcessor
... and so Symfony knows precisely which service we want, include the #[Autowire()]
attribute, with service
set to PersistProcessor
(in this case, there's only one to choose from) ::class
.
// ... lines 1 - 4 | |
use ApiPlatform\Doctrine\Common\State\PersistProcessor; | |
// ... line 6 | |
use ApiPlatform\State\ProcessorInterface; | |
// ... lines 8 - 10 | |
use Symfony\Component\DependencyInjection\Attribute\Autowire; | |
class EntityClassDtoStateProcessor implements ProcessorInterface | |
{ | |
public function __construct( | |
private UserRepository $userRepository, | |
#[Autowire(service: PersistProcessor::class)] private ProcessorInterface $persistProcessor, | |
) | |
{ | |
} | |
// ... lines 22 - 53 | |
} |
Very nice! Below, save with $this->persistProcessor->process()
passing $entity
, $operation
, $uriVariables
, and $context
... which are all the same arguments we have up here.
// ... lines 1 - 4 | |
use ApiPlatform\Doctrine\Common\State\PersistProcessor; | |
// ... line 6 | |
use ApiPlatform\State\ProcessorInterface; | |
// ... lines 8 - 10 | |
use Symfony\Component\DependencyInjection\Attribute\Autowire; | |
class EntityClassDtoStateProcessor implements ProcessorInterface | |
{ | |
public function __construct( | |
private UserRepository $userRepository, | |
#[Autowire(service: PersistProcessor::class)] private ProcessorInterface $persistProcessor, | |
) | |
{ | |
} | |
// ... line 22 | |
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) | |
{ | |
// ... lines 25 - 28 | |
$this->persistProcessor->process($entity, $operation, $uriVariables, $context); | |
// ... lines 30 - 31 | |
} | |
// ... lines 33 - 53 | |
} |
Oh, and like before, when we generated this class, it generated process()
with a void
return type. That's not exactly correct. You don't have to return anything from state processors, but you can. And whatever you do return - in this case, we'll return $data
- will ultimately become the "thing" that is serialized and returned back to the user. If you don't return anything, it will use $data
.
// ... lines 1 - 4 | |
use ApiPlatform\Doctrine\Common\State\PersistProcessor; | |
// ... line 6 | |
use ApiPlatform\State\ProcessorInterface; | |
// ... lines 8 - 10 | |
use Symfony\Component\DependencyInjection\Attribute\Autowire; | |
class EntityClassDtoStateProcessor implements ProcessorInterface | |
{ | |
public function __construct( | |
private UserRepository $userRepository, | |
#[Autowire(service: PersistProcessor::class)] private ProcessorInterface $persistProcessor, | |
) | |
{ | |
} | |
// ... line 22 | |
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) | |
{ | |
// ... lines 25 - 28 | |
$this->persistProcessor->process($entity, $operation, $uriVariables, $context); | |
return $data; | |
} | |
// ... lines 33 - 53 | |
} |
Setting the id onto the DTO
Ok, I think this should work (Famous last words...).
symfony php bin/phpunit --filter=testPostToCreateUser
And... it bombs. We're still getting a 400 error, and it's still Unable to generate an IRI for the item
.
So... what's going on? We map the UserApi
to a new User
object and save the new User
... which causes Doctrine to assign the new id
to that entity object. But we never take that new id and put it back onto our UserApi
.
To fix this, after saving, add $data->id = $entity->getId()
.
// ... lines 1 - 4 | |
use ApiPlatform\Doctrine\Common\State\PersistProcessor; | |
// ... line 6 | |
use ApiPlatform\State\ProcessorInterface; | |
// ... lines 8 - 10 | |
use Symfony\Component\DependencyInjection\Attribute\Autowire; | |
class EntityClassDtoStateProcessor implements ProcessorInterface | |
{ | |
public function __construct( | |
private UserRepository $userRepository, | |
#[Autowire(service: PersistProcessor::class)] private ProcessorInterface $persistProcessor, | |
) | |
{ | |
} | |
// ... line 22 | |
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) | |
{ | |
// ... lines 25 - 28 | |
$this->persistProcessor->process($entity, $operation, $uriVariables, $context); | |
$data->id = $entity->getId(); | |
return $data; | |
} | |
// ... lines 34 - 54 | |
} |
And if we try it now...
symfony php bin/phpunit --filter=testPostToCreateUser
it still fails... but we got further this time! The response looks good. It returned a 201 status code with the new user info. It's failing on the part of the test where it tries to use the password to log in. That's because our password is currently set to... TODO
. We'll fix that in a minute.
Handling the Delete Operation
But first, when we set the processor
on the top level #[ApiResource]
, this became the processor for all operations: POST
, PUT
, PATCH
, and DELETE
. POST
, PUT
, and PATCH
are all pretty much the same: save the object to the database. But DELETE
is different: we're not saving, we're removing.
To handle that, check if ($operation instanceof DeleteOperationInterface)
.
// ... lines 1 - 6 | |
use ApiPlatform\Metadata\DeleteOperationInterface; | |
// ... lines 8 - 14 | |
class EntityClassDtoStateProcessor implements ProcessorInterface | |
{ | |
// ... lines 17 - 25 | |
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) | |
{ | |
// ... lines 28 - 31 | |
if ($operation instanceof DeleteOperationInterface) { | |
// ... lines 33 - 35 | |
} | |
// ... lines 37 - 41 | |
} | |
// ... lines 43 - 63 | |
} |
Like with saving, deleting isn't hard... but it's still better to offload this work to the core Doctrine remove processor. So, up here, copy the argument... and inject another processor: RemoveProcessor
... and rename this to $removeProcessor
.
// ... lines 1 - 5 | |
use ApiPlatform\Doctrine\Common\State\RemoveProcessor; | |
use ApiPlatform\Metadata\DeleteOperationInterface; | |
// ... lines 8 - 14 | |
class EntityClassDtoStateProcessor implements ProcessorInterface | |
{ | |
public function __construct( | |
// ... lines 18 - 19 | |
#[Autowire(service: RemoveProcessor::class)] private ProcessorInterface $removeProcessor, | |
) | |
{ | |
} | |
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) | |
{ | |
// ... lines 28 - 31 | |
if ($operation instanceof DeleteOperationInterface) { | |
// ... lines 33 - 35 | |
} | |
// ... lines 37 - 41 | |
} | |
// ... lines 43 - 63 | |
} |
Back down here, say $this->removeProcessor->process()
and pass $entity
, $operation
, $uriVariables
, and $context
just like the other processor.
// ... lines 1 - 5 | |
use ApiPlatform\Doctrine\Common\State\RemoveProcessor; | |
use ApiPlatform\Metadata\DeleteOperationInterface; | |
// ... lines 8 - 14 | |
class EntityClassDtoStateProcessor implements ProcessorInterface | |
{ | |
public function __construct( | |
// ... lines 18 - 19 | |
#[Autowire(service: RemoveProcessor::class)] private ProcessorInterface $removeProcessor, | |
) | |
{ | |
} | |
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) | |
{ | |
// ... lines 28 - 31 | |
if ($operation instanceof DeleteOperationInterface) { | |
$this->removeProcessor->process($entity, $operation, $uriVariables, $context); | |
// ... lines 34 - 35 | |
} | |
// ... lines 37 - 41 | |
} | |
// ... lines 43 - 63 | |
} |
A key thing to note is that we're going to return null
. In the case of a DELETE
operation, we don't return anything in the response... which we accomplish by returning null
from here. I don't have a test set up for this, but we'll take a leap of faith and assume it works. Ship it!
// ... lines 1 - 5 | |
use ApiPlatform\Doctrine\Common\State\RemoveProcessor; | |
use ApiPlatform\Metadata\DeleteOperationInterface; | |
// ... lines 8 - 14 | |
class EntityClassDtoStateProcessor implements ProcessorInterface | |
{ | |
public function __construct( | |
// ... lines 18 - 19 | |
#[Autowire(service: RemoveProcessor::class)] private ProcessorInterface $removeProcessor, | |
) | |
{ | |
} | |
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) | |
{ | |
// ... lines 28 - 31 | |
if ($operation instanceof DeleteOperationInterface) { | |
$this->removeProcessor->process($entity, $operation, $uriVariables, $context); | |
return null; | |
} | |
// ... lines 37 - 41 | |
} | |
// ... lines 43 - 63 | |
} |
Hashing the Password
Just one more problem to tackle: hashing the plain password. We've done this before, so no biggie. Before we do too much here, open UserApi
... and add a public ?string $password = null
... with a comment. This will always hold null or the "plaintext" password if the user sends one. We're never going to need to handle the hashed password in our API, so we don't need any space for that... which is nice!
Back in the processor, if ($dto->password)
, then we know we need to hash that and set it on the user. If a new user is being created, this will always be set... but when updating a user, we'll make this field optional. If it's not set, do nothing so the user's current password stays.
To do the hashing, on top, add one more argument: private UserPasswordHasherInterface $userPasswordHasher
. Then back below, $entity->setPassword()
set to $this->userPasswordHasher->hashPassword()
, passing $entity
(the User
object) and the plain password: $dto->password
.
// ... lines 1 - 13 | |
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; | |
// ... line 15 | |
class EntityClassDtoStateProcessor implements ProcessorInterface | |
{ | |
public function __construct( | |
// ... lines 19 - 21 | |
private UserPasswordHasherInterface $userPasswordHasher, | |
) | |
{ | |
} | |
// ... lines 27 - 45 | |
private function mapDtoToEntity(object $dto): object | |
{ | |
// ... lines 48 - 60 | |
if ($dto->password) { | |
$entity->setPassword($this->userPasswordHasher->hashPassword($entity, $dto->password)); | |
} | |
// ... lines 64 - 66 | |
} | |
} |
Phew. Let's try the test again. And... it fails... with
The annotation "@The" in property
UserApi::$password
was never imported.
So... that's me tripping on my keyboard and adding an extra @
. Remove that... then try again:
symfony php bin/phpunit --filter=testPostToCreateUser
It passes! Which means it fully-logged in using that password! Though, uh oh, look at the dumped JSON response: this is after we POST
to create the user. In the JSON response, it includes the plaintext password
property that the user just set. Whoops!
The Flow of a Write Request
Let's break this down. Our state provider is used for all GET
operations as well as the PATCH
operation. And notice, we are not setting the password
ever. We don't want to return that field in the JSON, so we're, correctly, not mapping it from our entity to our DTO. That's good!
But the POST
operation is the one situation where the provider is never called. This data is deserialized directly into a new UserApi
object and that's passed to our processor. This means that our DTO does have the plain password set on it... And, ultimately, that DTO object is what is serialized and sent back to the user.
This is a long way of saying that, in UserApi
, this password is meant to be a write-only field. The user should never be able to read this. Next: let's talk about how we can do customizations like this inside of UserApi
, while avoiding the complexity of serialization groups.
Hello Rayan
I have created a "UserApi mapper" to map the Doctrine User entity and a "BlogApi mapper" to map the Doctrine Blog entity. I try to create a sub-resource of the type /api/users/{id}/blogs, with itemUriTemplate: '/users/{userId}/blogs/{blogId}'. However, I get an error :
Example of how to reproduce a bug :