Buy Access to Course
02.

State Providers, Processors & a Custom Field

|

Share this awesome video!

|

API Platform 3 rolled out snazzy new concepts called State Providers and State Processors. We chatted about them in the last tutorial and we're going to dive even deeper in this tutorial.

Providers & Processors Basics

Nestled within the "Upgrade Guide" of API Platform's docs lives one of my favorite sections on this very topic. Each API resource class - whether it's an entity or a normal class - will have a State Provider. Its job is to load the data, like from the database... or wherever. Each API resource class will also have a State Processor whose jobs is to save the data, like on a POST or PATCH request. It's also responsible for deleting.

The big bonus is that if your API resource is an entity, you automatically get a set of State Providers and State Processors. For example, the GetCollection operation uses a core CollectionProvider, which queries the database for you. And there's a similar ItemProvider to fetch one item from the database.

Entities also gets a complimentary PersistProcessor, which, no surprise, persists your data to the database.

In Episode 2, we decorated the PersistProcessor for the User entity. This let us hash the plain password up here... before calling the core PersistProcessor to handle the saving.

// ... lines 1 - 11
class UserHashPasswordStateProcessor implements ProcessorInterface
{
// ... lines 14 - 17
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
if ($data instanceof User && $data->getPlainPassword()) {
$data->setPassword($this->userPasswordHasher->hashPassword($data, $data->getPlainPassword()));
}
$this->innerProcessor->process($data, $operation, $uriVariables, $context);
}
}

Good & Better Ways to Add a Custom Field

We're talking about this because we can use a similar trick with the state provider to add a custom field: a field that you want in your API, but that doesn't live in the database.

In the last episode, we learned that one way to add a custom field is by extending the normalizer. We did this in AddOwnerGroupsNormalizer. Well, this does a few things, but importantly for us: if the object is a DragonTreasure - so if a DragonTreasure is being turned into JSON - and the currently authenticated user is the owner of that treasure, then add a totally custom isMine field.

// ... lines 1 - 12
class AddOwnerGroupsNormalizer implements NormalizerInterface, SerializerAwareInterface
{
// ... lines 15 - 18
public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
{
if ($object instanceof DragonTreasure && $this->security->getUser() === $object->getOwner()) {
$context['groups'][] = 'owner:read';
}
$normalized = $this->normalizer->normalize($object, $format, $context);
if ($object instanceof DragonTreasure && $this->security->getUser() === $object->getOwner()) {
$normalized['isMine'] = true;
}
return $normalized;
}
// ... lines 33 - 54
}

We can see this in our tests: tests/Functional/DragonTreasureResourceTest.php Search for isMine. Yep: testOwnerCanSeeIsPublishedAndIsMineFields. The important part is the bottom: when the treasure is serialized, isMine should be in the response.

// ... lines 1 - 13
class DragonTreasureResourceTest extends ApiTestCase
{
// ... lines 16 - 196
public function testOwnerCanSeeIsPublishedAndIsMineFields(): void
{
// ... lines 199 - 204
$this->browser()
->actingAs($user)
->patch('/api/treasures/'.$treasure->getId(), [
'json' => [
'value' => 12345,
],
])
->assertStatus(200)
->assertJsonMatches('value', 12345)
->assertJsonMatches('isPublished', true)
->assertJsonMatches('isMine', true)
;
}
}

This works great... except for one hiccup: in the documentation... there is no mention of the isMine field! It will be returned, but it's not documented.

If this matters to you, there are two better ways to handle this: add a non-persisted field to your entity - that's what we'll do in a moment - or create a totally custom API resource class. That will be our big topic later.

Adding the Non-Persisted Field

Step 1: remove the code in the normalizer... and just return. Copy the test method name... to make sure this fails:

// ... lines 1 - 12
class AddOwnerGroupsNormalizer implements NormalizerInterface, SerializerAwareInterface
{
// ... lines 15 - 18
public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
{
if ($object instanceof DragonTreasure && $this->security->getUser() === $object->getOwner()) {
$context['groups'][] = 'owner:read';
}
return $this->normalizer->normalize($object, $format, $context);
}
// ... lines 27 - 48
}
symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedAndIsMineFields

And... yay failure! Expected null to be the same as true from line 215... because no more isMine field!

Step 2: add this field as a real property on our class: how about private bool $isOwnedByAuthenticatedUser. Notice this is a non-persisted property: it only exists to help our API. Doing this isn't super common, but is allowed. Skip down to the bottom to add a getter and setter.

267 lines | src/Entity/DragonTreasure.php
// ... lines 1 - 88
class DragonTreasure
{
// ... lines 91 - 139
/**
* @var bool Non-persisted property to help determine if the treasure is owned by the authenticated user
*/
private bool $isOwnedByAuthenticatedUser;
// ... lines 144 - 256
public function isOwnedByAuthenticatedUser(): bool
{
return $this->isOwnedByAuthenticatedUser;
}
public function setIsOwnedByAuthenticatedUser(bool $isOwnedByAuthenticatedUser)
{
$this->isOwnedByAuthenticatedUser = $isOwnedByAuthenticatedUser;
}
}

Oh, and since the property doesn't have a default value, if the property hasn't been initialized, let's yell so we know.

271 lines | src/Entity/DragonTreasure.php
// ... lines 1 - 256
public function isOwnedByAuthenticatedUser(): bool
{
if (!isset($this->isOwnedByAuthenticatedUser)) {
throw new \LogicException('You must call setIsOwnedByAuthenticatedUser() before isOwnedByAuthenticatedUser()');
}
// ... lines 262 - 263
}
// ... lines 265 - 271

Last but not least, we need to expose this property to our API. Do that by putting it into the group called treasure:read... and then use SerializedName to call it isMine in the API.

273 lines | src/Entity/DragonTreasure.php
// ... lines 1 - 256
#[Groups(['treasure:read'])]
#[SerializedName('isMine')]
public function isOwnedByAuthenticatedUser(): bool
{
// ... lines 261 - 265
}
// ... lines 267 - 273

If we go run the test now:

symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedAndIsMineFields

We're greeted with a delicious 500 error! Thanks to the zenstruck/browser library, it saved that failed response to a file... which we can pop open in our browser. And... yup!

You must call setIsOwnedByAuthenticatedUser()

So it's trying to expose the field to our API... but nothing is setting that property. How will we set it? With a positive attitude! And... mostly a custom state provider. That's next.