Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Property Metadata

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

Thanks to the data collection provider, our endpoint returns one result... but there are no fields! Why not?

Normally if you don't set a normalizationContext - like we did in User with normalizationContext and groups:

... lines 1 - 17
/**
* @ApiResource(
... line 20
* normalizationContext={"groups"={"user:read"}},
... lines 22 - 34
* )
... lines 36 - 40
*/
class User implements UserInterface
{
... lines 44 - 286
}

Then your object will be serialized with no serialization groups... which basically means that every property will be included.

But... we are not seeing that at all! This is due to something we did in a previous tutorial. In src/Serializer/AdminGroupsContextBuilder.php, we added code to give you extra groups if you're an admin:

... lines 1 - 8
final class AdminGroupsContextBuilder implements SerializerContextBuilderInterface
{
... lines 11 - 19
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
{
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
$context['groups'] = $context['groups'] ?? [];
$isAdmin = $this->authorizationChecker->isGranted('ROLE_ADMIN');
if ($isAdmin) {
$context['groups'][] = $normalization ? 'admin:read' : 'admin:write';
}
$context['groups'] = array_unique($context['groups']);
return $context;
}
}

But to do this, if the groups are not set on the $context, we initialized them to an empty array:

... lines 1 - 8
final class AdminGroupsContextBuilder implements SerializerContextBuilderInterface
{
... lines 11 - 19
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
{
... lines 22 - 23
$context['groups'] = $context['groups'] ?? [];
... lines 25 - 34
}
}

Thanks to this, if we don't have a normalization group on the resource, instead of serializing everything, it will serialize nothing because it thinks we want to serialize no groups. It's... a quirk in our project.

Adding Normalization Groups

But, it's no problem because I prefer being explicit with my groups anyways. In other words, in DailyStats, add normalizationContext set to {} and groups equals {"daily-stats:read"}:

... lines 1 - 9
/**
* @ApiResource(
* normalizationContext={"groups"={"daily-stats:read"}},
... lines 13 - 21
* )
*/
class DailyStats
{
... lines 26 - 47
}

That follows a naming convention we've been using. Copy that group name so that we can add it above the properties we want. Above date, put @Groups({}) and paste. Now copy that entire doc block and put it above totalVisitors and also mostPopularListings:

... lines 1 - 7
use Symfony\Component\Serializer\Annotation\Groups;
... lines 9 - 23
class DailyStats
{
/**
* @Groups({"daily-stats:read"})
*/
public $date;
/**
* @Groups({"daily-stats:read"})
*/
public $totalVisitors;
/**
* @Groups({"daily-stats:read"})
*/
public $mostPopularListings;
... lines 40 - 47
}

But we do not need to put this above getDateString(). That is used as our identifier, but we don't need it as a real field in the API:

... lines 1 - 23
class DailyStats
{
... lines 26 - 40
/**
* @ApiProperty(identifier=true)
*/
public function getDateString(): string
{
... line 46
}
}

Ok, let's try it! When we refresh... Symfony politely reminds me that I'm missing a comma in my annotations:

... lines 1 - 9
/**
* @ApiResource(
* normalizationContext={"groups"={"daily-stats:read"}},
... lines 13 - 21
* )
*/
class DailyStats
{
... lines 26 - 47
}

There we go. Now... yes! We have fields!

How Property Metadata Works

Head back to the documentation... find this endpoint, look at the schema, and navigate to the hydra:member property. The docs now show the correct fields! But... it knows nothing about the types of each field. Are these strings? Integers? Aliens?

API Platform gets metadata about each property from many different places, like by reading Doctrine metadata, PHPDoc, looking at the return types of getter methods, looking at the argument type-hint on setters, PHP 7.4 property types and more. What's really neat about this, is that if you code well and document your code, API Platform will intelligently use that for its docs!

This becomes especially important to think about when your class is no longer a Doctrine entity. Why? Because with an entity, API Platform gets a ton of metadata from Doctrine. Without an entity, we need to do more work to fill in the gaps.

