Cross-posted on ApiPlatform's Github, now labeled as "bug": https://github.com/api-platform/api-platform/issues/2443
I'm setting up a skeleton for myself that has the basics included. Part of that is a User which can have Roles. A Role is not an Entity, it exists only as strings and they're stored as part of User records as a json
column (Postgres).
The trouble I'm facing: Both POST and PATCH endpoints for creating and updating a User have the same Constraints. However, the PATCH endpoint fails to validate incoming data prior to trying to use it.
The POST /api/users
& PATCH /api/users/:uuid
endpoint are configured like so:
ApiPlatform\Metadata\Post:
processor: App\User\PasswordHasher
normalizationContext:
groups:
- user:item
denormalizationContext:
groups:
- user:post
validationContext:
groups:
- user:post
ApiPlatform\Metadata\Patch:
processor: App\User\PasswordHasher
normalizationContext:
groups:
- user:item
denormalizationContext:
groups:
- user:patch
validationContext:
groups:
- user:patch
The validation for this is configured like so:
roles:
- Choice:
# callback: [ App\Role\Role, getRoles ] # <-- Alternative, same result
choices: [ 'ROLE_USER', 'ROLE_ADMIN' ]
multiple: true
message: 'The role "{{ value }}" is not a valid role.'
min: 0
minMessage: 'You must select at least {{ limit }} choice(s).'
groups:
- user:post
- user:patch
When debugging, I find that the class Symfony\Component\Validator\Constraint\Choice
gets instantiated and the correct options are present:
However, the Symfony\Component\Validator\Constraints\ChoiceValidator::validate
function is never called. I have set a breakpoint on the first line in that function, but it never gets there. (It is called when using the POST endpoint.)
Instead of using the validator, it continues as if the content is valid and tries to add an invalid "Role" value (INVALID
) to the User object. That it even attempts this means it got past the validation stage.
Observations
- The same code is being used for the
POST /api/users
endpoint, where usingINVALID
as the value fails with a422 Unprocessable Content
, as expected - Because the POST and PATCH endpoints have the same configuration for Resource (Entity) and the same Constraints, I expect the difference in handling to be because of the HTTP Method
POST
calls theChoiceValidator::validate
functionPATCH
does not call theChoiceValidator::validate
function
Additional code for context
Behat is the feature testing framework of choice for process and data validation. Below are the 2 tests, one for POST and the other for PATCH, that verify trying to save INVALID
should fail.
POST /api/users
- This one passes
# Purpose: validate that an administrator cannot create a user with an invalid role
Scenario Outline: An administrator cannot create a user with an invalid role
Given there is no user with email "john.doe@example.com"
And add "Accept" header equal to "<accept>"
When a POST request is sent to "/api/users" with body:
"""
{
"email": "john.doe@example.com",
"plainPassword": "SuperSecretPassword@123!",
"roles": <roles>
}
"""
Then the response status code should be 422
And the response should be in JSON
And a user with email "john.doe@example.com" should not exist
And the header "Content-Type" should contain "application/problem+json"
And the header "Content-Type" should contain "charset=utf-8"
And the JSON should be a valid item according to the schema "exception.schema.json"
Examples:
| accept | roles |
| application/json | ["INVALID"] |
| application/ld+json | ["INVALID", "ROLE_ADMIN"] |
PATCH /api/users/:uuid
- This one fails
# Purpose: validate that an administrator cannot patch a user with an invalid role
Scenario Outline: An administrator cannot patch a user with an invalid role
Given there is no user with email "john.doe@example.com"
And there is no user with uuid "1a2b3c4d-1a2b-1a2b-1a2b-1a2b3c4d5e6f"
And there is a user with email "john.doe@example.com" and uuid "1a2b3c4d-1a2b-1a2b-1a2b-1a2b3c4d5e6f"
And add "Content-Type" header equal to "application/merge-patch+json"
When a PATCH request is sent to "/api/users/1a2b3c4d-1a2b-1a2b-1a2b-1a2b3c4d5e6f" with body:
"""
{
"roles": <roles>
}
"""
Then the response status code should be 422
And the response should be in JSON
And the stored value for roles of user with email "john.doe@example.com" should not contain roles:
| <roles> |
And the header "Content-Type" should contain "application/problem+json"
And the header "Content-Type" should contain "charset=utf-8"
And the JSON should be a valid item according to the schema "exception.schema.json"
Examples:
| roles |
| ["INVALID"] |
| ["INVALID", "ANOTHER_INVALID"] |
Output of failed step:
Examples:
| roles |
| ["INVALID"] |
Failed step: Then the response status code should be 422
Current response status code is 404, but 422 expected. (Behat\Mink\Exception\ExpectationException)
│
│ JSON body:
│ {
│ "type": "https:\/\/tools.ietf.org\/html\/rfc2616#section-10",
│ "title": "An error occurred",
│ "detail": "The role 'INVALID' does not exist.",
│ "trace": [
│ // THE STACK TRACE HERE
The output detail
message comes from Role::addRole
which checks if the provided Role string value INVALID
exists in the array of known roles (['ROLE_USER', 'ROLE_ADMIN']
).