Updating with PUT [FOSRESTBundle]


At this point we can add new Albums, get an individual Album, and also get a full list of Albums. It would be super nice if we could update existing albums. Let's get right on to that now.

In this video we will cover how to use PUT.

A PUT request expects us to provide a full representation of our Album, with any field changes replacing existing data of the same field.

This is more obvious when seen in a test:

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:
    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

We must send in every field, even if we aren't updating / changing that fields data.

The PUT implementation is like a mix of the getAction and postAction.

First we need to lookup the existing Album in our database.

Then we use this album as the starting point for our form submission.

We take the incoming data and put (excuse the pun) this over the top of the existing data. We use the Symfony form for this.

Should validation fail, we return the form errors.

If validation passes, we save changes to the database.

Finally we return a 204 status code, which means the resource was successfully updated but we have "No Content" to send back to the API consumer.

Why no content? Well, they already knew the resource, they already sent in the new data... what else can we tell them? Nothing, that's what.

The PUT Implementation

As you no doubt expect by now, FOSRESTBundle has a convention we can follow which will do most of the hard work for us.

We just need to implement the putAction:

    public function putAction(Request $request, string $id)
    {

    }

Nothing new to inject here. We have the AlbumRepository from our getAction and cgetAction. And we have the EntityManagerInterface from our postAction. We will need both.

Checking the router:

bin/console debug:router
 -------------------------- -------- -------- ------ ----------------------------------- 
  Name                       Method   Scheme   Host   Path                               
 -------------------------- -------- -------- ------ ----------------------------------- 
  cget_album                 GET      ANY      ANY    /album                             
  get_album                  GET      ANY      ANY    /album/{id}                        
  post_album                 POST     ANY      ANY    /album                             
  put_album                  PUT      ANY      ANY    /album/{id}                        
 -------------------------- -------- -------- ------ ----------------------------------- 

Cool, a new route that's locked down to PUT requests.

Here's the new implementation:

    public function putAction(Request $request, string $id)
    {
        $existingAlbum = $this->albumRepository->find($id);

        $form = $this->createForm(AlbumType::class, $existingAlbum);

        $form->submit($request->request->all());

        if (false === $form->isValid()) {
            return $this->view($form);
        }

        $this->entityManager->flush();

        return $this->view(null, Response::HTTP_NO_CONTENT);
    }

And this passes:

vendor/bin/behat features/album_symfony_4.feature --tags=t

Feature: Provide insight into how Symfony 4 behaves on the unhappy path

  In order to eliminate bad Album data
  As a JSON API developer
  I need to ensure Album data meets expected criteria

  Background:                                          # features/album_symfony_4.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 - PUT # features/album_symfony_4.feature:43
    Given the request body is:                 # Imbo\BehatApiExtension\Context\ApiContext::setRequestBody()
      """
      {
        "title": "Renamed an album",
        "track_count": 9,
        "release_date": "2019-01-07T23:22:21+00:00"
      }
      """
    When I request "/album/2" using HTTP PUT   # 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.14s (9.74Mb)

All good?

Yes, on the happy path.

What happens if we try to PUT to a non-existent ID? Let's find out.

Updating a Non Existent ID

If we update the test - or use Postman to send in a manual request - to PUT to an ID that doesn't exist, let's see what happens:

vendor/bin/behat features/album_symfony_4.feature --tags=t

Feature: Provide insight into how Symfony 4 behaves on the unhappy path

  In order to eliminate bad Album data
  As a JSON API developer
  I need to ensure Album data meets expected criteria

  Background:                                          # features/album_symfony_4.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 - PUT # features/album_symfony_4.feature:43
    Given the request body is:                 # Imbo\BehatApiExtension\Context\ApiContext::setRequestBody()
      """
      {
        "title": "Renamed an album",
        "track_count": 9,
        "release_date": "2019-01-07T23:22:21+00:00"
      }
      """
    When I request "/album/9999999999999" using HTTP PUT # 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.16s (9.71Mb)

