Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Grupos de validación y formatos de parche

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.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Ahora que la propiedad plainPassword es una parte legítima de nuestra API, añadamos algo de validación... ¡porque no puedes crear un nuevo usuario sin contraseña! AñadeAssert\NotBlank:

... lines 1 - 67
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 70 - 94
#[Assert\NotBlank]
private ?string $plainPassword = null;
... lines 97 - 293
}

¡Pan comido! Bueno, eso acaba de crear un nuevo problema... pero avancemos a ciegas y finjamos que todo va bien.

Copia la primera prueba y pégala para crear un segundo método que nos asegure que podemos actualizar los usuarios. Llámalo testPatchToUpdateUser(). Este es sencillo: crea un nuevo usuario - $user = UserFactory::createOne(), añade actingAs($user) luego ->patch()a /api/users/ luego $user->getId() para editarnos a nosotros mismos.

Para el json, basta con enviar username, añadir assertStatus(200).... entonces no necesitamos ninguna de estas otras cosas:

... lines 1 - 7
class UserResourceTest extends ApiTestCase
{
... lines 10 - 32
public function testPatchToUpdateUser(): void
{
$user = UserFactory::createOne();
$this->browser()
->actingAs($user)
->patch('/api/users/' . $user->getId(), [
'json' => [
'username' => 'changed',
],
])
->assertStatus(200);
}
}

Como recordatorio, arriba en la operación Patch para User... aquí está, estamos requiriendo que el usuario tenga ROLE_USER_EDIT. Como estamos entrando como usuario "completo", deberíamos tenerlo... y todo debería funcionar bien... famosas últimas palabras.

Ejecuta:

symfony php bin/phpunit --filter=testPatchToUpdateUser

PATCH: El método HTTP más interesante del mundo

Y... ¡oh! 200 esperado, obtuvo 415. ¡Eso es nuevo! Haz clic para abrir la última respuesta... luego veré la fuente para que quede más claro. Interesante:

No se admite el tipo de contenido: application/json. Los tipos MIME admitidos son application/merge-patch+json.

Desmenucemos esto. Estamos haciendo una petición a PATCH... y las peticiones a PATCH son bastante sencillas: enviamos un subconjunto de campos, y sólo se actualizan esos campos.

Resulta que el método HTTP PATCH puede ser mucho más interesante que esto. En la gran interwebs, hay formatos que compiten por el aspecto que deben tener los datos cuando se utiliza una petición PATCH, y cada formato significa algo diferente.

Actualmente, API Platform sólo admite uno de estos formatos: application/merge-patch+json este formato es... más o menos lo que esperas. Dice: si envías un único campo, sólo se modificará ese único campo. Pero también tiene otras reglas, como que podrías establecer email en null... y eso en realidad eliminaría el campo email. Eso no tiene mucho sentido en nuestra API, pero la cuestión es: el formato define reglas sobre el aspecto que debe tener tu JSON para una petición PATCH y lo que eso significa. Si quieres saber más, hay un documento que lo describe todo: es bastante breve y legible.

Así que, de momento, API Platform sólo admite un formato para las peticiones PATCH. Pero, en el futuro, podrían admitir más. Y así, cuando haces una peticiónPATCH, API Platform requiere que envíes una cabecera Content-Type establecida en application/merge-patch+json... de modo que le estás diciendo explícitamente a API Platform qué formato está utilizando tu JSON.

En otras palabras, para solucionar nuestro error, pasa una clave headers con Content-Type establecido en application/merge-patch+json:

... lines 1 - 7
class UserResourceTest extends ApiTestCase
{
... lines 10 - 32
public function testPatchToUpdateUser(): void
{
... lines 35 - 36
$this->browser()
... line 38
->patch('/api/users/' . $user->getId(), [
... lines 40 - 42
'headers' => ['Content-Type' => 'application/merge-patch+json']
])
... line 45
}
}

Inténtalo ahora:

symfony php bin/phpunit --filter=testPatchToUpdateUser

Sigue fallando, ¡pero ahora es un error de validación! Las conclusiones son sencillas: Las peticiones PATCH requieren esta cabecera Content-Type.

Pero, ¡espera! Hicimos un montón de peticiones a PATCH en DragonTreasureResourceTesty ¡funcionaron bien sin la cabecera! ¿Qué?

Eso... fue un poco por accidente. Dentro de DragonTreasure, en el primer tutorial... aquí está, añadimos una clave formats para poder añadir soporte CSV:

... lines 1 - 28
#[ApiResource(
... lines 30 - 49
formats: [
'jsonld',
'json',
'html',
'jsonhal',
'csv' => 'text/csv',
],
... lines 57 - 66
)]
... lines 68 - 252

Resulta que, por algunas complejas razones internas, al añadir formats, eliminamos el requisito de necesitar esa cabecera. Así que nos estábamos "saliendo con la nuestra" al no fijar la cabecera en DragonTreasureResourceTest... aunque deberíamos fijarla. Quizá hubiera sido mejor establecer formats sólo en la operación GetCollection... ya que ése es el único punto en el que necesitamos CSV.

En fin, por eso antes no lo necesitábamos, pero ahora sí. Por cierto, si añadir esta cabecera cada vez que llamas a ->patch te resulta molesto, ésta es otra situación en la que podrías añadir un método personalizado al navegador -como ->apiPatch() - que funcionaría igual, pero añadiría esa cabecera automáticamente.

