Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Grupos de serialización: Elección de campos

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 mismo, que un campo de nuestra clase sea legible o escribible en la API está totalmente determinado por si esa propiedad es legible o escribible en nuestra clase (básicamente, si tiene o no un método getter o setter). Pero, ¿y si necesitas un getter o setter... pero no quieres que ese campo esté expuesto en la API? Para eso, tenemos dos opciones.

¿Una clase DTO?

Opción número uno: crear una clase DTO para el recurso API. Esto lo dejaremos para otro día... en un futuro tutorial. Pero, en pocas palabras, consiste en crear una clase dedicada para tu API DragonTreasure... y luego trasladar a ella el atributoApiResource. La clave es que diseñes la nueva clase para que se parezca exactamente a tu API... porque modelar tu API será su único trabajo. Lleva un poco más de trabajo configurar las cosas, pero la ventaja es que entonces tendrás una clase dedicada a tu API. ¡Listo!

Hola Grupos de Serialización

La segunda solución, y la que vamos a utilizar, son los grupos de serialización. Compruébalo. Sobre el atributo ApiResource, añade una nueva opción llamadanormalizationContext. Si recuerdas, la "normalización" es el proceso de pasar de un objeto a una matriz, como cuando haces una petición a GET para leer un tesoro. El normalizationContext son básicamente opciones que se pasan al serializador durante ese proceso. Y la opción más importante es groups. Establécela en un grupo llamado treasure:read:

... lines 1 - 16
#[ApiResource(
... lines 18 - 26
normalizationContext: [
'groups' => ['treasure:read'],
]
)]
class DragonTreasure
{
... lines 33 - 140
}

Hablaremos de lo que hace esto en un minuto. Pero puedes ver el patrón que estoy utilizando para el grupo: el nombre de la clase (podría ser dragon_treasure si quisiéramos) y luego :read... porque la normalización significa que estamos leyendo esta clase. Puedes nombrar estos grupos como quieras: éste es mi patrón.

Entonces... ¿qué hace eso? ¡Vamos a averiguarlo! Actualiza la documentación... y, para hacerte la vida más fácil, ve a la URL: /api/dragon_treasures.jsonld. ¡Uy! Ahora sólo estátreasures.jsonld. Ya está. Y... ¡no se devuelve absolutamente nada! Vale, tenemos los campos de la hidra, pero este hydra:member contiene la matriz de tesoros. Devuelve un tesoro... pero aparte de @id y @type... ¡no hay campos reales!

Cómo funcionan los grupos de serialización

Esto es lo que ocurre. En cuanto añadamos un normalizationContext con un grupo, cuando se normalice nuestro objeto, el serializador sólo incluirá las propiedades que tengan ese grupo. Y como no hemos añadido ningún grupo a nuestras propiedades, no devuelve nada.

¿Cómo añadimos grupos? ¡Con otro atributo! Encima de la propiedad $name, digamos#[Groups], pulsa "tab" para añadir su declaración use y luego treasure:read. Repite esto encima del campo $description... porque queremos que sea legible... y luego el campo $value... y finalmente $coolFactor:

... lines 1 - 14
use Symfony\Component\Serializer\Annotation\Groups;
... lines 16 - 31
class DragonTreasure
{
... lines 34 - 39
#[Groups(['treasure:read'])]
private ?string $name = null;
... lines 42 - 43
#[Groups(['treasure:read'])]
private ?string $description = null;
... lines 46 - 50
#[Groups(['treasure:read'])]
private ?int $value = null;
... lines 53 - 54
#[Groups(['treasure:read'])]
private ?int $coolFactor = null;
... lines 57 - 145
}

Buen comienzo. Muévete y actualiza la ruta. Ahora... ¡ya está! Vemos name,description, value, y coolFactor.

DenormlizaciónContexto: Control de los grupos escribibles

