Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

DTO Quirks: Embedded Objects

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

To see another, kind of, "quirk" of DTO's, go to /api/users.jsonld. Oh, this tells me to log in. Ooooook. I'll go to the homepage, hit log in, and... excellent. Close that tab and refresh again.

Check out the embedded cheeseListings field. That's... not right. An embedded object... with only the @id field?

We know that if none of the fields on a related object will be serialized, then API Platform should return an array of IRI strings instead of embedding the objects.

This is a bug in how the readableLink for properties is calculated when you have a DTO. I've actually fixed this bug... but I need to finish that pull request.

Specifically, in the User class, if we search for getPublishedCheeseListings(), this is the method that gives us the cheeseListings property:

... lines 1 - 42
class User implements UserInterface
{
... lines 45 - 210
/**
... line 212
* @SerializedName("cheeseListings")
... line 214
*/
public function getPublishedCheeseListings(): Collection
{
return $this->cheeseListings->filter(function(CheeseListing $cheeseListing) {
return $cheeseListing->getIsPublished();
});
}
... lines 222 - 288
}

But because CheeseListing uses a DTO, it doesn't calculate readableLink correctly. Remember: readableLink is calculated by checking to see if the embedded object - CheeseListing has any properties that are in the same normalization groups as User. But... since CheeseListing isn't actually the object that will ultimately be serialized... API Platform should really check to see if CheeseListingOutput has any fields in the user:read group.

Anyways, one way to fix this is just to force it. We can say @ApiProperty with readableLink=false:

... lines 1 - 42
class User implements UserInterface
{
... lines 45 - 210
/**
* @ApiProperty(readableLink=false)
... lines 213 - 215
*/
public function getPublishedCheeseListings(): Collection
{
... lines 219 - 221
}
... lines 223 - 289
}

Now, when we move over and refresh... that will force it to use IRI strings. So... this is another quirk to be aware of, but hopefully it will get fixed soon.

IRI String Problem with Multiple Output Classes

By the way, the problem of an object being embedded when it should be an IRI string gets a bit worse if you use multiple output classes. Like, if User also had a UserOutput with a cheeseListings field... even adding readableLink=false wouldn't help. If you have this situation, you can check out a conversation about it in the comments.

Re-Embedding some Fields

Anyways, I'm going to remove the readableLink. Why? Because originally, before we started with all this output stuff, we were actually embedding the CheeseListing data in User because we were including a couple of fields.

In CheeseListing, go down to the title property. We put this in the user:read group... and we did the same for price:

... lines 1 - 62
class CheeseListing
{
... lines 65 - 71
/**
... line 73
* @Groups({"cheese:read", "cheese:write", "user:read", "user:write"})
... lines 75 - 80
*/
private $title;
... lines 83 - 90
/**
... lines 92 - 94
* @Groups({"cheese:read", "cheese:write", "user:read", "user:write"})
... line 96
*/
private $price;
... lines 99 - 221
}

We did that because we wanted these two fields to be embedded when serializing a User.

The reason that wasn't happening now is... well... because I forgot to add these in CheeseListingOutput. Let's fix that: above title, add user:read and then also add user:read to price:

... lines 1 - 8
class CheeseListingOutput
{
/**
... lines 12 - 13
* @Groups({"cheese:read", "user:read"})
... line 15
*/
public $title;
... lines 18 - 24
/**
... line 26
* @Groups({"cheese:read", "user:read"})
*/
public $price;
... lines 30 - 59
}

Let's check it out! Refresh now. That is how it looked before.

Cleaning Up CheeseListing!

So... hey! We switched to an output DTO! And we're now getting the same output we had before! Yes, there were a few bumps along the way, but overall, it's a really clean process. This output class holds the fields that we actually want to serialize and the data transformer gives us a simple way to create that object from a CheeseListing:

