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 SubscribeEn lugar de asignar un CheeseListing
existente al usuario, ¿podríamos crear uno totalmente nuevo incrustando sus datos? ¡Vamos a averiguarlo!
Esta vez, no enviaremos una cadena IRI, sino un objeto de datos. Veamos... necesitamos un title
y... Haré trampa y miraré la ruta POST
para los quesos. Bien: necesitamos title
, price
owner
y description
. Establece price
en 20 dólares y pasa un description
. Pero no voy a enviar una propiedad owner
. ¿Por qué? Bueno... olvídate de la Plataforma API e imagina que utilizas esta API. Si enviamos una petición POST a /api/users
para crear un nuevo usuario... ¿no es bastante obvio que queremos que el nuevo listado de quesos sea propiedad de este nuevo usuario? Por supuesto, es nuestro trabajo hacer que esto funcione realmente, pero así es como yo querría que funcionara.
Ah, y antes de que lo intentemos, cambia el email
y el username
para asegurarte de que son únicos en la base de datos.
¿Preparado? ¡Ejecuta! ¡Funciona! No, no, estoy mintiendo: no es tan fácil. Tenemos un error conocido:
No se permiten documentos anidados para el atributo "cheeseListings". Utiliza en su lugar IRIs.
Bien, retrocedamos. El campo cheeseListings
es escribible en nuestra API porque la propiedad cheeseListings
tiene el grupo user:write
encima. Pero si no hiciéramos nada más, esto significaría que podemos pasar una matriz de IRIs a esta propiedad, pero no un objeto JSON de datos incrustados.
Para permitirlo, tenemos que entrar en CheeseListing
y añadir ese grupo user:write
a todas las propiedades que queramos permitir pasar. Por ejemplo, sabemos que, para crear un CheeseListing
, necesitamos poder establecer title
,description
y price
. Así que, ¡añadamos ese grupo! user:write
por encima de title
,price
y... aquí abajo, busca setTextDescription()
... y añádelo ahí.
... lines 1 - 39 | |
class CheeseListing | |
{ | |
... lines 42 - 48 | |
/** | |
... line 50 | |
* @Groups({"cheese_listing:read", "cheese_listing:write", "user:read", "user:write"}) | |
... lines 52 - 57 | |
*/ | |
private $title; | |
... lines 60 - 67 | |
/** | |
... lines 69 - 71 | |
* @Groups({"cheese_listing:read", "cheese_listing:write", "user:read", "user:write"}) | |
... line 73 | |
*/ | |
private $price; | |
... lines 76 - 134 | |
/** | |
... lines 136 - 137 | |
* @Groups({"cheese_listing:write", "user:write"}) | |
... line 139 | |
*/ | |
public function setTextDescription(string $description): self | |
... lines 142 - 197 | |
} |
Me encanta lo limpio que es elegir los campos que quieres que se incrusten... pero la vida se complica. Ten en cuenta ese coste de "complejidad" si decides admitir este tipo de cosas en tu API
En cualquier caso, ¡probemos! Ooh - un error 500. ¡Estamos más cerca! ¡Y también conocemos este error!
Se ha encontrado una nueva entidad a través de la relación
User.cheeseListings
que no estaba configurada para persistir en cascada.
¡Excelente! Esto me dice que la Plataforma API está creando un nuevo CheeseListing
y lo está configurando en la propiedad cheeseListings
del nuevo User
. Pero nada llama a $entityManager->persist()
en ese nuevo CheeseListing
, por lo que Doctrine no sabe qué hacer cuando intenta guardar el Usuario.
Si se tratara de una aplicación Symfony tradicional, en la que yo escribiera personalmente el código para crear y guardar estos objetos, probablemente me limitaría a encontrar dónde se está creando ese CheeseListing
y llamaría a $entityManager->persist()
sobre él. Pero como la Plataforma API se encarga de todo eso por nosotros, podemos utilizar una solución diferente.
Abre User
, busca la propiedad $cheeseListings
, y añade cascade={"persist"}
. Gracias a esto, cada vez que se persista un User
, Doctrine persistirá automáticamente cualquier objeto CheeseListing
en esta colección.
... lines 1 - 22 | |
class User implements UserInterface | |
{ | |
... lines 25 - 58 | |
/** | |
* @ORM\OneToMany(targetEntity="App\Entity\CheeseListing", mappedBy="owner", cascade={"persist"}) | |
... line 61 | |
*/ | |
private $cheeseListings; | |
... lines 64 - 184 | |
} |
Bien, veamos qué ocurre. ¡Ejecuta! Woh, ¡ha funcionado! Esto ha creado un nuevo User
, un nuevo CheeseListing
y los ha vinculado en la base de datos.
Pero... ¿cómo sabía Doctrine... o la Plataforma API que debía establecer la propiedad owner
del nuevo CheeseListing
en el nuevo User
... si no pasábamos una clave owner
en el JSON? Si creas un CheeseListing
de la forma normal, ¡es totalmente necesario!
Esto funciona... no por ninguna plataforma de la API ni por la magia de Doctrine, sino gracias a un código bueno, anticuado y bien escrito en nuestra entidad. Internamente, el serializador instala un nuevo CheeseListing
, le pone datos y luego llama a$user->addCheeseListing()
, pasando ese nuevo objeto como argumento. Y ese código se encarga de llamar a$cheeseListing->setOwner()
y establecerlo en $this
Usuario. Me encanta eso: nuestro código generado de make:entity
y el serializador están trabajando juntos. ¿Qué va a funcionar? ¡El trabajo en equipo!
Pero, al igual que cuando incrustamos los datos de owner
al editar un CheeseListing
, cuando permites que se cambien o creen recursos incrustados como éste, tienes que prestar especial atención a la validación. Por ejemplo, cambia los email
y username
para que vuelvan a ser únicos. Ahora se trata de un usuario válido. Pero establece el title
delCheeseListing
a una cadena vacía. ¿La validación detendrá esto?
No Ha permitido que el CheeseListing
se guarde sin título, ¡a pesar de que tenemos la validación para evitarlo! Esto se debe a que, como hemos hablado antes, cuando el validador procesa el objeto User
, no baja automáticamente a la matriz cheeseListings
y valida también esos objetos. Puedes forzarlo añadiendo @Assert\Valid()
.
... lines 1 - 22 | |
class User implements UserInterface | |
{ | |
... lines 25 - 58 | |
/** | |
... lines 60 - 61 | |
* @Assert\Valid() | |
*/ | |
private $cheeseListings; | |
... lines 65 - 185 | |
} |
Asegurémonos de que eso ha servido de algo: vuelve a subir, haz que los objetos email
y username
vuelvan a ser únicos y... ¡Ejecuta! ¡Perfecto! Un código de estado 400 porque
el campo
cheeseListings[0].title
no debería estar en blanco.
Vale, hemos hablado de cómo añadir nuevos listados de quesos a un usuario, ya sea pasando el IRI de un CheeseListing
existente o incrustando datos para crear un nuevoCheeseListing
. Pero, ¿qué pasaría si un usuario tuviera 2 listados de quesos... y realizáramos una petición para editar ese User
... y sólo incluyéramos el IRI de uno de esos listados? Eso debería... eliminar el CheeseListing
que le falta al usuario, ¿no? ¿Funciona? Y si es así, ¿pone el owner
de ese CheeseListing en cero? ¿O lo elimina por completo? ¡Busquemos algunas respuestas a continuación!
Hey triemli!
Hmm, is that right? It's certainly possible, but I would be surprised by that. Is it possible that, instead of the field being incorrectly named, there is an issue with the denormalization groups - i.e. so that the setTextDescription()
in this User embedded situation is not writable (and so the serializer is simply ignoring the description
field and then, since the description
property is blank, it fails validation)?
Let me know - I'm curious :).
Cheers!
If I use the next data direct to CheeseListing, so it works:
{
"title": "new cheese",
"price": 4000,
"description": "description cheese"
}
It doesn't work only in case of embedded.
#[ORM\Column(type: Types::TEXT)]
#[Groups(['cheese_listing:read', 'user:read', 'user:write'])]
#[NotBlank]
private ?string $description = null;
#[Groups(['cheese_listing:write'])]
#[SerializedName('description')]
public function setTextDescription(string $description): self
{
$this->description = nl2br($description);
return $this;
}
Also it works if I just add setDescription()
method. So I can make conclusion that #[SerializedName('description')]
don't goes being embedded.
Hey @triemli!
Hmm. What happens if you add the user:write
group to the setTextDescription()
method? I believe that's needed. By using setTextDescription()
to set the description
property (instead of the normal setDescription()
), when a description
property is sent to the API, API Platform sees that setTextDescription()
should be used and then reads its Groups
. It actually doesn't know or care that the description
property will ultimately be set. And so, it doesn't read the property's groups.
Anyways, let me know if that fixes it - I hope so. And happy new year!
Cheers!
Just a little side-note if anyone runs into the same issue I did.
The owning side must contain all autogenerated Entity field methods for this to work (at least addEntity and removeEntity method).
In my use case removeEntity was just in the way due to the setEntity(null) issue on required relation.
Expected behaviour (IMO) was for doctrine to complain about cascade persist (2:49) or tell me a required function is missing, but instead it ignored the addEntity method all together and threw no errors.
I'm getting "Cannot create metadata for non-objects." I'm using Symfony 5 here. Not sure where to go with this, Google searches are not helping.
Hey Andrew M.!
Hmm, this is a new one for me too! It looks like this is probably coming from deep in the serializer. Do you have a stack trace on this? What request are you making (e.g. a GET request to some object)? Is there any way to see the data that's being serialized?
Cheers!
Here's the request:
curl -X 'POST' \
'https://127.0.0.1:8000/api/users' \
-H 'accept: application/ld+json' \
-H 'Content-Type: application/ld+json' \
-d '{
"email": "use065465r@example.com",
"password": "654654",
"username": "2254455",
"cheeseListings": [
{
"title": "Cheese 654654",
"price": 6587,
"description": "descknjdn"
}
]
}'
Hi, thanks for the reply. Here's the full trace; it's pretty long.
{
"@context": "/api/contexts/Error",
"@type": "hydra:Error",
"hydra:title": "An error occurred",
"hydra:description": "Cannot create metadata for non-objects. Got: \"string\".",
"trace": [
{
"namespace": "",
"short_class": "",
"class": "",
"type": "",
"function": "",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/validator/Validator/RecursiveContextualValidator.php",
"line": 653,
"args": []
},
{
"namespace": "Symfony\\Component\\Validator\\Validator",
"short_class": "RecursiveContextualValidator",
"class": "Symfony\\Component\\Validator\\Validator\\RecursiveContextualValidator",
"type": "->",
"function": "validateGenericNode",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/validator/Validator/RecursiveContextualValidator.php",
"line": 516,
"args": []
},
{
"namespace": "Symfony\\Component\\Validator\\Validator",
"short_class": "RecursiveContextualValidator",
"class": "Symfony\\Component\\Validator\\Validator\\RecursiveContextualValidator",
"type": "->",
"function": "validateClassNode",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/validator/Validator/RecursiveContextualValidator.php",
"line": 313,
"args": []
},
{
"namespace": "Symfony\\Component\\Validator\\Validator",
"short_class": "RecursiveContextualValidator",
"class": "Symfony\\Component\\Validator\\Validator\\RecursiveContextualValidator",
"type": "->",
"function": "validateObject",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/validator/Validator/RecursiveContextualValidator.php",
"line": 138,
"args": []
},
{
"namespace": "Symfony\\Component\\Validator\\Validator",
"short_class": "RecursiveContextualValidator",
"class": "Symfony\\Component\\Validator\\Validator\\RecursiveContextualValidator",
"type": "->",
"function": "validate",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/validator/Validator/RecursiveValidator.php",
"line": 93,
"args": []
},
{
"namespace": "Symfony\\Component\\Validator\\Validator",
"short_class": "RecursiveValidator",
"class": "Symfony\\Component\\Validator\\Validator\\RecursiveValidator",
"type": "->",
"function": "validate",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/validator/Validator/TraceableValidator.php",
"line": 66,
"args": []
},
{
"namespace": "Symfony\\Component\\Validator\\Validator",
"short_class": "TraceableValidator",
"class": "Symfony\\Component\\Validator\\Validator\\TraceableValidator",
"type": "->",
"function": "validate",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/api-platform/core/src/Bridge/Symfony/Validator/Validator.php",
"line": 67,
"args": []
},
{
"namespace": "ApiPlatform\\Core\\Bridge\\Symfony\\Validator",
"short_class": "Validator",
"class": "ApiPlatform\\Core\\Bridge\\Symfony\\Validator\\Validator",
"type": "->",
"function": "validate",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/api-platform/core/src/Validator/EventListener/ValidateListener.php",
"line": 68,
"args": []
},
{
"namespace": "ApiPlatform\\Core\\Validator\\EventListener",
"short_class": "ValidateListener",
"class": "ApiPlatform\\Core\\Validator\\EventListener\\ValidateListener",
"type": "->",
"function": "onKernelView",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/event-dispatcher/Debug/WrappedListener.php",
"line": 117,
"args": []
},
{
"namespace": "Symfony\\Component\\EventDispatcher\\Debug",
"short_class": "WrappedListener",
"class": "Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener",
"type": "->",
"function": "__invoke",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/event-dispatcher/EventDispatcher.php",
"line": 230,
"args": []
},
{
"namespace": "Symfony\\Component\\EventDispatcher",
"short_class": "EventDispatcher",
"class": "Symfony\\Component\\EventDispatcher\\EventDispatcher",
"type": "->",
"function": "callListeners",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/event-dispatcher/EventDispatcher.php",
"line": 59,
"args": []
},
{
"namespace": "Symfony\\Component\\EventDispatcher",
"short_class": "EventDispatcher",
"class": "Symfony\\Component\\EventDispatcher\\EventDispatcher",
"type": "->",
"function": "dispatch",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/event-dispatcher/Debug/TraceableEventDispatcher.php",
"line": 151,
"args": []
},
{
"namespace": "Symfony\\Component\\EventDispatcher\\Debug",
"short_class": "TraceableEventDispatcher",
"class": "Symfony\\Component\\EventDispatcher\\Debug\\TraceableEventDispatcher",
"type": "->",
"function": "dispatch",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/http-kernel/HttpKernel.php",
"line": 161,
"args": []
},
{
"namespace": "Symfony\\Component\\HttpKernel",
"short_class": "HttpKernel",
"class": "Symfony\\Component\\HttpKernel\\HttpKernel",
"type": "->",
"function": "handleRaw",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/http-kernel/HttpKernel.php",
"line": 78,
"args": []
},
{
"namespace": "Symfony\\Component\\HttpKernel",
"short_class": "HttpKernel",
"class": "Symfony\\Component\\HttpKernel\\HttpKernel",
"type": "->",
"function": "handle",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/http-kernel/Kernel.php",
"line": 199,
"args": []
},
{
"namespace": "Symfony\\Component\\HttpKernel",
"short_class": "Kernel",
"class": "Symfony\\Component\\HttpKernel\\Kernel",
"type": "->",
"function": "handle",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/symfony/runtime/Runner/Symfony/HttpKernelRunner.php",
"line": 37,
"args": []
},
{
"namespace": "Symfony\\Component\\Runtime\\Runner\\Symfony",
"short_class": "HttpKernelRunner",
"class": "Symfony\\Component\\Runtime\\Runner\\Symfony\\HttpKernelRunner",
"type": "->",
"function": "run",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/autoload_runtime.php",
"line": 35,
"args": []
},
{
"namespace": "",
"short_class": "",
"class": "",
"type": "",
"function": "require_once",
"file": "/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/public/index.php",
"line": 5,
"args": [
[
"string",
"/home/andy/Personal/SymfonyCast/SymfonyAPI/api_platform/vendor/autoload_runtime.php"
]
]
}
]
Hey Andrew M.!
Ok, I think I know what's going on... and you already figured it out :). Remove @Assert\Valid
from above the username property. @Assert\Valid is only needed/used when a property is an object. It tells the validator system to recursively make sure that the object is ALSO valid. It's meaningless (and in fact gives you this error) if it's applied to a non-object property. For your username property, just put the normal NotBlank type of constraints that you need on it.
Let me know if that makes sense!
Cheers!!
Hello Ryan!
I'm trying to make an embedded entity but with a non-doctrine object
How could I create a form with Embedding custom DTO Object property (This object is not a doctrine entity)?
For example:
Dto model:
class Point
{
/**
* @Groups({"point:write"})
*/
public $latitude;
/**
* @Groups({"point:write"})
*/
public $longitude;
}
Entity //@ORM\Entity
class City
{
....
private $point;
}
Greetings!!!
Hey Juan,
To create a form - you need to use a custom form type for that "City::$point" field, that will contain 2 text fields: one for $latitude and one for $longitude. Then, Symfony Form component will know how to render that form, and how to read/write those values. But you will still need to think about how to store that non-doctrine object, probably you will need to use a serialization or json encode for that field - Doctrine has corresponding field types for this.
Cheers!
Hi Ryan,
I am having two entity with OneToOne relationship.
class User
{
/**
* @ORM\OneToOne(targetEntity=ClientProfile::class, mappedBy="user", cascade={"persist", "remove"})
* @Groups({"user:write"})
*/
private $clientProfile;
}
class ClientProfile
{
/**
* @ORM\OneToOne(targetEntity=User::class, inversedBy="clientProfile", cascade={"persist", "remove"})
*/
private $user;
/**
* @ORM\Column(type="string")
* @Groups({"user:write"})
*/
private $test;
}
so can we add embedded field test inside "User" table ?
Hi Vishal T.!
Sorry for the slow reply!
so can we add embedded field test inside "User" table ?
I think you are asking whether or not you can make a POST /api/users
request and send { "clientProfile": { "test": "foo" } }
as the JSON so that you can change the embedded "test" property when updating/creating a user. Is this correct?
If so... then... yea! You have user:write
groups on both User.clientProfile
and ClientProfile.test
, so you should be able to write that field in an embedded way. The interactive documentation should also reflect that fact. You would, of course, need methods like getClientProfile(), setClientProfile() and setTest(0 to make it work, but I think you are just not showing those to keep things short :).
Is this not working for you? If so, let me know what's going on - like any errors you are seeing.
Cheers!
Hi Ryan, thank you for this awesome serie of tutorials, really helpful.
Could you please give an example on how to accomplish this using DTO ?
I'm trying to adapt your code to create an Order entity that embed OrderItems, but I'm struggle.
Hey Auro
I believe this other tutorial may give you good ideas of how to do so
https://symfonycasts.com/sc...
Cheers!
Is it possible to update individual items in a collection during a PUT operation? Similar to how the CollectionType Field would updated enities if the id was present.
The body of my request (PUT /resource/uri/1) follows this structure:
`
{
"a_prop": "some value",
"collection": [
"@id": "/another_resources/uri/1",
"another_prop": "new value"
]
}<br />The response will always return new uri for the item in the collection that is been updated (the previous one is getting deleted form db).<br />Resonse would look like this:<br />
{
...
"collection": [
"@id": "/another_resources/uri/2", // new uri
"another_prop": "new value" // Updated value
]
}
`
I know the scenerario might be a little odd, but its all part of a old big form which the user may need to go back to and edit after it was already persisted to db. I'm considering splitting up the form to handle edits on a diferent view, but if possible i would like to avoid it. I wonder if its a configuration that i am missing (similar to the allow_add, allow_remove on the CollectionType), or is just not possible.
Hey Christopher hoyos!
Ha! Nice question. Tough question :). I think the answer is... yes! Um, maybe :P.
So, I tried this using this tutorial. Specifically, a made a PUT request to /api/users/8 and tried to update the "price" field on an existing cheeseListing with this body
{
"cheeseListings": [
{
"@id": "/api/cheeses/1",
"price": 500
}
]
}
This DOES work. Assuming you've got all of your serialization groups set up (in this case, a user:write
group is used when deserializing a User
object and I've also added that same group to the CheeseListing.price
property so that it can be updated), then it works just fine. This is a bit of a different result than you were getting... and I'm not sure why (well, your request and response body didn't look quite right to me - there should be an extra { }
around each item inside the collection []
but I wasn't sure if that was a typo).
But, apart from needing the groups to be set up correctly, there is one catch: if the User has 2 cheese listings and you only want to edit one of them, you'll need to make sure you include ALL the cheese listings in the request, else the others will be removed. Something like this:
{
"cheeseListings": [
{
"@id": "/api/cheeses/1",
"price": 500
},
"/api/cheeses/4"
]
}
Personally, to manage complexity, I'd prefer to update these individual cheese listings by making a PUT request to /api/cheeses/1 instead of trying to do it all inside on request to update the user. But, I also understand that if you're refactoring a giant form... it may be more natural to combine it all at once. But, it's something to think about :). And now that your form is submitting via JavaScript, you could even start updating each CheeseListing (or whatever your other resource really is) on "blur" - i.e. when the user clicks off a field, send a PUT request right then to update just that one item.
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
}
}
Curious that SerializedName('description') annotation for setTextDescription method in embedded case doesn't work. Just got when description is filled:
request: