Buy Access to Course
11.

Custom Resource State Processor

|

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

We haven't configured the operations key on our #[ApiResource]. And so, we get every default operation. But we really only need a few. Add operations with a new GetCollection(), new Get() to fetch a single quest and new Patch() so users can update the status of an existing quest when they complete it.

43 lines | src/ApiResource/DailyQuest.php
// ... lines 1 - 6
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
// ... lines 10 - 13
#[ApiResource(
// ... line 15
operations: [
new GetCollection(),
new Get(),
new Patch(),
],
// ... line 21
)]
class DailyQuest
// ... lines 24 - 43

Upon refreshing... I love it!

Speaking of that Patch operation, when it's used, API Platform will call the state processor, so we can save... or do whatever we want. We don't have one yet, so that'll be our next job.

Adding a Patch Test

But let's start with a test. Down in tests/Functional/, create a new class called DailyQuestResourceTest. Make this extend the ApiTestCase that we created in the last tutorial and use ResetDatabase from Foundry to make sure our database is empty at the start of every test. Also use Factories.

// ... lines 1 - 4
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
class DailyQuestResourceTest extends ApiTestCase
{
use ResetDatabase;
use Factories;
}

Ok, we don't need these... since we're not going to talk to the database... but if we decide to later on, we're ready.

Down here, add public function testPatchCanUpdateStatus(). The first thing we need is a new \DateTime() that represents $yesterday: -1 day.

// ... lines 1 - 12
public function testPatchCanUpdateStatus()
{
$yesterday = new \DateTime('-1 day');
// ... lines 16 - 26
}
// ... lines 28 - 29

Remember: in our provider, we're creating daily quests for today through the last 50 days. When we make a PATCH request, our item provider is called to "load" the object. So we need to use a date that we know will be found.

Now say $this->browser(), ->patch()... and the URL: /api/quests/ with $yesterday->format('Y-m-d'). Pass a second options argument with json and an array with 'status' => 'completed'.

The status field is an enum... but because it's backed by a string, the serializer will deserialize it from the string active or completed. Finish with ->assertStatus(200), ->dump() (that will be handy in a second), and then ->assertJsonMatches() to check that status changed to completed.

// ... lines 1 - 12
public function testPatchCanUpdateStatus()
{
// ... line 15
$this->browser()
->patch('/api/quests/'.$yesterday->format('Y-m-d'), [
'json' => [
'status' => 'completed',
],
// ... line 21
])
->assertStatus(200)
->dump()
->assertJsonMatches('status', 'completed')
;
}
// ... lines 28 - 29

Wonderful! We're not really going to save the updated status... but we should at least see that the final JSON has status completed. Copy this test name... and over here, run: symfony php bin/phpunit --filter= and paste that name:

symfony php bin/phpunit --filter=testPatchCanUpdateStatus

And... whoops! We get a 415. The error says:

The content-type application/json is not supported.

Ah... I forgot to add a header to my PATCH request. Add headers set to an array with Content-Type, application/merge-patch+json.

// ... lines 1 - 12
public function testPatchCanUpdateStatus()
{
// ... line 15
$this->browser()
->patch('/api/quests/'.$yesterday->format('Y-m-d'), [
// ... lines 18 - 20
'headers' => ['Content-Type' => 'application/merge-patch+json']
])
// ... lines 23 - 25
;
}
// ... lines 28 - 29

We talked about this in the last tutorial: this tells the system what type of patch we have. This is the only one that's supported right now, but it's still required.

If we try this... it passes! But wait, I think I tricked myself! Comment-out the status and then the test... still passes? Yup, change that to -2 days... and $yesterday to just $day.

In our provider, we make every other quest active or complete: and yesterday starts as complete. Whoops! When we try the test now... it fails. Add the status back to the JSON and now... got it! The test passes!

Behind the scenes, here's the process. One: API Platform calls our provider to fetch the one DailyQuest for this date. Two: the serializer updates that DailyQuest using the JSON sent on the request. Three: the state processor is called. And four: the DailyQuest is serialized back into JSON.

Creating the State Processor

Except... in our case, there is no step three... because we haven't created a state processor yet! Let's add one!

php bin/console make:state-processor

and call it DailyQuestStateProcessor.

Yet another name sparkling with genius. Go check it out: it's empty and full of potential.

15 lines | src/State/DailyQuestStateProcessor.php
// ... lines 1 - 7
class DailyQuestStateProcessor implements ProcessorInterface
{
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
// Handle the state
}
}

In DailyQuest, the processor should be used for the Patch operation, so add processor: DailyQuestStateProcessor::class.

46 lines | src/ApiResource/DailyQuest.php
// ... lines 1 - 14
#[ApiResource(
// ... line 16
operations: [
// ... lines 18 - 19
new Patch(
processor: DailyQuestStateProcessor::class,
),
],
// ... line 24
)]
class DailyQuest
// ... lines 27 - 46

To prove that this is working, dd($data).

15 lines | src/State/DailyQuestStateProcessor.php
// ... lines 1 - 7
class DailyQuestStateProcessor implements ProcessorInterface
{
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
dd($data);
}
}

Okay! Try the test again:

symfony php bin/phpunit --filter=testPatchCanUpdateStatus

And... boom! The status is set to completed.

By the way, we added the processor option directly to the Patch() operation, but we can also put it down here on the #[ApiResource()] attribute directly.

46 lines | src/ApiResource/DailyQuest.php
// ... lines 1 - 14
#[ApiResource(
// ... line 16
operations: [
// ... lines 18 - 19
new Patch(),
// ... line 21
],
// ... line 23
processor: DailyQuestStateProcessor::class,
)]
class DailyQuest
// ... lines 27 - 46

That makes no difference... because this is the only operation we have that even uses a processor: GET method operations never call a processor.

State Processor Logic

Anyway, this is normally where we would save the data or... do something, like send an email if this were a "reset password" API resource.

To make things a bit realistic, let's add a $lastUpdated property to DailyQuest and update it here. Add public \DateTimeInterface $lastUpdated.

47 lines | src/ApiResource/DailyQuest.php
// ... lines 1 - 25
class DailyQuest
{
// ... lines 28 - 33
public \DateTimeInterface $lastUpdated;
// ... lines 35 - 45
}

Then populate that inside the state provider: $quest->lastUpdated equals new \DateTimeImmutable()... with some randomness: between 10 and 100 days ago.

41 lines | src/State/DailyQuestStateProvider.php
// ... lines 1 - 10
class DailyQuestStateProvider implements ProviderInterface
{
// ... lines 13 - 23
private function createQuests(): array
{
// ... line 26
for ($i = 0; $i < 50; $i++) {
// ... lines 28 - 32
$quest->lastUpdated = new \DateTimeImmutable(sprintf('- %d days', rand(10, 100)));
// ... lines 34 - 35
}
// ... lines 37 - 38
}
}

Finally, head over to the state processor. We know that this is only used for DailyQuest objects... so $data will be one of those. Help your editor with assert($data instanceof DailyQuest) and, below, $data->lastUpdated = new \DateTimeImmutable('now').

18 lines | src/State/DailyQuestStateProcessor.php
// ... lines 1 - 8
class DailyQuestStateProcessor implements ProcessorInterface
{
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
assert($data instanceof DailyQuest);
$data->lastUpdated = new \DateTimeImmutable('now');
}
}

Cool! We don't have a test assertion for that field, but we are still dumping the response... and we can see it here. I'm looking at my watch and... that is the correct time in my little corner of the world. Our state processor is alive!

Celebrate by going back to the test and removing that dump.

Next: Let's make our resource more interesting by adding a relation to another API resource: a relation to dragon treasure.