Custom Resource State Processor
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 SubscribeWe 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.
// ... lines 1 - 6 | |
use ApiPlatform\Metadata\Get; | |
use ApiPlatform\Metadata\GetCollection; | |
use ApiPlatform\Metadata\Patch; | |
// ... lines 10 - 13 | |
( | |
// ... 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.
// ... 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
.
// ... lines 1 - 14 | |
( | |
// ... 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)
.
// ... 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.
// ... lines 1 - 14 | |
( | |
// ... 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
.
// ... 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.
// ... 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')
.
// ... 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.