Buy Access to Course
33.

Output Properties & Metadata

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

Let's go see how having an output class affects our documentation. Refresh the docs homepage. One of the things that this tells us is what to expect back when you use an endpoint. For example, if we look at the get item operation for cheeses, we know that, thanks to our "work-in-progress" output class, this will return something different than it did a few minutes ago. And if we look at the schema... yeah, awesome! The documentation recognizes this! It correctly tells us that the only field we should expect back is title.

DTO Documentation Models

Oh, and notice that it has this really weird name. This is referring to the "models" at the bottom of the page. Scroll down to see them.

API Platform creates a unique "model" for each different way that a resource might be returned based on your serialization groups. And when you create an output DTO, it creates yet another model class to describe that... with a unique "sha" in the name.

I don't know the full story behind why that sha is there, but technically, you can configure a different output class for a resource on an operation-by-operation basis. So basically, API Platform uses a hash to guarantee that each output model has a unique name. It's a little ugly, but I don't think it really makes much of a difference.

The big point is: API Platform does correctly notices that we're using an output class and is uses that class to generate which fields will be returned in the documentation, which, right now, is only title.

Documenting the Fields

But... it doesn't have any documentation for that field. Like, it doesn't know what type title will be.

And... that's no surprise! When we serialize a CheeseListing entity, API Platform can use the Doctrine metadata above the title property to figure out that it's a string:

223 lines | src/Entity/CheeseListing.php
// ... lines 1 - 62
class CheeseListing
{
// ... lines 65 - 71
/**
* @ORM\Column(type="string", length=255)
// ... lines 74 - 80
*/
private $title;
// ... lines 83 - 221
}

But in this case, when it looks at title, it doesn't get any info about it:

14 lines | src/Dto/CheeseListingOutput.php
// ... lines 1 - 6
class CheeseListingOutput
{
/**
* @Groups({"cheese:read"})
*/
public $title;
}

No problem! We just need to add that info ourselves. One way is by using PHP 7.4 property types. For example, I can say public string $title:

14 lines | src/Dto/CheeseListingOutput.php
// ... lines 1 - 6
class CheeseListingOutput
{
// ... lines 9 - 11
public string $title;
}

Now, my editor thinks this is invalid because it thinks I'm using PHP 7.3... but I'm actually using 7.4. So this will work. But if you're not using 7.4, you can always use @var instead.

DTO Metadata Cache Bug

Ok, refresh the docs now.... look at the same get item operation, go down to schema and... oh! It did not work. The docs still don't know the type for title!

We've just experienced our first "quirk" of the DTO system. Normally, if we modify something on CheeseListing, API Platform realizes that it needs to rebuild the property metadata cache. But there's a bug in that logic when using an input or output class.

Tip

You can track this issue here: https://github.com/api-platform/core/issues/3695

It's not a big deal once you know about it: we can trigger a rebuild manually by changing something inside of CheeseListing. I'll hit save, move over, and refresh. Notice the reload takes a bit longer this time because the cache is rebuilding. Check out the endpoint, go to the schema and... yes! It knows title is a string!

Back in the class, I'm going to remove the PHP 7.4 type and use @var instead, just so that everyone can code along with me. Let's also add a description:

The title of this listing

17 lines | src/Dto/CheeseListingOutput.php
// ... lines 1 - 6
class CheeseListingOutput
{
/**
* The title of this listing
*
// ... line 12
* @var string
*/
public $title;
}

That will also be used in the docs.

Adding More Fields

Ok, let's add the rest of the fields we need to this class. Check out CheeseListing: it looks like description is usually serialized and so is price:

223 lines | src/Entity/CheeseListing.php
// ... lines 1 - 62
class CheeseListing
{
// ... lines 65 - 83
/**
* @ORM\Column(type="text")
* @Groups({"cheese:read"})
* @Assert\NotBlank()
*/
private $description;
/**
* The price of this delicious cheese, in cents
*
* @ORM\Column(type="integer")
* @Groups({"cheese:read", "cheese:write", "user:read", "user:write"})
* @Assert\NotBlank()
*/
private $price;
// ... lines 99 - 221
}

Copy the title property, paste, rename it to description... and remove the docs. Copy this and make one more property called price, which is an int:

29 lines | src/Dto/CheeseListingOutput.php
// ... lines 1 - 6
class CheeseListingOutput
{
// ... lines 9 - 16
/**
* @var string
* @Groups({"cheese:read"})
*/
public $description;
/**
* @var integer
* @Groups({"cheese:read"})
*/
public $price;
}

