Conditional Fields by User: ApiProperty
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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeWe control which fields are readable and writable via serialization groups. But what if you have a field that should be included in the API... but only for certain users? Sadly, groups can't pull off that kind of magic on their own.
For example, find the $isPublished field and let's make this part of our API by adding the treasure:read and treasure:write groups:
| // ... lines 1 - 87 | |
| class DragonTreasure | |
| { | |
| // ... lines 90 - 127 | |
| (['treasure:read', 'treasure:write']) | |
| private bool $isPublished = false; | |
| // ... lines 130 - 248 | |
| } |
Now if we spin over and try the tests:
symfony php bin/phpunit
This makes one test fail: testGetCollectionOfTreasures sees that isPublished is being returned... and it's not expecting it.
Here's the plan: we'll sneak the field into our API but only for admin users or owners of this DragonTreasure. How can we pull that off?
Hello ApiProperty
Well, surprise! We don't often need it, but we can add an ApiProperty attribute above any property to help further configure it. It has a bunch of stuff, like a description that helps with your documentation and many edge-case things. There's even one called readable. If we said readable: false:
| // ... lines 1 - 88 | |
| class DragonTreasure | |
| { | |
| // ... lines 91 - 129 | |
| (readable: false) | |
| private bool $isPublished = false; | |
| // ... lines 132 - 250 | |
| } |
Then the serialization groups would say that this should be included in the response... but then this would override that. Watch: if we try the tests:
symfony php bin/phpunit
They pass because the field is gone.
The security Option
For our mission, we can leverage a super cool option called security. Set it to is_granted("ROLE_ADMIN"):
| // ... lines 1 - 8 | |
| use ApiPlatform\Metadata\ApiProperty; | |
| // ... lines 10 - 88 | |
| class DragonTreasure | |
| { | |
| // ... lines 91 - 129 | |
| (security: 'is_granted("ROLE_ADMIN")') | |
| private bool $isPublished = false; | |
| // ... lines 132 - 250 | |
| } |
That's it! If this expression return false, isPublished will not be included in the API: it won't be readable or writable.
And when we run the tests now:
symfony php bin/phpunit
They still pass, which means isPublished is not being returned.
Now let's go test the "happy" path where this field is returned. Pop open DragonTreasureResourceTest. Here's the original test: testGetCollectionOfTreasures(). We're anonymous, so isPublished isn't returned.
Now scroll down to testAdminCanPatchToEditTreasure(). When we create the DragonTreasure, let's make sure it always starts with isPublished => false:
| // ... lines 1 - 12 | |
| class DragonTreasureResourceTest extends ApiTestCase | |
| { | |
| // ... lines 15 - 138 | |
| public function testAdminCanPatchToEditTreasure(): void | |
| { | |
| $admin = UserFactory::new()->asAdmin()->create(); | |
| $treasure = DragonTreasureFactory::createOne([ | |
| 'isPublished' => false, | |
| ]); | |
| // ... lines 145 - 156 | |
| } | |
| } |
Then, down here, assertJsonMatches('isPublished', false) to test that the field is returned:
| // ... lines 1 - 12 | |
| class DragonTreasureResourceTest extends ApiTestCase | |
| { | |
| // ... lines 15 - 138 | |
| public function testAdminCanPatchToEditTreasure(): void | |
| { | |
| $admin = UserFactory::new()->asAdmin()->create(); | |
| $treasure = DragonTreasureFactory::createOne([ | |
| 'isPublished' => false, | |
| ]); | |
| $this->browser() | |
| // ... lines 147 - 154 | |
| ->assertJsonMatches('isPublished', false) | |
| ; | |
| } | |
| } |
Copy the test name, spin over and add --filter to run just that test:
symfony php bin/phpunit --filter=testAdminCanPatchToEditTreasure
And... it passes! The field is being returned when we're an admin.
Also Returning isPublished for the Owner
What about if we're the owner of the treasure? Copy the test... rename it to testOwnerCanSeeIsPublishedField()... and let's tweak a few things. Rename $admin to $user, simplify this to DragonTreasureFactory::createOne() and make sure the owner is set to our new $user:
| // ... lines 1 - 12 | |
| class DragonTreasureResourceTest extends ApiTestCase | |
| { | |
| // ... lines 15 - 158 | |
| public function testOwnerCanSeeIsPublishedField(): void | |
| { | |
| $user = UserFactory::new()->create(); | |
| $treasure = DragonTreasureFactory::createOne([ | |
| 'isPublished' => false, | |
| 'owner' => $user, | |
| ]); | |
| $this->browser() | |
| ->actingAs($user) | |
| ->patch('/api/treasures/'.$treasure->getId(), [ | |
| 'json' => [ | |
| 'value' => 12345, | |
| ], | |
| ]) | |
| ->assertStatus(200) | |
| ->assertJsonMatches('value', 12345) | |
| ->assertJsonMatches('isPublished', false) | |
| ; | |
| } | |
| } |
We could change this to a GET request... but PATCH is fine. In either situation, we want to make sure the isPublished field is returned.
Since we haven't implemented this yet... let's make sure it fails. Copy the method name and try it:
symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField
Failure achieved! And we know how to solve this! On the security option, we could inline the logic with or object.getOwner() === user. But remember: we created the voter so that we don't need to do crazy stuff like that! Instead, say is_granted(), EDIT then object:
| // ... lines 1 - 88 | |
| class DragonTreasure | |
| { | |
| // ... lines 91 - 129 | |
| (security: 'is_granted("EDIT", object)') | |
| private bool $isPublished = false; | |
| // ... lines 132 - 250 | |
| } |
Try the test now:
symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField
The Special securityPostDenormalize
Got it! Oh, and I haven't used it much, but there's also a securityPostDenormalize option. Just like with the securityPostDenormalize option on each operation, this runs after the new data is deserialized onto the object. What's interesting is that if the expression returns false, the data on the object is actually reverted.
For example, suppose the isPublished property started as false and then the user sent some JSON to change it to true. But then, securityPostDenormalize returned false. In that case, API Platform will revert the isPublished property back to its original value: it will change it from false back to true. Oh, and by the way, securityPostDenormalize is not executed on GET requests: it only happens when data is being deserialized. So be sure to put your main security logic in security and only use securityPostDenormalize if you need it.
Up next on our to-do list: let's level-up our user operations to hash the password before saving to the database. We'll need a fresh, non-persisted plain password property to make it happen.
12 Comments
I tried to change the test testAdminCanPatchToEditTreasure to actually edit the isPublished status.
That was not passing at the current time.
EG. From the script this is OK and passes when the only field changed is value.
But this test does not pass, changing the value of isPublished doesn't seem to work.
Should this work at this point? I think so but maybe I have missed something.
Hey @Lola-Slade!
Sorry for the very slow reply! So the test only passes if you include
'isPublished' => true,? If you don't include it, where is the failure exactly? Do you get a 200 status code, but thevaluereturned is wrong? Or is the failure elsewhere?Let me know and I bet we can unravel this mystery :).
Cheers!
I think I found the explanation for this one after much debugging:
The issue is that the
securityargument of theApiPropertyattribute is evaluated before the denormalization in order to find out which properties are allowed to be written to.But since the treasure object is not denormalized yet the
objectin thesecurityexpression will always benullwhile denormalizing, effectively failing ever time and thus never writing to the property.In this case this could be fixed by letting the
securityexpression pass in caseobjectis not instantiated yet and validate the permissions withsecurityPostDenormalizefor denormalization:I found this behavior of
objectbeingnullin thesecurityexpression while denormalizing very surprising and against my expectation. Especially as the documentation for the same arguments for the operations (Get,Putetc.) attributes mentions a different behavior:And since
securityPostDenormalizeis only evaluated after denormalization of the user provided data into the object this causes huge headaches when a property used for the access decision is writable.Because contrary to the operations
with
ApiPropertythere is noprevious_objectavailable in the expression.Hey @acran!
Fantastic debugging & explanation! Thanks for filling in for me ;)
Thanks a lot for getting back to me Ryan!
It's the opposite. The issue is that the value of isPublished cannot be changed to true.
So any test that does include
isPublishedand then tries to validate the changed value fails.Hey @Lola-Slade!
Glacial reply again, but, does this help? https://symfonycasts.com/screencast/api-platform-security/api-property#comment-33098
Strange Api-platform activity. It looks like ApiProperty Attribute doesn't work with json format. Why? And how to make ApiProperty to work on json format too?
I have simple ApiResource example
If i request jsonld format
http://localhost/api/users/1.jsonld, i get:'doNotShow' is not visible
If i request json format
http://localhost/api/users/1.json, i get:'doNotShow' is visible
PHP 8.2, Symfony 7, "api-platform/core": "3.3.2",
config file: api_platform.yaml
Hey @Mantasz
It seems like an ApiPlatform bug. Try upgrading to the latest version. If the problem remains, you may want to open an issue to the ApiPlatform GitHub repository
Cheers!
it is the latest version: "api-platform/core": "3.3.2".
The only thing I can think of is that it's an ApiPlatform bug. Try asking in their Slack channel
Sorry I cannot help further
is there a securityMessage for ApiProperty like for Operations ?
Hey Gamusta,
Here's the list of possible arguments for ApiProperty: https://github.com/api-platform/core/blob/main/src/Metadata/ApiProperty.php#L45 - there's
security, but nosecurityMessage.Cheers!
"Houston: no signs of life"
Start the conversation!