PUT to Update Existing Data [API Platform]


At this point in our API Platform implementation we can GET a single Album, we can GET a collection of Albums, and we can POST in new Album data to create new resources. It would be super useful if we could update existing Albums, too.

As a heads up, in the Symfony 4 JSON API, and Symfony 4 FOSRESTBundle implementations we implemented PUT and PATCH. The API Platform implementation will by-pass PATCH. However, PATCH is supported, and is automatically configured (if not customising item operations) when using raw JSON.

Anyway, let's implement PUT.

First, let's recap our Behat 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 |
    And the "Content-Type" request header is "application/json"

  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 already know that we don't have a defined route for PUT:

docker-compose exec php php bin/console debug:router
 ---------------------------- -------- -------- ------ --------------------------------- 
  Name                         Method   Scheme   Host   Path                             
 ---------------------------- -------- -------- ------ --------------------------------- 
  api_albums_get_collection    GET      ANY      ANY    /album.{_format}                 
  api_albums_post_collection   POST     ANY      ANY    /album.{_format}                 
  api_albums_get_item          GET      ANY      ANY    /album/{id}.{_format}            
 ---------------------------- -------- -------- ------ --------------------------------- 

We have manually defined our itemOperations, and at present only have a GET item operation defined. Adding in PUT is easy enough:

<?php

// api/src/Entity/Album.php

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use ApiPlatform\Core\Annotation\ApiProperty;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ApiResource(
 *      collectionOperations = {
 *          "get"={
 *              "method"="GET",
 *              "path"="/album.{_format}",
 *          },
 *          "post"={
 *              "method"="POST",
 *              "path"="/album.{_format}",
 *          },
 *      },
 *     itemOperations={
 *          "get"={
 *              "method"="GET",
 *              "path"="/album/{id}.{_format}",
 *          },
+*          "put"={
+*              "path"="/album/{id}.{_format}",
+*          },
 *     }
 * )
 * @ORM\Entity()
 * @ApiResource(iri="http://schema.org/MusicAlbum")
 */
class Album
{

Let's quickly check the router:

docker-compose exec php php bin/console debug:router
 ---------------------------- -------- -------- ------ --------------------------------- 
  Name                         Method   Scheme   Host   Path                             
 ---------------------------- -------- -------- ------ --------------------------------- 
  api_albums_get_collection    GET      ANY      ANY    /album.{_format}                 
  api_albums_post_collection   POST     ANY      ANY    /album.{_format}                 
  api_albums_get_item          GET      ANY      ANY    /album/{id}.{_format}            
  api_albums_put_item          PUT      ANY      ANY    /album/{id}.{_format}
 ---------------------------- -------- -------- ------ --------------------------------- 

Sweet.

Now, let's try the test:

vendor/bin/behat features/album.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.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: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()
      Expected response code 204, got 200. (Imbo\BehatApiExtension\Exception\AssertionFailedException)

--- Failed scenarios:

    features/album.feature:43

1 scenario (1 failed)
4 steps (3 passed, 1 failed)
0m0.16s (9.75Mb)

Arghh!!

It almost passes.

It fails because we expected a 204, but instead we get a 200 status code.

Such is the problem with HTTP API implementations. Both 200 and 204 are legitimate responses from a PUT request.

In the Symfony 4 JSON API, and Symfony 4 FOSRESTBundle implementations, my preference is to return a 204. This code means "No Content". And as covered, what's the point of returning any content when the person sending in the request not only knows all the existing and new data, they also know the exact ID in order to send the request in in the first place. What else can we tell them that they don't already know?

The flip side to this is to simply return the full resource. Which is what API Platform choose to do.

There's no right or wrong. It's personal preference.

But this throws a spanner in the Behat test works.

Yet again, do we update the tests and change the previous implementations. Or do we write a separate test for JSON-LD?

I don't have a definitive answer. I'm just going to write another test :(

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

Feature: Offer a fully featured Hypermedia API

  In order to offer a Hypermedia API
  As an API provider
  I need to ensure that JSON-LD works as expected

  Background:                                                   # features/album_jsonld.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 |
    And the "Content-Type" request header is "application/json" # Imbo\BehatApiExtension\Context\ApiContext::setRequestHeader()

  @api_platform @t
  Scenario: Can update an existing Album - PUT # features/album_jsonld.feature:57
    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 200              # Imbo\BehatApiExtension\Context\ApiContext::assertResponseCodeIs()

1 scenario (1 passed)
5 steps (5 passed)
0m0.14s (9.73Mb)

It's a passing test. Hoorah. On to DELETE.

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