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

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!

Leave a comment!

This tutorial works great for Symfony 5 and API Platform 2.5.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/api-pack": "^1.2", // v1.2.0
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "nesbot/carbon": "^2.17", // 2.21.3
        "symfony/console": "4.3.*", // v4.3.2
        "symfony/dotenv": "4.3.*", // v4.3.2
        "symfony/flex": "^1.1", // v1.4.5
        "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/webpack-encore-bundle": "^1.6", // v1.6.2
        "symfony/yaml": "4.3.*" // v4.3.2
    },
    "require-dev": {
        "hautelook/alice-bundle": "^2.5", // v2.5.1
        "symfony/maker-bundle": "^1.11", // v1.12.0
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/test-pack": "^1.0" // v1.0.6
    }
}