Proveedores de estado, procesadores y un campo personalizado
La API Platform 3 introdujo nuevos y atractivos conceptos llamados Proveedores de Estado y Procesadores de Estado. Hablamos de ellos en el último tutorial y vamos a profundizar aún más en este tutorial.
Conceptos básicos sobre proveedores y procesadores
En la "Guía de actualización" de la documentación de API Platform hay una de mis secciones favoritas sobre este tema. Cada clase de recurso API -ya sea una entidad o una clase normal- tendrá un Proveedor de Estado. Su trabajo consiste en cargar los datos, por ejemplo, de la base de datos... o de donde sea. Cada clase de recurso API tendrá también un Procesador de Estado, cuyo trabajo es guardar los datos, como en una petición POST o PATCH. También se encarga de borrarlos.
La gran ventaja es que si tu recurso API es una entidad, obtendrás automáticamente un conjunto de Proveedores de Estado y Procesadores de Estado. Por ejemplo, la operación GetCollection
utiliza un núcleo CollectionProvider
, que consulta la base de datos por ti. Y hay un ItemProvider
similar para obtener un elemento de la base de datos.
Las entidades también tienen un complemento PersistProcessor
, que, sin sorpresa, persiste tus datos en la base de datos.
En el Episodio 2, decoramos el PersistProcessor
para la entidad User
. Esto nos permitió hacer un hash de la contraseña simple aquí arriba... antes de llamar al núcleoPersistProcessor
para que se encargue de guardarla.
// ... 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); | |
} | |
} |
Formas buenas y mejores de añadir un campo personalizado
Hablamos de esto porque podemos utilizar un truco similar con el proveedor de estado para añadir un campo personalizado: un campo que quieres en tu API, pero que no vive en la base de datos.
En el último episodio, aprendimos que una forma de añadir un campo personalizado es ampliando el normalizador. Lo hicimos en AddOwnerGroupsNormalizer
. Bien, esto hace unas cuantas cosas, pero lo más importante para nosotros: si el objeto es un DragonTreasure
-por tanto, si un DragonTreasure
se está convirtiendo en JSON- y el usuario autenticado en ese momento es el propietario de ese tesoro, entonces añade un campo isMine
totalmente personalizado.
// ... 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 | |
} |
Podemos verlo en nuestras pruebas:tests/Functional/DragonTreasureResourceTest.php
Busca isMine
. Sí: testOwnerCanSeeIsPublishedAndIsMineFields
. La parte importante es la de abajo: cuando se serializa el tesoro, isMine
debe estar en la respuesta.
// ... 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) | |
; | |
} | |
} |
Esto funciona de maravilla... excepto por un contratiempo: en la documentación... ¡no se menciona el campo isMine
! Se devolverá, pero no está documentado.
Si esto te importa, hay dos formas mejores de manejarlo: añade un campo no persistente a tu entidad -eso es lo que haremos dentro de un momento- o crea una clase de recurso API totalmente personalizada. Ese será nuestro gran tema más adelante.
Añadir el campo no persistente
Paso 1: elimina el código del normalizador... y sólo vuelve. Copia el nombre del método de prueba... para asegurarte de que esto falla:
// ... 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
Y... ¡yay fallo! Esperaba que null
fuera el mismo que true
de la línea 215... ¡porque ya no existe el campo isMine
!
Paso 2: añade este campo como una propiedad real en nuestra clase: ¿qué te pareceprivate bool $isOwnedByAuthenticatedUser
. Fíjate en que se trata de una propiedad no persistente: sólo existe para ayudar a nuestra API. Hacer esto no es supercomún, pero está permitido. Ve hasta el final para añadir un getter y un setter.
// ... 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; | |
} | |
} |
Ah, y como la propiedad no tiene un valor por defecto, si la propiedad no se ha inicializado, gritemos para saberlo.
// ... 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 |
Por último, pero no menos importante, tenemos que exponer esta propiedad a nuestra API. Hazlo poniéndola en el grupo llamado treasure:read
... y luego utiliza SerializedName
para llamarla isMine
en la API.
// ... lines 1 - 256 | |
'treasure:read']) | ([|
'isMine') | (|
public function isOwnedByAuthenticatedUser(): bool | |
{ | |
// ... lines 261 - 265 | |
} | |
// ... lines 267 - 273 |
Si ahora vamos a ejecutar la prueba:
symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedAndIsMineFields
¡Nos recibe un delicioso error 500! Gracias a la biblioteca zenstruck/browser
, guardó esa respuesta fallida en un archivo... que podemos abrir en nuestro navegador. Y... ¡sí!
Debes llamar a setIsOwnedByAuthenticatedUser()
Así que está intentando exponer el campo a nuestra API... pero nada está estableciendo esa propiedad. ¿Cómo la estableceremos? ¡Con una actitud positiva! Y... sobre todo con un proveedor de estado personalizado. Eso a continuación.
I've upgraded the tutorial project to Symfony 6.4 and api-platform 3.2 including all recipes...
And I was fighting hard to get the same result as the tutorial.
Apparently.
If you have a property
isOwnedBy
, and you make a 'isser'isOwnerBy()
, that doesn't work! Or doesn't work anymore. I don't know.So, make an ugly
GetIsOwnedBy()
and everything suddenly matches.What I did, was to rename the property to
ownedBy
(so drop theis
from the property name), and the the isser / setter can be exactly the same as the tutorial.NOW, my getter is called and NOW it ends up in the output. Before that, my state provider was called and setting the property just fine, and from the API docs there should be a field 'isMine', but it was just not appearing in the API output... and also the reason why my results from running the tests were completely different to the tutorial.
This cost me an evening :(.