Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Grupos de serialización

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

Si la única forma de controlar la entrada y la salida de nuestra API fuera controlar los getters y setters de nuestra entidad, no sería tan flexible... y podría ser un poco peligroso. ¡Podrías añadir un nuevo método getter o setter para algo interno y no darte cuenta de que estabas exponiendo nuevos datos en tu API!

La solución para esto -y la forma en que recomiendo hacer las cosas en todos los casos- es utilizar grupos de serialización.

Añadir un grupo de normalización

En la anotación, añade normalizationContext. Recuerda que la normalización se produce cuando pasas de tu objeto a un array. Así que esta opción está relacionada con el momento en que estás leyendo datos de tu API. El contexto es básicamente "opciones" que pasas a ese proceso. La opción más común, con diferencia, se llama "groups", que se establece en otro array. Añade una cadena aquí: cheese_listing:read.

... lines 1 - 8
/**
* @ApiResource(
... lines 11 - 16
* normalizationContext={"groups"={"cheese_listing:read"}}
* )
... line 19
*/
class CheeseListing
... lines 22 - 125

Gracias a esto, cuando se serialice un objeto, el serializador sólo incluirá los campos que estén en este grupo cheese_listing:read, porque, en un segundo, vamos a empezar a añadir grupos a cada propiedad.

Pero ahora mismo, no hemos añadido ningún grupo a nada. Y así, si vas e intentas tu operación de colección get... ¡oh! ¡Ah! ¡Un gran error!

Depuración de errores

Vamos a... hacer como si lo hubiera hecho a propósito y ver cómo depurarlo! El problema es que el gigantesco error HTML es... un poco difícil de leer. Una forma de ver el error es utilizar nuestro truco de antes: ir a https://localhost:8000/_profiler/.

¡Woh! Vale, hay dos tipos de errores: los errores de ejecución, en los que algo ha ido mal específicamente en esa petición, y los errores de compilación, en los que alguna configuración no válida está matando todas las páginas. La mayoría de las veces, si ves una excepción, todavía hay un perfilador que puedes encontrar para esa petición utilizando el truco de ir a esta URL, encontrar esa petición en la lista - normalmente justo en la parte superior - y hacer clic en el sha en su perfilador. Una vez allí, puedes hacer clic en la pestaña "Excepción" de la izquierda para ver la gran y hermosa excepción normal.

Si tienes un error de compilación que mata todas las páginas, es aún más fácil: lo verás cuando intentes acceder a cualquier cosa.

De todos modos, el problema aquí es con mi sintaxis de anotación. Lo hago a menudo, lo cual no es un gran problema siempre que sepas cómo depurar el error. Y, ¡sí! He olvidado una coma al final.

Añadir grupos a los campos

¡Actualiza de nuevo! El perfilador funciona, así que ahora podemos volver a darle a ejecutar. Compruébalo: tenemos @id y @type de JSON-LD... ¡pero no contiene ningún campo real porque ninguno está en el nuevo grupo cheese_listing:read!

Copia el nombre del grupo cheese_listing:read. Para añadir campos a éste, por encima del título, utiliza @Groups(), {""} y pégalo. Pongamos también eso por encima de description... y price.

... lines 1 - 7
use Symfony\Component\Serializer\Annotation\Groups;
... lines 9 - 21
class CheeseListing
{
... lines 24 - 30
/**
... line 32
* @Groups({"cheese_listing:read"})
*/
private $title;
... line 36
/**
... line 38
* @Groups({"cheese_listing:read"})
*/
private $description;
... line 42
/**
... lines 44 - 46
* @Groups({"cheese_listing:read"})
*/
private $price;
... lines 50 - 127
}

Dale la vuelta y vuelve a intentarlo. ¡Muy bien! Obtenemos esos tres campos exactos. Me encanta este control.

Por cierto, el nombre cheese_listing:read... Me lo acabo de inventar - puedes usar cualquier cosa. Pero, voy a seguir una convención de nomenclatura de grupos que recomiendo. Te dará flexibilidad, pero mantendrá las cosas organizadas.

Añadir grupos de desnormalización

Ahora podemos hacer lo mismo con los datos de entrada. Copia normalizationContext, pégalo, y añade de delante para hacer denormalizationContext. Esta vez, utiliza el grupo cheese_listing:write

... lines 1 - 9
/**
* @ApiResource(
... lines 12 - 18
* denormalizationContext={"groups"={"cheese_listing:write"}}
* )
... line 21
*/
... lines 23 - 130

Copia esto y... veamos... sólo añade esto a title y price por ahora. En realidad no queremos añadirlo a description. En su lugar, hablaremos de cómo añadir este grupo al falso textDescription en un minuto.

... lines 1 - 22
class CheeseListing
{
... lines 25 - 31
/**
... line 33
* @Groups({"cheese_listing:read", "cheese_listing:write"})
*/
private $title;
... lines 37 - 43
/**
... lines 45 - 47
* @Groups({"cheese_listing:read", "cheese_listing:write"})
*/
private $price;
... lines 51 - 128
}

Muévete y actualiza de nuevo. ¡Abre la ruta POST.... y ahora los únicos campos que podemos pasar son title y price!

Así que normalizationContext y denormalizationContext son dos configuraciones totalmente separadas para las dos direcciones: lectura de nuestros datos - normalización - y escritura de nuestros datos - desnormalización.

Los modelos de lectura y escritura de la API abierta

En la parte inferior de los documentos, también te darás cuenta de que ahora tenemos dos modelos: el modelo de lectura - que es el contexto de normalización con title, description yprice, y el modelo de escritura con title y price.

Y, no es realmente importante, pero puedes controlar estos nombres si quieres. Añade otra opción: swagger_definition_name ajustada a "Lectura". Y a continuación lo mismo... ajustado a Escritura.

... lines 1 - 9
/**
* @ApiResource(
... lines 12 - 17
* normalizationContext={"groups"={"cheese_listing:read"}, "swagger_definition_name"="Read"},
* denormalizationContext={"groups"={"cheese_listing:write"}, "swagger_definition_name"="Write"}
* )
... line 21
*/
... lines 23 - 130

Normalmente no me importa esto, pero si quieres controlarlo, puedes hacerlo.

Añadir grupos a los campos falsos

Pero, ¡nos faltan algunos campos! Cuando leemos los datos, obtenemos title,description y price. ¿Pero qué pasa con nuestro campo createdAt o nuestro campo personalizado createdAtAgo?

Imaginemos que sólo queremos exponer createdAtAgo. ¡No hay problema! Sólo tienes que añadir la anotación @Groups a esa propiedad... oh, espera... no hay ninguna propiedad createdAtAgo. Ah, es igual de fácil: busca el getter y pon la anotación allí:@Groups({"cheese_listing:read"}). Y ya que estamos aquí, añadiré algo de documentación a ese método:

Hace cuánto tiempo en texto que se añadió este listado de quesos.

... lines 1 - 22
class CheeseListing
{
... lines 25 - 112
/**
* How long ago in text that this cheese listing was added.
*
* @Groups("cheese_listing:read")
*/
public function getCreatedAtAgo(): string
... lines 119 - 133
}

¡Vamos a probarlo! Actualiza la documentación. Abajo, en la sección de modelos... ¡qué bien! Ahí está nuestro nuevo campo createdAtAgo de sólo lectura. Y la documentación que hemos añadido aparece aquí. ¡Muy bien! No es de extrañar que cuando lo probamos... el campo aparezca.

Para la desnormalización -para el envío de datos- tenemos que volver a añadir nuestro campo falso textDescription. Busca el método setTextDescription(). Para evitar que los clientes de la API nos envíen directamente el campo description, eliminamos el método setDescription(). Por encima de setTextDescription(), añadimos @Groups({"cheese_listing:write"}). Y de nuevo, vamos a darle a esto algunos documentos adicionales.

