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.

Code For This Course

Get the code for this course.

Episodes