Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This tutorial has a new version, check it out!

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!

59
Login or Register to join the conversation
Gianluca-F Avatar
Gianluca-F Avatar Gianluca-F | posted 8 months ago

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?

Reply
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 10 months ago | 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
Musa Avatar Musa | posted 1 year ago | edited

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.

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
Musa Avatar Musa | weaverryan | posted 1 year ago | edited

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

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&quot;&gt;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;
    }
}
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 1 year 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 1 year 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 2 years 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 2 years 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 2 years 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 2 years 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 3 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
Gaetano S. Avatar Gaetano S. | MolloKhan | posted 3 years ago | edited

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 3 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
Default user avatar Hans Grinwis | Gaetano S. | posted 3 years ago | edited

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
erop Avatar erop | posted 3 years ago | edited

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
erop Avatar erop | erop | posted 3 years ago | edited

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>

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

Reply
erop Avatar
erop Avatar erop | weaverryan | posted 3 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 3 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 3 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 3 years ago | edited

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!

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
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
    }
}
userVoice