Buy Access to Course
29.

Making DragonTreasureApi Writable

|

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

Let's get our write endpoints working for DragonTreasureApi! If you look down here, we have a test called testPostToCreateTreasure(). That sounds like a good one! Over in your terminal, run it:

symfony php bin/phpunit --filter=testPostToCreateTreasure

And... it goes kaboom! It ran a few tests... and they all say the same thing:

No mapper found for DragonTreasureApi -> DragonTreasure

Ok, when we POST, it deserializes the JSON into a new DragonTreasureApi object and then calls our processor. Our processor takes that API object and tries to use MicroMapper to map it to the DragonTreasure entity. Since we're missing the mapper from DragonTreasureApi to DragonTreasure, kablooie!

Creating the Mapper

We know the drill! In src/Mapper/, create a new DragonTreasureApiToEntityMapper. Inside, implement MapperInterface, use #[AsMapper()] to say that we are mapping from: DragonTreasureApi::class, to: DragonTreasure::class... and add the two methods.

// ... lines 1 - 2
namespace App\Mapper;
use App\ApiResource\DragonTreasureApi;
use App\Entity\DragonTreasure;
use App\Repository\DragonTreasureRepository;
use Symfonycasts\MicroMapper\AsMapper;
use Symfonycasts\MicroMapper\MapperInterface;
#[AsMapper(from: DragonTreasureApi::class, to: DragonTreasure::class)]
class DragonTreasureApiToEntityMapper implements MapperInterface
{
// ... lines 14 - 45
}

This will be very similar to our UserApiToEntityMapper. In load(), if we have an ID, we want to query for that object. Add a constructor, with private DragonTreasureRepository $repository. Down here, include the now-familiar $dto = $from, and assert that $dto is an instanceof DragonTreasureApi. To make life even easier, steal some code from our other mapper. Copy this... and plop it over here. But Hit "Cancel" because we don't need that use statement... and rename this to just $entity. So if the $dto has an id, it means we're editing it and we want to find the existing one. Else, we're going to create a new DragonTreasure(). And while it shouldn't happen, we have an Exception in case the treasure wasn't found.

One interesting thing about the DragonTreasure entity is that it has a constructor argument: the name. And we don't have a setName() method: the only way to set it is through the constructor. So, to transfer the name from the $dto onto the entity, pass it to the constructor.

// ... lines 1 - 6
use App\Repository\DragonTreasureRepository;
// ... lines 8 - 11
class DragonTreasureApiToEntityMapper implements MapperInterface
{
public function __construct(
private DragonTreasureRepository $repository,
)
{
}
public function load(object $from, string $toClass, array $context): object
{
$dto = $from;
assert($dto instanceof DragonTreasureApi);
$entity = $dto->id ? $this->repository->find($dto->id) : new DragonTreasure($dto->name);
if (!$entity) {
throw new \Exception('DragonTreasure not found');
}
return $entity;
}
// ... lines 33 - 45
}

Two quick notes about this. Yes, this means that you can't change the name of an existing treasure via the API. And that's expected: if we've written our DragonTreasure without a setName() method, then we're intending for the name to be set once and never changed. Second, this is the one case where we do populate a bit of data inside load(). We normally save that work for populate(), but it can't be avoided here, and that's ok.

Head down to populate() and start with the same code from load(). Also add $entity = $to... and one more assert() that $entity instanceof DragonTreasure. Just say TODO for a moment.

// ... lines 1 - 11
class DragonTreasureApiToEntityMapper implements MapperInterface
{
// ... lines 14 - 33
public function populate(object $from, object $to, array $context): object
{
$dto = $from;
$entity = $to;
assert($dto instanceof DragonTreasureApi);
assert($entity instanceof DragonTreasure);
// ... lines 40 - 43
return $entity;
}
}

I want to make sure our mapper is at least being called. Earlier, when we ran the test, it executed three tests that match the name. So let's make the method a bit more unique. This is called testPostToCreateTreasure() and it uses the normal login mechanism, so add WithLogin at the end. When we run the test with the new name:

symfony php bin/phpunit --filter=testPostToCreateTreasureWithLogin

A 500 error! Let's see what's going on. Okay, good! We got further! It's now exploding when it hits the database. So it is trying to save, and it's complaining because owner_id is null.

Adding Validation Constraints

