Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Input DTO Update Problems

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

Our CheeseListingInput DTO now works to create new listings, but it does not work for updates. The reason is simple: our data transformer always creates new CheeseListing objects:

... lines 1 - 8
class CheeseListingInputDataTransformer implements DataTransformerInterface
{
... lines 11 - 13
public function transform($input, string $to, array $context = [])
{
... lines 16 - 17
$cheeseListing = new CheeseListing($input->title);
... lines 19 - 23
return $cheeseListing;
}
... lines 26 - 35
}

What we really need to do is query for an existing CheeseListing object from the database when the operation is an update.

And... doing this is pretty easy! When we make a put request - or really, any item operation - API Platform already queries for the underlying CheeseListing entity object. We just need to use that in our transformer. How? API Platform puts the current "item" for an item operation - the CheeseListing entity in this case - onto the context.

Fetching the Entity from the Context

Check it out: say if isset($context[]) and look for a special key: AbstractItemNormalizer::OBJECT_TO_POPULATE. Let me fix my syntax:

... lines 1 - 5
use ApiPlatform\Core\Serializer\AbstractItemNormalizer;
... lines 7 - 9
class CheeseListingInputDataTransformer implements DataTransformerInterface
{
... lines 12 - 14
public function transform($input, string $to, array $context = [])
{
if (isset($context[AbstractItemNormalizer::OBJECT_TO_POPULATE])) {
... lines 18 - 20
}
... lines 22 - 28
}
... lines 30 - 39
}

This is relates to a feature that's part of Symfony's serializer. And... I'm being a bit lazy. The constant actually lives on one of AbstractItemNormalizer's parent classes: AbstractNormalizer from Symfony. Feel free to reference that class instead.

Anyways, normally, when you deserialize JSON into an object, the serializer creates a new object. But if you want it to update an existing object, you can tell the serializer to do that by passing that object on this key of the $context.

In the case of an input DTO, the OBJECT_TO_POPULATE is actually the underlying CheeseListing object. So we can say $cheeseListing = and I'll copy that long $context and paste it here:

... lines 1 - 9
class CheeseListingInputDataTransformer implements DataTransformerInterface
{
... lines 12 - 14
public function transform($input, string $to, array $context = [])
{
if (isset($context[AbstractItemNormalizer::OBJECT_TO_POPULATE])) {
$cheeseListing = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE];
... lines 19 - 20
}
... lines 22 - 28
}
... lines 30 - 39
}

Else, copy and move up the $cheeseListing = new CheeseListing() line:

... lines 1 - 9
class CheeseListingInputDataTransformer implements DataTransformerInterface
{
... lines 12 - 14
public function transform($input, string $to, array $context = [])
{
if (isset($context[AbstractItemNormalizer::OBJECT_TO_POPULATE])) {
$cheeseListing = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE];
} else {
$cheeseListing = new CheeseListing($input->title);
}
... lines 22 - 28
}
... lines 30 - 39
}

That's it! Notice that this means the title can't be updated: if the user sent that field on a PUT request, we can't change it on the final CheeseListing because the only way to set title on CheeseListing is via the constructor. There is no setTitle() method.

And... that was actually already the case before! The title field was never used on update. API Platform does a great job of making our API work like our classes work.

Update Problem: Not Overriding Existing Data

Anywho, let's try this. Back at the docs, I still have the POST endpoint open. Copy all the data we sent. And, let's see... this created a new cheese listing with id 53. Close up this operation, open the put operation, hit "Try it out", enter 53 for the ID and, in the box, paste the fields. Let's change the price to be 5000.

Hit Execute. And... ah! 500 error!

owner_id cannot be null

Figuring this out requires some digging. Look over at CheeseListingInput. The owner is in the cheese:collection:post group:

... lines 1 - 8
class CheeseListingInput
{
... lines 11 - 22
/**
... line 24
* @Groups({"cheese:collection:post"})
*/
public $owner;
... lines 28 - 48
}

Thanks to our denormalization groups, this means that it will only be used during deserialization on the create, POST operation. Yep, we're sending owner, but it's being ignored.

But... why is that a problem? That... is the behavior we want! The owner is meant to be set on create, then never changed.

The issue is that when the serializer deserializes the JSON into the CheeseListingInput, the owner property will be null... and then we pass that null value onto $cheeseListing->setOwner(). When API Platform tries to save the CheeseListing with no owner... error!

But... let's back up: there's a bigger problem that causes this. In reality, when the CheeseListingInput object is passed to us, if a property on it is null, we don't really know if that field is null because the user explicitly sent null for a field - like title: null - or if they simply omitted the field.

And... that's important! If a field is not in the JSON for an update, it means the field should not be changed: we should use the value from the database.

Ideally, this CheeseListingInput object would first be initialized using the data from the CheeseListing that's in the database. And then the JSON would be deserialized onto it. If we did this, any fields that were not sent in the JSON would remain at their original values.

But... that does not happen and it means that we don't have enough information in this function to figure out how to handle null fields.

The new DataTransformerInitializerInterface

This is actually a missing feature in API Platform. Well, it was until we talked to the API Platform team about it... and then they added the feature. They rock! You can see it as pull request 3701 and it should be available in API Platform 2.6.

Here's how it's going to work: you'll add a new interface to your data transformer: DataTransformerInitializerInterface, which will force you to have a new initialize() method.

As soon as you have this, API Platform will call it before the transform method. Your job will be to grab the OBJECT_TO_POPULATE off of the $context - which will be the CheeseListing from the database - and use it to create and initialize the data on a CheeseListingInput. Then, when API Platform calls transform(), it will pass us an input object that is pre-filled with data.

If you're using API Platform 2.6 and have any questions, let us know in the comments. But since 2.6 hasn't been released yet, let's implement this feature ourselves by leveraging a trick inside a custom normalizer. That's next.

