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.

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

Unpublished 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().

... 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!

Leave a comment!

  • 2020-05-27 wannes

    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

  • 2020-05-27 weaverryan

    Hey wannes!

    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/sc...

    Let me know if that helps!

    Cheers!

  • 2020-05-27 wannes

    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

  • 2019-11-12 weaverryan

    Hey Christian!

    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!

  • 2019-11-08 Christian

    How would the owner fetch his unpublished CheeseListings to update/publish them?

  • 2019-10-09 Adrian Szuszkiewicz

    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 :)

  • 2019-10-01 weaverryan

    Yo Ramazan !

    > 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!

  • 2019-10-01 weaverryan

    Hey Ramazan!

    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!

  • 2019-10-01 Ramazan

    Hello weaverryan ,

    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 :)

  • 2019-10-01 Ramazan

    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...

  • 2019-10-01 weaverryan

    Hey Ramazan!

    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!

  • 2019-09-30 Ramazan

    Hello,

    The QueryItemExtensionInterface doesn't work if you have a DataProvider for the entity you request for.