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 SubscribeHey, we've made a pretty fancy API! We've got a few sub-resources and embedded relation data, which is readable and writable. This is all super awesome... but it sure does crank up the complexity of our API, especially when it comes to security.
For example, we can no longer see unpublished treasures from the GET collection or GET single endpoints. But we can still see unpublished treasures if you fetch a user and read its dragonTreasures
field.
Let's whip up a test real quick to expose this problem. Open our UserResourceTest
. At the bottom, add a public function testUnpublishedTreasuresNotReturned()
. Inside that, create a user with UserFactory::createOne()
. Then use DragonTreasureFactory
to create a treasure that's isPublished
false and has its owner
set to the $user
... just so we know who the owner is.
For the action, say $this->browser()
... and we do need to log in to use the endpoint... but we don't care who we're logged in as... so say actingAs()
UserFactory::createOne()
to log in as someone else.
Then ->get()
/api/users/
$user->getId()
. Finish with assertJsonMatches()
that the length()
of dragonTreasures
is zero - using a cool length()
function from that JMESPath syntax:
... lines 1 - 8 | |
class UserResourceTest extends ApiTestCase | |
{ | |
... lines 11 - 68 | |
public function testUnpublishedTreasuresNotReturned(): void | |
{ | |
$user = UserFactory::createOne(); | |
DragonTreasureFactory::createOne([ | |
'isPublished' => false, | |
'owner' => $user, | |
]); | |
$this->browser() | |
->actingAs(UserFactory::createOne()) | |
->get('/api/users/' . $user->getId()) | |
->assertJsonMatches('length("dragonTreasures")', 0); | |
} | |
} |
Let's try it! Copy the method... and run it with --filter=
that name:
symfony php bin/phpunit --filter=testUnpublishedTreasuresNotReturned
Ok! It expected 1 to be the same as 0 because we are returning the unpublished treasure... but we don't want to!
First... why is this unpublished DragonTreasure
being returned? Didn't we build query extension classes to prevent exactly this?
Well.... an important thing to understand is that these query extension classes are used for the main query on an endpoint only. For example, if we use the GET collection endpoint for treasures, the "main" query is for those treasures and the query collection extension is called.
But when we make a call to a user endpoint - like to GET a single User
- API Platform is not making a query for any treasures: it's making a query for that one User
. Once it has that User
, to get this dragonTreasures
field, it does not make another query for those, at least not directly. Instead, if you open the User
entity, API Platform - via the serializer - simply calls getDragonTreasures()
.
So it queries for the User
, calls ->getDragonTreasures()
... and whatever that returns is set onto the dragonTreasures
field. And since this returns all related treasures, that's what we get: including the unpublished ones.
How can we fix this? By adding a new method that only returns the published treasures. Say public function getPublishedDragonTreasures()
, which returns a Collection
. Inside, we can get fancy: return $this->dragonTreasures->filter()
passing that a callback with a DragonTreasure $treasure
argument. Then, return $treasure->getIsPublished()
:
... lines 1 - 69 | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 72 - 216 | |
public function getPublishedDragonTreasures(): Collection | |
{ | |
return $this->dragonTreasures->filter(static function (DragonTreasure $treasure) { | |
return $treasure->getIsPublished(); | |
}); | |
} | |
... lines 223 - 303 | |
} |
That's a nifty trick for looping through all the treasures and getting a shiny new collection with just the published ones.
Side note: one downside to this approach is that if a user has 100 treasures... but only 10 of them are published, internally, Doctrine will first query for all 100... even though we'll only return 10. If you have large collections, this can be a performance problem. In our Doctrine tutorial, we talk about fixing this with something called the Criteria system. But with both approaches, the result is the same: a method that returns a subset of the collection.
At this point, the new method will work, but it's not yet part of our API. Scroll up to the dragonTreasures
property. It's currently readable and writable in our API. Make the property only writable:
... lines 1 - 69 | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 72 - 105 | |
'user:write']) ([ | |
... lines 107 - 108 | |
private Collection $dragonTreasures; | |
... lines 110 - 305 | |
} |
Then, down on the new method, add #[Groups('user:read')]
to make this part of our API and #[SerializedName('dragonTreasures')]
to give it the original name:
... lines 1 - 69 | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 72 - 216 | |
'user:read']) ([ | |
'dragonTreasures') ( | |
public function getPublishedDragonTreasures(): Collection | |
{ | |
... lines 221 - 223 | |
} | |
... lines 225 - 305 | |
} |
Drumroll! Try the test:
symfony php bin/phpunit --filter=testUnpublishedTreasuresNotReturned
It explodes! Because... I have a syntax error. Try it again. All green!
And... we're done! You did it! Thank you so much for joining me on this gigantic, cool, challenging journey into API Platform and security. Parts of this tutorial were pretty complex... because I want you to be able to solve real, tough security problems.
In the next tutorial, we're going to look at even more custom and powerful things that you can do with API Platform, including how to use classes for API resources that are not entities.
In the meantime, let us know what you're building and, as always, we're here for you in the comments section. Alright friends, see ya next time!
Hey @Sebastian-K!
Woohoo back! Nice work :). I need to get AssetMapper tutorial out first... part 3 will be the one after that (or MAYBe one other before). There's a bunch of good tutorials in the pipeline that I really want, so I'm hoping to bang through them quickly 🤞
Cheers!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "^3.0", // v3.1.2
"doctrine/annotations": "^2.0", // 2.0.1
"doctrine/doctrine-bundle": "^2.8", // 2.8.3
"doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
"doctrine/orm": "^2.14", // 2.14.1
"nelmio/cors-bundle": "^2.2", // 2.2.0
"nesbot/carbon": "^2.64", // 2.66.0
"phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
"phpstan/phpdoc-parser": "^1.15", // 1.16.1
"symfony/asset": "6.2.*", // v6.2.5
"symfony/console": "6.2.*", // v6.2.5
"symfony/dotenv": "6.2.*", // v6.2.5
"symfony/expression-language": "6.2.*", // v6.2.5
"symfony/flex": "^2", // v2.2.4
"symfony/framework-bundle": "6.2.*", // v6.2.5
"symfony/property-access": "6.2.*", // v6.2.5
"symfony/property-info": "6.2.*", // v6.2.5
"symfony/runtime": "6.2.*", // v6.2.5
"symfony/security-bundle": "6.2.*", // v6.2.6
"symfony/serializer": "6.2.*", // v6.2.5
"symfony/twig-bundle": "6.2.*", // v6.2.5
"symfony/ux-react": "^2.6", // v2.7.1
"symfony/ux-vue": "^2.7", // v2.7.1
"symfony/validator": "6.2.*", // v6.2.5
"symfony/webpack-encore-bundle": "^1.16", // v1.16.1
"symfony/yaml": "6.2.*" // v6.2.5
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
"mtdowling/jmespath.php": "^2.6", // 2.6.1
"phpunit/phpunit": "^9.5", // 9.6.3
"symfony/browser-kit": "6.2.*", // v6.2.5
"symfony/css-selector": "6.2.*", // v6.2.5
"symfony/debug-bundle": "6.2.*", // v6.2.5
"symfony/maker-bundle": "^1.48", // v1.48.0
"symfony/monolog-bundle": "^3.0", // v3.8.0
"symfony/phpunit-bridge": "^6.2", // v6.2.5
"symfony/stopwatch": "6.2.*", // v6.2.5
"symfony/web-profiler-bundle": "6.2.*", // v6.2.5
"zenstruck/browser": "^1.2", // v1.2.0
"zenstruck/foundry": "^1.26" // v1.28.0
}
}
Wohoo. Done. When can we expect part three?