Arreglar los grupos de validación

Vale, ¡volvamos a la prueba! Está fallando con un 422. Abre la respuesta de error. Ah, es de plainPassword: ¡este campo no debería estar en blanco!

La propiedad plainPassword no se persiste en la base de datos. Por tanto, siempre está vacía al inicio de una petición a la API. Cuando creamos un User, queremos absolutamente que este campo sea obligatorio. Pero cuando editamos un User, no necesitamos que este campo esté establecido. Pueden establecerlo para cambiar su contraseña, pero eso es opcional.

Este es el primer punto en el que necesitamos validación condicional: la validación debe producirse en una operación, pero no en otras. La forma de solucionarlo es con grupos de validación, que es muy similar a los grupos de serialización.

¡Busca la operación Post y pásale una nueva opción llamadavalidationContext con, lo has adivinado, groups! Colócalo en una matriz con un grupo llamado Default con D mayúscula. Luego inventa un segundo grupo:postValidation:

... lines 1 - 26
#[ApiResource(
... line 28
operations: [
... lines 30 - 31
new Post(
... line 33
validationContext: ['groups' => ['Default', 'postValidation']],
),
... lines 36 - 42
],
... lines 44 - 49
)]
... lines 51 - 296

Cuando el validador valida un objeto, por defecto, valida todo lo que está en un grupo llamado Default. Y cada vez que tienes una restricción, por defecto esa restricción está en ese grupo Default. Así que lo que estamos diciendo aquí es

Queremos validar todas las restricciones normales más cualquier restricción que estén en el grupo postValidation.

Ahora podemos coger ese postValidation, bajar a plainPassword y ponergroups en postValidation:

... lines 1 - 68
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 71 - 95
#[Assert\NotBlank(groups: ['postValidation'])]
private ?string $plainPassword = null;
... lines 98 - 294
}

Eso elimina esta restricción del grupo Default y sólo la incluye en el grupo postValidation. Gracias a esto, otras operaciones como Patchno la ejecutarán, pero sí la operación Post.

Ejecuta ahora la prueba:

symfony php bin/phpunit --filter=testPatchToUpdateUser

¡Somos imparables! De hecho, ¡todas nuestras pruebas están pasando!

Cuidado: PUT puede crear objetos

Pero ¡cuidado! En User, seguimos teniendo tanto Put como Patch. Aún no he jugado mucho con ello, pero el nuevo comportamiento Put, en teoría, sí admite la creación de objetos. Esto puede complicar las cosas: ¿necesitamos exigir la contraseña o no? Depende Ésta podría ser otra razón para eliminar la operación Put y simplificar las cosas. Así tenemos una operación para crear y otra para editar.

Siguiente: vamos a explorar la posibilidad de hacer que nuestros grupos de serialización sean dinámicos en función del usuario, lo que nos dará otra forma de incluir o no incluir campos en función de quién esté conectado. Y nos llevará a añadir campos superpersonalizados.

Leave a comment!

0
Login or Register to join the conversation
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^3.0", // v3.1.2
        "doctrine/annotations": "^2.0", // 2.0.1
        "doctrine/doctrine-bundle": "^2.8", // 2.8.3
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.14", // 2.14.1
        "nelmio/cors-bundle": "^2.2", // 2.2.0
        "nesbot/carbon": "^2.64", // 2.66.0
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.16.1
        "symfony/asset": "6.2.*", // v6.2.5
        "symfony/console": "6.2.*", // v6.2.5
        "symfony/dotenv": "6.2.*", // v6.2.5
        "symfony/expression-language": "6.2.*", // v6.2.5
        "symfony/flex": "^2", // v2.2.4
        "symfony/framework-bundle": "6.2.*", // v6.2.5
        "symfony/property-access": "6.2.*", // v6.2.5
        "symfony/property-info": "6.2.*", // v6.2.5
        "symfony/runtime": "6.2.*", // v6.2.5
        "symfony/security-bundle": "6.2.*", // v6.2.6
        "symfony/serializer": "6.2.*", // v6.2.5
        "symfony/twig-bundle": "6.2.*", // v6.2.5
        "symfony/ux-react": "^2.6", // v2.7.1
        "symfony/ux-vue": "^2.7", // v2.7.1
        "symfony/validator": "6.2.*", // v6.2.5
        "symfony/webpack-encore-bundle": "^1.16", // v1.16.1
        "symfony/yaml": "6.2.*" // v6.2.5
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "mtdowling/jmespath.php": "^2.6", // 2.6.1
        "phpunit/phpunit": "^9.5", // 9.6.3
        "symfony/browser-kit": "6.2.*", // v6.2.5
        "symfony/css-selector": "6.2.*", // v6.2.5
        "symfony/debug-bundle": "6.2.*", // v6.2.5
        "symfony/maker-bundle": "^1.48", // v1.48.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/phpunit-bridge": "^6.2", // v6.2.5
        "symfony/stopwatch": "6.2.*", // v6.2.5
        "symfony/web-profiler-bundle": "6.2.*", // v6.2.5
        "zenstruck/browser": "^1.2", // v1.2.0
        "zenstruck/foundry": "^1.26" // v1.28.0
    }
}
userVoice