Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

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

We've stopped returning unpublished treasures from the treasure collection endpoint, but you can still fetch them from the GET one endpoint. That's because these QueryCollectionExtensionInterface classes are only called when we are fetching a collection of items: not when we're selecting a single item.

To prove this, go into our test. Duplicate the collection test, paste, and call it testGetOneUnpublishedTreasure404s(). Inside, create just one DragonTreasure that's unpublished... and make a ->get() request to /api/treasures/... oh! I need a $dragonTreasure variable. That's better. Now add $dragonTreasure->getId().

At the bottom, assert that the status is 404... and we don't need any of these assertions, or this $json variable:

... lines 1 - 12
class DragonTreasureResourceTest extends ApiTestCase
{
... lines 15 - 46
public function testGetOneUnpublishedTreasure404s(): void
{
$dragonTreasure = DragonTreasureFactory::createOne([
'isPublished' => false,
]);
$this->browser()
->get('/api/treasures/'.$dragonTreasure->getId())
->assertStatus(404);
}
... lines 57 - 194
}

Very simple! Grab that method name and, you know the drill. Run just that test:

symfony php bin/phpunit --filter=testGetOneUnpublishedTreasure404s

And... yep! It currently returns a 200 status code.

Hello Query Item Extensions

How do we fix this? Well... just like how there's a QueryCollectionExtensionInterface for the collection endpoint, there's also a QueryItemExtensionInterface that's used whenever API Platform queries for a single item.

You can create a totally separate class for this... but you can also combine them. Add a second interface for QueryItemExtensionInterface. Then, scroll down and go to "Code"->"Generate" - or Command+N on a Mac - to add the one method we're missing: applyToItem():

... lines 1 - 5
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
... lines 7 - 11
class DragonTreasureIsPublishedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
... lines 14 - 24
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, Operation $operation = null, array $context = []): void
{
// TODO: Implement applyToItem() method.
}
}

Yea, it's almost identical to the collection method.... it works the same way... and we even need the same logic! So, copy the code we need, then go to the Refactor menu and say "Refactor this", which is also Control+T on a Mac. Select to extract this to a method... and call it addIsPublishedWhere():

... lines 1 - 11
class DragonTreasureIsPublishedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
... lines 14 - 23
/**
* @param string $resourceClass
* @param QueryBuilder $queryBuilder
* @return void
*/
private function addIsPublishedWhere(string $resourceClass, QueryBuilder $queryBuilder): void
{
... lines 31 - 34
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere(sprintf('%s.isPublished = :isPublished', $rootAlias))
->setParameter('isPublished', true);
}
}

Awesome! I'll clean things up... and, you know what? I should have added this if statement inside there too. So let's move that:

... lines 1 - 11
class DragonTreasureIsPublishedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
... lines 14 - 28
private function addIsPublishedWhere(string $resourceClass, QueryBuilder $queryBuilder): void
{
if (DragonTreasure::class !== $resourceClass) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere(sprintf('%s.isPublished = :isPublished', $rootAlias))
->setParameter('isPublished', true);
}
}

Which means we need a string $resourceClass argument. Above, pass $resourceClass to the method:

... lines 1 - 11
class DragonTreasureIsPublishedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void
{
$this->addIsPublishedWhere($resourceClass, $queryBuilder);
}
... lines 18 - 38
}

Perfect! Now, in applyToItem(), call that same method:

... lines 1 - 11
class DragonTreasureIsPublishedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
... lines 14 - 18
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, Operation $operation = null, array $context = []): void
{
$this->addIsPublishedWhere($resourceClass, $queryBuilder);
}
... lines 23 - 38
}

Ok, we're ready! Try the test now:

symfony php bin/phpunit --filter=testGetOneUnpublishedTreasure404s

And... it passes!

Fixing our Test Suite

We've been tinkering with our code quite a bit, so it's time for a test-a-palooza! Run all the tests:

symfony php bin/phpunit

And... whoops! 3 failures - all coming from DragonTreasureResourceTest. The problem is that, when we created treasures in our tests, we weren't explicit about whether we wanted a published or unpublished treasure... and that value is set randomly in our factory.

To fix this, we could be explicit by controlling the isPublished field whenever we create a treasure. Or... we can be lazier and, in DragonTreasureFactory, set isPublished to true by default:

... lines 1 - 29
final class DragonTreasureFactory extends ModelFactory
{
... lines 32 - 46
protected function getDefaults(): array
{
return [
... lines 50 - 51
'isPublished' => true,
... lines 53 - 56
];
}
... lines 59 - 73
}

Now, to keep our fixture data interesting, when we create the 40 dragon treasures, let's override isPublished and manually add some randomness: if a random number from 0 to 10 is greater than 3, then make it published:

... lines 1 - 10
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
... lines 15 - 20
DragonTreasureFactory::createMany(40, function () {
return [
... line 23
'isPublished' => rand(0, 10) > 3,
];
});
... lines 27 - 32
}
}

