Buy Access to Course
02.

Proveedores de estado, procesadores y un campo personalizado

|

Share this awesome video!

|

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.phpBusca 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.

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;
}
}

Ah, y como la propiedad no tiene un valor por defecto, si la propiedad no se ha inicializado, gritemos para saberlo.

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

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.

273 lines | src/Entity/DragonTreasure.php
// ... lines 1 - 256
#[Groups(['treasure:read'])]
#[SerializedName('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.