Defining A Custom POST Route [API Platform]


Towards the end of the previous video we had created our new Album entity, and in doing so, API Platform has automatically created the 5 typical RESTful routes:

docker-compose exec php php bin/console debug:router
 ---------------------------- -------- -------- ------ --------------------------------- 
  Name                         Method   Scheme   Host   Path                             
 ---------------------------- -------- -------- ------ --------------------------------- 
  api_albums_get_collection    GET      ANY      ANY    /albums.{_format}                
  api_albums_post_collection   POST     ANY      ANY    /albums.{_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}           
 ---------------------------- -------- -------- ------ --------------------------------- 

Just as a heads up, I have removed the other routes for clarity.

We also saw that our Behat test for POST is currently failing, as we expect to use the route of /album, not /albums.

I guess we could change the test. Singular vs plural - it's open to your own interpretation / requirements.

But I don't want to change the tests. And besides, it's as good an excuse as any to explore some more of API Platform.

Custom Routes

In order to define a custom route - or 5 custom routes in our case - we need to understand the concept of on Operation.

We touched on Operations already in the healthcheck video. Operations are the tasks that we can perform on a given resource. In simpler terms, they are the way we create, read, update, and delete our entities. Ok, so there are more to operations than this, but this covers our use case.

It's really important to understand that there are two types of operations:

  • Collection operations
  • Item operations

POST and GET act on collections.

GET, PUT, and DELETE act on items.

Which is a bit odd, as GET appears twice.

But think back to the Symfony 4 JSON API, or Symfony 4 FOSRESTBundle API implementations and we have already seen this. In both cases we had a getAction, and a cgetAction. In other words, GET one, and GET multiple.

And we can see this in the routing output:

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

Interesting to note, the word collection, or item appears in the generated route name. Helpful.

As a quick heads up here, when GETting a collection the results will be paginated by default. No need for an extra bundle. Sweet.

To the very best of my knowledge, it is not possible to define a resource level prefix for our operation. In other words, we cannot generically say all routes for an Album resource start with /album (singular). Instead, we must manually define each operation.

In order to define an operation we can use either XML, YAML, or Annotations.

I'm going to work with annotations.

Why?

Because they keep everything together in one file - our Album.php file. No need for a separate file, but you have the option to do that if you wish.

Any operations we explicitly define are not automatically merged with the implicitly defined operations. Be careful here. This is easier to see in action than to explain with words, so here goes:

<?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()
+* @ApiResource(
+*      collectionOperations = {
+*          "post"={
+*              "method"="POST",
+*              "path"="/album.{_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    /albums.{_format}                
- api_albums_post_collection   POST     ANY      ANY    /albums.{_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}           
 ---------------------------- -------- -------- ------ --------------------------------- 

Ok, a bit weird. We don't generally diff shell output, but it makes this a lot easier to see.

Because we explicitly defined our collectionOperations key, API Platform only configures what we explicitly tell it too.

This means any auto-generated routes that we do not define are no longer automatically created for us, and are therefore not available. If we explicitly define one part of the collectionOperations (or itemOperations), then we must define all of the operations we want.

Note: Both GET routes are mandatory.

However, and equally important, we only made changes to the collectionOperations key.

These were then happily merged with the automatically generated itemOperations, and other keys we can configure through annotations, without losing that config.

We absolutely do want and need the mandatory Collection GET route. And we can't get much further until we add it back in. If we send in our test now:

vendor/bin/behat features/album_common.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_common.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 add a new Album             # features/album_common.feature:30
    Given the request body is:              # Imbo\BehatApiExtension\Context\ApiContext::setRequestBody()
      """
      {
        "title": "Awesome new Album",
        "track_count": 7,
        "release_date": "2030-12-05T01:02:03+00:00"
      }
      """
    When I request "/album" using HTTP POST # Imbo\BehatApiExtension\Context\ApiContext::requestPath()
    Then the response code is 201           # Imbo\BehatApiExtension\Context\ApiContext::assertResponseCodeIs()
      Expected response code 201, got 500. (Imbo\BehatApiExtension\Exception\AssertionFailedException)

--- Failed scenarios:

    features/album_common.feature:30

1 scenario (1 failed)
5 steps (4 passed, 1 failed)
0m0.13s (10.01Mb)

We're getting a 500 error. Checking the logs:

tail -f api/var/log/dev.log

[2018-03-12 15:23:58] request.CRITICAL: Uncaught PHP Exception ApiPlatform\Core\Exception\InvalidArgumentException: "No collection route associated with the type "App\Entity\Album"." at /srv/api/vendor/api-platform/core/src/Bridge/Symfony/Routing/RouteNameResolver.php line 62 {"exception":"[object] (ApiPlatform\\Core\\Exception\\InvalidArgumentException(code: 0): No collection route associated with the type \"App\\Entity\\Album\". at /srv/api/vendor/api-platform/core/src/Bridge/Symfony/Routing/RouteNameResolver.php:62)"} []

The problematic part:

"No collection route associated with the type "App\Entity\Album"

Ok, let's add that mandatory collection GET route back in:

<?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}",
 *          },
 *      },
 * )
 */