Ahora tenemos control sobre qué campos son legibles... y podemos hacer lo mismo para elegir qué campos deben ser escribibles en la API. Eso se llama "desnormalización", y apuesto a que adivinas lo que vamos a hacer. CopianormalizationContext, pégalo, cámbialo por denormalizationContext... y utilizatreasure:write:

... lines 1 - 17
#[ApiResource(
... lines 19 - 30
denormalizationContext: [
'groups' => ['treasure:write'],
]
)]
class DragonTreasure
{
... lines 37 - 148
}

Ahora dirígete a la propiedad $name y añade treasure:write. Voy a saltarme$description (recuerda que antes borramos nuestro método setDescription()a propósito)... pero añade esto a $value... y $coolFactor:

... lines 1 - 34
class DragonTreasure
{
... lines 37 - 42
#[Groups(['treasure:read', 'treasure:write'])]
private ?string $name = null;
... lines 45 - 53
#[Groups(['treasure:read', 'treasure:write'])]
private ?int $value = null;
... lines 56 - 57
#[Groups(['treasure:read', 'treasure:write'])]
private ?int $coolFactor = null;
... lines 60 - 148
}

Oh, ¡está enfadado conmigo! En cuanto pasemos varios grupos, tenemos que convertirlo en un array. Añade algo de [] alrededor de esas tres propiedades. Mucho más contento.

Para comprobar si esto es A-OK, refresca la documentación... abre la ruta PUT y... ¡genial! Vemos name, value, y coolFactor, que son actualmente los únicos campos que se pueden escribir en nuestra API.

Añadir grupos a los métodos

Sin embargo, nos faltan algunas cosas. Antes hicimos un método getPlunderedAtAgo()...

... lines 1 - 34
class DragonTreasure
{
... lines 37 - 132
public function getPlunderedAtAgo(): string
{
return Carbon::instance($this->plunderedAt)->diffForHumans();
}
... lines 137 - 148
}

y queremos que se incluya cuando leamos nuestro recurso. Ahora mismo, si comprobamos la ruta, no está ahí.

Para solucionarlo, también podemos añadir grupos a los métodos anteriores. Digamos#[Groups(['treasure:read'])]:

... lines 1 - 34
class DragonTreasure
{
... lines 37 - 132
#[Groups(['treasure:read'])]
public function getPlunderedAtAgo(): string
{
return Carbon::instance($this->plunderedAt)->diffForHumans();
}
... lines 138 - 149
}

Y cuando vayamos a comprobarlo... voilà, aparece.

Busquemos también el método setTextDescription()... y hagamos lo mismo:#[Groups([treasure:write])]:

... lines 1 - 34
class DragonTreasure
{
... lines 37 - 93
#[Groups(['treasure:write'])]
public function setTextDescription(string $description): self
{
... lines 97 - 99
}
... lines 101 - 150
}

¡Genial! Si volvemos a la documentación, el campo no está actualmente allí... pero cuando actualizamos... y volvemos a comprobar la ruta PUT... textDescription ¡ha vuelto!

Volver a añadir métodos

Oye, ¡ahora podemos volver a añadir cualquiera de los métodos getter o setter que eliminamos antes! Por ejemplo, quizá sí necesite un método setDescription() en mi código para algo. CopiasetName() para dar pereza, pega y cambia "nombre" por "descripción" en algunos sitios.

¡Ya está! Y aunque hemos recuperado ese definidor, cuando miramos la ruta PUT, description no aparece. Tenemos un control total sobre nuestros campos gracias a los grupos de desnormalización. Haz lo mismo con setPlunderedAt()... porque a veces es útil -especialmente en las fijaciones de datos- poder establecer esto manualmente.

Y... ¡listo!

Añadir valores de campo predeterminados

Ya sabemos que obtener un recurso funciona. Ahora vamos a ver si podemos crear un nuevo recurso. Haz clic en la ruta POST, pulsa "Probar", y... rellenemos algunos datos sobre nuestro nuevo tesoro, que es, por supuesto, un Giant jar of pickles. Es muy valioso y tiene un coolFactor de 10. También añadiré una descripción... aunque este tarro de pepinillos habla por sí solo.

