Chapters
-
Course Code
Subscribe to download the code!Compatible PHP versions: ^7.1.3
Subscribe to download the code!Compatible PHP versions: ^7.1.3
-
This Video
Subscribe to download the video!
Subscribe to download the video!
-
Subtitles
Subscribe to download the subtitles!
Subscribe to download the subtitles!
-
Course Script
Subscribe to download the script!
Subscribe to download the script!
Scroll down to the script below, click on any sentence (including terminal blocks) to jump to that spot in the video!
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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeHe aquí una cuestión interesante: si recuperamos un solo CheeseListing
, podemos ver que el username
aparece en la propiedad owner
. Y, obviamente, si nosotros, editamos un CheeseListing
concreto, podemos cambiar totalmente el propietario por otro distinto. En realidad, probemos esto: establezcamos owner
a /api/users/2
. Ejecuta y... ¡sí! ¡Se ha actualizado!
Eso es genial, y funciona más o menos como una propiedad escalar normal. Pero... volviendo a mirar los resultados de la operación GET... aquí está, si podemos leer la propiedad username
del propietario relacionado, en lugar de cambiar el propietario por completo, ¿podríamos actualizar el nombre de usuario del propietario actual mientras actualizamos un CheeseListing
?
Es un ejemplo un poco raro, pero la edición de datos a través de una relación incrustada es posible... y, como mínimo, es una forma impresionante de entender realmente cómo funciona el serializador.
Intentando actualizar el propietario incrustado
De todos modos... ¡probemos! En lugar de establecer el propietario a un IRI, establécelo a un objeto e intenta actualizar el username
a cultured_cheese_head
. ¡Vamos, vamos, vamos!
Y... no funciona:
No se permiten documentos anidados para el atributo "owner". Utiliza en su lugar IRIs.
Entonces... ¿es esto posible, o no?
Bueno, la razón por la que username
se incrusta al serializar un CheeseListing
es que, por encima de username
, hemos añadido el grupo cheese_listing:item:get
, que es uno de los grupos que se utilizan en la operación "obtener" el elemento.
La misma lógica se utiliza cuando se escribe un campo, o se desnormaliza. Si queremos queusername
se pueda escribir mientras se desnormaliza un CheeseListing
, tenemos que ponerlo en un grupo que se utilice durante la desnormalización. En este caso, escheese_listing:write
.
Cópialo y pégalo encima de username
.
Show Lines
|
// ... lines 1 - 22 |
class User implements UserInterface | |
{ | |
Show Lines
|
// ... lines 25 - 51 |
/** | |
Show Lines
|
// ... line 53 |
* @Groups({"user:read", "user:write", "cheese_listing:item:get", "cheese_listing:write"}) | |
Show Lines
|
// ... line 55 |
*/ | |
private $username; | |
Show Lines
|
// ... lines 58 - 184 |
} |
En cuanto lo hagamos -porque la propiedad owner
ya tiene este grupo- ¡se podrá escribir la propiedad username
incrustada! Volvamos a intentarlo: seguimos intentando pasar un objeto con username
. ¡Ejecuta!
Envío de nuevos objetos frente a referencias en JSON
Y... oh... ¡sigue sin funcionar! ¡Pero el error es fascinante!
Se ha encontrado una nueva entidad a través de la relación
CheeseListing.owner
que no estaba no estaba configurada para realizar operaciones de persistencia en cascada para la entidad Usuario.
Si llevas un tiempo en Doctrine, puede que reconozcas este extraño error. Ignorando por un momento la Plataforma API, significa que algo creó un objeto User
totalmente nuevo, lo estableció en la propiedad CheeseListing.owner
y luego intentó guardar. Pero como nadie llamó a $entityManager->persist()
en el nuevo objetoUser
, ¡Doctrine entró en pánico!
Así que... ¡sí! ¡En lugar de consultar el propietario existente y actualizarlo, la Plataforma API tomó nuestros datos y los utilizó para crear un objeto User
totalmente nuevo! ¡Eso no es en absoluto lo que queríamos! ¿Cómo podemos decirle que actualice el objeto User
existente en su lugar?
Aquí está la respuesta, o en realidad, aquí está la regla simple: si enviamos una matriz de datos, o en realidad, un "objeto" en JSON, la Plataforma API asume que se trata de un nuevo objeto y así... crea un nuevo objeto. Si quieres indicar que, en cambio, quieres actualizar un objeto existente, sólo tienes que añadir la propiedad @id
. Establécela como/api/users/2
. Gracias a esto, la Plataforma API consultará ese usuario y lo modificará.
Vamos a probarlo de nuevo. ¡Funciona! Bueno... probablemente ha funcionado: parece que ha tenido éxito, pero no podemos ver el nombre de usuario aquí. Desplázate hacia abajo y busca el usuario con id 2.
¡Ahí está!
¿Crear nuevos usuarios?
Así pues, ahora sabemos que, al actualizar... o realmente crear... un CheeseListing
, podemos enviar los datos de owner
incrustados y señalar a la Plataforma API que debe actualizar un owner
existente a través de la propiedad @id
.
Y cuando no añadimos @id
, intenta crear un nuevo objeto User
... que no funciona por ese error de persistencia. Pero, podemos arreglar totalmente ese problema con un persist en cascada... que mostraré en unos minutos para resolver un problema diferente.
Entonces, espera... ¿significa esto que, en teoría, podríamos crear un User
completamente nuevo mientras editamos un CheeseListing
? La respuesta es.... ¡sí! Bueno... casi. Hay dos cosas que lo impiden ahora mismo: primero, la falta de persistencia de la cascada, que nos dio ese gran error de Doctrine. Y en segundo lugar, en User
, también tendríamos que exponer los campos $password
y $email
porque ambos son necesarios en la base de datos. Cuando empiezas a hacer que las cosas incrustadas sean escribibles, sinceramente se añade complejidad. Asegúrate de llevar un registro de lo que es posible y lo que no es posible en tu API. No quiero que se creen usuarios accidentalmente al actualizar un CheeseListing
, así que esto es perfecto.
Validación incrustada
Pero queda una cosa rara. Establece username
como una cadena vacía. Eso no debería funcionar porque tenemos un @NotBlank()
por encima de $username
.
Intenta actualizar de todos modos. Por supuesto Me sale el error 500 en cascada - déjame volver a poner la propiedad @id
. Inténtalo de nuevo.
¡Woh! ¡Un código de estado 200! ¡Parece que ha funcionado! Baja y recupera este usuario... con id=2. ¡No tiene nombre de usuario! ¡No te preocupes!
Esto... es un poco de gotcha. Cuando modificamos el CheeseListing
, se ejecutan las reglas de validación: @Assert\NotBlank()
, @Assert\Length()
, etc. Pero cuando el validador ve el objeto owner
incrustado, no continúa hacia abajo en ese objeto para validarlo. Eso es normalmente lo que queremos: si sólo estábamos actualizando un CheeseListing
, ¿por qué debería intentar validar también un objeto User
relacionado que ni siquiera hemos modificado? No debería
Pero cuando haces actualizaciones de objetos incrustados como nosotros, eso cambia: sí queremos que la validación continúe hasta este objeto. Para forzar eso, encima de la propiedad owner
, añade @Assert\Valid()
.
Show Lines
|
// ... lines 1 - 39 |
class CheeseListing | |
{ | |
Show Lines
|
// ... lines 42 - 86 |
/** | |
Show Lines
|
// ... lines 88 - 90 |
* @Assert\Valid() | |
*/ | |
private $owner; | |
Show Lines
|
// ... lines 94 - 197 |
} |
Bien, vuelve atrás y... intenta de nuevo nuestra ruta de edición. Ejecuta. ¡Lo tengo!
owner.username: Este valor no debe estar en blanco
¡Muy bien! Volvamos atrás y démosle un nombre de usuario válido... para no tener un usuario malo en nuestra base de datos. ¡Perfecto!
Poder hacer modificaciones en las propiedades incrustadas está muy bien... pero añade complejidad. Hazlo si lo necesitas, pero recuerda también que podemos actualizar un CheeseListing
y un User
de forma más sencilla haciendo dos peticiones a dos rutas.
A continuación, vamos a ponernos aún más locos y a hablar de la actualización de colecciones: ¿qué ocurre si intentamos modificar la propiedad cheeseListings
directamente en un User
?
59 Comments
DISCLAIMER
I'm no by no means a pro with api-platform and symfony, so excuse me if I'm incorrect and do correct me if possible.
However after spending 3 hours digging through 7 concrete validator classes that were all injected via the same interface, starting from the api-platform vendor files and adding performance checks, ending up at Symfony\Component\Validator\Validator\RecursiveContextualValidator.
I feel I need to share.
For anyone who stumbles across this screencast and wants to implement embedded writes:
Be careful when using the valid() constraint as it can add very large overhead.
After implementing a new feature (in production that has way more data), our users found that an entity related to the feature was taking ~23 seconds for PUT and POST operations. This being after route caching.
After digging it showed that related entities that had the valid() annotation were the culprits.
Adding the option traverse=false to the annotation did not affect performance.
The relative added ms to every request reflected locally, where there is less data, and could be seen via the profiler under performance.
If you have implemented this feature and feel that the performance has taken a hit, look for kernel.view (section) and validateListener (event_listener).
After removing the valid() annotations and going about validation in other ways that worked out, the request(s) have dropped from ~3k ms (locally) to ~350ms.
Hi Musa!
Wow, thanks for posting this! I don't think you are doing anything wrong. As you mentioned, it's simply likely that, as soon as you use Valid
, you can potentially be asking the validation system to validate MANY objects. And actually, the problem may not even come from the validator, but from the fact that the validator (in order to validate) my "hydrate" some objects that it might not normally need to do (e.g. image a Product
class with Valid
above a collection of productPhotos
- the validator would call $product->getProductPhotos()
to validate them, which would cause Doctrine to query and hydrate ALL of the related photos). This is just a guess, but the point remains the same: your warning about being cautious about using Valid
is... I think, valid ;).
Cheers!
Hi,
so if I embed a JSON, the Platform will create a new object , if I use an IRI the platform simply add a reference.
Perfect but how to handle this situation; let I have an API to add a news from an external resource.
The external resounce ( an external CMS for example ), send to the API the news data AND the author data.
The flow is simple: if the author does not exists , the API has to create an author, if the author exists, the API has to link the author to the news.
How to recover the author? My Author entity has the "slug" as identifier, so if I send something like this:
{
"author": {
"slug": "luca"
}
"title":"news Title"
}
I expect that the API search for a user, add or create a new one, than save the news.
Apparently it does not work; it create a new user on every request and if I add a unique constrain on the author slug, I got a database error.
Summarizing: I 'd like to create an API using a JSON for a related resource, that ADD the related resource or UPDATE if exists based on the identifier.
Which is for you the best way to archieve this? Using some kind of event subscriber? or a DTO?
Thanks in advance
Probably the answer to my question is to use a PlainIdentifierDenormalizer
I'm trying to follow this example: https://api-platform.com/docs/core/serialization/#denormalization
es:
class PlainIdentifierDenormalizer implements ContextAwareDenormalizerInterface, DenormalizerAwareInterface
{
use DenormalizerAwareTrait;
private $iriConverter;
public function __construct(IriConverterInterface $iriConverter)
{
$this->iriConverter = $iriConverter;
}
/**
* {@inheritdoc}
*/
public function denormalize($data, $class, $format = null, array $context = [])
{
$data['area'] = $this->iriConverter->getIriFromResource( Area::class, ['uri_variables' => ['id' => $data['area']]]);
return $this->denormalizer->denormalize($data, $class, $format, $context + [__CLASS__ => true]);
}
/**
* {@inheritdoc}
*/
public function supportsDenormalization($data, $type, $format = null, array $context = []): bool
{
return \in_array($format, ['json', 'jsonld'], true) && is_a($type, News::class, true) && !empty($data['area']) && !isset($context[__CLASS__]);
}
}
But I get this error:
Argument 1 passed to App\Serializer\PlainIdentifierDenormalizer::__construct() must be an instance of ApiPlatform\Api\IriConverterInterface, instance of ApiPlatform\Core\Bridge\Symfony\Routing\IriConverter given
I think I solved my issue; I have created a EventSubscriber , listening on PRE_WRITE event; than, I get the Related entity that is NOT in database from my News entity, and I search for an existing record using repository; then, I use setter to inject the object found ( if exists ) in the News entity.
It works and is also very clean and powerfull !.
Great.
Hi @Gianluca-F
Hmm. The PlainIdentifierDenormalizer
sounds like the right approach, but I've never worked with it before. And it might not be what you need. That might just make it possible to use some other field for an identifier in the relation (e.g. slug
)... but when you send a slug, it ALWAYS thinks that this must already exist. And if it does not, it throws an error. But, that's just a guess.
Anyways, about the error, try changing the use
statement for IrIConverterInterface
to use ApiPlatform\Core\Api\IriConverterInterface
. That is just a guess - there are two IriConverterInterface
and I'm wondering if the docs are showing the wrong one. Though, this is a total guess. That looks a bit confusing to me.
If I were implementing this, honestly, I'd just make 2 API calls - it makes life much simpler (if it's ok in your case to do that). I'd make an API call to see if there is an author for the given slug and to fetch its IRI if there is. Then, I'd use this information to structure my next API request: either sending the IRI for the relation or an embedded object so a new one is created.
Cheers!
I am going through this tutorial with the latest version of Symfony (6.1) and API Platform (2.6). When attempting to update the owner object via an embedded object within my cheese listing, I did not receive the doctrine error described in this video. It just worked as expected without having to provide the "@id" field. I am using PATCH instead of PUT for my cheeses endpoint. I'm just curious if this behavior is because I am using a PATCH request, or if this is a bug fix or feature implemented in a recent version of API Platform.
Hey jdevine
That's quite interesting. What happens if you use PUT (as in the video)? It's likely that ApiPlatform improved how it works internally, since you're updating a ManyToOne relationship, and you're already specifying the parent's id, then, IMO the id of the embedded object coulb be omitted
Cheers!
Is it possible to do this in a OneToMany scenario, where the one writes to the many?
I tried but I keep getting <b>"The type of the key \"baz\" must be \"int\", \"string\" given."</b> for:
{
"foo":{
"@id":"/foobar/1",
"baz":"/baz/2"
}
}
When trying to update a relation on a embedded relation. When I try to write a scalar field(string in testing), it gives me 200 OK but nothing changes in database.
Hey Musa!
Hmm, yes, I believe this should be possible. The foo/baz example is confusing me a little, so let me try to re-state how I would expect this to work, but with a real example.
Suppose that User has many cheeseListings. Then I would expect this to be possible:
{
"cheeseListings": [
{ "@id": "/api/cheeses/1", name: "updated cheese name" },
{ "@id": "/api/cheeses/2", name: "updated cheese name2" },
]
}
Something like that. It depends on exactly what you're trying to do (e.g. update data on the existing cheeses assigned to a user OR change which cheeses are assigned to a user), but something like this should be possible. The JSON in your example has a different structure than the one I suggest, which could be part of the problem... or could be me just misunderstanding your use-case :).
Cheers!
Thanks for replying, I thought it wasn't working because I was changing the relationships of a related entity.
Turns out that my project was just hard caching for some reason, I came back the day after I had this issue and reloaded. Everything was magically fine, docs advertising the ability to update through relation.
I was however unable to advertise on the swagger the need to specify the "@id"
attribute.
Hey Musa!
Ah, great news!
> I was however unable to advertise on the swagger the need to specify the "@id" attribute.
This I know less about. It gets tricky to customize the documentation at this level. This also "strikes" me as something that should be advertised out-of-the-box. And, hmm, maybe it is "implied" by "hydra" itself, because that's how hydra is supposed to work (not that this would help a human reading the documentation, I'm just thinking out loud).
Cheers!
Hi, I have a problem when I want to update the title and price of my cheese through a PUT or a PATCH, the HTTP request is well sent with my new title and price, however only the price is well updated and returned in the body of the response.
Thanks for the work.
<a href="https://ibb.co/QFwwGcp">Swagger capture</a>
CheeseListingEntity
namespace App\Entity;
use ApiPlatform\Core\Annotation\ApiFilter;
use ApiPlatform\Core\Annotation\ApiProperty;
use ApiPlatform\Core\Annotation\ApiResource;
use ApiPlatform\Core\Bridge\Doctrine\Common\Filter\SearchFilterInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Core\Serializer\Filter\PropertyFilter;
use App\Repository\CheeseListingRepository;
use Carbon\Carbon;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Uid\Ulid;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Type;
use Symfony\Component\Validator\Constraints\Ulid as UlidConstraint;
#[ORM\Entity(repositoryClass: CheeseListingRepository::class)]
#[ApiResource(
collectionOperations: ['get', 'post'],
itemOperations: ['get' => [
'normalization_context' => [
'groups' => [
self::GROUPS_CHEESE_LISTING_READ,
self::GROUPS_CHEESE_LISTING_ITEM_GET,
],
'swagger_definition_name' => 'read-item',
],
], 'put', 'delete', 'patch'],
shortName: 'cheeses',
attributes: [
'pagination_items_per_page' => 10,
'formats' => ['jsonld', 'json', 'jsonld', 'html', 'csv' => 'text/csv'],
],
denormalizationContext: [
'groups' => [self::GROUPS_CHEESE_LISTING_WRITE],
'swagger_definition_name' => 'write',
],
normalizationContext: [
'groups' => [self::GROUPS_CHEESE_LISTING_READ],
'swagger_definition_name' => 'read',
]
)]
#[ApiFilter(BooleanFilter::class, properties: ['isPublished'])]
#[ApiFilter(SearchFilter::class, properties: ['title' => SearchFilterInterface::STRATEGY_PARTIAL])]
#[ApiFilter(RangeFilter::class, properties: ['price'])]
#[ApiFilter(PropertyFilter::class)]
class CheeseListing
{
public const GROUPS_CHEESE_LISTING_WRITE = 'cheese_listing:write';
public const GROUPS_CHEESE_LISTING_READ = 'cheese_listing:read';
public const GROUPS_CHEESE_LISTING_ITEM_GET = 'cheese_listing:item:get';
#[ORM\Id]
#[ORM\Column(type: 'ulid')]
#[UlidConstraint]
private ?Ulid $id;
#[ORM\Column(type: 'string', length: 255)]
#[ApiProperty(description: 'la description coute de mon fromage')]
#[Groups([self::GROUPS_CHEESE_LISTING_READ, self::GROUPS_CHEESE_LISTING_WRITE, User::GROUPS_USER_READ])]
#[NotBlank]
#[Length(min: 5, max: 50, maxMessage: 'Décrivé votre formage en 50 caractères ou moins')]
private ?string $title;
#[ORM\Column(type: 'text')]
#[ApiProperty(description: 'La description du fromage')]
#[Groups([self::GROUPS_CHEESE_LISTING_READ])]
#[NotBlank]
private ?string $description;
#[ORM\Column(type: 'integer')]
#[ApiProperty(description: 'Le prix du fromage')]
#[Groups([self::GROUPS_CHEESE_LISTING_READ, self::GROUPS_CHEESE_LISTING_WRITE, User::GROUPS_USER_READ])]
#[NotBlank]
#[Type('int')]
private ?int $price;
#[ORM\Column(type: 'datetime_immutable')]
private ?\DateTimeImmutable $createdAt;
#[ORM\Column(type: 'boolean')]
private ?bool $isPublished = false;
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'cheeseListings')]
#[ORM\JoinColumn(nullable: false)]
#[Groups([self::GROUPS_CHEESE_LISTING_READ, self::GROUPS_CHEESE_LISTING_WRITE])]
private ?User $owner;
public function __construct(string $title = null)
{
$this->title = $title;
$this->id = new Ulid();
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?Ulid
{
return $this->id;
}
public function setId(?Ulid $id): self
{
$this->id = $id;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function getDescription(): ?string
{
return $this->description;
}
#[Groups([self::GROUPS_CHEESE_LISTING_READ])]
public function setShortDescription(): ?string
{
if (strlen($this->description) < 40) {
return $this->description;
}
return substr($this->description, 0, 40).'...';
}
#[Groups([self::GROUPS_CHEESE_LISTING_WRITE])]
#[SerializedName('description')]
#[ApiProperty(description: 'La description du fromage en tant que texte brute')]
public function setTextDescription(string $description): self
{
$this->description = nl2br($description);
return $this;
}
public function getPrice(): ?int
{
return $this->price;
}
public function setPrice(int $price): self
{
$this->price = $price;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
#[Groups([self::GROUPS_CHEESE_LISTING_READ])]
#[ApiProperty(description: "Depuis combien de temps en texte le fromage a-t'il été ajouté")]
public function getCreatedAtAgo(): string
{
return (Carbon::instance($this->getCreatedAt()))->locale('fr_FR')->diffForHumans();
}
public function setCreatedAt(\DateTimeImmutable $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
public function getIsPublished(): ?bool
{
return $this->isPublished;
}
public function setIsPublished(bool $isPublished): self
{
$this->isPublished = $isPublished;
return $this;
}
public function getOwner(): ?User
{
return $this->owner;
}
public function setOwner(?User $owner): self
{
$this->owner = $owner;
return $this;
}
}
Hey gabrielmustiere!
Hmm. It could be something simple, because the ONLY thing I can spot is this: there is no setTitle() method (unless you just didn't include it here in the code). If that's true, then the title property cannot be written to.
Cheers!
Indeed, with the addition of the setTtitle method the title is updated, however before writing my message, I downloaded the source code available for this tutorial and in the finish folder the CheeseListing entity does not have a setTitle method either, and moreover it is during a previous chapter that we removed this method in favor of the constructor. I have not tested the code provided in the finish folder. I wonder if my problem is a regression between my version of api-patform and the version who used in this tutorial.
Ty
Hey gabrielmustiere!
and in the finish folder the CheeseListing entity does not have a setTitle method either, and moreover it is during a previous chapter that we removed this method in favor of the constructor
So this was not an accident, and it touches on a cool thing with API Platform / Symfony;'s serializer. If you have a $title
constructor property, then you do NOT need a setTitle()
method to make it writable. However, if you use this approach, title
will be writable on create but NOT on update. If you think about it: this makes perfect sense: if $title
appears in the constructor but there is no setTitle()
method, then it is immutable: it can only be set when the object is originally created, and then never after. In the case of API Platform, during a PUT, it first queries the database for your Question object. After it does this, unless you have a setTitle()
method, title
is not writable :).
I hope that helps clarify!
Cheers!
Indeed everything makes sense from a technical point of view but can we consider that it is normal to make an update by sending data for a field which is not modifiable through a PATCH or a PUT and to receive a response 200 ? Ty
Hey gabrielmustiere!
> from a technical point of view but can we consider that it is normal to make an update by sending data for a field which is not modifiable through a PATCH or a PUT and to receive a response 200
That is a fair question, and I'm actually not sure if (or what) the correct answer to this is. According to JSON schema (just one standard, but which has a system for validating input), extra fields ARE allowed, unless you choose explicitly to NOT allow them: https://json-schema.org/und...
So, it seems like the correct behavior is a design decision. Very interesting!
Cheers!
Is there a reason why the PUT operation allows partial updates?
It was my understanding that this was specifically what the PATCH operation is for.
Is there any way to configure the PUT operation to behave as it should? (eg. replacing the whole object resulting in defaults or nulling fields that weren't included)
Hey Patrick,
Hm, from the docs page https://api-platform.com/do... I see PUT should replace an element, and PATCH should apply a partial modification to an element. Also I see that the PATCH method must be enabled explicitly in the configuration, see Content Negotiation section for more information: https://api-platform.com/do...
Probably if you enable it - PUT will start working differently?
Also, I think configuring skipping null values might help too, see: https://symfony.com/doc/cur....
I hope this helps!
Cheers!
Hi Victor,
I read through the documentation the other day, I agree that PUT should replace an element but unfortunately it really just does a "partial update".
PATCH explicitly enabled doesn't have any effect.
Skip null values also doesn't have any effect.
From reading through some of the historic issues from API Platforms repo, I can see that PATCH was a late addition and there were comments around changing PUT functionality to make "partial updates" in lieu of PATCH being added into the framework.
I haven't been able to see any further comments around it, but my assumption is that after PATCH functionality was added, the PUT "partial update" functionality was never reverted and thus still has that behaviour.
Hey Patrick,
Ah, I see. I wonder if you have the latest version of ApiPlatform? Probably it was fixed/reverted in the newer version, but I'm not sure. If you're on the latest already, probably feel free to open the issue about it in the repo and probably maintainers may shed the light on it and tell the exact reasons why it works this way. Sorry for not able to help more on this!
Cheers!
Thanks Victor, appreciate you looking into it, just thought maybe there was something I was missing.
I think I'll do as you said and raise an issue about it, thanks again
Hey everyone, so I was following this tutorial and got everything to work fine. But I wanted to experiment a bit and was wondering how things would look like if the owner of a cheeseListing actually wasn't mandatory, and I set it to nullable.
Again, worked out fine, but now if I have a look at the prefilled request body of the PUT request for a cheeseListing, the preview doesn't include the "owner{ ... }" part anymore. If I fill it by hand the request is successful, but I would prefer it if even though the owner is nullable, it would be included in the preview in the docs. Is there any quick and easy way to also include optional embedded resources in the request preview / example value section of the OpenAPI documentation?
Hey Bastian!
Sorry for my slow reply! This is an excellent question :). The answer is, yes! The key is to add extra metadata to your property via the @ApiProperty aannotation. Specifically, you can pass more "openapi" info via an openapiContext option:
/**
* @ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="cheeseListings")
* @ORM\JoinColumn(nullable=true)
* @Groups({"cheese_listing:read", "cheese_listing:write"})
* @ApiProperty(openapiContext={"example": {"username": "bar"}})
*/
private $owner;
If you're using Swagger 2.0 (like we do in this tutorial because that was the version available when we recorded), then this would be swaggerContext
instead of openapiContext
.
Let me know if this helps!
Cheers!
Hello guys and thanks for your work about tutorials (bless you),
I'm finished this chapter and when I did some test to understand Embedded Validation, I noticed something wrong: maybe I'm missing something or I don't understand. When I do a PUT on cheeses I'm able to edit price, description and, obviously, username but nothing happen when I edit title (it's always the same title used during POST creation). Could you help me? Thanks
Hey Gaetano S.
I believe your Groups
are not set correctly. Double check that the "title" field has the writing group. Also, double check the itemOperations
of the CheeseListing class uses the same groups as the option denormalizationContext
Cheers!
Hi Diego,
thanks for your help. I think that if POST method works, the Groups is right.
This is my code:
/**
* @ApiResource(
* routePrefix="/market",
* collectionOperations={"get", "post"},
* itemOperations={
* "get_chees"={
* "method"="GET",
* "normalization_context"={"groups"={"cheese_listing:read", "cheese_listing:item:get"}},
* },
* "put"
* },
* shortName="cheeses",
* normalizationContext={"groups"={"cheese_listing:read"}, "swagger_definition_name"="Read"},
* denormalizationContext={"groups"={"cheese_listing:write"}, "swagger_definition_name"="Write"},
* attributes={
* "pagination_items_per_page"=10
* }
*
* )
* @ApiFilter(BooleanFilter::class, properties={"isPublished"})
* @ApiFilter(SearchFilter::class, properties={"title"="partial", "description"="partial"})
* @ApiFilter(RangeFilter::class, properties={"price"})
* @ApiFilter(PropertyFilter::class)
*
* @ORM\Entity(repositoryClass=CheeseListeningRepository::class)
*/
class CheeseListing
{
/**
* @ORM\Id()
* @ORM\GeneratedValue()
* @ORM\Column(type="integer")
* @Groups({"cheese_listing:read"})
*/
private $id;
/**
* @ORM\Column(type="string", length=255)
* @Groups({"cheese_listing:read", "cheese_listing:write", "user:read"})
* @Assert\NotBlank()
* @Assert\Length(
* min=2,
* max=50,
* maxMessage="Describe your cheese in 50 chars or less"
* )
*/
private $title;
/**
* @ORM\Column(type="text")
* @Groups({"cheese_listing:read"})
* @Assert\NotBlank()
*/
private $description;
/**
* The price of this delicious cheese in Euro cents.
* @ORM\Column(type="integer")
* @Groups({"cheese_listing:read", "cheese_listing:write", "user:read"})
* @Assert\NotBlank()
*/
private $price;
/**
* @ORM\Column(type="datetime")
*/
private $createdAt;
/**
* @ORM\Column(type="boolean")
*/
private $isPublished= false;
/**
* @ORM\ManyToOne(targetEntity=User::class, inversedBy="cheeseListings")
* @ORM\JoinColumn(nullable=false)
* @Groups({"cheese_listing:read", "cheese_listing:write"})
* @Assert\Valid()
*/
private $owner;
/**
* CheeseListing constructor.
* @param String $title
*/
public function __construct(String $title = null)
{
$this->createdAt = new \DateTimeImmutable();
$this->title = $title; //I'm using here the title and it works fine because I'm using the word title (same of property of entity)
}
public function getId(): ?int
{
return $this->id;
}
public function getTitle(): ?string
{
return $this->title;
}
//it is possible passing title also in the constructor
// public function setTitle(string $title): self
// {
// $this->title = $title;
//
// return $this;
// }
public function getDescription(): ?string
{
return $this->description;
}
/**
* @Groups("cheese_listing:read")
*/
public function getShortDescription(): ?string
{
if(strlen($this->description) < 40){
return $this->description;
}
return substr($this->description, 0, 40) . '...';
}
/**
* The description of the cheese as raw text.
* @SerializedName("description")
* @Groups("cheese_listing:write")
* @param string $description
* @return CheeseListing
*/
public function setTextDescription(string $description): self
{
$this->description = nl2br($description);
return $this;
}
public function getPrice(): ?int
{
return $this->price;
}
public function setPrice(int $price): self
{
$this->price = $price;
return $this;
}
public function getCreatedAt(): ?\DateTimeInterface
{
return $this->createdAt;
}
/**
* How long ago in text that this cheese listing was added.
*
* @Groups("cheese_listing:read")
*/
public function getCreatedAtAgo(): string
{
return Carbon::instance($this->getCreatedAt())->diffForHumans();
}
public function getIsPublished(): ?bool
{
return $this->isPublished;
}
public function setIsPublished(bool $isPublished): self
{
$this->isPublished = $isPublished;
return $this;
}
public function getOwner(): ?User
{
return $this->owner;
}
public function setOwner(?User $owner): self
{
$this->owner = $owner;
return $this;
}
}
I don't know where is the problem....
Hey Gaetano,
Hm, your code looks correct. Could you clear the cache and try again? Make sure the cache is cleared for the environment you have the problem with. I think it might be a cache error only, because it should just work.
I hope this helps.
Cheers!
Thanks Victor,
I think I'll go crazy :). I deleted all kinds of cache (cache clear, pool, deleting by rm). Just the title is not editable....sorry. If you need more info, pictures or whatever....tell me. It's very strange.
Thanks again
Isn't this because the title must be set through the constructor and there is no setTitle
method? The constructor is only used when the object is created, not when it is updated.
I think Hans Grinwis just nailed the answer :). If you have a "title" argument to the constructor, then it IS something you can set on CREATE, because API Platform will use your constructor. But if it has not setter, then you cannot update it. Excellent catch on that! It's a feature, but it *is* subtle.
Oh yeahhhhhh. Eccellentissimo :) catch..thank you so much....
I did embedded write a couple of times before. I just quickly created a project from scratch to make sure embedded write is still working. But in my project I’ve been developing within the last two months I have a weird issue - the embedded write works in an opposite way :) Front-end dev asked me to implement embedded write for the Owner (Person entity) of the Vehicle. This works perfectly while using Peron’s IRI. But as soon as I add vehicle:write
@Groups to Person::$firstName
, the Vehicle::$owner
key is completely erased from JSON template for POST operation. Even created a new entity NewVehicle and associated it with Person - same result. Any ideas why this could happen or at least how to debug an issue?
Looks like something totally wrong with \Symfony\Component\Serializer\Serializer
in the app... I found out that same "removing" from JSON-template issue connects also to PhoneNumber (part of odolbeau/phone-number-bundle) typed Person::$phone property. Weird but all the services needed (PhoneNumberNormalizer, PhoneNumberUtil) are present in debug:container
. The only thing it's a message
<blockquote>! [NOTE] The "Misd\PhoneNumberBundle\Serializer\Normalizer\PhoneNumberNormalizer" service or alias has been removed or inlined when the container was compiled. </blockquote>
Hey erop!
Ah, it sounds like a complex issue! Let's see if we can figure out out :). 2 things:
1) Can you POST some additional info? I'd like to see: (A) the relevant code or the Person entity (B) the relevant code of the Vehicle entity (C) the exact POST request you are making and the exact data you are sending and (D) the exact response (including JSON) from that POST request. You have many of the details here... but because I'm can't see the full project, it's hard to follow.
2) About the "service or alias has been removed or inlined when the container was compiled" - don't worry about that at all. That is simply notifying you of an optimization that's happening internally. So, this is a "red herring" as we say: it is something totally unrelated to the problem (and not actually a problem) so you can ignore it :).
Btw, I might also try one thing - downgrade api-platform/core to maybe version 2.5.0 and see if it makes any difference. I can't remember the specifics, but I may have heard about a bug in recent API Platform versions regarding embedded writes.
Cheers!
Hey, weaverryan!
Here are my code snippets:
- Vehicle.php
- Person.php
- request-response JSONs
As you can see in request-response JSONs the embedded write works. But the "ownerPerson" property disappeares from body template in UI. Here are screenshots:
- before adding @Groups{"vehicle:write"} to Person::$firstName
- after adding @Groups{"vehicle:write"} to Person::$firstName
Moreover the Vehicle object in UI displays OK .
Versions: Symfony 5.0.9, ApiPlatform Pack 1.2.2
Hey erop!
Ok, MUCH better - this was an awesome number of details. I think we're closer... but I still have some questions:
A) Try adding a setFirstName()
method to Person. I want to check to make sure that the fact that there is no setter (just the constructor argument) isn't confusing API Platform. It's possible that it incorrectly thinks that the firstName property is not "settable", and thus is not including it in the POST API docs (and then possibly because ownerPerson
has no properties, it completely removes it from the docs - this is a TOTAL guess).
B) Try adding "api-platform/core": "2.5.0"
and then running composer update
. That should downgrade your API Platform. I doubt this will help - but I want to eliminate the possibility that there is some bug introduced in a newer version.
C) On the request-response you sent - https://gist.github.com/erop/bf488b8a80f65b1d0da9e89280bf00e1 - what is the expected behavior? It looks to me like this does create a new Person resource (IRI /api/people/9878dcf6-7b04-428c-bce6-b0307f04a370
) and sets it on the Vehicle. Is this not what you want? If not, what were you expecting? Or is this correct, but you are wondering only why the documentation is wrong?
Overall, when it comes to the missing field in the documentation, that does smell like a bug... especially if you ARE in fact able to POST this field successfully.
Cheers!
Hey, weaverryan !
A) I intentionally reduced an amount of Person's code in the gist and have to say that Person::setFirstName() was always in its place. I event tried to explicitly annotate setter with @Groups but with no luck.
B) The oldest "api-platform/core" version I managed to downgrade to was 2.5.2: 2.5.0 requires Symfony ^4.0, 2.5.1 raised an error connected to ExceptionListener from Symfony HttpKernel package. And ta-da-am! 2.5.2 works as it should!
C) Yeah, it works but "I'm wondering why the documentation is wrong"! At first I added embedded write on front-dev request without even checking the result, then he signaled me that there is no "person" property in the doc. After that I found out that the correct Vehicle schema structure is shown in UI and make a request and it worked! But it confused other people :)
Hey @Egor!
Ah, then it seems we have our answer! This *is* likely a bug in ApiPlatform. I’d encourage you to open a bug report about this. Two things would make it likely that it could get fixed quickly:
A) if you can identify the exact first version where the bug was introduced, that’s helpful - e.g. was it 2.5.3 or 2.5.4?
B) if you’re able to create a small project that reproduces the issue and put it on GitHub. That’s a bit more work, but it makes it much easier to debug the cause for a maintainer :).
Cheers! And... I guess... congrats on finding a bug!? 😉
A) OK, as we know “api-platform/core”: “2.5.2” works OK. But there is something new I found out... The doc displays OK only if Person::$firstName annotated with @Groups of noramlizationContext of Vehicle! That is @Groups{“person:read”, “”person:write”, “vehicle:read”, “vehicle:write”} As I remove “vehicle:read” it becomes just a "ownerPerson": "string" in Vehicle docs. I tried to create pictures combining screenshot but my image editing kung-fu is too bad. Let's start moving versions up. 2.5.3 - same behaviour. 2.5.4 - same behaviour. 2.5.5 - “ownerPerson” completely disappears from Vehicle docs until Person::$firstName has @Groups from Vehicle's denormalizationContext (i.e. “vehicle:write”). 2.5.6 - same as 2.5.5. Move to Clause B…
B) Unfortunately I haven’t managed to reproduce the issue on a brand new project. I used exactly the same versions of FrameworkBundle and “api-platform/core” but in a new project it works OK. Sorry... Looks like something wrong with my own code. But on the other side manipulating with "api-platform/core" versions changes the result. May be you could give some direction to pointing me to debug the issue by myself on a local machine?
Hey erop!
Wow :). So, 2.5.5 is where it's introduced. Even these patch releases are pretty big in API Platform, but I don't see anything that jumps out at me - https://github.com/api-plat... - though if this is a bug, it's pretty subtle, so it could be some tiny detail.
> B) Unfortunately I haven’t managed to reproduce the issue on a brand new project
Hmm, yea... if it IS a bug (especially due to the complexity), then you'll need a repeater. Since you haven't been able to reproduce it in a new project, maybe work backwards? Clone your project, then start ripping everything out? See if you can reduce it down to a tiny project that still has the behavior? Or, you could use a slightly less hard-core option, and start a new project, but then copy the composer.lock file from your old project (to ensure the *exact* same dependencies) and see if you can repeat it. If you can, start removing "extra" dependencies to make the app as small as you can.
Let me know what you find out!
Cheers and happy hunting!
Real good tutorial, thank you very much for the effort.
I have an odd situation I wish you could help me with. I don't know if I am missing something or it is just the way it works.
Let's take for example the relation User OneToOne UserAddress.
Let's say I have a User#1 that is related to a UserAddress#1, but I also have an existing UserAddress#2
If I do the following, it will associate the UserAddress#2 to User#1, which is something I don't want to (and also this would update de UserAddress#2)
PUT /users/1
{
userAddress: {
id: "/user_addresses/2",
"postcode": 1234
}
}
Is there a way to prevent this behaviour? I would expect the API to ignore the id and just update the current embedded object with the new postcode value.
Thanks in advance!
Hey Emilio Clemente
I'm not sure if that's the way to update an embedded resource because you are trying to update a UserAddress record that's not related to the User endpoint (/users/1) you are accessing. I think you should update it directly by using the UserAddress endpoint, or by doing so through the User record that's related to UserAddress#2
I hope this makes sense to you. Cheers!
It sure makes sense, but in the context of my app, maybe it is more reasonable to do it in just one request, because I have many user properties that could be updated alongside the address and visually it is all in the same html form. So I was thinking more about security issues here, what if I allow the client to write to the embedded object (UserAddress) and a malicious user tricks the request to modify not only the object but the relationship itself.
Maybe it is not possible, maybe I am getting the concept wrong, but I would like I could set up the API like that.
And thank you very much for the really fast answer.
I get your point, it would be simpler for users indeed. I think what you can do is to only add the fields you want to be editable of the UserAddress resource and don't allow to change the related ID between User and UserAddress
Is this tutorial up to date? Because even if I set the property @id in the owner object I am getting the same error <i>"A new entity was found through the relationship CheeseListing.owner that was not configured to cascade persist operations for entity User."</i>.
`{
"owner": {
"@id": "/users/2",
"username": "MyNewUsername"
}
}`
Thanks!
If someone had the same problem, the solution is to add the Content-Type : application/ld+json to your request and it should works
Hey Fadel,
Thank you for sharing the solution with others!
Cheers!
"Houston: no signs of life"
Start the conversation!
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.21.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
}
}
Hi all,
I'm trying to save an entity with a ManyToOne relation ( in my case, I have a Company with some User; each company belongs to many User and when I persist the company I want to persist User too).
First of all, when you create a ManyToOne entity, doctrine create method "add" and "remove"; if you want to enable embed write, you have to create a setter for user, setUser(array $users ), otherwise embedded write does not work cause the normalizer try to do a set<Field>.
The problem is that apparently, the @Valid annotation does not work, my related entities are not validated and I got a database error in stead of a Violation error; do you know if there is some kind of bug ? Is there a way to validate these subresource?