El serializador
La clave detrás de cómo API Platform convierte nuestros objetos en JSON... y también de cómo transforma JSON de nuevo en objetos es el Serializador de Symfony. symfony/serializer
es un componente independiente que puedes utilizar fuera de API Platform y es increíble. Le das cualquier entrada -como un objeto u otra cosa- y lo transforma en cualquier formato, como JSON
, XML
o CSV
.
El funcionamiento interno del serializador
Como puedes ver en este elegante diagrama, sigue dos pasos. Primero, toma tus datos y los normaliza en una matriz. En segundo lugar, los codifica en el formato final. También puede hacer lo mismo a la inversa. Si partimos de JSON, como si enviáramos JSON a nuestra API, primero lo descodifica en una matriz y luego lo desnormaliza de nuevo en un objeto.
Para que todo esto ocurra, internamente hay muchos objetos normalizadores distintos que saben cómo trabajar con datos diferentes. Por ejemplo, hay unDateTimeNormalizer
que es realmente bueno manejando objetos DateTime
. Compruébalo: nuestra entidad tiene un campo createdAt
, que es un objeto DateTime
:
// ... lines 1 - 26 | |
class DragonTreasure | |
{ | |
// ... lines 29 - 48 | |
#[ORM\Column] | |
private ?\DateTimeImmutable $plunderedAt = null; | |
// ... lines 51 - 130 | |
} |
Si te fijas en nuestra API, cuando probamos la ruta GET
, ésta se devuelve como una cadena especial de fecha y hora. El DateTimeNormalizer
es el responsable de hacerlo.
Averiguar qué campos serializar
También hay otro normalizador muy importante llamado ObjectNormalizer
. Su trabajo consiste en leer las propiedades de un objeto para poder normalizarlas. Para ello, utiliza otro componente llamado property-access
. Ese componente es inteligente.
Por ejemplo, si miramos nuestra API, cuando hacemos una petición GET a la ruta de recogida, uno de los campos que devuelve es name
. Pero si nos fijamos en la clase,name
es una propiedad privada:
// ... lines 1 - 26 | |
class DragonTreasure | |
{ | |
// ... lines 29 - 33 | |
#[ORM\Column(length: 255)] | |
private ?string $name = null; | |
// ... lines 36 - 130 | |
} |
Entonces, ¿cómo demonios se lee eso?
Ahí es donde entra en juego el componente PropertyAccess
. Primero mira si la propiedadname
es pública. Y si no lo es, busca un método getName()
:
// ... lines 1 - 26 | |
class DragonTreasure | |
{ | |
// ... lines 29 - 33 | |
#[ORM\Column(length: 255)] | |
private ?string $name = null; | |
// ... lines 36 - 59 | |
public function getName(): ?string | |
{ | |
return $this->name; | |
} | |
// ... lines 64 - 130 | |
} |
Así que eso es lo que se llama realmente cuando se construye el JSON.
Lo mismo ocurre cuando enviamos JSON, por ejemplo para crear o actualizar un DragonTreasure
. PropertyAccess examina cada campo del JSON y, si ese campo se puede establecer, por ejemplo mediante un método setName()
, lo establece:
// ... lines 1 - 26 | |
class DragonTreasure | |
{ | |
// ... lines 29 - 33 | |
#[ORM\Column(length: 255)] | |
private ?string $name = null; | |
// ... lines 36 - 59 | |
public function getName(): ?string | |
{ | |
return $this->name; | |
} | |
public function setName(string $name): self | |
{ | |
$this->name = $name; | |
return $this; | |
} | |
// ... lines 71 - 130 | |
} |
Y lo que es aún mejor: ¡incluso buscará métodos getter o setter que no se correspondan con ninguna propiedad real! Puedes utilizar esto para crear campos extra" en tu API que no existen como propiedades en tu clase.
Añadir un campo virtual "textDescription
¡Vamos a probarlo! Imagina que, cuando estamos creando o editando un tesoro, en lugar de enviar un campo description
, queremos poder enviar un campo textDescription
que contenga texto sin formato... pero con saltos de línea. Luego, en nuestro código, transformaremos esos saltos de línea en etiquetas HTML <br>
.
Te mostraré lo que quiero decir. Copia el método setDescription()
. Luego, debajo, pega y llama a este nuevo método setTextDescription()
. Básicamente va a establecer la propiedad description
... pero antes llama a nl2br()
. Esa función transforma literalmente las nuevas líneas en etiquetas <br>
. Si llevas por aquí tanto tiempo como yo, recordarás cuando nl2br
era superguay:
// ... lines 1 - 26 | |
class DragonTreasure | |
{ | |
// ... lines 29 - 83 | |
public function setTextDescription(string $description): self | |
{ | |
$this->description = nl2br($description); | |
return $this; | |
} | |
// ... lines 90 - 137 | |
} |
De todos modos, sólo con ese cambio, actualiza la documentación y abre las rutas POST o PUT. Y.. ¡Tenemos un nuevo campo llamado textDescription
! ¡Sí! El serializador ha visto el método setTextDescription()
y ha determinado que textDescription
es una propiedad virtual "definible"
Sin embargo, no lo vemos en la ruta GET. ¡Y eso es perfecto! No existe el método getTextDescription()
, por lo que aquí no habrá un nuevo campo. El nuevo campo es escribible, pero no legible.
Vamos a probar esta ruta Primero... Tengo que ejecutar la ruta de recolección GET para ver qué identificadores tenemos en la base de datos. Perfecto: tengo un Tesoro con ID 1. Cierra esto. Vamos a probar la ruta PUT para hacer nuestra primera actualización. Cuando utilizas la ruta PUT, no necesitas enviar todos los campos: sólo los que quieras cambiar.
Pasa textDescription
... e incluiré \n
para representar algunas líneas nuevas en JSON.
Cuando lo probemos, ¡sí! código de estado 200. Y fíjate: ¡el campo description
tiene esas etiquetas <br>
!
Eliminar campos
Vale, ahora que tenemos setTextDescription()
... quizá sea la única forma en que queremos permitir que se establezca ese campo. Para imponerlo, erradica el método setDescription()
.
Ahora, cuando actualizamos... y miramos la ruta PUT, ¡todavía tenemos textDescription
, pero el campo description
ha desaparecido! El serializador se ha dado cuenta de que ya no es configurable y lo ha eliminado de nuestra API. Seguiría siendo devuelto porque es algo que podemos leer, pero ya no es escribible.
Todo esto es realmente increíble. Simplemente nos preocupamos de escribir nuestra clase como queremos y luego API Platform construye nuestra API en consecuencia.
Hacer que el campo plunderedAt sea de sólo lectura
Vale, ¿qué más? Bueno, es un poco raro que podamos establecer el campo createdAt
: normalmente se establece interna y automáticamente. Vamos a arreglarlo.
Pero, ¿sabes qué? Quería llamar a este campo plunderedAt
. Refactorizaré y cambiaré el nombre de esa propiedad... y dejaré que PhpStorm cambie también el nombre de mis métodos getter y setter.
¡Genial! Esto también hará que cambie la columna de mi base de datos... así que gira a tu consola y ejecuta:
symfony console make:migration
Viviré peligrosamente y lo ejecutaré inmediatamente:
symfony console doctrine:migrations:migrate
¡Listo! Gracias a ese cambio de nombre... en la API, excelente: el campo es ahoraplunderedAt
.
Vale, olvídate de la API por un momento: vamos a hacer un poco de limpieza. La finalidad de este campo plunderedAt
es que se establezca automáticamente cada vez que creemos un nuevo DragonTreasure
.
Para ello, crea un public function __construct()
y, dentro, ponthis->plunderedAt = new DateTimeImmutable()
. Y ahora no necesitamos el = null
en la propiedad.
// ... lines 1 - 26 | |
class DragonTreasure | |
{ | |
// ... lines 29 - 48 | |
#[ORM\Column] | |
private \DateTimeImmutable $plunderedAt; | |
// ... lines 51 - 54 | |
public function __construct() | |
{ | |
$this->plunderedAt = new \DateTimeImmutable(); | |
} | |
// ... lines 59 - 128 | |
} |
Y si buscamos setPlunderedAt
, en realidad ya no necesitamos ese método, ¡elimínalo!
Esto significa ahora que la propiedad plunderedAt
es legible pero no escribible. Así que, no te sorprendas, cuando actualizamos y abrimos la ruta PUT
o POST
, plunderedAt
está ausente. Pero si miramos el aspecto que tendría el modelo si obtuviéramos un tesoro, plunderedAt
sigue ahí.
Añadir un campo "Fecha Hace" falso
Muy bien, ¡un objetivo más! Vamos a añadir un campo virtual llamado plunderedAtAgo
que devuelva una versión legible por humanos de la fecha, como "hace dos meses". Para ello, tenemos que instalar un nuevo paquete:
composer require nesbot/carbon
Una vez que termine... busca el método getPlunderedAt()
, cópialo, pégalo debajo, devolverá un string
y llámalo getPlunderedAtAgo()
. Dentro, devuelveCarbon::instance($this->getPlunderedAt()))
y luego ->diffForHumans()
.
// ... lines 1 - 11 | |
use Carbon\Carbon; | |
// ... lines 13 - 27 | |
class DragonTreasure | |
{ | |
// ... lines 30 - 118 | |
/** | |
* A human-readable representation of when this treasure was plundered. | |
*/ | |
public function getPlunderedAtAgo(): string | |
{ | |
return Carbon::instance($this->plunderedAt)->diffForHumans(); | |
} | |
// ... lines 126 - 137 | |
} |
Así que, como ahora entendemos, no hay ninguna propiedad plunderedAtAgo
... pero elserializer
debería ver esto como legible a través de su getter y exponerlo. Ah, y ya que estoy aquí, añadiré un poco de documentación arriba para describir el significado del campo.
Bien, probemos esto. En cuanto actualizamos y abrimos una ruta GET
, ¡vemos el nuevo campo en el ejemplo! También podemos ver los campos que recibiremos abajo, en la sección Esquemas. Volvamos atrás, probemos la ruta GET
con el ID one
. Y... ¿a que mola?
A continuación: ¿qué pasa si queremos tener ciertos métodos getter o setter en nuestra clase, como setDescription()
, pero no queremos que formen parte de nuestra API? La respuesta: grupos de serialización.
Since I am upgrading an app from 2.6 to 3 right now, I was wondering about a behavior of the serializer that I ovserved.
In API-Platform >= 3, the default setting in the api_platform.yaml is:
So far, so good. Properties, which are null, are no longer part of the response.
But in that specific app I work a lot with Doctrine Embeddables because I have giant forms, and it is a great way to put fields into logical blocks.
But when all properties of an embeddable are null, the endpoint returns an empty array.
I expected the embeddable not to be returned at all, or to be an empty object, but an empty array is returned.
Is that really the desired behavior?
I am right now wondering how to deal with that, since I have dozens of embeddable in use in that app.
I could modify all the getters and manually loop though all the properties and return null, if they are all null, or create some kind of custom normalizer.
Furthermore, I have two questions:
Is that really the wanted behavior?
What is the best workaround?