Cuando intentamos esto... vaya... obtenemos un error 500:

Se ha producido una excepción al ejecutar una consulta: Violación no nula, null valor en la columna isPublished.

Hemos reducido nuestra API a sólo los campos que queremos que se puedan escribir... pero aún hay una propiedad que debe establecerse en la base de datos. Desplázate hacia arriba y buscaisPublished. Sí, actualmente está por defecto en null. Cámbialo a = false... y ahora la propiedad nunca será null.

Si lo probamos... ¡el Giant jar of pickles se almacena en la base de datos! ¡Funciona!

A continuación: vamos a explorar algunos trucos más de serialización que nos darán aún más control.

Leave a comment!

19
Login or Register to join the conversation
Roberto Avatar
Roberto Avatar Roberto | posted hace 1 mes

if it helps anybody, the moment you modify the metadata and add attributes, etc, you need to clear the cache unless you run the profiler debug toolbar which does that for you. I was stuck wondering why it didn't worked and that was the reason, then after installing the debugger everything worked fine just by reloading.

1 Reply

Hey Reborto,

Thank you for this tip! Yeah, it's always a good idea to clear the cache first in any weird case, i.e. when things should work but somehow work not like you expect.

Cheers!

Reply
triemli Avatar
triemli Avatar triemli | posted hace 1 mes

Hi guys, what the difference between normalization or denormalization contexts placed in ApiResource scope and explicitly for operations get/post/patch/...?

Reply

Hi @triemli

it will configure serializer behavior for serialize and deserialize operations, you will use groups to select what values you will need to pass from Entity to JSON and vice versa.

Cheers!

1 Reply
Aurelien-A Avatar
Aurelien-A Avatar Aurelien-A | posted hace 3 meses

Hi Ryan,

I watched the API Platform 2 course. I plan to watch this one but for now I did not find the time to do it. I still searched if there was the same course "Automatic Serialization Groups" that was in API Platform 2 course in API Platform 3 course. I could not find it, and it seems that the old version does not work anymore. Do you have any idea about how to do something similar ?

Thank you for your excellent work !

Reply

Hey @Aurelien-A!

It's funny you ask about that - it was maybe the ONE thing I didn't include in the API Platform 3 tutorial and at least 2 people have asked about it :P. Here is the other conversation - https://symfonycasts.com/screencast/api-platform/install#comment-29468 - it looks like the feature should be implemented the same way... it's just that some class names changed.

Let me know if you try it and hit any hiccups.

Cheers!

1 Reply
Aurelien-A Avatar
Aurelien-A Avatar Aurelien-A | weaverryan | posted hace 3 meses | HIGHLIGHTED

Hello Ryan, you were right. With a little reworking of the code in your original course, I believe I've managed to reproduce the same operation. I'm leaving the code in question available here, perhaps it could be useful to someone.

Thanks for your help and congratulations for your work on Symfony :)

1 Reply

Ah, thank you for posing that! ❤️❤️❤️ Nice work!

Reply
Pierre-A Avatar
Pierre-A Avatar Pierre-A | posted hace 4 meses | edited

Just to signal a little fault : DenormlizationContext should be DenormalizationContext in the title "DenormlizationContext: Controlling Writable Groups" ! (i see another but cannot find it after reading the course twice!) But very good job, many thanks !

Reply

Hi support Team,

maybe you can share few examples with DTO ?

Thanks in advance

Reply

Hey @Mepcuk!

That's coming! Not until episode 3, but definitely it's coming. It was better to wait until ep3 than try to smash it in earlier - it deserves some space :).

Cheers!

Reply
Auro Avatar

Hi Ryan,

In this chapter you are talking about the DTO solution, are you going to do a course to explain how it works in Api Platform v3?

I've always used them in v2, to separate the Entities from the Api. But in v3 I encounter a problem I don't know how to solve.

The @id field in DTO looks like "@id": "/.well-known/genid/01e546d3f38c0b5d3b8a", and I don't find anyway to get the IRI instead. Do you know if it's possible?

