DTO -> Entity State 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 SubscribeWe've checked off the "provider" side of things for our new UserApi
class. So let's shift our focus to the processor so we can save things. And we do have some rather delightful tests for our User
endpoints. Open UserResourceTest
.
The Anatomy of the Request & State Processor
Ok, testPostToCreateUser()
, posts some data, creates the user, then tests to make sure that the password we posted works by logging in. Add ->dump()
to help us see what's going on. Then, copy that method name and run it:
symfony php bin/phpunit --filter=testPostToCreateUser
No surprise... it fails:
Current response status code is 400, but 201 expected.
The dump is really helpful. It's our favorite error!
Unable to generate an IRI for the item of type
UserApi
.
We already talked about what's happening: the JSON is deserialized into a UserApi
object. Good! Then the core Doctrine PersistProcessor
is called because that's the default processor
when using stateOptions
. But... because our UserApi
isn't an entity, PersistProcessor
does nothing. Finally, API Platform serializes the UserApi
back into JSON... but without the id
populated, it fails to generate the IRI.
Watch! Over in UserApi
, temporarily default $id
to 5
. When we try the test now...
symfony php bin/phpunit --filter=testPostToCreateUser
It appears to work. Ok, it fails... but only later... down here in UserResourceTest
line 33. It is getting through the POST successfully.
Creating the State Processor
Look at the response on top, it is returning this user JSON. But, still, nothing is saving. Change the id back to null. We need to fix this lack of saving by creating a new state processor. So spin over and run:
php bin/console make:state-processor
Call it EntityClassDtoStateProcessor
because, again, we're going to make this class generic so that it works for any API resource class that's tied to a Doctrine entity. We'll use it later for DragonTreasure
.
// ... lines 1 - 2 | |
namespace App\State; | |
use ApiPlatform\Metadata\Operation; | |
use ApiPlatform\State\ProcessorInterface; | |
class EntityClassDtoStateProcessor implements ProcessorInterface | |
{ | |
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void | |
{ | |
// Handle the state | |
} | |
} |
With the empty processor generated, go hook it up in UserApi
with processor:
EntityClassDtoStateProcessor::class.
// ... lines 1 - 9 | |
use App\State\EntityClassDtoStateProcessor; | |
// ... lines 11 - 13 | |
( | |
// ... lines 15 - 17 | |
processor: EntityClassDtoStateProcessor::class, | |
// ... line 19 | |
) | |
// ... lines 21 - 23 | |
class UserApi | |
{ | |
// ... lines 26 - 37 | |
} |
Henceforth, every time we POST, PATCH, or DELETE this resource, this processor will be called.
Mapping the DTO Back to an Entity
But what is this $data
variable exactly? You may have a guess, but just in case, let's dd($data)
... and rerun the test.
// ... lines 1 - 9 | |
use App\State\EntityClassDtoStateProcessor; | |
// ... lines 11 - 13 | |
( | |
// ... lines 15 - 17 | |
processor: EntityClassDtoStateProcessor::class, | |
// ... line 19 | |
) | |
// ... lines 21 - 23 | |
class UserApi | |
{ | |
// ... lines 26 - 37 | |
} |
symfony php bin/phpunit --filter=testPostToCreateUser
Yup, it's a UserApi
object! The JSON we sent is deserialized into this UserApi
object, and then that is passed to our state processor. The UserApi
object is the "central object" inside of API Platform for this request.
Our job in the state processor is simple but important: to convert this UserApi
back to a User
entity so that we can save it. Say assert($data instanceof UserApi)
and, inside, $entity =
set to a new helper function: $this->mapDtoToEntity($data)
. Below, dd($entity)
.
// ... lines 1 - 6 | |
use App\ApiResource\UserApi; | |
// ... lines 8 - 10 | |
class EntityClassDtoStateProcessor implements ProcessorInterface | |
{ | |
// ... lines 13 - 19 | |
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void | |
{ | |
assert($data instanceof UserApi); | |
$entity = $this->mapDtoToEntity($data); | |
dd($entity); | |
} | |
// ... lines 27 - 47 | |
} |
Then go add that new private function mapDtoToEntity()
, which will accept an object $dto
argument and return another object
.
Again, we know this will really accept a UserApi
object and return a User
entity... but we're trying to keep this class generic so we can reuse it later. Though we are going to have some user-specific code down here temporarily. In fact, to help our editor, add another assert($dto instanceof UserApi)
.
// ... lines 1 - 6 | |
use App\ApiResource\UserApi; | |
// ... lines 8 - 10 | |
class EntityClassDtoStateProcessor implements ProcessorInterface | |
{ | |
// ... lines 13 - 19 | |
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void | |
{ | |
assert($data instanceof UserApi); | |
$entity = $this->mapDtoToEntity($data); | |
dd($entity); | |
} | |
private function mapDtoToEntity(object $dto): object | |
{ | |
assert($dto instanceof UserApi); | |
// ... lines 31 - 46 | |
} | |
} |
Querying for the Existing Entity
We need to think about two different cases. The first is when we POST to create a brand-new user. In that case, $dto
will have a null
id. And that means we should create a fresh User
object. The other case is if we were making, for example, a PATCH
request to edit a user. In that case, the item provider will first load that User
entity from the database... our provider will turn that into a UserApi
object with id
equal to 6
... and that will eventually be passed to us here. If the id
is 6... we don't want to create a new User
object: we want to query the database for tha existing User
. Our job is to handle both situations.
Undo the changes to the test so we don't break anything... and now, if
$dto->id
, we need to query for an existing User
. To do that, on top, add a constructor with private UserRepository $userRepository
.
// ... lines 1 - 8 | |
use App\Repository\UserRepository; | |
class EntityClassDtoStateProcessor implements ProcessorInterface | |
{ | |
public function __construct( | |
private UserRepository $userRepository | |
) | |
{ | |
} | |
// ... lines 19 - 47 | |
} |
Back down here, say $entity = $this->userRepository->find($dto->id)
.
If we don't find that User
, throw a big giant exception that will trigger a 500 error with Entity %d not found
.
// ... lines 1 - 8 | |
use App\Repository\UserRepository; | |
class EntityClassDtoStateProcessor implements ProcessorInterface | |
{ | |
public function __construct( | |
private UserRepository $userRepository | |
) | |
{ | |
} | |
// ... lines 19 - 27 | |
private function mapDtoToEntity(object $dto): object | |
{ | |
assert($dto instanceof UserApi); | |
if ($dto->id) { | |
$entity = $this->userRepository->find($dto->id); | |
if (!$entity) { | |
throw new \Exception(sprintf('Entity %d not found', $dto->id)); | |
} | |
// ... lines 37 - 38 | |
} | |
// ... lines 40 - 46 | |
} | |
} |
You might be wondering:
Shouldn't this trigger a 404 error instead?
The answer, in this case, is no. If we're in this situation, it means the item state provider has already successfully queried for a User
with this id. So there should be no way for us to suddenly not find it. There are some exceptions to this, like if you allowed your user to change their id
... or if you allowed users to create brand-new objects and set the id manually... but for most situations, including ours, if this happens, something went weird.
Next up, if we don't have an id
, say $entity = new User()
.
// ... lines 1 - 7 | |
use App\Entity\User; | |
use App\Repository\UserRepository; | |
class EntityClassDtoStateProcessor implements ProcessorInterface | |
{ | |
public function __construct( | |
private UserRepository $userRepository | |
) | |
{ | |
} | |
// ... lines 19 - 27 | |
private function mapDtoToEntity(object $dto): object | |
{ | |
assert($dto instanceof UserApi); | |
if ($dto->id) { | |
$entity = $this->userRepository->find($dto->id); | |
if (!$entity) { | |
throw new \Exception(sprintf('Entity %d not found', $dto->id)); | |
} | |
} else { | |
$entity = new User(); | |
} | |
// ... lines 40 - 46 | |
} | |
} |
Done! In both cases, down here, we're going to map the $dto
object to the $entity
object. This code is boring... so I'll speed through this. For the password, put a TODO
temporarily because we still need to hash that. Also add a TODO
for handle dragon treasures
. Just focus on the easy stuff... and at the bottom, return $entity
.
// ... lines 1 - 7 | |
use App\Entity\User; | |
use App\Repository\UserRepository; | |
class EntityClassDtoStateProcessor implements ProcessorInterface | |
{ | |
public function __construct( | |
private UserRepository $userRepository | |
) | |
{ | |
} | |
// ... lines 19 - 27 | |
private function mapDtoToEntity(object $dto): object | |
{ | |
assert($dto instanceof UserApi); | |
if ($dto->id) { | |
$entity = $this->userRepository->find($dto->id); | |
if (!$entity) { | |
throw new \Exception(sprintf('Entity %d not found', $dto->id)); | |
} | |
} else { | |
$entity = new User(); | |
} | |
$entity->setEmail($dto->email); | |
$entity->setUsername($dto->username); | |
$entity->setPassword('TODO properly'); | |
// TODO: handle dragon treasures | |
return $entity; | |
} | |
} |
If we've done things correctly, we'll take the UserApi
, transform that into an $entity
and dump it. Rerun the test:
symfony php bin/phpunit --filter=testPostToCreateUser
And... 404! Let's see what happened here. Oh... of course. I never put my test back together. This should be ->post('/api/users')
. Try that again and... got it! There's our User
entity object with the email and username transferred correctly!
Next: Let's save this by leveraging the core Doctrine PersistProcessor
and RemoveProcessor
. We'll also handle hashing the password. By the end, our user tests will be passing with flying colors.
I have an Endpoint with takes a UUID property and an array of UUIDs via POST.
In API Platform 2.6, I had a class tagged as ApiResource with has a path and an Output-Class defined (another ApiResource, which is also a Doctrine-Entity).
Then I had a DataTransformer, which took the ApiResource, which acted as Input-DTO, used the IDs to make a Database-Query and returned a collection of Entities.
Now, I tried to do the same with the help of a data-processor, because there are no data-transformers in API-Platform > 3.
But always, when I try to return more than one entity, I get a 500 response coming from "get_class(): Argument #1 ($object) must be of type object, array given".
How can I take a post with ID's, make the query, and return a collection of entities, without data-transformers?