Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Lucky you! You found an early release chapter - it will be fully polished and published shortly!

Validating how Values Change

This Chapter isn't
quite ready...

Rest assured, the gnomes are hard at work
completing this video!

Browse Tutorials

We still have a massive problem making sure treasures don't end up stolen! We just covered the main case: if you make a POST or a PUT request to a treasure endpoint, thanks to our new validation, we make sure you assign the owner to yourself, unless you're an admin. Yay!

But in our API, when POSTing or PATCHing to a user endpoint, you are allowed to send a dragonTreasures field. This, unfortunately allows treasures to be stolen. Simply send a PATCH request to modify your own User record... then set the dragonTreasures field to an array containing the IRI strings of some treasures that you do not own. Whoops!

The easiest solution would be to... make the field not writable. So, inside of User, for dragonTreasures, we would keep this readable, but remove the write group. That would force everyone to use the /api/treasures endpoints to manage their treasures.

The Trickiness of this Problem

If you do want to keep the writable dragonTreasures field... you can, but this problem is tricky to solve.

Let's think: if you send a dragonTreasures field that contains the IRI of a treasure you do not own, that should trigger a validation error. Ok... so maybe we add a validation constraint above this property? The problem is that, by the time that validation runs, the treasures sent over in the JSON have already been set onto this dragonTreasures property. And importantly, the owner on those treasures has already been updated to this User!

Remember: when the serializer sees a DragonTreasure that is not already owned by this user, it will call addDragonTreasure... which then calls setOwner($this). So, by the time validation runs, it's going to look like we are the owner of the treasure... even though we originally weren't!

Using Previous Data?

What can we do? Well, API Platform does have a concept of "previous data". API Platform clones the data before deserializing the new JSON onto it, which means it is possible to get what the User object originally looked like.

Unfortunately, that clone is shallow, meaning that it clones scalar fields - like username - but any objects - like the DragonTreasure objects are not cloned. There's no way via API Platform to see what they originally looked like.

Testing for the Bug

So, we are going to solve this with validation... but with the help of a special class from Doctrine called the UnitOfWork.

Alrighty, let's whip up a test to shine a light on this pesky bug. Inside tests/Functional/, open UserResourceTest. Copy the previous test, paste, and call it testTreasuresCannotBeStolen. Create a second user with UserFactory::createOne()... and we need a DragonTreasure that we're going to try to steal. Assign its owner to $otherUser.

Let's do this! We log in as $user, update ourselves - which is allowed - then, for the JSON, sure, maybe we still send username... but we also send dragonTreasures set to an array with /api/treasures/ and $dragonTreasure->getId().

At the bottom, assert that this returns a 422.

Ok! Copy the method name. We're expecting this to fail:

symfony php bin/phpunit --filter=testTreasuresCannotBeStolen

And... it does! Status code 200, which means we are allowing treasure to be stolen! Gasp!

Creating the Validator

Ok, let's cook up a new validator class:

php bin/console make:validator

Call it TreasuresAllowedOwnerChange.

Go use this immediately. Above the dragonTreasures property, add #[TreasuresAllowedOwnerChange].

Next, over in src/Validator/, open up the validator class. We'll do some basic cleanup: use the assert() function to assert that $constraint is an instance of TreasuresAllowedOwnerChange. And also assert that value is an instance of Collection from Doctrine.

We know that this will be used above this property... so it will be some sort of collection of DragonTreasures.

Enter UnitOfWork

But... this will be the collection of DragonTreasure objects after they've been modified. We need to ask Doctrine what each DragonTreasure looked like when it was originally queried from the database. To do that, we need to grab an internal object from Doctrine called the UnitOfWork.

On top, add a constructor, autowire EntityManagerInterface $entityManager.. and make that's a private property. Below, grab the unit of work with $unitOfWork = $this->entityManager->getUnitOfWork().

This is a powerful object that keeps track of how entity objects are changing and is responsible for knowing which objects need to be inserted, updated or deleted from the database when the entity manger flushes.

Next, foreach over $value - which will be a collection - as $dragonTreasure. To help my editor, I'll assert that $dragonTreasure is an instance of DragonTreasure. And now, get the original data: $originalData = $unitOfWork->getOriginalEntityData($dragonTreasure).

Pretty sweet right? Let's dd($dragonTreasure) and $originalData so we can see what they look like.

Go test go:

symfony php bin/phpunit --filter=testTreasuresCannotBeStolen

Yes! It hit the dump! And this is cool! The first part is the updated DragonTreasure object and its owner has ID 1. It's not super obvious, but $user will be id 1 and $otherUser will be id 2. So the owner was originally ID 2, but yeah: user id 1 has stolen it! Below this, we see the original data as an array. And its owner was ID 2!

This info makes us dangerous. Back inside our validator, say $originalOwnerId = originalData['owner_id']. And to be super clear, set $newOwnerId to $dragonTreasure->getOwner()->getId().

If these don't match, we have a problem. Well actually, if we don't have an $originalOwnerId, we're creating a new DragonTreasure and that's ok. So if there is no $originalOwnerId or the $originalOwnerId is equal to the $newOwnerId, we're good!

Else... there's some plundering happening! Move the $violationBuilder up, but remove setParameter(). That's it!

Oh, but I never customized the error message. In the Constraint class, give the $message property a better default message.

All right team, moment of truth! Run that test:

symfony php bin/phpunit --filter=testTreasuresCannotBeStolen

Nailed it! Treasure stealing is officially off the table. Oh, and though I didn't do it, we could also inject the Security service to allow admin users to do whatever they want.

Up next: when we create a DragonTreasure, we must send the owner field. Let's finally make that optional. If we don't pass the owner, we'll set it to the currently authenticated user. To do that, we need to hook into API platform's "saving" process one more time.

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