... lines 1 - 8
class CheeseListingOutputDataTransformer implements DataTransformerInterface
{
... lines 11 - 13
public function transform($cheeseListing, string $to, array $context = [])
{
$output = new CheeseListingOutput();
$output->title = $cheeseListing->getTitle();
$output->description = $cheeseListing->getDescription();
$output->price = $cheeseListing->getPrice();
$output->owner = $cheeseListing->getOwner();
$output->createdAt = $cheeseListing->getCreatedAt();
return $output;
}
... lines 25 - 29
}

So let's celebrate! If you bring the pizza, I'll clean up the CheeseListing class. Because... it no longer needs anything related to serializing.... because this object is no longer being serialized!

Search for :read to find things we can delete. Remove cheese:read and user:read from title, but keep the write groups because we are still deserializing into this object when creating or updating cheese listings:

... lines 1 - 62
class CheeseListing
{
... lines 65 - 71
/**
... line 73
* @Groups({"cheese:write", "user:write"})
... lines 75 - 80
*/
private $title;
... lines 83 - 198
}

Then, down on description, remove @Groups entirely... for price, remove the two read groups, and also remove cheese:read above owner:

... lines 1 - 62
class CheeseListing
{
... lines 65 - 83
/**
* @ORM\Column(type="text")
* @Assert\NotBlank()
*/
private $description;
/**
... lines 91 - 93
* @Groups({"cheese:write", "user:write"})
... line 95
*/
private $price;
... lines 98 - 109
/**
... lines 111 - 112
* @Groups({"cheese:collection:post"})
... line 114
*/
private $owner;
... lines 117 - 198
}

Finally, down on getShortDescription(), we can remove the method entirely! Well, if you're calling it from somewhere else in your app, keep it. But we're not. Also delete getCreatedAtAgo():

... lines 1 - 62
class CheeseListing
{
... lines 65 - 139
/**
* @Groups("cheese:read")
*/
public function getShortDescription(): ?string
{
if (strlen($this->description) < 40) {
return $this->description;
}
return substr($this->description, 0, 40).'...';
}
... lines 151 - 188
/**
* How long ago in text that this cheese listing was added.
*
* @Groups("cheese:read")
*/
public function getCreatedAtAgo(): string
{
return Carbon::instance($this->getCreatedAt())->diffForHumans();
}
... lines 198 - 221
}

This is a nice benefit of DTO's: we can slim down our entity class and focus it on just being an entity that persists data. The serialization logic is somewhere else.

Let's make sure I didn't break something accidentally: move over, refresh the users endpoint and... bah! The cheeseListings property became an array of IRIs! This is, once again, a case where readableLink is not being calculated correctly. Now that we've removed the groups from CheeseListing, API Platform incorrectly thinks that User and CheeseListing don't have any overlapping normalization groups... but in reality, CheeseListingOutput does.

Re-add the @ApiProperty but this time say readableLink=true because we do want to force an embedded object:

... lines 1 - 42
class User implements UserInterface
{
... lines 45 - 210
/**
* @ApiProperty(readableLink=true)
... lines 213 - 215
*/
public function getPublishedCheeseListings(): Collection
{
... lines 219 - 221
}
... lines 223 - 289
}

When we refresh now... yes! It's back to an embedded object. Also try /api/cheeses.jsonld... that looks good, and let's run the tests one last time:

symfony php bin/phpunit

They do pass. With output DTO's, you need to be a bit more careful, though some - but not all - of these "quirks" have already been fixed or will be soon. The important thing to keep in mind is that DTO's are not serialized in exactly the same way as ApiResource classes. So code carefully.

Next: let's talk about using an input DTO.

Leave a comment!

12
Login or Register to join the conversation
Bernard A. Avatar
Bernard A. Avatar Bernard A. | posted 5 months ago

Hmmm... I am a bit underwhelmed by DTO so far.

At least with the example provided in this course.

Interesting as it may be to be able to have another tool to do what I could do with Entities. Just separating code somewhere else does not feel

like much of an upside, at least to me.

So, my question is: can one with DTO also access - in the instance of this course - the daily stats data source and add the corresponding output to the DTO?

Or, generally speaking, can one add any external source, like complex queries coming from the repositories ( group by, sum, multiple tables ) to the output.

Reply

Hey Bernard A.!