Leave a comment!

4
Login or Register to join the conversation
Kiuega Avatar
Kiuega Avatar Kiuega | posted 2 years ago | edited

Hello ! I'm using <b>API Platform <u>2.6</u></b>, so I can use the <b>DataTransformerInitializerInterface</b> interface.
So I tried something like this :


    public function initialize(string $inputClass, array $context = [])
    {
        $dto = new CheeseListingInput();

        if(isset($context[AbstractItemNormalizer::OBJECT_TO_POPULATE])) {

            /** @var CheeseListing $cheeseListing */
            $cheeseListing = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE];

            $dto->title = $cheeseListing->getTitle();
            $dto->description = $cheeseListing->getDescription();
            $dto->price = $cheeseListing->getPrice();
            $dto->isPublished = $cheeseListing->getIsPublished();
            $dto->owner = $cheeseListing->getOwner();
        }

        return $dto;
    }

Is this the correct way to initialize data ?

I also had to nullable each property of the <b>CheeseListingInput</b> class, like this:


    #[Groups(['cheese:write', 'user:write'])]
    public ?string $title = null;

    #[Groups(['cheese:write', 'user:write'])]
    public ?int $price = null;

    #[Groups(['cheese:collection:post'])]
    public ?User $owner = null;

    #[Groups(['cheese:write'])]
    public ?bool $isPublished = false;

    public ?string $description = null;

<u><b>The files :</b></u>

<b>CheeseListingInput</b> : https://gist.github.com/bastien70/859da9286ceb6705f1edd28337e4bc1f
<b>CheeseListingInputDataTransformer</b> : https://gist.github.com/bastien70/aabe1d26569cae96a5c254eb75e9bb5c

Everything seems to be working at this point. I am therefore awaiting your approval to know if this was the way to proceed.

Thank you ! :)

<b>EDIT</b> : Aaaargh ... continuing the course, I saw that you had developed a 'createDto ()' method in the denormalizer that we create in the event that we do not have the right version of API Platform.


    /**
     * @throws \Exception
     */
    private function createDto(array $context): CheeseListingInput
    {
        $entity = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] ?? null;
        $dto = new CheeseListingInput();
        // not an edit, so just return an empty DTO
        if (!$entity) {
            return $dto;
        }
        if (!$entity instanceof CheeseListing) {
            throw new \Exception(sprintf('Unexpected resource class "%s"', get_class($entity)));
        }
        $dto->title = $entity->getTitle();
        $dto->price = $entity->getPrice();
        $dto->description = $entity->getDescription();
        $dto->owner = $entity->getOwner();
        $dto->isPublished = $entity->getIsPublished();
        return $dto;
    }

In the end I was not that far from the right answer, apart from the fact that I had not put any safety instructions there to avoid potential errors. But I finally imagine that I can use this function by simply calling it inside my '<b>initialize()</b>' method!

1 Reply

Hey again Kiuega!

Nice work! So yes, your initialize() method looks right to me, as I think you figured out by looking at the createDto(). And I'm happy you were able to use the new 2.6 feature to make this easier.

If you still have any doubts about any of it, let me know :).

Cheers!

2 Reply
Jakub Avatar
Jakub Avatar Jakub | posted 6 months ago | edited

Hi!
I was wondering if this whole block:

if (isset($context[AbstractItemNormalizer::OBJECT_TO_POPULATE])) {
            $cheeseListing = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE];
        } else {
            $cheeseListing = new CheeseListing($input->title);
        }

could be shortened into one line like this:
$cheeseListing = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] ?? new CheeseListing($input->title);

Reply

Hey Jakub,

Yes, it should do the same and will read a bit nicer

Cheers!

Reply
Cat in space

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

This tutorial also works great with API Platform 2.6.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.5.10
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.12.1
        "doctrine/doctrine-bundle": "^2.0", // 2.1.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.2
        "doctrine/orm": "^2.4.5", // 2.8.2
        "nelmio/cors-bundle": "^2.1", // 2.1.0
        "nesbot/carbon": "^2.17", // 2.39.1
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0 || ^5.0", // 5.2.2
        "ramsey/uuid-doctrine": "^1.6", // 1.6.0
        "symfony/asset": "5.1.*", // v5.1.5
        "symfony/console": "5.1.*", // v5.1.5
        "symfony/debug-bundle": "5.1.*", // v5.1.5
        "symfony/dotenv": "5.1.*", // v5.1.5
        "symfony/expression-language": "5.1.*", // v5.1.5
        "symfony/flex": "^1.1", // v1.18.7
        "symfony/framework-bundle": "5.1.*", // v5.1.5
        "symfony/http-client": "5.1.*", // v5.1.5
        "symfony/monolog-bundle": "^3.4", // v3.5.0
        "symfony/security-bundle": "5.1.*", // v5.1.5
        "symfony/twig-bundle": "5.1.*", // v5.1.5
        "symfony/validator": "5.1.*", // v5.1.5
        "symfony/webpack-encore-bundle": "^1.6", // v1.8.0
        "symfony/yaml": "5.1.*" // v5.1.5
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.3.2
        "symfony/browser-kit": "5.1.*", // v5.1.5
        "symfony/css-selector": "5.1.*", // v5.1.5
        "symfony/maker-bundle": "^1.11", // v1.23.0
        "symfony/phpunit-bridge": "5.1.*", // v5.1.5
        "symfony/stopwatch": "5.1.*", // v5.1.5
        "symfony/twig-bundle": "5.1.*", // v5.1.5
        "symfony/web-profiler-bundle": "5.1.*", // v5.1.5
        "zenstruck/foundry": "^1.1" // v1.8.0
    }
}
userVoice