Upsetting Purists with PATCH [Raw Symfony 4]
I'm going to state immediately that you may disagree with this entire video / concept. This is not a true implementation of how PATCH
is supposed to work. However, it's pragmatic one, so please feel free to disregard / alter however you see fit.
You really don't need to implement, or support PATCH
. In the vast majority of cases, PUT
is good enough.
However, partial updates can be useful. And whilst this isn't going to satisfy purists, it will get the job done - depending on the scale and scope of your application.
Still with me?
Cool.
Ok, so PATCH
is best explained with the help of our Behat feature:
Background:
Given there are Albums with the following details:
| title | track_count | release_date |
| some fake album name | 12 | 2020-01-08T00:00:00+00:00 |
| another great album | 9 | 2019-01-07T23:22:21+00:00 |
| now that's what I call Album vol 2 | 23 | 2018-02-06T11:10:09+00:00 |
Scenario: Can update an existing Album - PUT
Given the request body is:
"""
{
"title": "Renamed an album",
"track_count": 9,
"release_date": "2019-01-07T23:22:21+00:00"
}
"""
When I request "/album/2" using HTTP PUT
Then the response code is 204
Scenario: Can update an existing Album - PATCH
Given the request body is:
"""
{
"track_count": 10
}
"""
When I request "/album/2" using HTTP PATCH
Then the response code is 204
The high level difference?
When sending data in via PATCH
, you only need to send the fields that have changed.
From the Behat feature above, before the PATCH
scenario has run, we would expect the entity to have:
- A
title
of "another great album" - A
trackCount
of 9 - A
releaseDate
of "2018-02-06T11:10:09+00:00"
And after the patch:
- A
title
of "another great album" - A
trackCount
of 10 - A
releaseDate
of "2018-02-06T11:10:09+00:00"
This is important as whilst this all sounds very similar to PUT
, there is a single crucial implementation detail we need to make Symfony aware of.
The PATCH
Implementation
Here's the code:
/**
* @Route(
* "/album/{id}",
* name="patch_album",
* methods={"PATCH"},
* requirements={"id"="\d+"}
* )
* @param int $id
*
* @return JsonResponse
*/
public function patch(
Request $request,
$id
)
{
$data = json_decode(
$request->getContent(),
true
);
$existingAlbum = $this->findAlbumById($id);
$form = $this->createForm(AlbumType::class, $existingAlbum);
$form->submit($data, false);
if (false === $form->isValid()) {
return new JsonResponse(
[
'status' => 'error',
'errors' => $this->formErrorSerializer->convertFormToArray($form),
],
JsonResponse::HTTP_BAD_REQUEST
);
}
$this->entityManager->flush();
return new JsonResponse(
[
'status' => 'ok',
],
JsonResponse::HTTP_NO_CONTENT
);
}
This is almost identical to the put
method.
The routing annotation dictates that this method only accepts PATCH
requests.
Aside from this, can you spot the difference?
It's not super obvious, at first glance.
The difference is just one argument:
$form->submit($data, false);
In post
, or put
we used:
$form->submit($data);
The second argument, by default is true
.
PHP not having named arguments makes this a little hard to decipher. You should look to the interface
for instruction (ctrl + click in PhpStorm):
/**
* Submits data to the form, transforms and validates it.
*
* @param mixed $submittedData The submitted data
* @param bool $clearMissing Whether to set fields to NULL when they
* are missing in the submitted data
*
* @return $this
*
* @throws Exception\AlreadySubmittedException if the form has already been submitted
*/
public function submit($submittedData, $clearMissing = true);
Most of the time, if one of our Symfony 4 API consumers forgets to send in a field, we want to throw a 400
error. This would be a bad request. We would be missing some important piece of data.
With our patch
implementation, however, partial requests are exactly what we are targeting.
By setting $clearMissing
to false
then we effectively tell Symfony's Form component to keep what was originally on our entity (our Album
in this case), and only update using the data explicitly provided in the current request.
And that's it!
php vendor/bin/behat --tags=t
Feature: Provide a consistent standard JSON API endpoint
In order to build interchangeable front ends
As a JSON API developer
I need to allow Create, Read, Update, and Delete functionality
Background: # features/album.feature:7
Given there are Albums with the following details: # FeatureContext::thereAreAlbumsWithTheFollowingDetails()
| title | track_count | release_date |
| some fake album name | 12 | 2020-01-08T00:00:00+00:00 |
| another great album | 9 | 2019-01-07T23:22:21+00:00 |
| now that's what I call Album vol 2 | 23 | 2018-02-06T11:10:09+00:00 |
@t
Scenario: Can update an existing Album - PATCH # features/album.feature:80
Given the request body is: # Imbo\BehatApiExtension\Context\ApiContext::setRequestBody()
"""
{
"track_count": 10
}
"""
When I request "/album/2" using HTTP PATCH # Imbo\BehatApiExtension\Context\ApiContext::requestPath()
Then the response code is 204 # Imbo\BehatApiExtension\Context\ApiContext::assertResponseCodeIs()
1 scenario (1 passed)
4 steps (4 passed)
0m0.26s (9.62Mb)
Jolly good.
The only task left is to allow ourselves to DELETE
existing data. Let's get to it.