GET'ting Multiple Resources [API Platform]


We are now well on our way with our customised API Platform implementation. We are following along with the same Behat test suite that we used for our standalone Symfony 4 JSON API, and our Symfony 4 with FOSRESTBundle implementations. So far, things are looking pretty good - and with quite a lot less effort on our part.

Previously we covered the GET operation for a single Album - e.g. /album/3.

Now we want to get back a list of all Album resources in our system. This would be a Collection Operation.

We've already defined this route. We had to. In API Platform, both GET operations are mandatory, and we had to define a custom collectionOperation option in order to get our POST test to pass.

Here are the annotations so far:

<?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;

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

And again we already have a predefined Behat test for this:

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 get a collection of Albums
    Given I request "/album" using HTTP GET
    Then the response code is 200
    And the response body contains JSON:
    """
    [
      {
        "id": 1,
        "title": "some fake album name",
        "track_count": 12,
        "release_date": "2020-01-08T00:00:00+00:00"
      },
      {
        "id": 2,
        "title": "another great album",
        "track_count": 9,
        "release_date": "2019-01-07T23:22:21+00:00"
      },
      {
        "id": 3,
        "title": "now that's what I call Album vol 2",
        "track_count": 23,
        "release_date": "2018-02-06T11:10:09+00:00"
      }
    ]
    """

However, at this point we do hit on a problem with our generic Behat test setup.

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 |

  @symfony_4_edge_case @t
  Scenario: Can get a collection of Albums  # features/album.feature:15
    Given I request "/album" 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"
        },
        {
          "id": 2,
          "title": "another great album",
          "track_count": 9,
          "release_date": "2019-01-07T23:22:21+00:00"
        },
        {
          "id": 3,
          "title": "now that's what I call Album vol 2",
          "track_count": 23,
          "release_date": "2018-02-06T11:10:09+00:00"
        }
      ]
      """
      The needle is a list, while the haystack is not.

      ================================================================================
      = Needle =======================================================================
      ================================================================================
      [
          {
              "id": 1,
              "title": "some fake album name",
              "track_count": 12,
              "release_date": "2020-01-08T00:00:00+00:00"
          },
          {
              "id": 2,
              "title": "another great album",
              "track_count": 9,
              "release_date": "2019-01-07T23:22:21+00:00"
          },
          {
              "id": 3,
              "title": "now that's what I call Album vol 2",
              "track_count": 23,
              "release_date": "2018-02-06T11:10:09+00:00"
          }
      ]

      ================================================================================
      = Haystack =====================================================================
      ================================================================================
      {
          "@context": "\/contexts\/Album",
          "@id": "\/album",
          "@type": "hydra:Collection",
          "hydra:member": [
              {
                  "@id": "\/album\/1",
                  "@type": "Album",
                  "id": 1,
                  "title": "some fake album name",
                  "release_date": "2020-01-08T00:00:00+00:00",
                  "track_count": 12
              },
              {
                  "@id": "\/album\/2",
                  "@type": "Album",
                  "id": 2,
                  "title": "another great album",
                  "release_date": "2019-01-07T23:22:21+00:00",
                  "track_count": 9
              },
              {
                  "@id": "\/album\/3",
                  "@type": "Album",
                  "id": 3,
                  "title": "now that's what I call Album vol 2",
                  "release_date": "2018-02-06T11:10:09+00:00",
                  "track_count": 23
              }
          ],
          "hydra:totalItems": 3
      }
       (Imbo\BehatApiExtension\Exception\ArrayContainsComparatorException)

--- Failed scenarios:

    features/album.feature:15

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

Yeah, so basically the entire thing is wrong :(

Why?

Well, if it's not immediately obvious from the test output (which is admittedly hard to read without nice console colouration), let's send in a request using Postman:

{
    "@context": "/contexts/Album",
    "@id": "/album",
    "@type": "hydra:Collection",
    "hydra:member": [
        {
            "@id": "/album/1",
            "@type": "Album",
            "id": 1,
            "title": "some fake album name",
            "release_date": "2020-01-08T00:00:00+00:00",
            "track_count": 12
        },
        {
            "@id": "/album/2",
            "@type": "Album",
            "id": 2,
            "title": "another great album",
            "release_date": "2019-01-07T23:22:21+00:00",
            "track_count": 9
        },
        {
            "@id": "/album/3",
            "@type": "Album",
            "id": 3,
            "title": "now that's what I call Album vol 2",
            "release_date": "2018-02-06T11:10:09+00:00",
            "track_count": 23
        }
    ],
    "hydra:totalItems": 3
}

Ahh. So everything is wrapped in Hydra 'stuff'.

Now, interestingly we can get a pass. If we cheat.

Just change the requested endpoint from:

https://localhost:8443/album

to:

https://localhost:8443/album.json

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 |

  @symfony_4_edge_case @t
  Scenario: Can get a collection of Albums       # features/album.feature:15
    Given I request "/album.json" 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"
        },
        {
          "id": 2,
          "title": "another great album",
          "track_count": 9,
          "release_date": "2019-01-07T23:22:21+00:00"
        },
        {
          "id": 3,
          "title": "now that's what I call Album vol 2",
          "track_count": 23,
          "release_date": "2018-02-06T11:10:09+00:00"
        }
      ]
      """

