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.

Episodes

# Title Duration
1 What will our JSON API actually do? 08:46
2 What needs to be in our Database for our Tests to work? 12:32
3 Cleaning up after each Test Run 02:40
4 Docker makes for Easy Databases 09:01
5 Healthcheck [Raw Symfony 4] 07:53
6 Send in JSON data using POST [Raw Symfony 4] 05:33
7 Keep your data nice and tidy using Symfony's Form [Raw Symfony 4] 10:48
8 Validating incoming JSON [Raw Symfony 4] 08:26
9 Nicer error messages [Raw Symfony 4] 06:23
10 GET'ting data from our Symfony 4 API [Raw Symfony 4] 08:11
11 GET'ting a collection of Albums [Raw Symfony 4] 01:50
12 Update existing Albums with PUT [Raw Symfony 4] 05:00
13 Upsetting Purists with PATCH [Raw Symfony 4] 02:39
14 Hitting DELETE [Raw Symfony 4] 02:11
15 How to open your API to the outside world with CORS [Raw Symfony 4] 07:48
16 Getting Setup with Symfony 4 and FOSRESTBundle [FOSRESTBundle] 09:11
17 Healthcheck [FOSRESTBundle] 06:14
18 Handling POST requests [FOSRESTBundle] 08:31
19 Saving POST data to the database [FOSRESTBundle] 09:44
20 Work with XML, or JSON, or Both [FOSRESTBundle] 04:31
21 Going far, then Too Far with the ViewResponseListener [FOSRESTBundle] 03:19
22 GET'ting data from your Symfony 4 API [FOSRESTBundle] 05:58
23 GET'ting a Collection of data from your Symfony 4 API [FOSRESTBundle] 01:27
24 Updating with PUT [FOSRESTBundle] 02:58
25 Partially Updating with PATCH [FOSRESTBundle] 02:15
26 DELETE'ing Albums [FOSRESTBundle] 01:27
27 Handling Errors [FOSRESTBundle] 08:58
28 Introducing the API Platform [API Platform] 08:19
29 The Entry Point [API Platform] 04:30
30 The Context [API Platform] 05:52
31 Healthcheck - Custom Endpoint [API Platform] 05:17
32 Starting with POST [API Platform] 07:08
33 Creating Entities with the Schema Generator [API Platform] 07:38
34 Defining A Custom POST Route [API Platform] 07:31
35 Finishing POST [API Platform] 06:29
36 GET'ting One Resource [API Platform] 02:50
37 GET'ting Multiple Resources [API Platform] 02:59
38 PUT to Update Existing Data [API Platform] 02:19
39 DELETE to Remove Data [API Platform] 01:15
40 No One Likes Errors [API Platform] 03:28