Hmm. Can you ask this again... I don't quite understand yet. Are you asking if you could... add OTHER data to an output DTO? If so... my answer is "sure, you can add whatever you want to the DTO... because you could do whatever custom stuff you want in the "data provider". But I think that I am not really answering your question yet ;).

Cheers!

Reply
Bernard A. Avatar

Not quite answering my question. really! :)

And you threw a curve ball now when you mentioned "data provider".

At least in this course you did not bring up a DataProvider in connection with DTO/DataTransformer.

All is happening here is that - as it seems to me - you are refactoring code within the Entity out to DTO/DataTransformer.

When one wanted to add data from an external source, as in your case with DailyStat, or mine with GROUP BY query, one had to reach for a DataProvider.

If there is or could be a connection from the DataTransformer to the DataProvider I am at a bit of a loss.

The DataTransformer has only one actual working method which is "transform".

So, you are saying that one can on 'transform" access the DataProvider info. How?

1 Reply

Hey Bernard A.!

Hmm. Ok, let's back up :). Can you explain (on a high level, not really thinking about the code) what you'd like to accomplish?

So yes, when you use an "output DTO", then you have a data transformer, whose job is to "transform" from the source entity into the "output DTO" object. The transform() method is passed the entity object... and you use it to create and populate the DTO object. If your goal is to add *extra* data to the DTO - data that is not directly on the entity, but is instead from some custom query that you create - then you *are* free to add that inside of the data transformer. For example, in CheeseListingOutputDataTransformer, I could inject the CheeseListingRepository, create a custom query inside of transform(), and use that result to populate some properties on CheeseListingOutput.

Does that help? I'm still not sure I'm directly answering your question, but hopefully we're closer at least :).

Cheers!

Reply
Bernard A. Avatar

That will do, thanks!

Reply

There's one thing which isn't covered by this course, yet makes my brain explode and crash.

I'm trying to make my API use exclusively DTOs for both input and output. Furthermore, I want to reuse same classes for both input and output.

This works fine so long as I'm not trying to send embedded objects via JSON. However, I hit a very thick and sturdy wall as soon as I attempt to do that. I've been banging my head and trying to break that wall for a few weekdays already, to no avail. The problem is, It seems that even though I specify an input class for a resource which I'm sending embedded, a denormalizer for that DTO class is never called, and neither is the DTO to Entity transformer. Instead, Api Platform attempts to denormalize my input directly into the Entity class, creates an entity full of null values, and then causes an exception when trying to save that very very broken value into the database.

I'm talking about Api Platform 2.5 here. I sure hope that this is fixed in 2.6, but I need to investigate whether or not that is the case.

Does this sound familiar? Do you maybe have an idea about how I could solve this problem?

Edit: I've filed this as a bug in GitHub after chatting to Kévin on slack.

Reply

Hey Adeoweb!

It sounds like you already answered your question by talking with Kévin. I was also going to guess "bug" because the DTO's, in general, have quirks (as you saw in this video). So it definitely seems to me like you tripped over yet another quirk. When things get SUPER custom, this is one big reason I lean towards totally custom API classes, instead of entity classes with DTO's. But there are so many use-cases, and what's best varies.

Good luck!

1 Reply

Thanks! Actually, totally custom classes is also what Kévin suggested. But I just don't feel like willing to lose all the perks that come with Doctrine-based resources (yet), such as pagination.

Reply

FAIR point :). So you're stuck choosing the "best available option"... but nothing is perfect! Ah, programming...

Reply
Default user avatar
Default user avatar Tuan Vu | posted 1 year ago

Hi Ryan,
Is there a way to add the ApiResource configuration to a DTO by yaml file format? I'm struggling with that.

Reply

Hey Tuan Vu !

Hmm, I've never tried! From my reading, a DTO should not look any different than an "entity" resource - https://api-platform.com/do...

As long as you create that config/api_platform/resources.yaml file and point to it from config/packages/api_platform.yaml, things should work.

Are you getting an error... or API Platform is just not seeing your DTO at all?

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": "^7.4.0 || ^8.0",
        "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
    }
}