Adding Metadata with a Constructor

To tell API Platform the type of each property, we could definitely use PHP 7.4 property types or add @var PHPDoc above each one. But we can also add a constructor. Now, my true motivation for adding a constructor is not really documentation - that's a nice side effect. My true motivation is that I want to make sure that anytime a DailyStats object is instantiated, all three properties are set.

I'll cheat to do this: go to the "Code"->"Generate" menu - or Command+N on a Mac - choose "Constructor" and select all 3 properties. Then fill in the types: DateTimeInterface, int and array:

... lines 1 - 23
class DailyStats
{
... lines 26 - 43
public function __construct(\DateTimeInterface $date, int $totalVisitors, array $mostPopularListings)
{
$this->date = $date;
$this->totalVisitors = $totalVisitors;
$this->mostPopularListings = $mostPopularListings;
}
... lines 50 - 57
}

I'm also going to remove most of the documentation. This is totally your call, but I usually only include documentation that adds more information: the first two are redundant.

But, hmm, we can add more info about $mostPopularListings. The type-hint tells us that this is an array... but not what will be inside the array. Help it out by setting the type to CheeseListing[]:

... lines 1 - 23
class DailyStats
{
... lines 26 - 40
/**
* @param array|CheeseListing[] $mostPopularListings
*/
public function __construct(\DateTimeInterface $date, int $totalVisitors, array $mostPopularListings)
{
... lines 46 - 48
}
... lines 50 - 57
}

Now, in DailyStatsProvider, we just need to rearrange all the data into the constructor. Pass an empty array for the popular cheese listings:

... lines 1 - 7
class DailyStatsProvider implements CollectionDataProviderInterface, RestrictedDataProviderInterface
{
public function getCollection(string $resourceClass, string $operationName = null)
{
$stats = new DailyStats(
new \DateTime(),
1000,
[]
);
... lines 18 - 19
}
... lines 21 - 25
}

I love this! We've written good code and API Platform is going to read our constructor as documentation for the properties! Refresh the docs... open up the operation, look at the schema, go to hydra:member and... awesome! The date is a string that will be formatted as a date-time, totalVisitors is an integer and eventually mostPopularListings will be an array of strings: an array of IRI strings.

Want to add more documentation? We already know how:

The 5 most popular cheese listings from this date!

... lines 1 - 23
class DailyStats
{
... lines 26 - 35
/**
* The 5 most popular cheese listings from this date!
... lines 38 - 39
*/
public $mostPopularListings;
... lines 42 - 59
}

Or even above the constructor.

Oh, and by the way: helping API Platform determine the type of each field is more than just for documentation: it's also used during deserialization. For example, if you send an IRI string to a field that is a CheeseListing type, the denormalization system will correctly convert that IRI string into a CheeseListing object. Similar things happen for date strings and many other types.

And next, when we start returning CheeseListing objects on the mostPopularListings field, we're going to learn another way that property metadata affects how your objects are serialized.

Leave a comment!

10
Login or Register to join the conversation
Gianluca-F Avatar
Gianluca-F Avatar Gianluca-F | posted 10 months ago

Hi,
I have a hierarchy of contents: News extends Content, Tag extends content.
Slug is defined on Abstract Content superclass.

The problem is that the identifier for the News is the {id} but the identifier for the Tag is {slug}; the Slug, common to both entity, is on Superclass.
Is there a way to customize ApiProperty(identifier=<<some function>>) so I can avoid to replicate slug property also on tag, only because identifier is different from news?
I have tried with ApiProperty(identifier=self::SLUG_AS_IDENTIFIER) , in superclass, but obviusly does not work cause self is related to masterclass, also in Tag class ... static::SLUG_AS_IDENTIFIER does not work..

Is possible? or I have to define $slug also on Tag?

Thanks

Reply

Hey Gianluca-F!

