Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Grupos Dinámicos: Creador de Contextos

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

En DragonTreasure, busca el campo $isPublished. Antes añadimos esto ApiPropertysecurity para que el campo sólo se devuelva a los usuarios administradores o propietarios de este tesoro. Esta es una forma sencilla y 100% válida de manejar esta situación.

Sin embargo, hay otra forma de manejar los campos que deben ser dinámicos en función del usuario actual... y puede tener o no dos ventajas dependiendo de tu situación.

Las opciones de seguridad frente a los Grupos Dinámicos

En primer lugar, consulta la documentación. Abre la ruta GET para un único DragonTreasure. E, incluso sin probarlo, puedes ver que isPublished es un campo que se anuncia correctamente en nuestra documentación.

Entonces, eso es bueno, ¿no? Sí Bueno, probablemente. Si isPublished fuera realmente un campo interno, sólo para administradores, quizá no quisiéramos que se anunciara al mundo.

El segundo posible problema con security es que, si tienes esta opción en muchas propiedades, va a ejecutar esa comprobación de seguridad muchas veces al devolver una colección de objetos. Sinceramente, eso probablemente no cause problemas de rendimiento, pero es algo a tener en cuenta.

Inventar nuevos grupos de serialización

Para resolver estos dos posibles problemas -y, sinceramente, sólo para aprender más sobre cómo funciona la API Platform bajo el capó- quiero mostrarte una solución alternativa. Elimina el atributo ApiProperty:

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

Y sustitúyelo por dos nuevos grupos. No vamos a utilizar los normalestreasure:read y treasure:write... porque entonces los campos siempre formarían parte de nuestra API. En su lugar, utiliza admin:read y admin:write:

... lines 1 - 88
class DragonTreasure
{
... lines 91 - 128
#[Groups(['admin:read', 'admin:write'])]
private bool $isPublished = false;
... lines 131 - 249
}

Esto no funcionará todavía... porque estos grupos no se utilizan nunca. Pero ésta es la idea: si el usuario actual es un administrador, cuando serialicemos, añadiremos estos dos grupos.

La parte complicada es que, ahora mismo, ¡los grupos son estáticos! Los establecemos aquí arriba, en el atributoApiResource -o en una operación específica- ¡y ya está! Pero podemos hacerlos dinámicos.

Hola ContextBuilder

Internamente, la API Platform tiene un sistema llamado constructor de contextos, que se encarga de construir los contextos de normalización o desnormalización que luego se pasan al serializador. Y, podemos engancharnos a él para cambiar el contexto: por ejemplo, para añadir grupos adicionales.

Hagámoslo En src/ApiPlatform/, crea una nueva clase llamadaAdminGroupsContextBuilder... y haz que implementeSerializerContextBuilderInterface:

... lines 1 - 2
namespace App\ApiPlatform;
use ApiPlatform\Serializer\SerializerContextBuilderInterface;
... lines 6 - 7
class AdminGroupsContextBuilder implements SerializerContextBuilderInterface
{
... lines 10 - 13
}

Luego, ve a "Código"->"Generar" -o Command+N en un Mac- y selecciona "Implementar métodos" para crear el que necesitamos: createFromRequest():

... lines 1 - 5
use Symfony\Component\HttpFoundation\Request;
class AdminGroupsContextBuilder implements SerializerContextBuilderInterface
{
public function createFromRequest(Request $request, bool $normalization, array $extractedAttributes = null): array
{
// TODO: Implement createFromRequest() method.
}
}

Es bastante sencillo: API Platform lo llamará, nos pasará el Request, si estamos normalizando o desnormalizando... y luego nos devolverá el array context que debe pasarse al serializador.

¡Hagamos algo de Decoración!

Como ya hemos visto unas cuantas veces, nuestra intención no es sustituir al núcleo constructor de contextos. No, queremos que el constructor de contextos principal haga lo suyo... y luego añadiremos nuestras propias cosas.

Para ello, una vez más, utilizaremos la decoración de servicios. Sabemos cómo funciona: añade un método __construct() que acepte unSerializerContextBuilderInterface privado y lo llamaré $decorated:

... lines 1 - 7
class AdminGroupsContextBuilder implements SerializerContextBuilderInterface
{
public function __construct(private SerializerContextBuilderInterface $decorated)
{
}
... lines 13 - 20
}

Luego, aquí abajo, digamos $context = this->decorated->createFromRequest()pasando $request, $normalization y $extractedAttributes. Añade un dump()para asegurarte de que funciona y devuelve $context:

... lines 1 - 7
class AdminGroupsContextBuilder implements SerializerContextBuilderInterface
{
... lines 10 - 13
public function createFromRequest(Request $request, bool $normalization, array $extractedAttributes = null): array
{
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
dump('I AM WORKING!');
return $context;
}
}

Para decirle a Symfony que utilice nuestro constructor de contexto en lugar del real, añade nuestro #[AsDecorator()].

Aquí, necesitamos el ID de servicio de lo que sea el constructor de contexto principal. Es algo que puedes encontrar en la documentación: es api_platform.serializer.context_builder:

... lines 1 - 5
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
... lines 7 - 8
#[AsDecorator('api_platform.serializer.context_builder')]
class AdminGroupsContextBuilder implements SerializerContextBuilderInterface
{
... lines 12 - 22
}

Ah, pero ten cuidado al utilizar SerializerContextBuilderInterface: hay dos. Uno de ellos es de GraphQL: asegúrate de seleccionar el de ApiPlatform\Serializer, a menos que estés utilizando GraphQL.

