GET'ting One Resource [API Platform]


We now have our customised Album POST endpoint up and working. In the process we also had to amend the GET endpoint for a collection of Albums: /album. And thanks to the defaults that API Platform provides, we can POST, or GET album data in JSON, JSON-LD, JSON-HAL, and XML. Nice.

There are still some inconsistencies to address. Looking at 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    /albums/{id}.{_format}           
  api_albums_delete_item       DELETE   ANY      ANY    /albums/{id}.{_format}           
  api_albums_put_item          PUT      ANY      ANY    /albums/{id}.{_format}           
 ---------------------------- -------- -------- ------ --------------------------------- 

The Collection operations are using /album singular, whereas the Item operations are still using /albums plural.

Knowing what we now know, if we are going to update one of the Item operations then we must update all of them. Any that we omit will be effectively disabled.

Before proceeding, however, let's make sure we have a failing test:

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

  @t
  Scenario: Can get a single Album            # features/album.feature:17
    Given I request "/album/1" using HTTP GET # Imbo\BehatApiExtension\Context\ApiContext::requestPath()
    Then the response code is 200             # Imbo\BehatApiExtension\Context\ApiContext::assertResponseCodeIs()
      Expected response code 200, got 404. (Imbo\BehatApiExtension\Exception\AssertionFailedException)
    And the response body contains JSON:      # Imbo\BehatApiExtension\Context\ApiContext::assertResponseBodyContainsJson()
      """
      {
        "id": 1,
        "title": "some fake album name",
        "track_count": 12,
        "release_date": "2020-01-08T00:00:00+00:00"
      }
      """

--- Failed scenarios:

    features/album.feature:17

1 scenario (1 failed)
5 steps (3 passed, 1 failed, 1 skipped)
0m0.14s (9.99Mb)

This is fine. We expect a failure, and we get one. Specifically we get a 404, which is also expected as our test wants to hit /album/1, and right now we only offer /albums/{whatever}.

Let's fix this by defining an Item Operation for GETting individual album resources:

<?php

namespace App\Entity;

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

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

And checking 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}            
 ---------------------------- -------- -------- ------ --------------------------------- 

Ok, so as already mentioned (and covered previously), if we manually define one operation then we must manually define all operations for the given key. In other words, we just lost the automatically created DELETE and PUT routes because we didn't explicitly ask for them.

We will come back to PUT and DELETE again shortly.

For now, let's re-test:

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

  @t
  Scenario: Can get a single Album            # features/album.feature:17
    Given I request "/album/1" using HTTP GET # Imbo\BehatApiExtension\Context\ApiContext::requestPath()
    Then the response code is 200             # Imbo\BehatApiExtension\Context\ApiContext::assertResponseCodeIs()
    And the response body contains JSON:      # Imbo\BehatApiExtension\Context\ApiContext::assertResponseBodyContainsJson()
      """
      {
        "id": 1,
        "title": "some fake album name",
        "track_count": 12,
        "release_date": "2020-01-08T00:00:00+00:00"
      }
      """

1 scenario (1 passed)
5 steps (5 passed)
0m0.18s (9.71Mb)

Nice.

Very little effort required on our part.

Now, what's interesting here is that whilst it looks like we're returning the same JSON as in our previous Symfony 4 and Symfony 4 with FOSRESTBundle implementations, actually here we return JSON-LD:

{
    "@context": "/contexts/Album",
    "@id": "/album/1",
    "@type": "Album",
    "id": 1,
    "title": "some fake album name",
    "release_date": "2020-01-08T00:00:00+00:00",
    "track_count": 12
}

Note the inclusion of the keys with the @ prefix - aka JSON-LD keywords.

This is 'extra' data that our Behat test doesn't care about. Behat is somewhat unusual in that it lets this extra stuff happen, so long as the behaviour we do care about is occurring.

Even though we didn't have to do very much here, we are effectively done. If we didn't need to make our route / endpoint singular, then this process is even easier. Pretty nice, eh? I think so.

Ok, onwards to GETting a collection.

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