If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
Look 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.
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
.
Very nice! Below, save with $this->persistProcessor->process()
passing
$entity
, $operation
, $uriVariables
, and $context
... which are all the same
arguments we have up here.
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
.
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()
.
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.
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)
.
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
.
Back down here, say $this->removeProcessor->process()
and pass $entity
,
$operation
, $uriVariables
, and $context
just like the other processor.
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!
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
.
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!
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.
Hey @mcdzign!
Hmm. That error - "Unable to generate an IRI" - can happen for several reasons, the simplest being that the BlogApi
object (for some reason) has null for its id. But there are other things that can cause this. When I see this error, I open the profiler for that request (you can go to /_profiler
to find the most recent requests) and then go to the Exceptions tab. This error will be on top, but usually there is a nested error further below that may give more granular details about what's going on. Do you see a 2nd exception message at the bottom?
Cheers!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "3.1.x-dev", // 3.1.x-dev
"doctrine/annotations": "^2.0", // 2.0.1
"doctrine/doctrine-bundle": "^2.8", // 2.10.2
"doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.4
"doctrine/orm": "^2.14", // 2.16.1
"nelmio/cors-bundle": "^2.2", // 2.3.1
"nesbot/carbon": "^2.64", // 2.69.0
"phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
"phpstan/phpdoc-parser": "^1.15", // 1.23.1
"symfony/asset": "6.3.*", // v6.3.0
"symfony/console": "6.3.*", // v6.3.2
"symfony/dotenv": "6.3.*", // v6.3.0
"symfony/expression-language": "6.3.*", // v6.3.0
"symfony/flex": "^2", // v2.3.3
"symfony/framework-bundle": "6.3.*", // v6.3.2
"symfony/property-access": "6.3.*", // v6.3.2
"symfony/property-info": "6.3.*", // v6.3.0
"symfony/runtime": "6.3.*", // v6.3.2
"symfony/security-bundle": "6.3.*", // v6.3.3
"symfony/serializer": "6.3.*", // v6.3.3
"symfony/stimulus-bundle": "^2.9", // v2.10.0
"symfony/string": "6.3.*", // v6.3.2
"symfony/twig-bundle": "6.3.*", // v6.3.0
"symfony/ux-react": "^2.6", // v2.10.0
"symfony/ux-vue": "^2.7", // v2.10.0
"symfony/validator": "6.3.*", // v6.3.2
"symfony/webpack-encore-bundle": "^2.0", // v2.0.1
"symfony/yaml": "6.3.*", // v6.3.3
"symfonycasts/micro-mapper": "^0.1.0" // v0.1.1
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.4
"mtdowling/jmespath.php": "^2.6", // 2.6.1
"phpunit/phpunit": "^9.5", // 9.6.11
"symfony/browser-kit": "6.3.*", // v6.3.2
"symfony/css-selector": "6.3.*", // v6.3.2
"symfony/debug-bundle": "6.3.*", // v6.3.2
"symfony/maker-bundle": "^1.48", // v1.50.0
"symfony/monolog-bundle": "^3.0", // v3.8.0
"symfony/phpunit-bridge": "^6.2", // v6.3.2
"symfony/stopwatch": "6.3.*", // v6.3.0
"symfony/web-profiler-bundle": "6.3.*", // v6.3.2
"zenstruck/browser": "^1.2", // v1.4.0
"zenstruck/foundry": "^1.26" // v1.35.0
}
}
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 :