The only way I can think of is to hook into the "property metadata factory" system: this is the system that collects all of the API Platform metadata about each property. To do this, you would create a service that decorates https://github.com/api-platform/core/blob/2.7/src/Metadata/Property/Factory/PropertyMetadataFactoryInterface.php - kind of like we do for the resource metadata factory here - https://symfonycasts.com/screencast/api-platform-security/resource-metadata-factory - except that decoration is easier in Symfony now with the AsDecorator attribute.

Anyways, you would add your own property metadata factory decorator that, when called, would call the inner property metadata factory then, if the resource class is the class you want and the property is the one you want, change its metadata to set it as an identifier / not an identifier. Looking at one of the core decorators might help: https://github.com/api-platform/core/blob/2.7/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php

So, doable - but not in a super simple way.

Cheers!

Reply
Default user avatar

hello, tried with old and the latest api platform versions, but somehow normalization context +groups does not help show fields for this DailyStats, really strange, for User and CheeseListings - works as expected, I use SF5.2
clearing cache didn't help https://github.com/api-plat...

Reply

Hey Mihail !

Hmmm. So you simply mean that, if you set normalizationContext={"groups"={"daily-stats:read"}}, and then put @Groups({"daily-stats:read"}) above a property (exactly like we're doing in this video), that property isn't returned? Or are you doing something a bit different that we're doing here? It is possible that a bug was introduced in Symfony 5.2, but in that case, I can't think why it would affect this class, but not the entity classes.

Cheers!

Reply
Default user avatar

Hey, Ryan!

Definitely, I tried to rewrite this part several times, this happens only to the custom resource.
I checked on github that APIPlatform is still being updated to SF5.2 and the issue is present in `2.6.0-alpha.1` https://github.com/api-plat...
The version for code example to this tutorial for SF5.1 works as expected.

Reply

Hey Mihail!

Ah, interesting! What happens if you try Symfony 5.2 and API Platform 2.5? API Platform 2.5 does work Symfony 5.2 - the ^5.1 in API Platform's composer.json allows 5.2. I would be curious if the bug lies in using Symfony 5.2 or API Platform 2.6 (my "instinct" is that it is in API Platform 2.6, but I could definitely be wrong). If we can figure this out, it would be worth creating an issue on either API Platform or Symfony - it smells like a bug.

Cheers!

Reply
Default user avatar
Default user avatar Mihail | weaverryan | posted 2 years ago | edited

OMG I see the mistake "dailystats:read" vs "daily-stats:read"

Somehow it does not react to manual setting normalizationContext groups either in camelcase "dailyStats:read" or with dash "daily-stats:read", or "dummystats:read". But the data is displayed correctly with default groups "dailystats:read"`

For class defined like below I see only totalVisitors``

/**

  • @ApiResource(
  • normalizationContext={"groups"={"dummystats:read"}},
  • itemOperations={
  • "get"={},
  • },
  • collectionOperations={"get"}
  • )
    / class DailyStats { /*

    • @Groups({"dummystats:read"})
      */
      public $date;

    /**

    • @Groups({"dailystats:read"})
      */
      public $totalVisitors;
      }
      `
1 Reply

Hey Mihail!

Wait, I want to make sure I'm not getting confused. In the above code, where you have "groups"={"dummystats:read", ONLY the totalVisitors property is returned... even though it only has the group dailystats:read? Who/what is setting that group - do you have some "context builder" somewhere that's adding extra groups? I think I'm not seeing some detail...

Cheers!

1 Reply
Default user avatar

Hey Ryan,

Oh yeah, now I see my mistake, I'm still using the `AutoGroupResourceMetadataFactory::getDefaultGroups` from https://symfonycasts.com/sc...

While it has been deleted for the 3rd tutorial :)

Just disabled - everything works!

Reply

Whooops! Yea, sorry about that. I deleted it for the 3rd tutorial to help make "less" variables for people watching the tutorial to keep track of... but it had the opposite effect for you!

Happy you figured it out!

Cheers!

1 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": ">=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
    }
}
userVoice