Now that we've added these properties, we need to go into our data transformer and set them. So, $output->description = $cheeseListing->getDescription() and $output->price = $cheeseListing->getPrice():

// ... lines 1 - 8
class CheeseListingOutputDataTransformer implements DataTransformerInterface
{
// ... lines 11 - 13
public function transform($cheeseListing, string $to, array $context = [])
{
// ... line 16
$output->title = $cheeseListing->getTitle();
$output->description = $cheeseListing->getDescription();
$output->price = $cheeseListing->getPrice();
// ... lines 20 - 21
}
// ... lines 23 - 27
}

These data transformer classes are delightfully boring.

Before we try this, let's grab a couple other fields from CheeseListing. Search for cheese:read. But ignore owner for now: we'll come back to that in a minute:

223 lines | src/Entity/CheeseListing.php
// ... lines 1 - 62
class CheeseListing
{
// ... lines 65 - 71
/**
// ... line 73
* @Groups({"cheese:read", "cheese:write", "user:read", "user:write"})
// ... lines 75 - 80
*/
private $title;
/**
// ... line 85
* @Groups({"cheese:read"})
// ... line 87
*/
private $description;
/**
// ... lines 92 - 94
* @Groups({"cheese:read", "cheese:write", "user:read", "user:write"})
// ... line 96
*/
private $price;
// ... lines 99 - 139
/**
* @Groups("cheese:read")
*/
public function getShortDescription(): ?string
{
// ... lines 145 - 149
}
// ... lines 151 - 188
/**
// ... lines 190 - 191
* @Groups("cheese:read")
*/
public function getCreatedAtAgo(): string
{
// ... line 196
}
// ... lines 198 - 221
}

Ok: we also output a shortDescription field via this getShortDescription() method. Copy that whole thing and, in CheeseListingOutput paste it at the bottom:

41 lines | src/Dto/CheeseListingOutput.php
// ... lines 1 - 6
class CheeseListingOutput
{
// ... lines 9 - 28
/**
* @Groups("cheese:read")
*/
public function getShortDescription(): ?string
{
if (strlen($this->description) < 40) {
return $this->description;
}
return substr($this->description, 0, 40).'...';
}
}

That will work exactly like before: it's referencing the description property and it has the group on it.

Back in CheeseListing, if you search again, there is one more field to move: createdAtAgo. Copy this method... then paste at the bottom. PhpStorm politely asks me if I want to import the Carbon use statement. I do!

52 lines | src/Dto/CheeseListingOutput.php
// ... lines 1 - 4
use Carbon\Carbon;
// ... lines 6 - 7
class CheeseListingOutput
{
// ... lines 10 - 41
/**
* How long ago in text that this cheese listing was added.
*
* @Groups("cheese:read")
*/
public function getCreatedAtAgo(): string
{
return Carbon::instance($this->getCreatedAt())->diffForHumans();
}
}

But, hmm: this method references a $createdAt property... which we do not have inside this class. We need to add it. Add a public $createdAt, but I'm not going to put any groups above this because this isn't a field that we will expose in our API directly. We just need its data:

54 lines | src/Dto/CheeseListingOutput.php
// ... lines 1 - 7
class CheeseListingOutput
{
// ... lines 10 - 29
public $createdAt;
// ... lines 31 - 52
}

Oh, and, by the way, we could simplify this by, instead, creating a $createdAtAgo property, exposing that, then setting the string onto that property from our data transformer. I won't do that now, but... it's a pretty great idea and shows off the power of data transformers: you can do the work there and then have super simple DTO classes.

Anyways, back in the data transformer, set this property: $output->createdAt = $cheeseListing->getCreatedAt():

// ... lines 1 - 8
class CheeseListingOutputDataTransformer implements DataTransformerInterface
{
// ... lines 11 - 13
public function transform($cheeseListing, string $to, array $context = [])
{
// ... lines 16 - 18
$output->price = $cheeseListing->getPrice();
$output->createdAt = $cheeseListing->getCreatedAt();
// ... lines 21 - 22
}
// ... lines 24 - 28
}

I think we're ready! Let's first refresh the documentation: open the item operation, go to schema and... yes! It did rebuild the cache that time and we can see all our custom fields and their types.

And if we go over and refresh the actual endpoint... that works to! How awesome is that? We have 5 fields and you can quickly look at our output class to know what they will be.

Next: the one field that we aren't exposing yet is the owner field. Let's add that. Though, when we do, there's going to be a slight problem.