Automatic 404 on Unpublished Items
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.
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.
Testing for 404 on Unpublished Items
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.
The QueryItemExtensionInterface
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!
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