Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Escritura incrustada

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

He 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 CheeseListinges 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.

... lines 1 - 22
class User implements UserInterface
{
... lines 25 - 51
/**
... line 53
* @Groups({"user:read", "user:write", "cheese_listing:item:get", "cheese_listing:write"})
... line 55
*/
private $username;
... 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 Usercompletamente 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().

... lines 1 - 39
class CheeseListing
{
... lines 42 - 86
/**
... lines 88 - 90
* @Assert\Valid()
*/
private $owner;
... 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?

Leave a comment!

58
Login or Register to join the conversation
Musa Avatar

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.

Reply

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!

Reply
Gianluca-F Avatar
Gianluca-F Avatar Gianluca-F | posted hace 1 mes | edited

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

Reply
Gianluca-F Avatar

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

Reply
Gianluca-F Avatar

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.

1 Reply

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!

Reply

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.

Reply

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!

Reply
Musa Avatar

Is it possible to do this in a OneToMany scenario, where the one writes to the many?
I tried but I keep getting "The type of the key \"baz\" must be \"int\", \"string\" given." 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.

Reply

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!

1 Reply
Musa Avatar

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.

Reply

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!

Reply
gabrielmustiere Avatar
gabrielmustiere Avatar gabrielmustiere | posted hace 9 meses

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.

Swagger capture

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;
}
}
Reply

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!

1 Reply
gabrielmustiere Avatar
gabrielmustiere Avatar gabrielmustiere | weaverryan | posted hace 9 meses

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

1 Reply

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!

1 Reply
gabrielmustiere Avatar
gabrielmustiere Avatar gabrielmustiere | weaverryan | posted hace 9 meses

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

1 Reply

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!

1 Reply
Patrick D. Avatar
Patrick D. Avatar Patrick D. | posted hace 1 año

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)

Reply

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!

Reply
Patrick D. Avatar
Patrick D. Avatar Patrick D. | victor | posted hace 1 año

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.

Reply

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!

Reply
Patrick D. Avatar
Patrick D. Avatar Patrick D. | victor | posted hace 1 año

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

Reply
Bastian Avatar

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?

Reply

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!

1 Reply
Gaetano S. Avatar
Gaetano S. Avatar Gaetano S. | posted hace 2 años

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

Reply

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!

Reply
Gaetano S. Avatar

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....

Reply

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!

Reply
Gaetano S. Avatar

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

Reply
Default user avatar

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.

4 Reply

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.

2 Reply
Gaetano S. Avatar

Oh yeahhhhhh. Eccellentissimo :) catch..thank you so much....

1 Reply
erop Avatar

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?

Reply
erop Avatar

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

! [NOTE] The "Misd\PhoneNumberBundle\Serializer\Normalizer\PhoneNumberNormalizer" service or alias has been removed or inlined when the container was compiled.
Reply

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!

Reply
erop Avatar

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

Reply

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/ero... - 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!

Reply
erop Avatar
erop Avatar erop | weaverryan | posted hace 2 años | edited

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 :)

Reply

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!? 😉

Reply
erop Avatar

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?

Reply

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!

Reply
Default user avatar
Default user avatar Emilio Clemente | posted hace 2 años

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!

Reply

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!

Reply
Default user avatar
Default user avatar Emilio Clemente | MolloKhan | posted hace 2 años

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.

Reply

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

1 Reply
Olivier Avatar

Is this tutorial up to date? Because even if I set the property @id in the owner object I am getting the same error "A new entity was found through the relationship CheeseListing.owner that was not configured to cascade persist operations for entity User.".

{
"owner": {
"@id": "/users/2",
"username": "MyNewUsername"
}
}

Thanks!

Reply
Fadel C. Avatar

If someone had the same problem, the solution is to add the Content-Type : application/ld+json to your request and it should works

1 Reply

Hey Fadel,

Thank you for sharing the solution with others!

Cheers!

Reply

Hey Olivier!

Yep, the tutorial should be up-to-date :) - API Platform has only had one minor release since we recorded this. So, hmmm. It definitely looks like it's *creating* a new User object instead of updating the existing one (as we would expect). Is the @id value correct? If you're using the normal API Platform setup, the IRI would be /api/users/2 (with /api</code)>

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

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

What PHP libraries does this tutorial use?

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