Buy Access to Course
27.

Quick! Create a DragonTreasure DTO

|

Share this awesome video!

|

Keep on Learning!

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Time 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.

171 lines | src/Entity/DragonTreasure.php
// ... 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.

26 lines | src/ApiResource/DragonTreasureApi.php
// ... 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.

26 lines | src/ApiResource/DragonTreasureApi.php
// ... 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\EntityClassDtoStateProvider;
#[ApiResource(
shortName: 'Treasure',
paginationItemsPerPage: 10,
provider: EntityClassDtoStateProvider::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.

26 lines | src/ApiResource/DragonTreasureApi.php
// ... 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\EntityClassDtoStateProvider;
#[ApiResource(
shortName: 'Treasure',
paginationItemsPerPage: 10,
provider: EntityClassDtoStateProvider::class,
processor: EntityClassDtoStateProcessor::class,
stateOptions: new Options(entityClass: DragonTreasure::class),
)]
class DragonTreasureApi
{
#[ApiProperty(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;
#[AsMapper(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;
#[AsMapper(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;
#[AsMapper(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;
#[AsMapper(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;
#[AsMapper(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.

28 lines | src/ApiResource/DragonTreasureApi.php
// ... 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.

40 lines | src/ApiResource/DragonTreasureApi.php
// ... 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!