gstreamer0.10-ffmpeg
gstreamer0.10-plugins-good
packages.
Publishing a DragonTreasure
is easy: make a Patch
request to the treasure endpoint with isPublished
set to true and... celebration! But... what if, when a DragonTreasure
is published, we need to run some custom code - maybe trigger some notifications on the site.
One option is to create a custom operation - like maybe POST /api/treasures/5/publish
. You can do that - and it might be fun to look at in a future tutorial. But who wants extra work? We can keep that simple Patch
request and still run the code that we want. How? By using a state processor and detecting the change.
Let's start by creating a test that publishes a treasure. At the bottom, copy this last test, paste, and rename it testPublishTreasure
. We start with a user that owns a treasure with isPublished
false
. Then we log in as that user, make a ->patch()
request to /api/treasures/
using the id... and send isPublished: true
. This should be a 200 status code... and then ->assertJsonMatches()
that isPublished
is true
.
... lines 1 - 13 | |
class DragonTreasureResourceTest extends ApiTestCase | |
{ | |
... lines 16 - 219 | |
public function testPublishTreasure(): void | |
{ | |
$user = UserFactory::createOne(); | |
$treasure = DragonTreasureFactory::createOne([ | |
'owner' => $user, | |
'isPublished' => false, | |
]); | |
$this->browser() | |
->actingAs($user) | |
->patch('/api/treasures/'.$treasure->getId(), [ | |
'json' => [ | |
'isPublished' => true, | |
], | |
]) | |
->assertStatus(200) | |
->assertJsonMatches('isPublished', true) | |
; | |
} | |
} |
Simple enough! Copy that test name, spin over and run it:
symfony php bin/phpunit --filter=testPublishTreasure
Whoops! It fails: expected false
to be the same as true
. That's from the last line: the JSON still has isPublished
false. Maybe... the field isn't writable? Check the groups above that property. Ah: in a previous tutorial, we made this field writable by admin users, but not normal users. Add treasure:write
.
... lines 1 - 90 | |
class DragonTreasure | |
{ | |
... lines 93 - 130 | |
'admin:read', 'admin:write', 'owner:read', 'treasure:write']) ([ | |
private bool $isPublished = false; | |
... lines 133 - 273 | |
} |
That means anyone with access to the Patch
operation can write to this field... which in reality, thanks to the security
on that operation... and a custom voter we created... is just admin users and the owner.
... lines 1 - 30 | |
( | |
... lines 32 - 33 | |
operations: [ | |
... lines 35 - 43 | |
new Patch( | |
security: 'is_granted("EDIT", object)', | |
), | |
... lines 47 - 49 | |
], | |
... lines 51 - 68 | |
) | |
... lines 70 - 90 | |
class DragonTreasure | |
... lines 92 - 275 |
Try the test now:
symfony php bin/phpunit --filter=testPublishTreasure
Got it! To run some code when the treasure is published, we need a state processor. And we already have one for `DragonTreasure! We originally created it to set the owner to the currently authenticated user. So... should we jam the new code into here or create a second processor?
It's up to you, but I like to have one processor per resource class. It just makes my life simpler. But let's rename this class to be more clear: DragonTreasureStateProcessor
.
In the last tutorial, we learned that there are two ways to add a custom state provider or processor into the system. We used the first method a few minutes ago with the state provider: create a normal boring service... use #[Autowire]
to inject the core services... then set the provider
option on DragonTreasure
to point to it.
The other way - which we did in the last tutorial for this class - is to decorate the core processor. Here, we decorated the PersistProcessor
from Doctrine... which means that whenever any API resource is saved, when it tries to use the core PersistProcessor
, our service is called instead. This was easy to set up because all we needed was #[AsDecorator]
and... bam! Our service started being called for all our resources. But that's also why we need this extra code that checks which object is being saved.
... lines 1 - 10 | |
'api_platform.doctrine.orm.state.persist_processor') ( | |
class DragonTreasureStateProcessor implements ProcessorInterface | |
{ | |
... lines 14 - 17 | |
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void | |
{ | |
if ($data instanceof DragonTreasure && $data->getOwner() === null && $this->security->getUser()) { | |
$data->setOwner($this->security->getUser()); | |
} | |
... lines 23 - 28 | |
} | |
} |
Both ways are fine. But for consistency with the provider, let's refactor this to use the other method. This is 3 steps. First, remove #[AsDecorator]
. Suddenly, instead of overriding a core service, this is a normal, boring service that nobody is using at the moment. Second, because we're no longer decorating a core service, Symfony won't know what to pass for $innerProcessor
. Break this onto multiple lines... then use the #[Autowire]
trick to point to the core PersistProcessor
. And I'll clean up the old use
statement.
... lines 1 - 11 | |
class DragonTreasureStateProcessor implements ProcessorInterface | |
{ | |
public function __construct( | |
#[Autowire(service: PersistProcessor::class)] | |
private ProcessorInterface $innerProcessor, | |
private Security $security | |
) | |
{ | |
} | |
... lines 21 - 33 | |
} |
Step 3 is to tell API Platform when to use this processor. In DragonTreasure
, we want this to be used for both our Post
and Patch
operations. Set processor
to DragonTreasureStateProcessor::class
... and repeat that down for Patch
.
... lines 1 - 19 | |
use App\State\DragonTreasureStateProcessor; | |
... lines 21 - 31 | |
( | |
... lines 33 - 34 | |
operations: [ | |
... lines 36 - 41 | |
new Post( | |
... line 43 | |
processor: DragonTreasureStateProcessor::class, | |
), | |
new Patch( | |
... line 47 | |
processor: DragonTreasureStateProcessor::class, | |
), | |
... lines 50 - 71 | |
) | |
... lines 73 - 93 | |
class DragonTreasure | |
... lines 95 - 278 |
Done! API Platform will call our processor... and it contains the core PersistProcessor
so we can make it do the real work. Re-run the test to give us infinite confidence:
symfony php bin/phpunit --filter=testPublishTreasure
That feels great.
And the nice thing about doing the processor with this method is that you don't need this conditional code: this will always be a DragonTreasure
. To help my editor and prove it, assert()
that $data
is an instanceof
DragonTreasure
.
... lines 1 - 11 | |
class DragonTreasureStateProcessor implements ProcessorInterface | |
{ | |
... lines 14 - 21 | |
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void | |
{ | |
assert($data instanceof DragonTreasure); | |
... lines 25 - 29 | |
} | |
} |
And my editor is already yelling:
Hey this code down here isn't needed anymore dude!
So, remove that too. Now that we've refactored our state processor, let's get back to the task at hand: running custom code when a treasure becomes published.
"Houston: no signs of life"
Start the conversation!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "3.1.x-dev", // 3.1.x-dev
"doctrine/annotations": "^2.0", // 2.0.1
"doctrine/doctrine-bundle": "^2.8", // 2.10.2
"doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.4
"doctrine/orm": "^2.14", // 2.16.1
"nelmio/cors-bundle": "^2.2", // 2.3.1
"nesbot/carbon": "^2.64", // 2.69.0
"phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
"phpstan/phpdoc-parser": "^1.15", // 1.23.1
"symfony/asset": "6.3.*", // v6.3.0
"symfony/console": "6.3.*", // v6.3.2
"symfony/dotenv": "6.3.*", // v6.3.0
"symfony/expression-language": "6.3.*", // v6.3.0
"symfony/flex": "^2", // v2.3.3
"symfony/framework-bundle": "6.3.*", // v6.3.2
"symfony/property-access": "6.3.*", // v6.3.2
"symfony/property-info": "6.3.*", // v6.3.0
"symfony/runtime": "6.3.*", // v6.3.2
"symfony/security-bundle": "6.3.*", // v6.3.3
"symfony/serializer": "6.3.*", // v6.3.3
"symfony/stimulus-bundle": "^2.9", // v2.10.0
"symfony/string": "6.3.*", // v6.3.2
"symfony/twig-bundle": "6.3.*", // v6.3.0
"symfony/ux-react": "^2.6", // v2.10.0
"symfony/ux-vue": "^2.7", // v2.10.0
"symfony/validator": "6.3.*", // v6.3.2
"symfony/webpack-encore-bundle": "^2.0", // v2.0.1
"symfony/yaml": "6.3.*", // v6.3.3
"symfonycasts/micro-mapper": "^0.1.0" // v0.1.1
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.4
"mtdowling/jmespath.php": "^2.6", // 2.6.1
"phpunit/phpunit": "^9.5", // 9.6.11
"symfony/browser-kit": "6.3.*", // v6.3.2
"symfony/css-selector": "6.3.*", // v6.3.2
"symfony/debug-bundle": "6.3.*", // v6.3.2
"symfony/maker-bundle": "^1.48", // v1.50.0
"symfony/monolog-bundle": "^3.0", // v3.8.0
"symfony/phpunit-bridge": "^6.2", // v6.3.2
"symfony/stopwatch": "6.3.*", // v6.3.0
"symfony/web-profiler-bundle": "6.3.*", // v6.3.2
"zenstruck/browser": "^1.2", // v1.4.0
"zenstruck/foundry": "^1.26" // v1.35.0
}
}