Profile - Part 3 - Updating (PUT) - Happy Path


We also want our Users to be able to change their profile. Things change, and inevitably someone will want to Update the information they have provided.

We can do this with the HTTP verbs: PATCH and PUT.

A PATCH request sends in all the mandatory fields, and one or more optional fields.

a PUT request sends in every field.

This isn't how PATCH should work in the strict sense of the RFC. I've discussed this before when talking about PATCH, so won't dive into it again.

We will start off by implementing PUT. Once we have the PUT implementation, you will see that implementing PATCH is essentially the flip of a boolean.

Let's write out the Behat feature spec, and then cover the differences:

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"

  # snip

  Scenario: Can replace their own profile
    When I send a "PUT" request to "/profile/1" with body:
      """
      {
        "username": "peter",
        "email": "new_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": "new_email@test.com"
      }
      """

I have omitted the previous test setup for brevity.

Sending in a PUT request is no different to how we sent in a POST request in the login section. Changing the verb (from POST to PUT) means we will need a different controller action. We haven't defined this yet, so no problem there. Sending in the body content is the same as in the POST for login.

We expect back a 204 status code. This means we didn't get back any content (HTTP_NO_CONTENT), which is fine as we already know the new data - we just sent it in.

Where we differ here from a typical test is that we don't directly check the database to see if our profile has been updated.

Instead, we "dogfood" (read: use) the API to get back the outcome as our system would present it. Our users don't have direct access to the database (you would hope), so why test things that way? If everything went well then surely we should see the updated profile data - the email field in this scenario.

Defining The putAction

Unlike in the getAction, we won't be creating the vast majority of the logic in this instance.

The idea here is that we replicate the functionality of FOSUserBundle, only changing up the way in which we respond. We want to respond with JSON, not HTML. Aside from the 'rendering' steps, the rest of the logic is the same as the FOSUserBundle supplied implementation.

Ok, so that's a long winded way of saying we are going to copy / paste from FOSUserBundle's ProfileController::editAction.

Yes, burn the heretic. Copy / paste.

It's unfortunate. I looked for a way to do this more elegantly, but without being able to change the implementation of FOSUserBundle, I don't currently see a way to do this differently.

Of course, please feel free to tell me of a better way.

The real downside to copy / paste is that we take ownership of the problem. The problem in this instance being that we must now ensure our implementation is kept in sync with any changes to the equivalent controller actions in FOSUserBundle. This is a burden not to be taken on lightly.

There are some alternative approaches to this. One may be to change up FOSUserBundle itself. Another would be to put these controllers and associated bits and pieces into a separate bundle, which could be re-used, meaning at least there is a single central place to update, rather than many disparate implementations. Thoughts for the future, though I am interested in your opinion, so please do leave a comment below.

The Implementation

Ok, so enough with the damage mitigation, let's just see the implementation:

<?php

namespace AppBundle\Controller;

use AppBundle\Entity\User;
use FOS\RestBundle\View\View;
use FOS\RestBundle\Controller\Annotations;
use FOS\RestBundle\Controller\Annotations\Get;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\Routing\ClassResourceInterface;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\UserBundle\Event\FilterUserResponseEvent;
use FOS\UserBundle\Event\GetResponseUserEvent;
use FOS\UserBundle\FOSUserEvents;
use FOS\UserBundle\Event\FormEvent;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Component\Security\Core\User\UserInterface;

/**
 * @RouteResource("profile", pluralize=false)
 */
class RestProfileController extends FOSRestController implements ClassResourceInterface
{
    /** snip */

    /**
     * @param Request       $request
     * @param UserInterface $user
     *
     * @ParamConverter("user", class="AppBundle:User")
     *
     * @return View|\Symfony\Component\Form\FormInterface
     */
    public function putAction(Request $request, UserInterface $user)
    {
        $user = $this->getAction($user);

        /** @var $dispatcher \Symfony\Component\EventDispatcher\EventDispatcherInterface */
        $dispatcher = $this->get('event_dispatcher');

        $event = new GetResponseUserEvent($user, $request);
        $dispatcher->dispatch(FOSUserEvents::PROFILE_EDIT_INITIALIZE, $event);

        if (null !== $event->getResponse()) {
            return $event->getResponse();
        }

        /** @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());

        if (!$form->isValid()) {
            return $form;
        }

        /** @var $userManager \FOS\UserBundle\Model\UserManagerInterface */
        $userManager = $this->get('fos_user.user_manager');

        $event = new FormEvent($form, $request);
        $dispatcher->dispatch(FOSUserEvents::PROFILE_EDIT_SUCCESS, $event);

        $userManager->updateUser($user);

        // there was no override
        if (null === $response = $event->getResponse()) {
            return $this->routeRedirectView(
                'get_profile',
                ['user' => $user->getId()],
                Response::HTTP_NO_CONTENT
            );
        }

        // unsure if this is now needed / will work the same
        $dispatcher->dispatch(FOSUserEvents::PROFILE_EDIT_COMPLETED, new FilterUserResponseEvent($user, $request, $response));

        return $this->routeRedirectView(
            'get_profile',
            ['user' => $user->getId()],
            Response::HTTP_NO_CONTENT
        );
    }
}

All that we have done here is to take out the parts that would return HTML, and replaced them with the equivalent JSON response.

We can rely on FOSRESTBundle to transform our $form to JSON, should it be invalid:

        if (!$form->isValid()) {
            return $form;
        }

Which will return a rather horribly nested representation of what went wrong. But it is good enough - don't expect to win too many friends in the front end community with this one though.

As mentioned already, a successful PUT would result in a 204 status code. We won't have any content to return. Instead, we just want to indicate to the user - via a Response header - where to go next.

By using the routeRedirectView, we can do just this, adding in the location header to the response, generating the route from get_profile, passing in our $user->getId() in place of the {user} placeholder, and adding the Response::HTTP_NO_CONTENT constant to indicate the 204 code.

Code For This Course

Get the code for this course.

Episodes