Quick! Create a DragonTreasure DTO
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 SubscribeTime to convert our DragonTreasure
ApiResource into a proper DTO class! We'll start by deleting a ton of stuff: everything related to API Platform in DragonTreasure
... so we have a clean slate to start from. We'll add back what we need little-by-little. Goodbye filter stuff... the validators... all the serialization group stuff... and then we can do some cleanup on our properties. We had some fairly complex code in here... and while we won't add all of it back, we will add the most important things.
// ... lines 1 - 2 | |
namespace App\Entity; | |
use App\Repository\DragonTreasureRepository; | |
use Carbon\Carbon; | |
use Doctrine\DBAL\Types\Types; | |
use Doctrine\ORM\Mapping as ORM; | |
use function Symfony\Component\String\u; | |
#[ORM\Entity(repositoryClass: DragonTreasureRepository::class)] | |
class DragonTreasure | |
{ | |
#[ORM\Id] | |
#[ORM\GeneratedValue] | |
#[ORM\Column] | |
private ?int $id = null; | |
#[ORM\Column(length: 255)] | |
private ?string $name = null; | |
#[ORM\Column(type: Types::TEXT)] | |
private ?string $description = null; | |
/** | |
* The estimated value of this treasure, in gold coins. | |
*/ | |
#[ORM\Column] | |
private ?int $value = 0; | |
#[ORM\Column] | |
private ?int $coolFactor = 0; | |
#[ORM\Column] | |
private \DateTimeImmutable $plunderedAt; | |
#[ORM\Column] | |
private bool $isPublished = false; | |
#[ORM\ManyToOne(inversedBy: 'dragonTreasures')] | |
#[ORM\JoinColumn(nullable: false)] | |
private ?User $owner = null; | |
/** | |
* @var bool Non-persisted property to help determine if the treasure is owned by the authenticated user | |
*/ | |
private bool $isOwnedByAuthenticatedUser = false; | |
// ... lines 48 - 169 | |
} |
Lemme scroll down to make sure we got everything. Yea, that should be it! We now have a good old-fashioned, boring entity class. In src/ApiPlatform/
, let's also delete AdminGroupsContextBuilder
. This was a complex way to make fields readable or writable by our admin... but we're going to solve that with ApiProperty
security. Also get rid of the custom normalizer... which added a field and an extra group. And finally, remove the custom DragonTreasureStateProvider
and DragonTreasureStateProcessor
classes.
Query Extensions are Still Called!
But we did keep one thing: DragonTreasureIsPublishedExtension
. Because the new system will still use the core Doctrine CollectionProvider
, this query extension stuff will continue to work and be called. That's just one less thing we need to worry about.
Head over and refresh the documentation. Ok! Only Quest
and User
. Though, you may notice some DragonTreasure
stuff down here... because UserApi
has a relation to the DragonTreasure
entity. So even though DragonTreasure
isn't an API resource, API Platform still tries to document what that field is on User
. It doesn't really matter, because we're going to fix that and completely use API classes everywhere
Creating the DTO Class
In src/ApiResource/
, create the new class: DragonTreasureApi
.
// ... lines 1 - 2 | |
namespace App\ApiResource; | |
// ... lines 4 - 18 | |
class DragonTreasureApi | |
{ | |
// ... lines 21 - 24 | |
} |
Next, in UserApi
, steal some of the basic code from our #[ApiResource]
... paste that over here, and, for now, delete operations
. We can also get rid of these use
statements. Perfect!
We will use a shortName
- Treasure
- give this 10
items per page, and remove the security
line. The most important thing is that we have provider
and processor
(just as they are here), and stateOptions
, which will point to DragonTreasure::class
.
// ... lines 1 - 2 | |
namespace App\ApiResource; | |
use ApiPlatform\Doctrine\Orm\State\Options; | |
// ... line 6 | |
use ApiPlatform\Metadata\ApiResource; | |
use App\Entity\DragonTreasure; | |
use App\State\EntityClassDtoStateProcessor; | |
use App\State\EntityToDtoStateProvider; | |
( | |
shortName: 'Treasure', | |
paginationItemsPerPage: 10, | |
provider: EntityToDtoStateProvider::class, | |
processor: EntityClassDtoStateProcessor::class, | |
stateOptions: new Options(entityClass: DragonTreasure::class), | |
) | |
class DragonTreasureApi | |
{ | |
// ... lines 21 - 24 | |
} |
Also grab the $id
property. Like before, we don't really want this to be part of our API, so it's readable: false
and writable: false
. Down here, add public ?string $name = null
.
// ... lines 1 - 2 | |
namespace App\ApiResource; | |
use ApiPlatform\Doctrine\Orm\State\Options; | |
use ApiPlatform\Metadata\ApiProperty; | |
use ApiPlatform\Metadata\ApiResource; | |
use App\Entity\DragonTreasure; | |
use App\State\EntityClassDtoStateProcessor; | |
use App\State\EntityToDtoStateProvider; | |
( | |
shortName: 'Treasure', | |
paginationItemsPerPage: 10, | |
provider: EntityToDtoStateProvider::class, | |
processor: EntityClassDtoStateProcessor::class, | |
stateOptions: new Options(entityClass: DragonTreasure::class), | |
) | |
class DragonTreasureApi | |
{ | |
readable: false, writable: false, identifier: true) | (|
public ?int $id = null; | |
public ?string $name = null; | |
} |
Great start! We have one tiny class and... what the heck, let's just go try it! Refresh the docs. Yes! Our Treasure operations are here! If we try the collection endpoint... we get:
No mapper found for
DragonTreasure
->DragonTreasureApi
Adding the Mapper Class
That's fantastic! The only real work we need to do is implement those mappers. So let's go!
In the src/Mapper/
directory, create a class called DragonTreasureEntityToApiMapper
. We've done this before: implement MapperInterface
and add the #[AsMapper()]
attribute. We're going from: DragonTreasure::class
to: DragonTreasureApi::class
.
// ... lines 1 - 2 | |
namespace App\Mapper; | |
use App\ApiResource\DragonTreasureApi; | |
use App\Entity\DragonTreasure; | |
use Symfonycasts\MicroMapper\AsMapper; | |
use Symfonycasts\MicroMapper\MapperInterface; | |
from: DragonTreasure::class, to: DragonTreasureApi::class) | (|
class DragonTreasureEntityToApiMapper implements MapperInterface | |
{ | |
// ... lines 13 - 34 | |
} |
And just like that, micro mapper knows to use this. Generate the two methods for the interface: load()
and populate()
. For sanity, add $entity = $from
, and assert()
that $entity
is an instanceof DragonTreasure
.
// ... lines 1 - 2 | |
namespace App\Mapper; | |
use App\ApiResource\DragonTreasureApi; | |
use App\Entity\DragonTreasure; | |
use Symfonycasts\MicroMapper\AsMapper; | |
use Symfonycasts\MicroMapper\MapperInterface; | |
from: DragonTreasure::class, to: DragonTreasureApi::class) | (|
class DragonTreasureEntityToApiMapper implements MapperInterface | |
{ | |
public function load(object $from, string $toClass, array $context): object | |
{ | |
$entity = $from; | |
assert($entity instanceof DragonTreasure); | |
// ... lines 17 - 21 | |
} | |
// ... lines 23 - 34 | |
} |
Down here, create the DTO object with $dto = new DragonTreasureApi()
. And remember, the job of load()
is to create the object and put an identifier on it if there is one. So add $dto->id = $entity->getId()
. Finally, return $dto
.
// ... lines 1 - 2 | |
namespace App\Mapper; | |
use App\ApiResource\DragonTreasureApi; | |
use App\Entity\DragonTreasure; | |
use Symfonycasts\MicroMapper\AsMapper; | |
use Symfonycasts\MicroMapper\MapperInterface; | |
from: DragonTreasure::class, to: DragonTreasureApi::class) | (|
class DragonTreasureEntityToApiMapper implements MapperInterface | |
{ | |
public function load(object $from, string $toClass, array $context): object | |
{ | |
$entity = $from; | |
assert($entity instanceof DragonTreasure); | |
$dto = new DragonTreasureApi(); | |
$dto->id = $entity->getId(); | |
return $dto; | |
} | |
// ... lines 23 - 34 | |
} |
For populate()
, steal a few lines from above that set the $entity
variable... then also say $dto = $to
, and add one more assert()
that $dto
is an instanceof DragonTreasureApi
.
// ... lines 1 - 2 | |
namespace App\Mapper; | |
use App\ApiResource\DragonTreasureApi; | |
use App\Entity\DragonTreasure; | |
use Symfonycasts\MicroMapper\AsMapper; | |
use Symfonycasts\MicroMapper\MapperInterface; | |
from: DragonTreasure::class, to: DragonTreasureApi::class) | (|
class DragonTreasureEntityToApiMapper implements MapperInterface | |
{ | |
public function load(object $from, string $toClass, array $context): object | |
{ | |
$entity = $from; | |
assert($entity instanceof DragonTreasure); | |
$dto = new DragonTreasureApi(); | |
$dto->id = $entity->getId(); | |
return $dto; | |
} | |
public function populate(object $from, object $to, array $context): object | |
{ | |
$entity = $from; | |
$dto = $to; | |
assert($entity instanceof DragonTreasure); | |
assert($dto instanceof DragonTreasureApi); | |
// ... lines 30 - 33 | |
} | |
} |
The only property we have on our DTO right now is name
, so all we need is $dto->name = $entity->getName()
. At the end, return $dto
.
// ... lines 1 - 2 | |
namespace App\Mapper; | |
use App\ApiResource\DragonTreasureApi; | |
use App\Entity\DragonTreasure; | |
use Symfonycasts\MicroMapper\AsMapper; | |
use Symfonycasts\MicroMapper\MapperInterface; | |
from: DragonTreasure::class, to: DragonTreasureApi::class) | (|
class DragonTreasureEntityToApiMapper implements MapperInterface | |
{ | |
public function load(object $from, string $toClass, array $context): object | |
{ | |
$entity = $from; | |
assert($entity instanceof DragonTreasure); | |
$dto = new DragonTreasureApi(); | |
$dto->id = $entity->getId(); | |
return $dto; | |
} | |
public function populate(object $from, object $to, array $context): object | |
{ | |
$entity = $from; | |
$dto = $to; | |
assert($entity instanceof DragonTreasure); | |
assert($dto instanceof DragonTreasureApi); | |
$dto->name = $entity->getName(); | |
return $dto; | |
} | |
} |
And, people! We just created a class that maps from the entity to the DTO... and our state provider is using micro mapper internally... so I think this should... just work!
And... it does! Wow! With just the API Resource class and this one mapper, we now have a database-powered custom API Resource class. Woo!
Adding A Relation Field
Now things get interesting. Every DragonTreasure
entity has an owner, which is a relationship to the User
entity. In our API, we're going to have the same relationship. But instead of this being a relation from DragonTreasureApi
to a User
entity object, it will be to a UserApi
object.
Check it out! Say public ?UserApi $owner = null
.
// ... lines 1 - 18 | |
class DragonTreasureApi | |
{ | |
// ... lines 21 - 25 | |
public ?UserApi $owner = null; | |
} |
Then let's go populate that in the mapper. Down here, say $dto->owner =
... but... hold on a second. This isn't as simple as saying $entity->getOwner()
, because that's a user entity object. We need a UserApi
object! Can you think of anything that's really good at converting a User
entity to UserApi
? That's right, MicroMapper!
Up here on top, inject private MicroMapperInterface $microMapper
... and, down here, say $dto->owner = $this->microMapper->map()
to map from $entity->getOwner()
- the User
entity object - to UserApi::class
.
// ... lines 1 - 9 | |
use Symfonycasts\MicroMapper\MicroMapperInterface; | |
// ... lines 11 - 12 | |
class DragonTreasureEntityToApiMapper implements MapperInterface | |
{ | |
public function __construct( | |
private MicroMapperInterface $microMapper, | |
) | |
{ | |
} | |
// ... lines 20 - 31 | |
public function populate(object $from, object $to, array $context): object | |
{ | |
// ... lines 34 - 39 | |
$dto->owner = $this->microMapper->map($entity->getOwner(), UserApi::class); | |
// ... lines 41 - 48 | |
} | |
} |
How cool is that? One thing to be aware of is that if, in your system, $entity->getOwner()
might be null
, you should code for that. Like, if you have an owner, call the mapper, else just set owner
to null
... or don't set it at all. For us, we're always going to have an owner, so this should be safe.
Let's try it! Refresh and... oooh. We have an owner
field and it's an IRI. Why is that showing up as an IRI? Because API Platform recognizes that the UserApi
object is an API resource. And how does it show API resources that are relations? That's right! It sets them as an IRI. So that's exactly what we wanted to see.
Adding More Fields
Let's fill in the rest of the fields we need: I'll go through this super-fast. One of the fields I'm adding is $shortDescription
. That was a custom field before... but it'll be simpler now. Another custom field we had was $isMine
, which will also just be a normal property.
// ... lines 1 - 18 | |
class DragonTreasureApi | |
{ | |
// ... lines 21 - 25 | |
public ?string $description = null; | |
public int $value = 0; | |
public int $coolFactor = 0; | |
public ?UserApi $owner = null; | |
public ?string $shortDescription = null; | |
public ?string $plunderedAtAgo = null; | |
public ?bool $isMine = null; | |
} |
Over in our mapper, let's set everything. I'll speed through the boring parts. But $shortDescription
is a bit interesting. Before, in DragonTreasure
, we had a getShortDescription()
method and that was exposed directly as the API field.
With the new setup, it's a normal property like anything else, and we handle setting the custom data in our mapper: $shortDescription
is equal to $entity->getShortDescription()
. Finally, for $dto->isMine
, temporarily hardcode that to true
.
// ... lines 1 - 12 | |
class DragonTreasureEntityToApiMapper implements MapperInterface | |
{ | |
// ... lines 15 - 31 | |
public function populate(object $from, object $to, array $context): object | |
{ | |
// ... lines 34 - 40 | |
$dto->description = $entity->getDescription(); | |
$dto->value = $entity->getValue(); | |
$dto->coolFactor = $entity->getCoolFactor(); | |
$dto->shortDescription = $entity->getShortDescription(); | |
$dto->plunderedAtAgo = $entity->getPlunderedAtAgo(); | |
$dto->isMine = true; | |
// ... lines 47 - 48 | |
} | |
} |
Let's check it! Refresh and... that's beautiful!
In tests/Functional/
, we have DragonTreasureResourceTest
. In here, we have testGetCollectionOfTreasures()
, which tests to make sure that we only see published items. If our query extension is still working, this will pass. This also checks to make sure we see the correct keys.
Let's see if this works:
symfony php bin/phpunit --filter=testGetCollectionOfTreasures
It does. Mind blown.
Populating the Weird isMine Field
Before we finish, let's fix the hard coded true
on isMine
. This is easy, but shows off just how nice it is to work with custom fields. In our mapper, this is a service, so we can inject other services like the $security
service. Then, we can populate that with whatever data we want. So isMine
is true if $this->security->getUser()
equals the DragonTreasure
, getOwner()
(which is a User
entity object).
// ... lines 1 - 7 | |
use Symfony\Bundle\SecurityBundle\Security; | |
// ... lines 9 - 13 | |
class DragonTreasureEntityToApiMapper implements MapperInterface | |
{ | |
public function __construct( | |
// ... line 17 | |
private Security $security, | |
) | |
{ | |
} | |
// ... lines 22 - 33 | |
public function populate(object $from, object $to, array $context): object | |
{ | |
// ... lines 36 - 47 | |
$dto->isMine = $this->security->getUser() && $this->security->getUser() === $entity->getOwner(); | |
// ... lines 49 - 50 | |
} | |
} |
Try the test one more time to make sure this is working, and... it is. Woo!
Next: I want to dive deeper into relationships in our DTO-powered API. Because, if you're not careful, we can get the dreaded infinite recursion!
My question is, will all the filters work, or need to create custom filters for the dto-s?