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 SubscribeThanks 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.
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!
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.
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.
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!
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...
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!
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.
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!
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
``
/**
)
/
class DailyStats
{
/*
/**
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!
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!
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!
// 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
}
}
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