That should fix most of our tests. Though search for isPublished. Ah yea, we're testing that an admin can PATCH to edit a treasure. We created an unpublished DragonTreasure... just so we could assert that this was in the response. Let's change this to true in both places:

... lines 1 - 12
class DragonTreasureResourceTest extends ApiTestCase
{
... lines 15 - 153
public function testAdminCanPatchToEditTreasure(): void
{
... line 156
$treasure = DragonTreasureFactory::createOne([
'isPublished' => true,
]);
$this->browser()
... lines 162 - 169
->assertJsonMatches('isPublished', true)
;
}
... lines 173 - 194
}

There's one other similar test: change isPublished to true here as well:

... lines 1 - 12
class DragonTreasureResourceTest extends ApiTestCase
{
... lines 15 - 173
public function testOwnerCanSeeIsPublishedAndIsMineFields(): void
{
... line 176
$treasure = DragonTreasureFactory::createOne([
'isPublished' => true,
... line 179
]);
$this->browser()
... lines 183 - 190
->assertJsonMatches('isPublished', true)
... line 192
;
}
}

Now try the tests:

symfony php bin/phpunit

Allowing Updates to an Unpublished Item

They're happy! I'm happy! Well, mostly. We still have one teensie problem. Find the first PATCH test. We're creating a published DragonTreasure, updating it... and it works just fine. Copy this entire test... paste it.. but delete the bottom part: we only need the top. Call this method testPatchUnpublishedWorks()... then make sure the DragonTreasure is unpublished:

... lines 1 - 12
class DragonTreasureResourceTest extends ApiTestCase
{
... lines 15 - 153
public function testPatchUnpublishedWorks()
{
... line 156
$treasure = DragonTreasureFactory::createOne([
... line 158
'isPublished' => false,
]);
... lines 161 - 171
}
... lines 173 - 215
}

Think about it: if I have a DragonTreasure with isPublished false, I should be able to update it, right? This is my treasure... I created it and I'm still working on it. We want this to be allowed.

Will it? You can probably guess:

symfony php bin/phpunit --filter=testPatchUnpublishedWorks

Nope! We get a 404! This is both a feature... and a "gotcha"! When we create a QueryCollectionExtensionInterface, that's only used for this one collection endpoint. But when we create an ItemExtensionInterface, that's used whenever we fetch a single treasure: including for the Delete, Patch and Put operations. So, when an owner tries to Patch their own DragonTreasure, thanks to our query extension, it can't be found.

There are two solutions for this. First, in applyToItem(), API Platform passes us the $operation. So we could use this to determine if this a Get, Patch or Delete operation and only apply the logic for some of those.

And... this might make sense. After all, if you're allowed to edit or delete a treasure... that means you've already passed a security check... so we don't necessarily need to lock things down via this query extension.

The other solution is to change the query to allow owners to see their own treasures. One cool thing about this solution is that it will also allow unpublished treasures to be returned from the collection endpoint if the current user is the owner of that treasure.

Let's give this a shot. Add the public function __construct()... and autowire the amazing Security service:

... lines 1 - 10
use Symfony\Bundle\SecurityBundle\Security;
class DragonTreasureIsPublishedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(private Security $security)
{
}
... lines 18 - 50
}

Below... life gets a bit trickier. Start with $user = $this->security->getUser(). If we have a user, we're going to modify the QueryBuilder in a similar... but slightly different way. Oh, actually, let me bring the $rootAlias up above my if statement. Now, if the user is logged in, add OR %s.owner = :owner... then pass in one more rootAlias... followed by ->setParameter('owner', $user).

Else, if there is no user, use the original query. And we need the isPublished parameter in both cases... so keep that at the bottom:

... lines 1 - 12
class DragonTreasureIsPublishedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
... lines 15 - 33
private function addIsPublishedWhere(string $resourceClass, QueryBuilder $queryBuilder): void
{
if (DragonTreasure::class !== $resourceClass) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$user = $this->security->getUser();
if ($user) {
$queryBuilder->andWhere(sprintf('%s.isPublished = :isPublished OR %s.owner = :owner', $rootAlias, $rootAlias))
->setParameter('owner', $user);
} else {
$queryBuilder->andWhere(sprintf('%s.isPublished = :isPublished', $rootAlias));
}
$queryBuilder->setParameter('isPublished', true);
}
}

I think I like that! Let's see what the test thinks:

symfony php bin/phpunit --filter=testPatchUnpublishedWorks

It likes it too! In fact, all of our tests seem happy.

Ok team: final topic. When we fetch a User resource, we return its dragon treasures. Does that collection also include unpublished treasures? Ah... yep it does! Let's talk about why and how to fix it next.

Leave a comment!

0
Login or Register to join the conversation
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// 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
    }
}
userVoice