Lucky you! You found an early release chapter - it will be fully polished and published shortly!
Rest assured, the gnomes are hard at work
completing this video!
Doing the data transformation, from the UserApi
to the User
entity, or the User
entity to the UserApi
, is the only part of our provider and processor that
isn't generic and reusable. Darn. If it wasn't for that code, we could quickly
create a DragonTreasureApi
class and do this whole thing over again with almost
no work! Fortunately, this is a well-known problem called "data mapping".
For this tutorial, I tried a few data mapping libraries, most notably
jane-php/automapper-bundle
, which is super fast, advanced, and fun to use.
However, it isn't quite as flexible as I needed... and extending it looked complex.
Honestly, I got stuck in a few places... though I know that work is being done
to make it even friendlier.
The point is, we're not going to use that library. Instead, to handle the mapping, I created a small package of my own. It's easy to understand, and gives us full control... even if it's not quite as cool as jane's automapper.
So let's get it installed! Run:
composer require symfonycasts/micro-mapper
That kind of sounds like a superhero. Now that we have this in our app, we have one new micromapper service that's good at converting the data from one object to another. Let's start by using it in our processor.
Up at the top here, autowire a private MicroMapperInterface $microMapper
. And
down here, for all of the mapping stuff, copy the existing logic, because we'll
need it in a minute. But all we need now is return $this->microMapper->map()
.
This has two main arguments: The $from
object, which will be $dto
and the
toClass,so User::class
.
Done! Well... not quite, but let's try running our testPostToCreateUser
anyway.
symfony php bin/phpunit --filter=testPostToCreateUser
And... it fails with a 500 error. The interesting thing is what that 500 error is. Let's "View Page Source" so we can read this even better. It says
No mapper found for
App\UserResource\UserApi
->App\Entity\User
And this is coming from MicroMapper
. This is basically saying:
Hey, I don't know how to convert a
UserApi
object to aUser
object!
MicroMapper isn't magic... it's really the opposite. To teach micro mapper how to do this conversion, we're going to create a class that explains how to do it. That's called a mapper class. And these are fun!
Let me start by closing a few things... and then creating a new Mapper/
directory
in src/
. Inside of that, add a new PHP class called... how about
UserApiToEntityMapper
, because we're going from UserApi
to the User
entity.
This class needs 2 things. First, to implement MapperInterface
. And second, above
the class, to describe what it's mapping to and from, we need an #[AsMapper()]
attribute with from: UserApi::class
and to: User::class
.
Since we've implemented this interface, go to "Code Generate" (or "command" + "N"
on a Mac) and generate the two methods we need - load()
and populate()
. For
starters, let's dd($from, $toClass)
.
Now, just by creating this and giving it #[AsMapper]
, when we use MicroMapper
to do this transformation, it should call our load()
method. Let's see if it
does!
Run the test:
symfony php bin/phpunit --filter=testPostToCreateUser
And... got it! There's the UserApi
object we're passing, and it's passing
us the User
class. The purpose of load()
is to load the $toClass
object
and return it, like by querying for a User
entity or creating a new one.
To do the query, on top, add public function __construct()
and inject the normal
UserRepository $userRepository
. Down here, this will hold the same code that we
saw earlier. I like to say $dto = $from
and assert($dto instanceof UserApi)
.
That will help my brain and my editor.
Next, if our $dto
has an id
, then call $this->userRepository->find($dto->id)
,
else create a brand new User()
object.
It's that simple. And if, for some reason, we don't have a $userEntity
,
throw new \Exception('User not found')
, similar to what we did before. Down here,
return $userEntity
.
So we've initialized our $to
object and returned it. And that's the point of
load()
: to do the least amount of work to get the $to
object... but without
populating the data.
Internally, after calling load()
, micro mapper will then call populate()
and pass us the User
entity object that we just returned. To see this, let's
dd('$from, $to)
.
Run that test:
symfony php bin/phpunit --filter=testPostToCreateUser
Perfect! Here's our "from" UserApi
object, and here is our new User
entity.
Now... you might be wondering why we have both a load()
method and a populate()
method... when it seems like these could just be one method. And you'd mostly
be right! But there's actually a technical reason why they're separated, and it's
going to come in handy later when we talk about relationships. But for now, you can
imagine these two methods are really just one, continuous process: load()
is
called, then populate()
.
No surprise, this is where we will take the data from the $from
object and
put them into $to
. Once again, to keep me sane, I'll say $dto = $from
and
assert($dto instanceof UserApi)
. Do the same thing for $from
:
$entity = $to
and assert($entity instanceof User)
.
The code down here is going to be really normal and boring... so I'll paste it.
And at the bottom, return $entity
.
We're using $this->userPasswordHasher
here... so we also need to make sure, at
the top, to add private UserPasswordHasherInterface $userPasswordHasher
.
So this is basically the same code we had before... but in a different location.
Let's see what the test thinks:
symfony php bin/phpunit --filter=testPostToCreateUser
It passes! This is huge! We've offloaded this work to our mapper... which
means our processor is almost completely generic. Now we can remove the old
UserPasswordHasher
that we don't need anymore... and the UserRepository
up here.
We can even remove those use
statements.
We still do need to write the mapping code, but now it lives in a nice, central location.
Ready to repeat this for the provider. Close the processor... and open it up.
This time, we're going from the User
entity to UserApi
. Copy all of this code,
delete it and, just like before, autowire MicroMapperInterface $microMapper
.
Down here, this simplifies to return $this->microMapper->map()
going from our
$entity
to UserApi::class
.
Sweet! If we tried this now, we'd get a 500 error because we don't have a mapper
for it. Back in src/Mapper/
, create a new class called UserEntityToApiMapper
...
implement MapperInterface
... and above the class, add #[AsMapper()]
. In this
case, we're going from: User::class
, to: UserApi::class
.
Implement both of the methods we need... and we start pretty much the same way as
before, with $entity = $from
and assert($entity instanceof User)
.
Down here, to create the DTO, we don't need to do any queries. We're always
going to instantiate a fresh new UserApi()
. Set the ID onto it with
$dto->id = $entity->getId()
... then return $dto
.
Ok, the job of the load()
method is really to create the $to
object and
at least make sure it has its identifier populated.
Everything else we need to do is down here in populate()
. Start our usual way:
$entity = $from
, $dto = $to
and two asserts: assert($entity instanceof User)
and assert($dto instanceof UserApi)
. Below that, use the exac code we had before.
We're just transferring the data. At the bottom, return $dto
.
Phew! Let's try this! Head over to your browser, refresh this page, and... oh...
Full authentication is required to access this resource.
Of course. That's because we added security! Head back over to the homepage, click this username and password shortcut... boop... and now try to refresh that page. And... it works! We are missing some of the data, though, which is my fault.
I said $dto = new UserApi()
. So instead of modifying the $to
object I'm being
passed, I created a new one... and the original wasn't modified. There we go. If
I try it again... it works much better.
So this is huge people! Our provider and processor are now completely generic. Let's finish the process of making them work for any API resource class next
"Houston: no signs of life"
Start the conversation!
// 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
}
}