... lines 1 - 88
/**
* The description of the cheese as raw text.
*
* @Groups("cheese_listing:write")
*/
public function setTextDescription(string $description): self
... lines 95 - 140

Esta vez, cuando refresquemos los documentos, podrás verlo en el modelo de escritura y, por supuesto, en los datos que podemos enviar a la operación POST.

Ten los Getters y Setters que quieras

Y... ¡esto nos lleva a una gran noticia! Si decidimos que algo interno de nuestra aplicación necesita establecer la propiedad de descripción directamente, ahora es perfectamente posible volver a añadir el método original setDescription(). Eso no formará parte de nuestra API.

... lines 1 - 88
public function setDescription(string $description): self
{
$this->description = $description;
return $this;
}
... lines 95 - 147

Valor predeterminado de isPublished

Vamos a probar todo esto. Actualiza la página de documentos. Creemos un nuevo listado: Delicioso chèvre -disculpa mi francés- por 25 dólares y una descripción con algunos saltos de línea. ¡Ejecuta!

¡Woh! ¡Un error 500! Podría ir a mirar esta excepción en el perfilador, pero ésta es bastante fácil de leer: una excepción en nuestra consulta: is_published no puede ser nulo. Oh, eso tiene sentido: el usuario no está enviando is_published... así que nadie lo está estableciendo. Y está establecido como no nulo en la base de datos. No te preocupes: pon la propiedad por defecto en false.

... lines 1 - 22
class CheeseListing
{
... lines 25 - 59
private $isPublished = false;
... lines 61 - 145
}

Tip

En realidad, la autovalidación no estaba activada por defecto en Symfony 4.3, pero puede que lo esté en Symfony 4.4.

Por cierto, si estás usando Symfony 4.3, en lugar de un error de Doctrine, puede que hayas obtenido un error de validación. Esto se debe a una nueva función en la que las restricciones de la base de datos Doctrine pueden utilizarse automáticamente para añadir validación. Así que, si ves un error de validación, ¡genial!

De todos modos, intenta ejecutarlo de nuevo. ¡Funciona! Tenemos exactamente los campos de entrada y salida que queremos. El campo isPublished no está expuesto en absoluto en nuestra API, pero se está configurando en segundo plano.

A continuación, vamos a aprender algunos trucos más de serialización, como el control del nombre del campo y el manejo de los argumentos del constructor.

Leave a comment!

Este tutorial funciona muy bien para Symfony 5 y la Plataforma API 2.5/2.6.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.4.3
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.10.2
        "doctrine/doctrine-bundle": "^1.6", // 1.11.2
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "doctrine/orm": "^2.4.5", // v2.7.2
        "nelmio/cors-bundle": "^1.5", // 1.5.5
        "nesbot/carbon": "^2.17", // 2.19.2
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
        "symfony/asset": "4.2.*|4.3.*|4.4.*", // v4.3.11
        "symfony/console": "4.2.*", // v4.2.12
        "symfony/dotenv": "4.2.*", // v4.2.12
        "symfony/expression-language": "4.2.*|4.3.*|4.4.*", // v4.3.11
        "symfony/flex": "^1.1", // v1.17.6
        "symfony/framework-bundle": "4.2.*", // v4.2.12
        "symfony/security-bundle": "4.2.*|4.3.*", // v4.3.3
        "symfony/twig-bundle": "4.2.*|4.3.*", // v4.2.12
        "symfony/validator": "4.2.*|4.3.*", // v4.3.11
        "symfony/yaml": "4.2.*" // v4.2.12
    },
    "require-dev": {
        "symfony/maker-bundle": "^1.11", // v1.11.6
        "symfony/stopwatch": "4.2.*|4.3.*", // v4.2.9
        "symfony/web-profiler-bundle": "4.2.*|4.3.*" // v4.2.9
    }
}