Thank you in advance

Reply

Hey @Auro!

In this chapter you are talking about the DTO solution, are you going to do a course to explain how it works in Api Platform v3?

Yes! But not until episode 3 so we can give them proper attention.

The @id field in DTO looks like "@id": "/.well-known/genid/01e546d3f38c0b5d3b8a", and I don't find anyway to get the IRI instead. Do you know if it's possible?

Hmm. This is new to me. It's generating something called a "skolem", which I know almost nothing about. And so, I'm giving advice... without really understanding ;). Looking at the code, which is deep and complex, you could try:

A) Setting force_resource_class: true as under normalizationContext of your ApiResource.
B) There is a new, undocumented feature in API Platform 3.1 that allows you to, sort of, "tie" your DTO to your entity a bit closer. Looking at the code, this seems related - but there are a lot of layers to it, and I'm honestly not sure what it does or doesn't do. But I believe, above your ApiResource in your DTO, you would add <br />stateOptions: new DoctrineORMOptions(entityClass: MyClass::class)<br />

Part of the tricky thing here is that DTO can mean at least 2 different things: a class that actually has the ApiResource attribute on it and IS the resource, or you have that attribute on an entity, then use the input or output config.

Cheers!

Reply
Auro Avatar
Auro Avatar Auro | weaverryan | posted hace 5 meses | edited

Hi @weaverryan,

Thank you for your answer.

I've tried the two options, and this is my feedback:

A. This solution works perfectly if you use id as default identifier. Sadly is not my case, I use uuid, and I think there is a bug when using uuid.

in the IriConverter class, method generateSymfonyRoute, it calls the following:

$identifiers = $this->identifiersExtractor->getIdentifiersFromItem($resource, $identifiersExtractorOperation, $context);

and in the IdentifiersExtractor class the first line of the getIdentifiersFromItem method

if (!$this->isResourceClass($this->getObjectClass($item))) { return ['id' => $this->propertyAccessor->getValue($item, 'id')]; }

If I use id instead of uuid o replace this line by uuid in both case it works and generate correctly the @id.

Instead of hardcoding the id, i think that it should dynamically use the identifier property

B. Digging into the code, there is a stateOptions but it looks to only works with elasticsearchOptions for the moment.

I have an ultimate question about DTOs, what would be the equivalent of the DataTransformerInitializerInterface?

I'm trying to initialize/hydrate the DTO when working with PUT/PATCH methods, but i don't find any way to have the object initialized before the validation step.

Reply

Hi @Auro!

Thanks for the update - sorry it's proving so tricky!

Instead of hardcoding the id, i think that it should dynamically use the identifier property

I don't know this code well. But I agree that it seems odd that it is looking specifically for an id property

I have an ultimate question about DTOs, what would be the equivalent of the DataTransformerInitializerInterface?

In short, I don't know yet... because I haven't dug into this - that will be soon for episode 3. They do talk about this briefly in the upgrade doc - https://api-platform.com/docs/core/upgrade-guide/#datatransformers-and-dto-support - you're supposed to use state providers to load the DTO data - https://api-platform.com/docs/core/dto/#implementing-a-write-operation-with-an-input-different-from-the-resource - however, I'm not sure this works yet with relation to validation.

Sorry I can't be more helpful! If you find anything out, I'd love to know :)

Reply
Auro Avatar
Auro Avatar Auro | weaverryan | posted hace 4 meses | edited

Thank you for your help @weaverryan ,

For the first point i've open an issue

https://github.com/api-platform/api-platform/issues/2411

For the second one, I will wait for the episode 3. I'm already using state providers and it works fine for POST methods. The problem is how to bypass validate and hydrate the DTO before the state provider code is executed.

Reply
Auro Avatar
Auro Avatar Auro | Auro | posted hace 4 meses | HIGHLIGHTED

Just to give some feeback, i've found a solution that works, but not really sure if it's a hack or the correct way to go.

