Update existing Albums with PUT [Raw Symfony 4]


Next up is PUT. This is where we start taking data that exists in our system, and making modifications to it. There's two ways we will do this, one is a touch contentious. To update we use either PUT, or PATCH.

With PUT we are expected to send in a full resource, and our implementation will apply our changes over the top of the existing entity.

With PATCH we can send in only the fields that change, and any existing properties will be left alone.

If this doesn't make a great deal of sense, let's quickly recap the tests which illustrate this more clearly:

  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

As a heads up, PATCH is contentious verb as it's not a true implementation of how PATCH is supposed to work. But this isn't a true RESTful API. It's a JSON API, and it's going to be good enough for most circumstances - at least, it has been for me.

With a PUT we will need to send in every field / property, with the exception of the id, which we can omit. The reason we can omit this is that we don't have an id field on our AlbumType / form. If you do send it in, you will get an error that you've sent in extra data. You can set an option on your form to disregard additional form fields:

{
    "status": "error",
    "errors": {
        "errors": [
            "This form should not contain extra fields."
        ],
        "children": {
            "title": [],
            "release_date": [],
            "track_count": []
        }
    }
}

To "fix" this, either don't send in extra data in the first place, or instruct Symfony to silently ignore it:

<?php

namespace App\Form;

use App\Entity\Album;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class AlbumType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('title')
            ->add(
                'release_date',
                DateTimeType::class,
                [
                    'widget'        => 'single_text',
                    'format'        => 'yyyy-MM-dd\'T\'HH:mm:ssZZZZZ',
                    'property_path' => 'releaseDate',
                ]
            )
            ->add(
                'track_count',
                NumberType::class,
                [
                    'property_path' => 'trackCount',
                ]
            );
    }

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

Either is fine, I suppose.

The PUT Controller Method

There's a bit of work required here. It's a combination of what we did in the get method, and what we did in the post method.

Let's start with the method definition itself:

    /**
     * @Route(
     *     "/album/{id}",
     *     name="put_album",
     *     methods={"PUT"},
     *     requirements={"id"="\d+"}
     * )
     * @param int $id
     *
     * @return JsonResponse
     */
    public function put(
        Request $request,
        $id
    )
    {

    }

The Route annotation is interesting.

We will need to explicitly state which Album ID it is that we wish to update (PUT).

Therefore our route will have the {id} placeholder.

Remember we could use a ParamConvertor to aid in taking the given ID and querying for the matching Album entity. I leave this to you as an optional improvement. It's quite cool, I'd recommend you read the linked docs and see if you can add this in.

Make sure to give the route a unique name. I've used the imaginative put_album.

I'm restricting this down to a PUT request. And the id placeholder must be a positive integer.

I want access to this $id value in my put controller method. This is the same as in the get method.

I'm also injecting the $request object, as we will need to use the form submission data here. This is the same as in the post method.

Handling The Update

    /**
     * @Route(
     *     "/album/{id}",
     *     name="get_album",
     *     methods={"PUT"},
     *     requirements={"id"="\d+"}
     * )
     * @param int $id
     *
     * @return JsonResponse
     */
    public function put(
        Request $request,
        $id
    )
    {
        $data = json_decode(
            $request->getContent(),
            true
        );

        $existingAlbum = $this->findAlbumById($id);

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

        $form->submit($data);

Like in the post method we start by converting the raw JSON data that is being sent in as the $request's body content, and turning this into an associative PHP array.

Next, we want to update an existing Album, so rather than create a new Album(), as we did in the post method, we instead retrieve the existing Album that matches the given ID.

Thanks to the work we put in during the creation of the get method, we will either find an existing Album, or throw a 404.

With the post method we created our AlbumType Symfony form with a new / empty Album entity. Here we pass in the $existingAlbum to createForm, which means our form will be pre-populated with the existing entity data.

Finally $form->submit($data); kick starts the Symfony Form submission process. Any changed data is going to be used for updating our existing Album entity.

Next do three things:

        if (false === $form->isValid()) {
            return new JsonResponse(
                [
                    'status' => 'error',
                    'errors' => $this->formErrorSerializer->convertFormToArray($form),
                ],
                JsonResponse::HTTP_BAD_REQUEST
            );
        }

        $this->entityManager->flush();

        return new JsonResponse(
            null,
            JsonResponse::HTTP_NO_CONTENT
        );
    }

We check if the form submission is considered valid.

We already covered our entity validation. Here, those rules are checked. If any are broken, our form is considered invalid and we return a 400 / JsonResponse::HTTP_BAD_REQUEST error with some helpful form error messages.

If we didn't return a 400 error then by this point we're feeling pretty good about ourselves.

At this point we tell Doctrine to save / flush any changes to the database.

We don't need to persist here as the Album entity is already managed by Doctrine. It is known. We only need to persist with a new entity instance.

Finally, we return a 204 / JsonResponse::HTTP_NO_CONTENT, which simply tells our JSON API consumer that their request was successful, but we have nothing interesting to tell them. After all, they submitted the data, they know the entities ID, what else do we have for them they don't already know?

The Behat test should be passing at this point:

 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 - PUT # features/album.feature:68
    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.38s (9.62Mb)

Onwards, to 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