If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeOur 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.
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.
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 benull
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.
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.
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!
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);
// 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
}
}
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 :
Is this the correct way to initialize data ?
I also had to nullable each property of the <b>CheeseListingInput</b> class, like this:
<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.
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!