Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Embedded Write

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

Here's an interesting question: if we fetch a single CheeseListing, we can see that the username comes through on the owner property. And obviously, if we, edit a specific CheeseListing, we can totally change the owner to a different owner. Let's actually try this: let's just set owner to /api/users/2. Execute and... yep! It updated!

That's great, and it works pretty much like a normal, scalar property. But... looking back at the results from the GET operation... here it is, if we can read the username property off of the related owner, instead of changing the owner entirely, could we update the current owner's username while updating a CheeseListing?

It's kind of a weird example, but editing data through an embedded relation is possible... and, at the very least, it's an awesome way to really understand how the serializer works.

Trying to Update the Embedded owner

Anyways... let's just try it! Instead of setting owner to an IRI, set it to an object and try to update the username to cultured_cheese_head. Go, go, go!

And... it doesn't work:

Nested documents for attribute "owner" are not allowed. Use IRIs instead.

So... is this possible, or not?

Well, the whole reason that username is embedded when serializing a CheeseListing is that, above username, we've added the cheese_listing:item:get group, which is one of the groups that's used in the "get" item operation.

The same logic is used when writing a field, or, denormalizing it. If we want username to be writable while denormalizing a CheeseListing, we need to put it in a group that's used during denormalization. In this case, that's cheese_listing:write.

Copy that and paste it above 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
}

As soon as we do that - because the owner property already has this group - the embedded username property can be written! Let's go back and try it: we're still trying to pass an object with username. Execute!

Sending New Objects vs References in JSON

And... oh... it still doesn't work! But the error is fascinating!

A new entity was found through the relationship CheeseListing.owner that was not configured to cascade persist operations for entity User.

If you've been around Doctrine for awhile, you might recognize this strange error. Ignoring API Platform for a moment, it means that something created a totally new User object, set it onto the CheeseListing.owner property and then tried to save. But because nobody ever called $entityManager->persist() on the new User object, Doctrine panics!

So... yep! Instead of querying for the existing owner and updating it, API Platform took our data and used it to create a totally new User object! That's not what we wanted at all! How can we tell it to update the existing User object instead?

Here's the answer, or really, here's the simple rule: if we send an array of data, or really, an "object" in JSON, API Platform assumes that this is a new object and so... creates a new object. If you want to signal that you instead want to update an existing object, just add the @id property. Set it to /api/users/2. Thanks to this, API Platform will query for that user and modify it.

Let's try it again. It works! Well... it probably worked - it looks successful, but we can't see the username here. Scroll down and look for the user with id 2.

There it is!

Creating new Users?

So, we now know that, when updating... or really creating... a CheeseListing, we can send embedded owner data and signal to API Platform that it should update an existing owner via the @id property.

And when we don't add @id, it tries to create a new User object... which didn't work because of that persist error. But, we can totally fix that problem with a cascade persist... which I'll show in a few minutes to solve a different problem.

So wait... does this mean that, in theory, we could create a brand new User while editing a CheeseListing? The answer is.... yes! Well... almost. There are 2 things preventing it right now: first, the missing cascade persist, which gave us that big Doctrine error. And second, on User, we would also need to expose the $password and $email fields because these are both required in the database. When you start making embedded things writeable, it honestly adds complexity. Make sure you keep track of what and what is not possible in your API. I don't want users to be created accidentally while updating a CheeseListing, so this is perfect.

Embedded Validation

But, there is one weird thing remaining. Set username to an empty string. That shouldn't work because we have a @NotBlank() above $username.

Try to update anyways. Oh, of course! I get the cascade 500 error - let me put the @id property back on. Try it again.

Woh! A 200 status code! It looks like it worked! Go down and fetch this user... with id=2. They have no username! Gasp!

This... is a bit of a gotcha. When we modify the CheeseListing, the validation rules are executed: @Assert\NotBlank(), @Assert\Length(), etc. But when the validator sees the embedded owner object, it does not continue down into that object to validate it. That's usually what we want: if we were only updating a CheeseListing, why should it also try to validate a related User object that we didn't even modify? It shouldn't!

