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 SubscribeUnpublished cheese listings will no longer be returned from our collection endpoint: this extension class has taken care of that. Of course... if we want to have some sort of an admin section where admin users can see all cheese listings... that's a problem... because we've just filtered them out entirely!
No worries, let's add the same admin "exception" that we've added to a few other places. Start with public function __construct()
so we can autowire the Security
service. I'll hit Alt + Enter and click "Initialized fields" to create that property and set it.
... lines 1 - 8 | |
use Symfony\Component\Security\Core\Security; | |
... line 10 | |
class CheeseListingIsPublishedExtension implements QueryCollectionExtensionInterface | |
{ | |
private $security; | |
public function __construct(Security $security) | |
{ | |
$this->security = $security; | |
} | |
... lines 19 - 33 | |
} |
Down in the method, very nicely, if $this->security->isGranted('ROLE_ADMIN')
, return and do nothing.
... lines 1 - 19 | |
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) | |
{ | |
... lines 22 - 25 | |
if ($this->security->isGranted('ROLE_ADMIN')) { | |
return; | |
} | |
... lines 29 - 32 | |
} |
Whoops, I added an extra exclamation point to make this not. Don't do that! I'll fix it in a few minutes.
Anyways, apart from my mistake, admin users can now fetch every CheeseListing
once again.
That takes care of the collection stuff. But we're not done yet! We also don't want a user to be able to fetch an individual CheeseListing
if it's unpublished. The collection query extension does not take care of this: this method is only called when API Platform needs to query for a collection of items - a different query is used for a single item.
Let's write a quick test for this. Copy the collection test method, paste the entire thing, rename it to testGetCheeseListingItem()
... and I'll remove cheese listings two and three. This time, make the GET
request to /api/cheeses/
and then $cheeseListing1->getId()
.
This is an unpublished CheeseListing
... so we eventually want this to not be accessible. But... because we haven't added the logic yet, let's start by testing the current functionality. Assert that the response code is 200.
... lines 1 - 9 | |
class CheeseListingResourceTest extends CustomApiTestCase | |
{ | |
... lines 12 - 107 | |
public function testGetCheeseListingItem() | |
{ | |
$client = self::createClient(); | |
$user = $this->createUser('cheeseplese@example.com', 'foo'); | |
$cheeseListing1 = new CheeseListing('cheese1'); | |
$cheeseListing1->setOwner($user); | |
$cheeseListing1->setPrice(1000); | |
$cheeseListing1->setDescription('cheese'); | |
$em = $this->getEntityManager(); | |
$em->persist($cheeseListing1); | |
$em->flush(); | |
$client->request('GET', '/api/cheeses/'.$cheeseListing1->getId()); | |
$this->assertResponseStatusCodeSame(200); | |
} | |
} |
Copy that method name, and let's make sure it passes:
php bin/phpunit --filter=testGetCheeseListingItem
It does! But... that's not the behavior we want. To make this really obvious, let's say $cheeseListing->setIsPublished(false)
. That CheeseListing
was already unpublished - that's the default - but this is more clear to me. For the status code, when a CheeseListing
is unpublished, we want it to return a 404. Try the test now:
... lines 1 - 107 | |
public function testGetCheeseListingItem() | |
{ | |
... lines 110 - 116 | |
$cheeseListing1->setIsPublished(false); | |
... lines 118 - 123 | |
$this->assertResponseStatusCodeSame(404); | |
} | |
... lines 126 - 127 |
php bin/phpunit --filter=testGetCheeseListingItem
Failing! We're ready.
So if the applyToCollection()
method is only called when API Platform is making a query for a collection of items... how can we modify the query when API Platform needs a single item? Basically... the same way! Add a second interface: QueryItemExtensionInterface
. This requires us to have one new method. Go to the Code -> Generate menu - or Command + N on a Mac - and select "Implement Methods" one more time. And... ha! We could have guessed that method name: applyToItem()
. This is called whenever API Platform is making a query for a single item... and we basically want to make the exact same change to the query.
... lines 1 - 5 | |
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryItemExtensionInterface; | |
... lines 7 - 11 | |
class CheeseListingIsPublishedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface | |
{ | |
... lines 14 - 25 | |
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, string $operationName = null, array $context = []) | |
{ | |
... line 28 | |
} | |
... lines 30 - 44 | |
} |
I'll hit Control+t, which, on a Mac, is the same as going to the Refactor menu on top and selecting "Refactor this". Let's extract this logic to a "Method" - call it addWhere
.
... lines 1 - 11 | |
class CheeseListingIsPublishedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface | |
{ | |
... lines 14 - 20 | |
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) | |
{ | |
$this->addWhere($queryBuilder, $resourceClass); | |
} | |
... lines 25 - 30 | |
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void | |
{ | |
if ($resourceClass !== CheeseListing::class) { | |
return; | |
} | |
if ($this->security->isGranted('ROLE_ADMIN')) { | |
return; | |
} | |
$rootAlias = $queryBuilder->getRootAliases()[0]; | |
$queryBuilder->andWhere(sprintf('%s.isPublished = :isPublished', $rootAlias)) | |
->setParameter('isPublished', true); | |
} | |
} |
Cool! That gives us a new private function addWhere()
... and applyToCollection()
is already calling it. Do the same thing in applyToItem()
.
Tip
This method is also used for the PUT (update) and DELETE operations. To allow unpublished items to be updated or deleted by the owner, you should update the query to return listings owned by the current user:
// CheeseListingIsPublishedExtension::addWhere()
if (!$this->security->getUser()) {
// existing code to check for isPublished=true
} else {
$queryBuilder->andWhere(sprintf('
%s.isPublished = :isPublished
OR %s.owner = :owner',
$rootAlias, $rootAlias
))
->setParameter('isPublished', true)
->setParameter('owner', $this->security->getUser());
}
... lines 1 - 25 | |
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, string $operationName = null, array $context = []) | |
{ | |
$this->addWhere($queryBuilder, $resourceClass); | |
} | |
... lines 30 - 45 |
Let's try this! Run the test again and...
php bin/phpunit --filter=testGetCheeseListingItem
It fails? Hmm. Oh... I reversed the check for ROLE_ADMIN
. Get rid of that exclamation point... and try that test again.
php bin/phpunit --filter=testGetCheeseListingItem
We are green! How cool was that? We're able to modify the collection and item queries for a specific resource with one class and two methods.
There's just one more problem: the collection of cheese listings is returned in two places - the GET
operation to /api/cheeses
and... somewhere else. And that "somewhere else" is not filtering out the unpublished cheese listings. Oh nooooooo.
Let's find out more and fix that next!
Hey Wannes V.!
I understand :). First, let me explain why this happens.
A) When you make a request to GET /api/child
, API Platform makes a query for the "child" entities. And when it does that, it includes ysour QueryCollectionExtensionInterface.
B) When you make a request to GET /api/parent
, API Platform makes a query for the "parent" entities. Then, when it outputs the "children" property, internally, it simply calls $parent->getChildren()
. What I mean is, there is no explicit query for the "children" in this situation. The "children" (or maybe it's a single "child" property - it doesn't matter, but I might be using different names than your app!) works just like any other property: to get the "children" property, API Platform simply calls $parent->getChildren()
. This, of course, returns the full, unfiltered collection of objects. This is expected :).
So, what's the fix? The fix is to create a new "getters" in your Parent entity - e.g. getFilteredChildren()
and expose this to your serialization (via @Groups) instead of the "children" property. You can even use @SerializedName to make it still be called "children". Inside this method, you would perform the filter and return the filtered collection of "children". For a small collection, you could do this in PHP (e.g. filter overall children, apply your filtering logic, and return only the matching children) or if your collection is a bit bigger or you want to maximize performance, then use a Criteria - here's an example: https://symfonycasts.com/screencast/collections/criteria-collection-filtering
Let me know if that helps!
Cheers!
Thanks a lot Ryan!
The Criteria were indeed what I was looking for. Tbh, I recently saw it in the doctrine course but I totally must have forgotten!
Anyways, thanks for the nice description and your super-fast reply, I appreciate !
Best!
Wannes
Hey hacktic!
Excellent question :). The most straightforward way would be to modify the query inside this "extension class" to allow for items that are owned by the user - something like this:
$queryBuilder->andWhere(sprintf('%s.isPublished = :isPublished OR %s.owner = :owner', $rootAlias, $rootAlias))
->setParameter('isPublished', true)
->setParameter('owner', $this->security->getUser());
You would need to make this a bit smarter, however: if the user is NOT logged in, then you don't want to include the OR %s.owner...
part at all.
Let me know if this helps! Cheers!
Hello there ,
With this Query Extention I realize that I have to put many default filters, isRemoved (instead of deleting I put a flag), isPublic (you could create a private item) and even more if you want to filter depends on price, creation date, owner, location... I wish there will be some performance topic in the next API Platform course, because with a big database mysql could be low performing :)
Yo Rakodev !
> I wish there will be some performance topic in the next API Platform course, because with a big database mysql could be low performing
That's true! However, I also think it comes down to three simple strategies to handle this:
A) It's not a problem so you do nothing :). Unless you know your database *will* be huge (which certainly *is* the case sometimes), I wouldn't optimize too early.
B) If you do want to optimize (or you already know there is a problem), most things can be fixed by adding some indexes. Really, the performance issues isn't about API Platform, it's about optimizing queries that may have multiple WHERE statements on them. If you focus on optimizing the queries, the problem is "smaller" and you can put your energy at the right spot :).
C) Some filtering (especially the "Search" filter with the fuzzy-searching) can't be optimized beyond a certain point. If you hit this (or you just want a more "intelligent" search), then you should switch to use Elasticsearch. We haven't talked about it yet, but API Platform has integration for Elasticsearch. Basically, instead of querying the database, you index your items inside Elastic and then "query" Elastic to get results.
Let me know if this helps... or doesn't ;).
Cheers!
I really hope, you have ElasticSearch part in your plans :).
For now, in my project, I created custom collection DataProvider, which query ElasticSearch and return results as doctrine entities collection. It works like a charm, but I'm curious what will be your approach :)
Hello,
The QueryItemExtensionInterface
doesn't work if you have a DataProvider for the entity you request for.
Hey Rakodev!
Hmm, that's true - and I should have mentioned that... and so we might need a note. But question, by "entity", do you mean specifically "Doctrine entity" or are you just using that to mean "model class"? If you're using it to mean "model class", then I understand your point completely and you're right. If you're using it to mean "Doctrine entity", then what is the use-case for having a Doctrine entity with a custom data provider? I can't think of one right now, but I am very likely missing something :).
Cheers!
Hello @weaverryan,
I mean model class. For test purpose, I put an ItemDataProvider with a getItem method who basically do nothing but:$this->em->getRepository(CheeseListing::class)->find($id);
But as I did it few weeks ago, I completely forgot and yesterday I spent some time trying to understand why my QueryItemExtension didn't work.
And even if I have xDebug I didn't know how to solve my issue, I just checked my listeners, providers, subscribers, serializers, voters... With so much event I'm not sure what's the best way to debug your code. I just imagine if it's a big project it could be tricky do understand in which order they are executed, or understand all the way your request go through or why it's not going through a code you think it should be...
Hey Rakodev!
Yep, that makes sense. We're going to add a note about this: it's important to know that if you have *anything* that is loading data in a way *other* than the standard Doctrine loader (custom data provider, or also Elastic search) then you are taking complete control of the data loading and the extension won't work. Thanks for the conversation ;).
Cheers!
// composer.json
{
"require": {
"php": "^7.1.3, <8.0",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "^2.1", // v2.4.5
"composer/package-versions-deprecated": "^1.11", // 1.11.99
"doctrine/annotations": "^1.0", // 1.13.2
"doctrine/doctrine-bundle": "^1.6", // 1.11.2
"doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
"doctrine/orm": "^2.4.5", // v2.7.2
"nelmio/cors-bundle": "^1.5", // 1.5.6
"nesbot/carbon": "^2.17", // 2.21.3
"phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
"symfony/asset": "4.3.*", // v4.3.2
"symfony/console": "4.3.*", // v4.3.2
"symfony/dotenv": "4.3.*", // v4.3.2
"symfony/expression-language": "4.3.*", // v4.3.2
"symfony/flex": "^1.1", // v1.18.7
"symfony/framework-bundle": "4.3.*", // v4.3.2
"symfony/http-client": "4.3.*", // v4.3.3
"symfony/monolog-bundle": "^3.4", // v3.4.0
"symfony/security-bundle": "4.3.*", // v4.3.2
"symfony/twig-bundle": "4.3.*", // v4.3.2
"symfony/validator": "4.3.*", // v4.3.2
"symfony/webpack-encore-bundle": "^1.6", // v1.6.2
"symfony/yaml": "4.3.*" // v4.3.2
},
"require-dev": {
"hautelook/alice-bundle": "^2.5", // 2.7.3
"symfony/browser-kit": "4.3.*", // v4.3.3
"symfony/css-selector": "4.3.*", // v4.3.3
"symfony/maker-bundle": "^1.11", // v1.12.0
"symfony/phpunit-bridge": "^4.3", // v4.3.3
"symfony/stopwatch": "4.3.*", // v4.3.2
"symfony/web-profiler-bundle": "4.3.*" // v4.3.2
}
}
Hi there!
I am using a custom doctrinefilter in my project on lets say entity "child" (using QueryCollectionExtensionInterface).
Entity "child" is a subresource of entity "parent".
When i ask for "api/child" the filter does its job but this is not the case when I ask for "api/parent", then the subresource "child" has the filter not applied anymore.
Is there a way to fix this?
Thank you!
Wannes