Buy Access to Course
34.

DTO Quirks

Share this awesome video!

|

Keep on Learning!

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

The last field that we're missing on CheeseListingOutput is owner:

223 lines | src/Entity/CheeseListing.php
// ... lines 1 - 62
class CheeseListing
{
// ... lines 65 - 110
/**
* @ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="cheeseListings")
* @ORM\JoinColumn(nullable=false)
* @Groups({"cheese:read", "cheese:collection:post"})
* @IsValidOwner()
*/
private $owner;
// ... lines 118 - 221
}

No worries: in CheeseListingOutput, add public $owner. Then copy the PHPDoc from price and paste that here. We know that this will be a User object and we'll put it in the cheese:read group:

61 lines | src/Dto/CheeseListingOutput.php
// ... lines 1 - 4
use App\Entity\User;
// ... lines 6 - 8
class CheeseListingOutput
{
// ... lines 11 - 32
/**
* @var User
* @Groups({"cheese:read"})
*/
public $owner;
// ... lines 38 - 59
}

Over in the data transformer, populate that with $output->owner = $cheeseListing->getOwner():

// ... lines 1 - 8
class CheeseListingOutputDataTransformer implements DataTransformerInterface
{
// ... lines 11 - 13
public function transform($cheeseListing, string $to, array $context = [])
{
// ... lines 16 - 19
$output->owner = $cheeseListing->getOwner();
// ... lines 21 - 23
}
// ... lines 25 - 29
}

Easy enough! Try it: find your browser, refresh, and it works! The owner field is an embedded object because the phoneNumber field is being exposed.

It turns out, this detail is important. Go into the User class and look at the phoneNumber property. This is actually in two groups: owner:read an admin:read:

290 lines | src/Entity/User.php
// ... lines 1 - 42
class User implements UserInterface
{
// ... lines 45 - 92
/**
// ... line 94
* @Groups({"admin:read", "owner:read", "user:write"})
*/
private $phoneNumber;
// ... lines 98 - 288
}

Right now, I'm logged in as an admin... and we created special code in the last tutorial to always add the admin:read group in this situation. This is the reason why we're able to see the phoneNumber on every user.

Let's see what happens when we are not an admin. Open a new tab, go to the homepage and click log out. Perfect.

User Serialization Has Changed?

Now that we're anonymous, refresh the same endpoint. Error? Interesting:

The return value of UserNormalizer::normalize() - that's a class we created in a previous tutorial - must be type array, string returned.

Let's go check that out: src/Serializer/Normalizer/UserNormalizer.php. The purpose of this is to add an extra owner:read group if the User that's being serialized is the currently-authenticated user:

// ... lines 1 - 11
class UserNormalizer implements ContextAwareNormalizerInterface, CacheableSupportsMethodInterface, NormalizerAwareInterface
{
// ... lines 14 - 27
public function normalize($object, $format = null, array $context = array()): array
{
$isOwner = $this->userIsOwner($object);
if ($isOwner) {
$context['groups'][] = 'owner:read';
}
$context[self::ALREADY_CALLED] = true;
return $this->normalizer->normalize($object, $format, $context);
}
// ... lines 39 - 65
}

The error says that this method is returning a string but it's supposed to return an array. And... it's right. Look at my normalize() method: I gave it an array return type:

// ... lines 1 - 11
class UserNormalizer implements ContextAwareNormalizerInterface, CacheableSupportsMethodInterface, NormalizerAwareInterface
{
// ... lines 14 - 27
public function normalize($object, $format = null, array $context = array()): array
{
// ... lines 30 - 37
}
// ... lines 39 - 65
}

But apparently, when we call $this->normalizer->normalize(), this returns a string:

// ... lines 1 - 11
class UserNormalizer implements ContextAwareNormalizerInterface, CacheableSupportsMethodInterface, NormalizerAwareInterface
{
// ... lines 14 - 27
public function normalize($object, $format = null, array $context = array()): array
{
// ... lines 30 - 36
return $this->normalizer->normalize($object, $format, $context);
}
// ... lines 39 - 65
}

And, hmm... that makes sense. Now that we're anonymous, the phoneNumber field will not be returned. And so, when the embedded User object is serialized, instead of returning an array of fields, it is now normalized into its IRI string.

Ok! So if you normalize a User object, sometimes it will be an object and sometimes it will be an IRI string. The fix is to remove the array return type:

// ... lines 1 - 11
class UserNormalizer implements ContextAwareNormalizerInterface, CacheableSupportsMethodInterface, NormalizerAwareInterface
{
// ... lines 14 - 27
public function normalize($object, $format = null, array $context = array())
{
// ... lines 30 - 37
}
// ... lines 39 - 65
}

That was actually never needed... it's not on the normalize() method's interface. I added it simply because I thought this would always return an array.

When we refresh now... yep! The owner property is an IRI string.

But... wait a second. Why didn't we have this error before? Before we started working with the output DTO stuff... shouldn't we have had this same problem with UserNormalizer? Why wasn't it a problem until now?

DTO's Serialize Differently

Here's the answer, and it's important. When you use an output class like we're doing for CheeseListing, the object that's ultimately serialized is CheeseListingOutput:

223 lines | src/Entity/CheeseListing.php
// ... lines 1 - 20
/**
* @ApiResource(
* output=CheeseListingOutput::CLASS,
// ... lines 24 - 47
* )
// ... lines 49 - 61
*/
class CheeseListing
{
// ... lines 65 - 221
}

And because that class isn't technically an API Resource class, it's serialized in a slightly different way internally. For the serialization nerds out there, API resource classes are usually normalized using ItemNormalizer which extends AbstractItemNormalizer. But with a DTO object, it instead uses the simpler ObjectNormalizer.

Where Did @type Go?

This causes small, but important differences. For example, when you use an output DTO, the @type field is gone. We have @id... but not @type. This actually makes one of our tests fail.

Find your terminal and run:

symfony php bin/phpunit

Yep! One failure because one test is looking for @type. Let's open this test up: tests/Functional/CheeseListingResourceTest.php and then scroll down to testGetCheeseListingCollection(). Let's see... here it is:

// ... lines 1 - 10
class CheeseListingResourceTest extends CustomApiTestCase
{
// ... lines 13 - 146
public function testGetCheeseListingCollection()
{
// ... lines 149 - 167
$this->assertJsonContains(['hydra:member' => [
0 => [
// ... line 170
'@type' => 'cheese',
// ... lines 172 - 177
]
]]);
}
// ... lines 181 - 197
}

That @type is no longer being returned. For now, just delete it so that the tests will pass.

But good news! Thanks to the API Platform team, this bug has been fixed and should be released in API Platform 2.5.8. But since that hasn't been released yet at the time of this recording, we'll move on.

Run the tests again:

symfony php bin/phpunit

And... green! Next, I want to look a bit deeper at how the serialization of embedded objects is different with DTO classes and what to do about it.