Hmm.

That's not right.

What's happening here?

Ok, our implementation again for reference:

    public function putAction(Request $request, string $id)
    {
        $existingAlbum = $this->albumRepository->find($id);

        $form = $this->createForm(AlbumType::class, $existingAlbum);

        $form->submit($request->request->all());

        if (false === $form->isValid()) {
            return $this->view($form);
        }

        $this->entityManager->flush();

        return $this->view(null, Response::HTTP_NO_CONTENT);
    }

We try to find the Album by ID.

Of course Album with ID 9999999999999 doesn't exist.

We could throw here, or alternatively, return a View with a 404 code. I'll explain why I prefer to throw momentarily.

As we don't throw / return early / exit, so let's carry on.

At this point $existingAlbum is null.

If we pass in a null to createForm, then the AlbumType will fall back to a new instance of whatever data_class we configured on that Form Type:

// src/Form/AlbumType.php

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(
            [
                'data_class' => Album::class,
                'allow_extra_fields' => true,
            ]
        );
    }

Our PUT data is valid. We have all the expected fields in the right formats. Therefore the form submission valid check returns true.

Crucially we do not need to persist this new Album entity.

We do flush, but as this Album is not managed by Doctrine (not persisted) the flush call does not save the data to the database. Therefore, we do not get a newly created Album in this instance.

It may be that you do want to allow PUT requests to create new resources if the requested ID doesn't exist. I don't like this myself. I'd rather the API consumer did the check themselves. This is open to your own interpretation - do what you want, just be consistent, and document / test the process.

Finally because we return a 204 response, our test passes. Ahhh a false positive. Nice.

404 To The Floor

I'm going to add in additional logic to throw if $this->albumRepository->find($id); returns null:

use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

// ...

    public function putAction(Request $request, string $id)
    {
        $existingAlbum = $this->albumRepository->find($id);

        if (null === $existingAlbum) {
            throw new NotFoundHttpException();
        }

Why throw, and not return a View?

Well, I have two reasons:

  • Reusability
  • FOSRESTBundle will helpfully serialize our Exceptions

There are multiple locations inside this single AlbumController where we make a call to $this->albumRepository->find($id).

We have one in getAction. Another here in putAction. We'll duplicate most of this for patchAction, and yet another call in the forthcoming deleteAction.

It makes sense, in my mind, to extract this into a private method inside the AlbumController:

    public function putAction(Request $request, string $id)
    {
        $existingAlbum = $this->findAlbumById($id);

        $form = $this->createForm(AlbumType::class, $existingAlbum);

        $form->submit($request->request->all());

        if (false === $form->isValid()) {
            return $this->view($form);
        }

        $this->entityManager->flush();

        return $this->view(null, Response::HTTP_NO_CONTENT);
    }

    /**
     * @param string $id
     *
     * @return Album
     * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
     */
    private function findAlbumById(string $id)
    {
        $existingAlbum = $this->albumRepository->find($id);

        if (null === $existingAlbum) {
            throw new NotFoundHttpException();
        }

        return $existingAlbum;
    }

We either want to absolutely find the Album, or throw.

I'm choosing to throw something built in to Symfony: the NotFoundHttpException.

We could create a more specific exception class here. I can't see any benefit to this in this particularly instance, but on a larger API it's a good shout.

By throwing this exception, we don't need to explicitly handle a null inside putAction, or getAction, or any of the other actions that will make use of this private method.

With that in mind, let's update getAction also:

    public function getAction(string $id)
    {
        return $this->view(
            $this->findAlbumById($id)
        );
    }

Cool, now getAction will either find, or throw. This is the same as the existing behaviour for that method - nothing new here. Just standardising behaviour.

There's more we could do with FOSRESTBundle exception handling. I urge you to read the docs as this is a very cool feature.

But this gets us to a working putAction. Now, onto PATCH.

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