De acuerdo ¡Veamos si funciona nuestro volcado! Ejecuta todas nuestras pruebas: También quiero ver cuáles fallan:

symfony php bin/phpunit

Y... ¡bien! Vemos el volcado un montón de veces, seguido de dos fallos. El primero es testAdminCanPatchToEditTreasure. Es el caso en el que estamos trabajando ahora. Nos preocuparemos de testOwnerCanSeeIsPublishedFieldI dentro de un momento.

Copia el nombre del método de prueba y vuelve a ejecutarlo con --filter=:

symfony php bin/phpunit --filter=testAdminCanPatchToEditTreasure

Cuando se llama al constructor de contexto

¡Perfecto! Vemos el volcado: en realidad tres veces, lo cual es interesante. Abre esa prueba para que podamos ver qué está pasando. ¡Sí! Estamos haciendo una única petición dePATCH a /api/treasure/1. Entonces, ¿se llama al constructor de contextos 3 veces durante una sola petición?

¡Pues sí! Se llama una vez cuando la API Platform está consultando y cargando elDragonTreasure desde la base de datos. Es... una situación un poco extraña, porque se supone que el contexto se utiliza para el serializador... pero nosotros simplemente estamos consultando el objeto. Pero en fin, ésa es la primera vez.

Las dos siguientes tienen sentido: se llama cuando el JSON que estamos enviando se desnormaliza en el objeto... y una tercera vez cuando el DragonTreasurefinal se normaliza de nuevo en JSON.

De todos modos, vamos a añadir los grupos dinámicos. Para determinar si el usuario es un administrador, añade un segundo argumento constructor - private Security de SecurityBundlellamado $security:

... lines 1 - 5
use Symfony\Bundle\SecurityBundle\Security;
... lines 7 - 9
#[AsDecorator('api_platform.serializer.context_builder')]
class AdminGroupsContextBuilder implements SerializerContextBuilderInterface
{
public function __construct(private SerializerContextBuilderInterface $decorated, private Security $security)
{
}
... lines 16 - 26
}

Luego aquí abajo, si isset($context['groups']) y$this->security->isGranted('ROLE_ADMIN'), entonces añadiremos los grupos:$context['groups'][] =. Si estamos normalizando, añade admin:read si no, añade admin:write:

... lines 1 - 10
class AdminGroupsContextBuilder implements SerializerContextBuilderInterface
{
... lines 13 - 16
public function createFromRequest(Request $request, bool $normalization, array $extractedAttributes = null): array
{
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
if (isset($context['groups']) && $this->security->isGranted('ROLE_ADMIN')) {
$context['groups'][] = $normalization ? 'admin:read' : 'admin:write';
}
... lines 24 - 25
}
}

Ahora te preguntarás por qué comprobamos si isset($context['groups']). Bueno, no es aplicable a nuestra aplicación, pero imagina que serializáramos un objeto que no tuviera ningún groups -como si nunca hubiéramos establecido el normalizationContexten ese ApiResource. En ese caso, ¡añadir estos groups haría que devolviera menos campos! Recuerda que si no hay grupos de serialización, el serializador devuelve todos los campos accesibles. Pero en cuanto añades un solo grupo, sólo serializa las cosas de ese grupo. Así que si no hay ningún groups, no hagas nada y deja que todo se serialice o deserialice normalmente.

¡De acuerdo! ¡Probemos ahora la prueba!

symfony php bin/phpunit --filter=testAdminCanPatchToEditTreasure

¡Pasa! El campo isPublished se devuelve si somos un usuario administrador. Pero... ve a actualizar los documentos... y abre la ruta GET one treasure endpoint. Ahora no vemos isPublished anunciado como campo en nuestros documentos... aunque se devuelva si somos un usuario administrador. Eso puede ser bueno o malo. Es posible hacer que los documentos se carguen dinámicamente en función de quién haya iniciado sesión, pero no es algo que vayamos a abordar en este tutorial. Ya hablamos de ello en nuestro tutorial API Platform 2... pero el sistema de configuración ha cambiado.

Analicemos el siguiente método, que comprueba que un propietario puede ver el campoisPublished. Esto falla actualmente... y es aún más complicado que la situación del administrador, porque tenemos que incluir o no el campo isPublished objeto por objeto.

Leave a comment!

2
Login or Register to join the conversation
Jay Avatar
Jay Avatar Jay | posted hace 2 meses | edited

Does any one know where I can read about how to make API Platform docs load dynamically based on who is logged in? @weaverryan said at the end of the video, "something we're going to tackle in this tutorial"

Reply

Hey @Jay!

I think I can help here :). At the end of the video, I said that this is NOT something we're going to tackle in this tutorial actually, but I am pretty sure I have the answer for you.

In version 2 of this tutorial, we DID tackle this: it's this topic - https://symfonycasts.com/screencast/api-platform2-security/resource-metadata-factory

And, I've had several people ask me how to do this in API Platform 3. Fortunately, it works "basically the same" - you just need to change some class names / namespaces. And, double-fortunately, someone has shared their solution! You can check it out here - https://symfonycasts.com/screencast/api-platform/serialization-groups#comment-29548

This may, at first, not look exactly like what you are referring to. But by adding your dynamic groups here (e.g. if I'm an admin, I get an extra admin:read group), they should show up in the docs. I think this won't work for all cases. If you are trying to add some sort of owner:read group that depends on the object (e.g. you are the owner of some objects but not others), that can't show up in the docs, really. The docs can only say that a field does or doesn't exist... it doesn't have the ability to communicate that, when you try to fetch a Product that you may or may not have a certain field... depending on the field.

Let me know if that helps!

Cheers!

Reply
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