https://github.com/api-platform/core/issues/5451

api_platform:
    mapping:
        paths: ['%kernel.project_dir%/config/api_platform', '%kernel.project_dir%/src/Dto']
resources:
    App\DomainBundle\Entity\AdminUser:
        normalizationContext:
            groups: [ 'admin-user:read' ]
        denormalizationContext:
            groups: [ 'admin-user:write' ]
        operations:
            ApiPlatform\Metadata\GetCollection:
                output: App\Dto\Output\AdminUserOutput
                provider: App\State\AdminUserCollectionProvider
                filters:
                    - api_platform.filter.admin_user.order
                    - api_platform.filter.admin_user.search
            ApiPlatform\Metadata\Get:
                output: App\Dto\Output\AdminUserOutput
                provider: App\State\AdminUserItemProvider
            ApiPlatform\Metadata\Post:
                processor: App\State\UserProcessor
                validationContext:
                    groups: [ 'Default', 'password' ]
            ApiPlatform\Metadata\Patch:
                processor: App\State\UserProcessor
            ApiPlatform\Metadata\Put:
                processor: App\State\UserProcessor
            ApiPlatform\Metadata\Delete: ~

properties:
    App\DomainBundle\Entity\AdminUser:
        id:
            identifier: false

        uuid:
            identifier: true

<?php

namespace App\Dto\Output;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\Get;
use App\DomainBundle\Entity\AdminUser;
use Ramsey\Uuid\UuidInterface;
use Symfony\Component\Serializer\Annotation\Groups;

#[Get(shortName: 'AdminUser')]
final class AdminUserOutput
{
    #[ApiProperty(identifier: true)]
    public UuidInterface $uuid;

    #[ApiProperty(identifier: false)]
    #[Groups('admin-user:read')]
    public int $id;

    #[Groups('admin-user:read')]
    public string $email;

    public function __construct(AdminUser $admin)
    {
        $this->id = $admin->getId();
        $this->uuid = $admin->getUuid();
        $this->email = $admin->getEmail();
    }
}
1 Reply

Awesome - thank you for sharing this. I'll have to dig more deeply into it when I look at DTO's. I'm totally unfamiliar with having an operation - like Get() right on the class vs inside of ApiResource 🤔

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.0.8
        "doctrine/annotations": "^1.0", // 1.14.2
        "doctrine/doctrine-bundle": "^2.8", // 2.8.0
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.14", // 2.14.0
        "nelmio/cors-bundle": "^2.2", // 2.2.0
        "nesbot/carbon": "^2.64", // 2.64.1
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.15.3
        "symfony/asset": "6.2.*", // v6.2.0
        "symfony/console": "6.2.*", // v6.2.3
        "symfony/dotenv": "6.2.*", // v6.2.0
        "symfony/expression-language": "6.2.*", // v6.2.2
        "symfony/flex": "^2", // v2.2.4
        "symfony/framework-bundle": "6.2.*", // v6.2.3
        "symfony/property-access": "6.2.*", // v6.2.3
        "symfony/property-info": "6.2.*", // v6.2.3
        "symfony/runtime": "6.2.*", // v6.2.0
        "symfony/security-bundle": "6.2.*", // v6.2.3
        "symfony/serializer": "6.2.*", // v6.2.3
        "symfony/twig-bundle": "6.2.*", // v6.2.3
        "symfony/ux-react": "^2.6", // v2.6.1
        "symfony/validator": "6.2.*", // v6.2.3
        "symfony/webpack-encore-bundle": "^1.16", // v1.16.0
        "symfony/yaml": "6.2.*" // v6.2.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "symfony/debug-bundle": "6.2.*", // v6.2.1
        "symfony/maker-bundle": "^1.48", // v1.48.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/stopwatch": "6.2.*", // v6.2.0
        "symfony/web-profiler-bundle": "6.2.*", // v6.2.4
        "zenstruck/foundry": "^1.26" // v1.26.0
    }
}
userVoice