Implementing PATCH for Users
In this video we are going to finish off the User feature by implementing the PATCH
method. We will cover how the form logic is largely abstracted, making it re-usable by all the subsequent actions (POST
, PUT
) that we implement in forthcoming controllers for Accounts and Files.
The workflow for PATCH
can be broken down in to a few smaller steps. From the UserController
's point of view, there are really only three steps:
- Check the logged in
User
is accessing their own data - (Try to) update the existing
User
data with the submitted form data - Tell the
User
where they can find their updated data
The controller doesn't really do very much heavy lifting. As we've discussed in previous videos, the controller largely delegates any real responsibility to a -low paid minion- more specialised service.
We're making use of the restricted User repository that we've covered in previous videos to firstly check if the currently logged in User is able to access the requested User's data. As we've already covered, this is handled by a Symfony Voter, and will throw an exception (resulting in a 403
error) if the User doesn't have access to the requested resource.
That's the first step out of the way.
If the controller hasn't errored out by this point, we can assume that the User is trying to update their own data. So far, so good.
PATCH
ing Like An Idiot
There's a well known article by Will Durand which I referenced in the [previous course on FOSRESTBundle] here on CodeReviewVideos. It's all about PATCH
ing like an idiot.
Well, my theory on this is - if you are creating a RESTful API for HSBC, or Barclays, or some other financial institution, then by all means make sure you have some military grade checks in place to ensure you are PATCH
ing atomically.
For the rest of us (no pun intended), a little pragmatism is fine. At least, I haven't been stung yet using this implementation. Fingers crossed, and all that.
From the controller, we start the PATCH
process like so:
/** @var $user \AppBundle\Entity\User */
$user = $this->getUserHandler()->patch(
$requestedUser,
$request->request->all()
);
The UserHandler
is the same one we used in the GET
method, which is to say it is returning the 'restricted' UserHandler
.
/**
* @return UserHandler
*/
private function getUserHandler()
{
return $this->container->get('crv.handler.restricted_user_handler');
}
If you're not a fan of service location, feel free to create your controllers as a service.
The patch
method of the UserHandler
is as follows:
/**
* @param UserInterface $user
* @param array $parameters
* @param array $options
* @return UserInterface
*/
public function patch($user, array $parameters, array $options = [])
{
if ( ! $user instanceof UserInterface) {
throw new \InvalidArgumentException('Not a valid User');
}
$user = $this->formHandler->handle(
$user,
$parameters,
Request::METHOD_PATCH,
$options
);
$this->repository->save($user);
return $user;
}
There are a few interesting points to note here.
Firstly, because we are using a shared interface for all our *Handler
services (AccountHandler
, FileHandler
, etc), we can't type hint the method with our UserInterface
. This is unfortunate, and leads to the need for a guard statement (throw
if not an instance of `UserInterface).
$parameters
is the data submitted by the API user. Another term for this may be 'submitted data', or 'form data'. You're free to use your own wording.
$options
becomes useful when your API gets a little more complex. You may wish to only run certain Symfony Validations when in a POST
, or others when in a PUT
. This is one way of achieving this goal. For now, our $options
array will be empty.
If anything untoward happens during the handle
method on our formHandler
, the form will throw
and we will never get to the stage of saving via the repository.
Finally, return the updated $user
object if everything went well.
The Fine Art of Delegation
Once again, we delegate the real hard work of handling a form submission to yet another service. In this case, the FormHandler
service:
<?php
namespace AppBundle\Form\Handler;
use AppBundle\Exception\InvalidFormException;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormTypeInterface;
class FormHandler implements FormHandlerInterface
{
/**
* @var FormFactoryInterface
*/
private $formFactory;
/**
* @var FormTypeInterface
*/
private $formType;
/**
* FormHandler constructor.
* @param FormFactoryInterface $formFactory
* @param FormTypeInterface $formType
*/
public function __construct(
FormFactoryInterface $formFactory,
FormTypeInterface $formType
)
{
$this->formFactory = $formFactory;
$this->formType = $formType;
}
/**
* @param mixed $object
* @param array $parameters
* @param string $method
* @param array $options
* @return mixed
* @throws InvalidFormException
*/
public function handle($object, array $parameters, $method, array $options = [])
{
$options = array_replace_recursive([
'method' => $method,
'csrf_protection' => false,
], $options);
$form = $this->formFactory->create(get_class($this->formType), $object, $options);
$form->submit($parameters, 'PATCH' !== $method);
if (!$form->isValid()) {
throw new InvalidFormException($form);
}
return $form->getData();
}
}
There's some interesting things happening here, and one change between a Symfony 2 implementation of this code, and Symfony 3.
Each different entity type that we implement will lead to a specific service definition inside services.yml
. Here is the definition for User
:
crv.form.handler.restricted_user_form_handler:
class: AppBundle\Form\Handler\FormHandler
arguments:
- "@form.factory"
- "@crv.form.type.restricted_user"
We pass in the Symfony Form Factory service. This allows forms to be instantiated using Symfony's Form Component behind the scenes. That's one part of the heavy lifting delegated very nicely.
Next, we need to pass in our form type. A form type is a funky way of saying... our Symfony form. I never really understood why they used the word 'type' to mean a form... but that's straying off-topic.
In Symfony 2 we could pass in the form type to the form factory and let the form factory create
method worry about how to create the right form.
In Symfony 3, that's changed. Instead, we must pass in a string representing our fully namespaced form type class. That explains this horrible line:
$form = $this->formFactory->create(get_class($this->formType), $object, $options);
The other interesting line is :
$form->submit($parameters, 'PATCH' !== $method);
submit
takes a boolean as it's second parameter. If that boolean is true
, then any missing fields from the form submission will be null
ed. This will likely lead to validation errors, or worse, your entities' state suddenly quite wrong. Be very careful of this. Follow the video for a further explanation.
This essentially allows us to keep re-using this same core form logic over and over, whenever we need to submit a form. Less code, less bugs. And also, our Behat test suite will continually test this code many, many times.
Speaking of Behat...
Behat PATCH
Tests
By now we have a working PATCH
action that:
- partially updates
User
objects - is secured
- returns the expected status code
Actually, not so much on that last one. At least, not from the point of view of Behat.
If we test the PATCH
functionality in Postman (other REST clients are available ;)) then we should be getting the expected outcome - updated data in the database, 204
status code, and a link to our updated resource.
However, the Behat test is still failing... why?
Scenario: User can PATCH to update their personal data
When I send a "PATCH" request to "/users/u1" with body:
"""
{
"email": "peter@something-else.net",
"current_password": "testpass"
}
"""
Then the response code should be 204
And I send a "GET" request to "/users/u1"
And the response should contain json:
"""
{
"id": "u1",
"email": "peter@something-else.net",
"username": "peter"
}
"""
That all looks good. We need to ensure we pass in our current password for security purposes. Even though we are authenticated, we still want our User's to submit their password whenever they are updating their User data. This is an extra safety measure, and it only applies to our User endpoint.
We need to look further up the Behat feature for the answer as to why our test fails:
Background:
Given there are Users with the following details:
| uid | username | email | password |
| u1 | peter | peter@test.com | testpass |
| u2 | john | john@test.org | johnpass |
# And there are Accounts with the following details:
# | uid | name | users |
# | a1 | account1 | u1 |
And I am successfully logged in with username: "peter", and password: "testpass"
# And when consuming the endpoint I use the "headers/content-type" of "application/json"
Yes, way back we commented out the line about setting the Content-type
header to application/json
. Oops. Behat is sending in text instead.
But shockingly, our API is still reporting a 204
status code. This is not only a bit pants, but is terrible for our developer / consumer user experience.
In the next video, we will fix this. Let me tell you, that was an enjoyable way to spend a few hours one evening whilst trying to debug that little issue ;)