class Album
{

We should be good, right? Seems that way. Let's test:

vendor/bin/behat features/album_common.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_common.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 add a new Album             # features/album_common.feature:30
    Given the request body is:              # Imbo\BehatApiExtension\Context\ApiContext::setRequestBody()
      """
      {
        "title": "Awesome new Album",
        "track_count": 7,
        "release_date": "2030-12-05T01:02:03+00:00"
      }
      """
    When I request "/album" using HTTP POST # Imbo\BehatApiExtension\Context\ApiContext::requestPath()
    Then the response code is 201           # Imbo\BehatApiExtension\Context\ApiContext::assertResponseCodeIs()
      Expected response code 201, got 500. (Imbo\BehatApiExtension\Exception\AssertionFailedException)

--- Failed scenarios:

    features/album_common.feature:30

1 scenario (1 failed)
5 steps (4 passed, 1 failed)
0m0.36s (10.01Mb)

Alas, still throwing a 500 error.

And again, back to the logs:

tail -f api/var/log/dev.log

[2018-03-12 15:29:59] request.CRITICAL: Uncaught PHP Exception Doctrine\DBAL\Exception\NotNullConstraintViolationException: "An exception occurred while executing 'INSERT INTO album (id, title, release_date, track_count) VALUES (?, ?, ?, ?)' with params [4, "Awesome new Album", null, null]:  SQLSTATE[23502]: Not null violation: 7 ERROR:  null value in column "release_date" violates not-null constraint DETAIL:  Failing row contains (4, Awesome new Album, null, null)." at /srv/api/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/AbstractPostgreSQLDriver.php line 61 {"exception":"[object] (Doctrine\\DBAL\\Exception\\NotNullConstraintViolationException(code: 0): An exception occurred while executing 'INSERT INTO album (id, title, release_date, track_count) VALUES (?, ?, ?, ?)' with params [4, \"Awesome new Album\", null, null]:\n\nSQLSTATE[23502]: Not null violation: 7 ERROR:  null value in column \"release_date\" violates not-null constraint\nDETAIL:  Failing row contains (4, Awesome new Album, null, null). at /srv/api/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/AbstractPostgreSQLDriver.php:61, Doctrine\\DBAL\\Driver\\PDOException(code: 23502): SQLSTATE[23502]: Not null violation: 7 ERROR:  null value in column \"release_date\" violates not-null constraint\nDETAIL:  Failing row contains (4, Awesome new Album, null, null). at /srv/api/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/PDOStatement.php:107, PDOException(code: 23502): SQLSTATE[23502]: Not null violation: 7 ERROR:  null value in column \"release_date\" violates not-null constraint\nDETAIL:  Failing row contains (4, Awesome new Album, null, null). at /srv/api/vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/PDOStatement.php:105)"} []

Yikes.

Ok, so there's two problems here:

  • The releaseDate should not be null
  • We're expecting releaseDate, not release_date

We will get further into both of these in the very next video.

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