Profile - Part 4- Adding PATCH - Happy Path
In this video we are going to add in additional "unhappy path" tests for PUT
/ updating our User's profile. Once we have added these tests, we will add in PATCH
functionality, which will allow partial updates of our Profile resource.
The two additional tests to add in for our Profile PUT
controller are to check that a User has supplied a valid password when updating their own profile, and that a User can only update their own profile.
Let's take a look at both of these scenarios inside the Behat feature file:
# /src/AppBundle/Features/profile.feature
Feature: Manage User profile data via the RESTful API
In order to allow a user to keep their profile information up to date
As a client software developer
I need to be able to let users read and update their profile
Background:
Given there are Users with the following details:
| id | username | email | password |
| 1 | peter | peter@test.com | testpass |
| 2 | john | john@test.org | johnpass |
And I am successfully logged in with username: "peter", and password: "testpass"
And I set header "Content-Type" with value "application/json"
Scenario: Must supply current password when updating profile information
When I send a "PUT" request to "/profile/1" with body:
"""
{
"email": "new_email@test.com"
}
"""
Then the response code should be 400
Scenario: Cannot replace another user's profile
When I send a "PUT" request to "/profile/2" with body:
"""
{
"username": "peter",
"email": "new_email@test.com",
"current_password": "testpass"
}
"""
Then the response code should be 403
I've only shown the additional tests here. For the profile.feature
in full, please click here.
Again, no need for us to write any additional Behat step definitions. We already have all the step definitions we need to write and test these scenarios.
As a profile can contain sensitive information, we need our Users to send in their current password whenever they are wanting to update their profile. This is an easy security precaution to take.
As we are exposing our profile information via a RESTful API, we open up a different angle of attack. Savvy users can try manipulating the URL to access / edit other User's profile data. Obviously, we don't want this to happen.
Now, at this point we cannot easily protect ourselves with firewalls, or the access_control
section of our security.yml
file. We need to allow secure requests, but we need to be more granular about who can access individual resources.
We've already added the security check in here as part of our PUT
happy path, but to cover it again:
// /src/AppBundle/Controller/RestProfileController.php
class RestProfileController extends FOSRestController implements ClassResourceInterface
{
/**
* @Get("/profile/{user}")
* @ParamConverter("user", class="AppBundle:User")
*/
public function getAction(UserInterface $user)
{
if ($user !== $this->getUser()) {
throw new AccessDeniedHttpException();
}
return $user;
}
/**
* @ParamConverter("user", class="AppBundle:User")
*/
public function putAction(Request $request, UserInterface $user)
{
$user = $this->getAction($user);
// * snip *
I've cut out the majority of extra stuff here in an attempt to highlight the important parts only.
We already have a getAction
that checks if the current User (represented by the JWT) is the User they are trying to access. If not, then an AccessDeniedHttpException
/ 403
error is returned.
Rather than re-implement this logic for PUT
(and PATCH
), we can simply call the same action. If it throws, it throws. If it doesn't, it returns the User
entity anyway. Win win.
That's about as much as we need to do to cover off the PUT
side of things. Our PATCH
implementation is going to make use of the same code, and with very few tweaks, very similar tests also.
Allowing Partial Updates
The way I am implementing PATCH
here is contentious. Feel free to implement a more robust PATCH
should your needs require it.
The gist of the difference between a PATCH
and a PUT
is that in a PUT
we must send in every single field, whether its value is being updated or staying the same. In contrast, a PATCH
allows us to only send in the fields that are being updated.
The 'problem' is that by default, Symfony's form submission will clear any missing fields.
In reality what this means is that if you do not send in some of the fields expected by the form, Symfony will set each missing field to null
for you. Most likely you do not want null
values appearing willy nilly in your data, and also there's a strong chance your form won't validate if there are null
values.
Knowing this, if we take a look at the implementation for our putAction
, we can see that currently we don't override the default second parameter in the form submission:
public function putAction(Request $request, UserInterface $user)
{
// * snip *
/** @var $formFactory \FOS\UserBundle\Form\Factory\FactoryInterface */
$formFactory = $this->get('fos_user.profile.form.factory');
$form = $formFactory->createForm(['csrf_protection' => false]);
$form->setData($user);
// second parameter is clearMissing
// which is true by default
$form->submit($request->request->all());
if (!$form->isValid()) {
return $form;
}
// * snip *
}
The only difference for PATCH
is that we want to set clearMissing
to be false
.
Let's start by adding a Behat scenario to cover the "happy path" of a profile update via PATCH
:
# /src/AppBundle/Features/profile.feature
Feature: Manage User profile data via the RESTful API
In order to allow a user to keep their profile information up to date
As a client software developer
I need to be able to let users read and update their profile
Background:
Given there are Users with the following details:
| id | username | email | password |
| 1 | peter | peter@test.com | testpass |
| 2 | john | john@test.org | johnpass |
And I am successfully logged in with username: "peter", and password: "testpass"
And I set header "Content-Type" with value "application/json"
Scenario: Can update their own profile
When I send a "PATCH" request to "/profile/1" with body:
"""
{
"email": "different_email@test.com",
"current_password": "testpass"
}
"""
Then the response code should be 204
And I send a "GET" request to "/profile/1"
And the response should contain json:
"""
{
"id": "1",
"username": "peter",
"email": "different_email@test.com"
}
"""
Notice here that we are only sending in the email
field. We don't need to include the username
field, which we have not changed.
This test should currently fail, largely because we have no patchAction
defined in our RestProfileController
.
However, we don't want to simply copy / paste the putAction
and change that one single clearMissing
variable. We already have enough copy / paste in this project...
If we extract the entire implementation from the putAction
in to a new method called updateProfile
, we can then add in a boolean variable to the updateProfile
method signature which will allow us to differentiate between PUT
and PATCH
:
class RestProfileController extends FOSRestController implements ClassResourceInterface
{
/**
* @param Request $request
* @param UserInterface $user
*
* @ParamConverter("user", class="AppBundle:User")
*
* @return View|\Symfony\Component\Form\FormInterface
*/
public function putAction(Request $request, UserInterface $user)
{
return $this->updateProfile($request, true, $user);
}
/**
* @param Request $request
* @param UserInterface $user
*
* @ParamConverter("user", class="AppBundle:User")
*
* @return View|\Symfony\Component\Form\FormInterface
*/
public function patchAction(Request $request, UserInterface $user)
{
return $this->updateProfile($request, false, $user);
}
/**
* @param Request $request
* @param bool $clearMissing
* @param UserInterface $user
*/
private function updateProfile(Request $request, $clearMissing = true, UserInterface $user)
{
// *snip*
/** @var $formFactory \FOS\UserBundle\Form\Factory\FactoryInterface */
$formFactory = $this->get('fos_user.profile.form.factory');
$form = $formFactory->createForm(['csrf_protection' => false]);
$form->setData($user);
$form->submit($request->request->all(), $clearMissing);
if (!$form->isValid()) {
return $form;
}
// *snip*
}
}
Again, I have removed most of the noise to highlight the most important part for this change.
With this change in place, our "happy path" test should now be passing.
The thing is, if you are making a Symfony API to power the back end of a modern JavaScript-y front end, the chances are very high that PUT
will be fine. You will very likely have all the information needed to make an update. But still, having the option to PATCH
is nice.
The only thing left to do is add in any "unhappy path" tests you can think of:
# /src/AppBundle/Features/profile.feature
Feature: Manage User profile data via the RESTful API
In order to allow a user to keep their profile information up to date
As a client software developer
I need to be able to let users read and update their profile
Background:
Given there are Users with the following details:
| id | username | email | password |
| 1 | peter | peter@test.com | testpass |
| 2 | john | john@test.org | johnpass |
And I am successfully logged in with username: "peter", and password: "testpass"
And I set header "Content-Type" with value "application/json"
Scenario: Cannot update another user's profile
When I send a "PATCH" request to "/profile/2" with body:
"""
{
"username": "peter",
"email": "new_email@test.com",
"current_password": "testpass"
}
"""
Then the response code should be 403
And that should be us done with our PATCH
implementation.