But when you're doing embedded object updates like we are, that changes: we do want validation to continue down into this object. To force that, above the owner property, add @Assert\Valid().

... lines 1 - 39
class CheeseListing
{
... lines 42 - 86
/**
... lines 88 - 90
* @Assert\Valid()
*/
private $owner;
... lines 94 - 197
}

Ok, go back, and... try our edit endpoint again. Execute. Got it!

owner.username: This value should not be blank

Nice! Let's go back and give this a valid username... just so we don't have a bad user sitting in our database. Perfect!

Being able to make modifications on embedded properties is pretty cool... but it does add complexity. Do it if you need it, but also remember that we can update a CheeseListing and a User more simply by making two requests to two endpoints.

Next, let's get even crazier and talking about updating collections: what happens if we start to try to modify the cheeseListings property directly on a User?

Leave a comment!

52
Login or Register to join the conversation

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 7 months ago

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 7 months ago

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 7 months ago

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 1 year ago

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 1 year ago

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 1 year ago

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
Bastian Avatar Bastian | posted 1 year ago

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 2 years ago

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
Gaetano S. Avatar Gaetano S. | victor | posted 2 years ago

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 2 years ago | 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 2 years ago

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 2 years ago

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
Olivier Avatar Olivier | posted 2 years ago

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
Olivier Avatar

I have removed the /api because my domain already contain "api", I don't like duplicate. My IRI are fonctionnal because it work good at others places so I can confirm you that the problem don't come from there. For example, I can make a POST on my "/employees" with owner = /users/2 and all work good. :)

My API is pretty new, I'm using the last version of API Platform with Symfony 5.0.1. I followed your tutorial since the beginning and all work nicely except for Embedded write.

On my side, my entity are Employee (with an owner property just like you) and User.
Employee: https://gist.github.com/coo...
User: https://gist.github.com/coo...

Is it possible for you to take a 2 minutes to look at my annotations? It's pretty weird :/
I hope you can help me, thanks!

EDIT: I use PUT on url http://api.domain.local:8785/employees/13 with this in body:

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

Reply

Hey Olivier

So you were using POST instead of PUT? Could you fix your problem?

Cheers!

Reply
Olivier Avatar

No I'm not using POST, I'm using PUT to do an "Embedded Write", is PUT the correct verb for that operation?

Here is it : https://i.imgur.com/wEFcGLZ...

Reply

Hey @Olivier!

Well... I don't know if I have good news or bad news :). Actually, I think it's good news. Here's what I did:

1) Installed a new Symfony 5.0 project using the website-skeleton (which just means that things like Doctrine, Twig, etc are pre-installed)
2) Added your 2 entities exactly to my project
3) Removed a few extra properties - like "job" - to get things working
4) Then used the API Platform admin area to do exactly what you were trying to do. And... it worked!

Here's the PUT request I made (I previously inserted 1 Employee with id 1 and 2 Users with id's 1 & 2):

https://imgur.com/2gfPkia

And here is the result:

https://imgur.com/H8UQtCT

As you can see, it worked as expected! By the way, here is the project I built: https://github.com/weaverry...

I believe the problem is that you're sending the wrong Content-Type header. It must be: Content-Type: application/ld+json. If you instead send Content-Type: application/json, you will get the error you're getting. The reason is that it is the JSON-ld format that understand the significance of the "@id" stuff. If API Platform processes this as raw JSON, it just tries to always create a new object and it sees @id as a normal property that it might try to set.

I can't see your headers to be sure, so let me know.

Cheers!

2 Reply
Olivier Avatar

Thanks for your time! I manage to fix the problem by myself, weirdly it was about validation with @Asset :/ Thanks again, I finally bought the tutorial because it's not so easy with the text only haha.

Reply
Ajie62 Avatar

At first, let me thank you (again) for this well explained chapter! I understand how embedded write works now, however I'm not quite sure in which context it might come in handy in a real life project. Could you please give me an example of its usefulness so I can fully understand the concept? Thank you!

Reply
Cat in space

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

This tutorial works great for Symfony 5 and API Platform 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
    }
}