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.