If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeCuando leemos un recurso CheeseListing
, obtenemos un campo description
. Pero cuando enviamos datos, se llama textDescription
. Y... eso está técnicamente bien: nuestros campos de entrada no tienen por qué coincidir con los de salida. Pero... si pudiéramos hacer que fuesen iguales, eso facilitaría la vida a cualquiera que utilice nuestra API.
Es bastante fácil adivinar cómo se crean estas propiedades: las claves dentro del JSON coinciden literalmente con los nombres de las propiedades dentro de nuestra clase. Y en el caso de una propiedad falsa como textDescription
, la Plataforma API elimina la parte "set" y la convierte en minúscula. Por cierto, como todo en la Plataforma API, la forma en que los campos se transforman en claves es algo que puedes controlar a nivel global: se llama "convertidor de nombres".
De todos modos, estaría bien que el campo de entrada se llamara simplemente description
. Tendríamos entrada description
, salida description
. Claro que, internamente, sabríamos que se llama setTextDescription()
en la entrada y getDescription()
en la salida, pero el usuario no tendría que preocuparse ni ocuparse de esto.
Y... ¡sí! Puedes controlar totalmente esto con una anotación súper útil. Por encima desetTextDescription()
, añade @SerializedName()
con description
.
... lines 1 - 23 | |
class CheeseListing | |
... lines 25 - 96 | |
/** | |
... lines 98 - 100 | |
* @SerializedName("description") | |
*/ | |
public function setTextDescription(string $description): self | |
... lines 104 - 147 | |
} |
¡Actualiza la documentación! Si probamos la operación GET... no ha cambiado: sigue siendodescription
. Pero para la operación POST... ¡sí! El campo se llama ahoradescription
, pero el serializador llamará internamente a setTextDescription()
.
Vale, ya sabemos que al serializador le gusta trabajar llamando a métodos getter y setter... o utilizando propiedades públicas o algunas otras cosas como los métodos hasser o isser. ¿Pero qué pasa si quiero dar a mi clase un constructor? Bueno, ahora mismo tenemos un constructor, pero no tiene ningún argumento necesario. Eso significa que el serializador no tiene problemas para instanciar esta clase cuando enviamos un nuevo CheeseListing
.
Pero... ¿sabes qué? Como todo CheeseListing
necesita un título, me gustaría darle a éste un nuevo argumento obligatorio llamado $title
. Definitivamente no necesitas hacer esto, pero para mucha gente tiene sentido: si una clase tiene propiedades requeridas: ¡obliga a pasarlas a través del constructor!
¡Y ahora que tenemos esto, también puedes decidir que no quieres tener un métodosetTitle()
! Desde una perspectiva orientada a objetos, esto hace que la propiedadtitle
sea inmutable: sólo puedes establecerla una vez al crear elCheeseListing
. Es un ejemplo un poco tonto. En el mundo real, probablemente querríamos que el título fuera modificable. Pero, desde una perspectiva orientada a objetos, hay situaciones en las que quieres hacer exactamente esto.
Ah, y no olvides decir $this->title = $title
en el constructor.
... lines 1 - 23 | |
class CheeseListing | |
{ | |
... lines 26 - 62 | |
public function __construct(string $title) | |
{ | |
$this->title = $title; | |
... line 66 | |
} | |
... lines 68 - 141 | |
} |
La pregunta ahora es... ¿podrá el serializador trabajar con esto? ¿Se va a enfadar mucho porque hemos eliminado setTitle()
? Y cuando pongamos un nuevo POST, ¿será capaz de instanciar el CheeseListing
aunque tenga un arg requerido?
¡Vaya! ¡Vamos a probarlo! ¿Qué tal unas migajas de queso azul... por 5$? Ejecuta y... ¡funciona! ¡El título es correcto!
Um... ¿cómo diablos ha funcionado? Como la única forma de establecer el título es a través del constructor, parece que sabía pasar la clave del título allí? ¿Cómo?
La respuesta es... ¡magia! ¡Es una broma! La respuesta es... ¡por pura suerte! No, sigo mintiendo totalmente. La respuesta es por el nombre del argumento.
Comprueba esto: cambia el argumento por $name
, y actualiza el código de abajo. Desde una perspectiva orientada a objetos, eso no debería cambiar nada. Pero vuelve a pulsar ejecutar.
... lines 1 - 62 | |
public function __construct(string $name) | |
{ | |
$this->title = $name; | |
... line 66 | |
} | |
... lines 68 - 143 |
¡Un gran error! Un código de estado 400:
No se puede crear una instancia de
CheeseListing
a partir de datos serializados porque su constructor requiere que el parámetro "nombre" esté presente.
Mis felicitaciones al creador de ese mensaje de error: ¡es impresionante! Cuando el serializador ve un argumento del constructor llamado... $name
busca una clave name
en el JSON que estamos enviando. Si no existe, ¡boom! ¡Error!
Así que mientras llamemos al argumento $title
, todo funciona bien.
Pero hay un caso límite. Imagina que estamos creando un nuevo CheeseListing
y nos olvidamos de enviar el campo title
por completo - como si tuviéramos un error en nuestro código JavaScript. Pulsa Ejecutar.
Nos devuelve un error 400... lo cual es perfecto: significa que la persona que hace la petición tiene algo mal en su petición. Pero, el hydra:title
no es muy claro:
Se ha producido un error
¡Fascinante! El hydra:description
es mucho más descriptivo... en realidad, demasiado descriptivo: muestra algunas cosas internas de nuestra API... que quizá no quiera hacer públicas. Al menos el trace
no aparecerá en producción.
Puede que mostrar estos detalles dentro de hydra:description
te parezca bien... Pero si quieres evitar esto, tienes que recurrir a la validación, que es un tema del que hablaremos en unos minutos. Pero lo que debes saber ahora es que la validación no puede producirse a menos que el serializador sea capaz de crear con éxito el objeto CheeseListing
. En otras palabras, tienes que ayudar al serializador haciendo que este argumento sea opcional.
... lines 1 - 62 | |
public function __construct(string $title = null) | |
{ | |
... lines 65 - 66 | |
} | |
... lines 68 - 143 |
Si lo vuelves a intentar... ¡ja! ¡Un error 500! Sí crea el objeto CheeseListing
con éxito... y luego explota cuando intenta añadir un título nulo en la base de datos. Pero, eso es exactamente lo que queremos, porque permitirá que la validación haga su trabajo... una vez que lo añadamos dentro de unos minutos.
Tip
En realidad, la autovalidación no estaba habilitada por defecto en Symfony 4.3, pero puede que lo esté en Symfony 4.4.
Ah, y si estás usando Symfony 4.3, ¡puede que ya veas un error de validación! Eso se debe a una nueva característica que puede convertir automáticamente tus reglas de base de datos -el hecho de que le hayamos dicho a Doctrine que title
es necesario en la base de datos- en reglas de validación. Como dato curioso, esta función fue aportada a Symfony por Kèvin Dunglas, el desarrollador principal de la Plataforma API. Kèvin, tómate un descanso de vez en cuando
A continuación: vamos a explorar los filtros: un potente sistema para permitir a tus clientes de la API buscar y filtrar a través de nuestros recursos CheeseListing.
Hey Wondrous!
Sorry for the slow reply! We're usually much faster - that's my bad - prepping for a conference next week!
This is an interesting question/problem. First, when using Doctrine (ignore API Platform for a moment), the constructor is only called ONE time: when you originally CREATE the object. Once it's been persisted to Doctrine, on future requests, when you query for that object from the database, the constructor is NOT called. That's by design: Doctrine wants it to feel like your object is created just once... then is kind of "put to sleep" in the database and woken up later.
You may have already known the above stuff :). I mention it because it narrows the scope. The question now is: how can we pass a custom argument to the constructor when our object is originally created - e.g. when a POST request is made to /api/cheeses? That's a question for the serializer - and though I haven't done this before, I think the answer is here: https://symfony.com/doc/current/components/serializer.html#handling-constructor-arguments
You should be able to accomplish this by creating a custom context builder - https://symfonycasts.com/screencast/api-platform-security/service-decoration - and if the "resource class" is currently the target class, you could add that AbstractNormalizer::DEFAULT_CONSTRUCTOR_ARGUMENTS
key to the context, set to the currently-logged-in user.
Let me know if you end up trying this and if it works out - it's an interesting problem!
Cheers!
Hi, I don't understand why we use "SeriallizedName" annotation. Doesn't "Serialize" mean that we are *reading* data (i.e. from Object to Format)? So why we use "Serialzed" and not "Deserialized" in setting property?
Hey @Danilo Di Moia!
That's an excellent question and perspective! It had not occurred to me before!
The truth is that SerializedName would be used for both reading data and/or writing data. In this example, we added the SerializedName above our setTextDescription()
method. So naturally (since this is a setter, so it's only used for writing data), the SerializedName is actually more of a "deserialized name" as you suggested :). If we had put this same SerializedName on a getTextDescription(), then it would only be used for "reading" data.
So it really controls the named that's used for both serialization and deserialization. But if you are adding it to a getter or setter method like we did, then, of course, it really only applies to either read (for the getter) or write (for the setter). However, if you added it to a property (imagine @SerializedName("cheeseDescription")
above the $description
property), then the field would be called cheeseDescription
for both reading and writing).
I hope that explains why it has just this one name (SerializedNamed)... even though that's imperfect in 1/2 of the situations :P.
Cheers!
Just trying to add some API capability to existing Symfony 4.4 project but probably missed something in config. On simple POST operation got an error message:<br />"@context": "/api/contexts/Error",<br /> "@type": "hydra:Error",<br /> "hydra:title": "An error occurred",<br /> "hydra:description": "Format 'jsonld' not supported, handler must be implemented",<br />
And can't find how to implement handler and where :(
Hey @isTom!
Hmm. You *will* need to add some config for API Platform - but it's pretty basic - https://github.com/symfony/... - and it doesn't include any "formats" config... you should get several formats out-of-the-box without needing any config. Are you also using FOSRestBundle by chance? I'm asking because, as far as I can tell, THAT is the library that is throwing this error - https://github.com/FriendsO...
Cheers!
Hello weaverryan !
I described the problem with more details in Stackoverflow question
https://stackoverflow.com/q....
Hey @isTom!
I replied over there. The tl;dr is: uninstall/disable FOSRestBundle to see if it fixes things or at least gives you a different error.
Cheers!
Hi, i have lots of properties i need to make immutable but i also need to use the SerializedName() to alter the camelcase that API Platform generates. When i remove the setter the property disappears completey from the POST even though it's still in the group. Any ideas how i can achieve this?
Thank you
Hey Tom!
Sorry for the slow reply! Hmm, by "immutable" - I think you are referring to "I want them immutable in my app/PHP" versus "I want them immutable in my API", correct? If you (for whatever reason - e.g. design reasons) want to not have setter methods, then you *can* instead have each property as a constructor argument - each argument name needs to match the field name in the POST (that's how the serializer matches each input field with the arguments in your constructor). I don't like doing it this way... because iirc, if the user fails to send a required constructor field, they will get a serialization error - which is still a 400 error, but not as clear as a validation error. Let me know if that helps... or if I've completely answered the wrong question :).
About the SerializedName() part, if you are consistently changing how your "casing" is done, you can also do this on a global level - https://symfony.com/doc/cur...
Cheers!
Hey Guys,
We have made the title property immutable but in the documentation for the PUT operation, the key "title" is still present. So we can believe that it is possible to change the title ...
Is there a solution to this problem?
Many thanks
Hey Mykl
Nice catch! I had to do some research to figure it out what's happening. The problem is that the field "title" has the group "cheese_listing:write", so all http methods will let you pass in that field. What you have to do is to only allow the POST method to access to it by defining a collection operation.
* @ApiResource(
* collectionOperations={
* "post"={
* "denormalization_context"={"groups"={"cheese_listing:write", "cheese_listing:collection:post"}}
* }
* },
* )
So then you can replace the group "cheese_listing:write" by "cheese_listing:collection:post" on the title property
Cheers!
Hey Guys,
This is a bit off topic, but in phpstorm, is there a way to get closing quotes and brackets in the annotations? For example, if I type { or "
, then } or "
this would immediately be inserted.
Hey Skylar,
Not sure about this feature out of the box like activating it with a tick... but I use PhpStorm's Live Templates for this. You can create your own template where you declare that when you write "{" and press "Tab" for example - it expands to "{$END$}" where "$END$" is a point where you will have the cursor. You can configure the action when it will trigger like pressing Tab, enter, etc. and in what file extensions it will work, like in ".php", or ".yaml" files.
I hope this helps, just play with it a bit.
Cheers!
// 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
}
}
I would like to set the logged in user in the constructor. The problem is I can't and don't want to simply inject services into the entity. And in the DataPersister the object was already initialized. Any ideas?
`
`