1 scenario (1 passed)
4 steps (4 passed)
0m0.15s (9.75Mb)

Hey, would ya' look at that! A passing test. Easy peasy.

Well... we did have to change our test, which will have now broken this test for our other implementations.

And raw JSON isn't officially supported by API Platform, so there's that.

But we kinda know it roughly works. So that's something.

A Custom Test

Other than updating the previous implementations to use JSON-LD, I don't see how we can keep one test here. Alas, I'm going to suggest we diverge and implement a custom test. It's not ideal, but there seems to be no other alternative.

I'm going to create a new file in the Behat project:

touch features/album_jsonld.feature

And to this file:

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

  @api_platform
  Scenario: Can get a collection of Albums
    Given I request "/album" using HTTP GET
    Then the response code is 200
    And the response body contains JSON:
    """
    {
        "@context": "/contexts/Album",
        "@id": "/album",
        "@type": "hydra:Collection",
        "hydra:member": [
            {
                "@id": "/album/1",
                "@type": "Album",
                "id": 1,
                "title": "some fake album name",
                "release_date": "2020-01-08T00:00:00+00:00",
                "track_count": 12
            },
            {
                "@id": "/album/2",
                "@type": "Album",
                "id": 2,
                "title": "another great album",
                "release_date": "2019-01-07T23:22:21+00:00",
                "track_count": 9
            },
            {
                "@id": "/album/3",
                "@type": "Album",
                "id": 3,
                "title": "now that's what I call Album vol 2",
                "release_date": "2018-02-06T11:10:09+00:00",
                "track_count": 23
            }
        ],
        "hydra:totalItems": 3
    }
    """

Which when run:

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 get a collection of Albums  # features/album_jsonld.feature:16
    Given I request "/album" 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()
      """
      {
          "@context": "/contexts/Album",
          "@id": "/album",
          "@type": "hydra:Collection",
          "hydra:member": [
              {
                  "@id": "/album/1",
                  "@type": "Album",
                  "id": 1,
                  "title": "some fake album name",
                  "release_date": "2020-01-08T00:00:00+00:00",
                  "track_count": 12
              },
              {
                  "@id": "/album/2",
                  "@type": "Album",
                  "id": 2,
                  "title": "another great album",
                  "release_date": "2019-01-07T23:22:21+00:00",
                  "track_count": 9
              },
              {
                  "@id": "/album/3",
                  "@type": "Album",
                  "id": 3,
                  "title": "now that's what I call Album vol 2",
                  "release_date": "2018-02-06T11:10:09+00:00",
                  "track_count": 23
              }
          ],
          "hydra:totalItems": 3
      }
      """

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

Ok, so a passing test.

Did we cheat? It sort of feels like we did.

I'm open to an alternative test process where we keep everything in one file. I really don't see a way to do this at the moment though.

I've intentionally called the file album_jsonld.feature, rather than album_apiplatform.feature, or similar. We're testing JSON-LD generally. Our other implementations could be adapter to support this format.

Anyway, that's both GET one and GET many, and POST covered. Let's move on to updating via PUT.

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