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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeTo 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.
readableLink on Embedded 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.
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.