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 SubscribeWe'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.
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!
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
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.
"Houston: no signs of life"
Start the conversation!
// 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
}
}