Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Campos condicionales por usuario: ApiProperty

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

Controlamos qué campos son legibles y escribibles mediante grupos de serialización. Pero, ¿y si tienes un campo que debe incluirse en la API... pero sólo para determinados usuarios? Lamentablemente, los grupos no pueden hacer ese tipo de magia por sí solos.

Por ejemplo, busca el campo $isPublished y hagamos que forme parte de nuestra API añadiendo los grupos treasure:read y treasure:write:

... lines 1 - 87
class DragonTreasure
{
... lines 90 - 127
#[Groups(['treasure:read', 'treasure:write'])]
private bool $isPublished = false;
... lines 130 - 248
}

Ahora si giramos y probamos las pruebas:

symfony php bin/phpunit

Esto hace que falle una prueba: testGetCollectionOfTreasures ve que se devuelve isPublished... y no lo espera.

Éste es el plan: colaremos el campo en nuestra API, pero sólo para usuarios administradores o propietarios de este DragonTreasure. ¿Cómo podemos conseguirlo?

Hola ApiProperty

Bueno, ¡sorpresa! No solemos necesitarlo, pero podemos añadir un atributo ApiProperty encima de cualquier propiedad para ayudar a configurarla mejor. Tiene un montón de cosas, como una descripción que ayuda con tu documentación y muchos casos extremos. Incluso hay uno llamado readable. Si dijéramos readable: false:

... lines 1 - 88
class DragonTreasure
{
... lines 91 - 129
#[ApiProperty(readable: false)]
private bool $isPublished = false;
... lines 132 - 250
}

Entonces los grupos de serialización dirían que esto debería incluirse en la respuesta... pero entonces esto lo anularía. Observa: si probamos las pruebas:

symfony php bin/phpunit

Pasan porque el campo no está.

La opción de la seguridad

Para nuestra misión, podemos aprovechar una opción superguay llamada security. Ponla en is_granted("ROLE_ADMIN"):

... lines 1 - 8
use ApiPlatform\Metadata\ApiProperty;
... lines 10 - 88
class DragonTreasure
{
... lines 91 - 129
#[ApiProperty(security: 'is_granted("ROLE_ADMIN")')]
private bool $isPublished = false;
... lines 132 - 250
}

¡Eso es! Si esta expresión devuelve false, isPublished no se incluirá en la API: no se podrá leer ni escribir.

Y cuando ahora ejecutamos las pruebas:

symfony php bin/phpunit

Siguen pasando, lo que significa que no se devuelve isPublished.

Ahora vamos a probar la ruta "feliz" en la que se devuelve este campo. AbreDragonTreasureResourceTest. Aquí está la prueba original: testGetCollectionOfTreasures(). Somos anónimos, así que isPublished no se devuelve.

Ahora desplázate hasta testAdminCanPatchToEditTreasure(). Cuando creemosDragonTreasure, asegurémonos de que siempre empieza por isPublished => false:

... lines 1 - 12
class DragonTreasureResourceTest extends ApiTestCase
{
... lines 15 - 138
public function testAdminCanPatchToEditTreasure(): void
{
$admin = UserFactory::new()->asAdmin()->create();
$treasure = DragonTreasureFactory::createOne([
'isPublished' => false,
]);
... lines 145 - 156
}
}

Luego, aquí abajo, assertJsonMatches('isPublished', false) para comprobar que se devuelve el campo:

... lines 1 - 12
class DragonTreasureResourceTest extends ApiTestCase
{
... lines 15 - 138
public function testAdminCanPatchToEditTreasure(): void
{
$admin = UserFactory::new()->asAdmin()->create();
$treasure = DragonTreasureFactory::createOne([
'isPublished' => false,
]);
$this->browser()
... lines 147 - 154
->assertJsonMatches('isPublished', false)
;
}
}

Copia el nombre de la prueba, gira y añade --filter para ejecutar sólo esa prueba:

symfony php bin/phpunit --filter=testAdminCanPatchToEditTreasure

Y... ¡pasa! El campo se devuelve cuando somos administradores.

Devolver también isPublished para el propietario

¿Y si somos el propietario del tesoro? Copia la prueba... cámbiale el nombre a testOwnerCanSeeIsPublishedField()... y vamos a retocar algunas cosas. Cambia el nombre de $admin a $user, simplifícalo a DragonTreasureFactory::createOne()y asegúrate de que owner se establece en nuestro nuevo $user:

... lines 1 - 12
class DragonTreasureResourceTest extends ApiTestCase
{
... lines 15 - 158
public function testOwnerCanSeeIsPublishedField(): void
{
$user = UserFactory::new()->create();
$treasure = DragonTreasureFactory::createOne([
'isPublished' => false,
'owner' => $user,
]);
$this->browser()
->actingAs($user)
->patch('/api/treasures/'.$treasure->getId(), [
'json' => [
'value' => 12345,
],
])
->assertStatus(200)
->assertJsonMatches('value', 12345)
->assertJsonMatches('isPublished', false)
;
}
}

Podríamos cambiar esto por una petición GET... pero PATCH está bien. En cualquiera de las dos situaciones, queremos asegurarnos de que se devuelve el campo isPublished.

Como aún no hemos implementado esto... asegurémonos de que falla. Copia el nombre del método y pruébalo:

symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField

¡Fallo conseguido! ¡Y ya sabemos cómo solucionarlo! En la opción security, podríamos alinear la lógica con or object.getOwner() === user. Pero recuerda: ¡hemos creado el votante para que no tengamos que hacer locuras como ésa! En lugar de eso, di is_granted(), EDIT y luego object:

... lines 1 - 88
class DragonTreasure
{
... lines 91 - 129
#[ApiProperty(security: 'is_granted("EDIT", object)')]
private bool $isPublished = false;
... lines 132 - 250
}

Haz la prueba ahora:

symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField

El especial seguridadPostDenormalizar

¡Ya está! Ah, y no la he utilizado mucho, pero también existe la opción securityPostDenormalize. Al igual que con la opción securityPostDenormalize en cada operación, ésta se ejecuta después de que los nuevos datos se deserialicen en el objeto. Lo interesante es que si la expresión devuelve false, en realidad se revierten los datos del objeto.

Por ejemplo, supongamos que la propiedad isPublished comenzó como false y luego el usuario envió algo de JSON para cambiarla a true. Pero entonces, securityPostDenormalize devolviófalse. En ese caso, API Platform revertirá la propiedad isPublished a su valor original: la cambiará de false a true. Ah, y por cierto, securityPostDenormalize no se ejecuta en las peticiones a GET: sólo ocurre cuando se están deserializando los datos. Así que asegúrate de poner tu lógica de seguridad principal en security y sólo utiliza securityPostDenormalize si lo necesitas.

Lo siguiente en nuestra lista de tareas: vamos a nivelar nuestras operaciones de usuario para hacer un hash de la contraseña antes de guardarla en la base de datos. Necesitaremos una nueva propiedad de contraseña simple no persistente para hacerlo.

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