Reminder time: the owner field is supposed to be optional. If we don't pass an owner, it should automatically be set to the authenticated user. We had code for that before, and we'll re-add it in a moment.

But this failure is actually coming from earlier: from line 71, right here. This test starts by checking our validation. It submits no JSON, and makes sure that our validation constraints save the day. We don't have any validation constraints, so instead of failing validation, it tries to save. Boo.

Let's re-add the constraints... this time to our API class. For $name, #[NotBlank], $description, #[NotBlank], $value will be #[GreaterThanOrEqual(0)] and $coolFactor will be #[GreaterThanOrEqual(0)] and also #[LessThanOrEqual(10)].

48 lines | src/ApiResource/DragonTreasureApi.php
// ... lines 1 - 10
use Symfony\Component\Validator\Constraints\GreaterThanOrEqual;
use Symfony\Component\Validator\Constraints\LessThanOrEqual;
use Symfony\Component\Validator\Constraints\NotBlank;
// ... lines 14 - 21
class DragonTreasureApi
{
// ... lines 24 - 26
#[NotBlank]
public ?string $name = null;
#[NotBlank]
public ?string $description = null;
#[GreaterThanOrEqual(0)]
public int $value = 0;
#[GreaterThanOrEqual(0)]
#[LessThanOrEqual(10)]
public int $coolFactor = 0;
// ... lines 39 - 46
}

Try the test again.

symfony php bin/phpunit --filter=testPostToCreateTreasureWithLogin

We're probably going to hit that same error, and... yep - 500 error. But look! Now it's coming from line 78! That means we are getting the validation error status code here. Then, below, when we POST valid data, it attempts to save it to the database, but can't because, like we saw a second ago, the owner_id is still null.

Automatically Setting the Owner

This is one of the great things about these mapper objects. In DragonTreasureApiToEntityMapper, normally, we're going to do things like $entity->setValue($dto->value): just transferring data from one to the other. But we can also do custom things - like setting weird fields that require calculations or... setting the owner to the currently-authenticated user.

Check it out: if ($dto->owner), then we're going to set that onto the entity. Well, we won't do it yet, just dd() for now. This is the case where we do include the owner field in the JSON... and we'll talk more about that soon.

// ... lines 1 - 12
class DragonTreasureApiToEntityMapper implements MapperInterface
{
// ... lines 15 - 35
public function populate(object $from, object $to, array $context): object
{
// ... lines 38 - 42
if ($dto->owner) {
dd($dto->owner);
// ... lines 45 - 46
}
// ... lines 48 - 52
}
}

For the else, this is when the user does not send an owner field. To set it to the currently authenticated user, on top, inject the Security service onto a new property. Then back below, set owner to $this->security->getUser().

// ... lines 1 - 12
class DragonTreasureApiToEntityMapper implements MapperInterface
{
public function __construct(
// ... line 16
private Security $security,
)
{
}
// ... lines 22 - 35
public function populate(object $from, object $to, array $context): object
{
// ... lines 38 - 42
if ($dto->owner) {
dd($dto->owner);
} else {
$entity->setOwner($this->security->getUser());
}
// ... lines 48 - 52
}
}

Beautiful! We are still missing the other field setting... so if we try to run the test... it will still hit a 500. But, if you check out the error, it's failing because description is null. The owner is being set.

So let's fill in the other fields: $entity->setDescription($dto->description), $entity->setCoolFactor($dto->coolFactor), and $entity->setValue($dto->value).

// ... lines 1 - 12
class DragonTreasureApiToEntityMapper implements MapperInterface
{
public function __construct(
// ... line 16
private Security $security,
)
{
}
// ... lines 22 - 35
public function populate(object $from, object $to, array $context): object
{
// ... lines 38 - 42
if ($dto->owner) {
dd($dto->owner);
} else {
$entity->setOwner($this->security->getUser());
}
$entity->setDescription($dto->description);
$entity->setCoolFactor($dto->coolFactor);
$entity->setValue($dto->value);
// TODO: set published
// ... lines 55 - 58

Boring but clear work. Also include a TODO down for published. We'll talk more about that shortly.

Ok, run the test now:

symfony php bin/phpunit --filter=testPostToCreateTreasureWithLogin

And... it passes. Woo! Try all the tests from DragonTreasure:

symfony php bin/phpunit tests/Functional/DragonTreasureResourceTest.php

And... ooo. We have several failures, related to missing headers, security, validation, etc. Let's make this green next.