Handling POST requests [FOSRESTBundle]


As our Behat tests rely on being able to "dogfood" our own API in order to run, the most important and immediately urgent task is to get the POST endpoint up and running.

We're going to make use of FOSRESTBundle's Automatic route generation facility to help speed up this process for us. This means we must follow a set convention for controller method naming. If you prefer, manually configured routing is available. We briefly covered this towards the end of the previous video, but as ever, the docs are your friend.

We'll need a few bits in place:

  • A new Controller class
  • FOSRESTBundle Routing setup
  • A postAction

Let's start by adding in the routing setup, as it's really easy:

# config/routes.yaml

albums:
    type:     rest
    resource: App\Controller\AlbumController

That's all we need to do there.

We don't have an AlbumController, so let's create one. If you installed the Maker Bundle then bin/console make:controller Album will see you right.

Either way, here's our starting point:

<?php

// src/Controller/AlbumController.php

namespace App\Controller;

use FOS\RestBundle\Controller\FOSRestController;

class AlbumController extends FOSRestController
{
}

This is very similar to our HealthcheckController.

Let's now define our POST handling route:

<?php

namespace App\Controller;

use FOS\RestBundle\Controller\FOSRestController;
use Symfony\Component\HttpFoundation\Request;

class AlbumController extends FOSRestController
{
    public function postAlbumAction(
        Request $request
    ) {
    }
}

As mentioned in the previous video even though we're using Symfony 4, our controller method names still need the Action suffix. This may change in future versions of FOSRESTBundle, in order to bring its functionality inline with Symfony 4 controllers as services.

Checking the router:

bin/console debug:router
 -------------------------- -------- -------- ------ ----------------------------------- 
  Name                       Method   Scheme   Host   Path                               
 -------------------------- -------- -------- ------ ----------------------------------- 
  app_healthcheck_get        GET      ANY      ANY    /ping                              
  _twig_error_test           ANY      ANY      ANY    /_error/{code}.{_format}           
  _wdt                       ANY      ANY      ANY    /_wdt/{token}                      
  _profiler_home             ANY      ANY      ANY    /_profiler/                        
  _profiler_search           ANY      ANY      ANY    /_profiler/search                  
  _profiler_search_bar       ANY      ANY      ANY    /_profiler/search_bar              
  _profiler_phpinfo          ANY      ANY      ANY    /_profiler/phpinfo                 
  _profiler_search_results   ANY      ANY      ANY    /_profiler/{token}/search/results  
  _profiler_open_file        ANY      ANY      ANY    /_profiler/open                    
  _profiler                  ANY      ANY      ANY    /_profiler/{token}                 
  _profiler_router           ANY      ANY      ANY    /_profiler/{token}/router          
  _profiler_exception        ANY      ANY      ANY    /_profiler/{token}/exception       
  _profiler_exception_css    ANY      ANY      ANY    /_profiler/{token}/exception.css   
  post_album                 POST     ANY      ANY    /albums.{_format}                  
 -------------------------- -------- -------- ------ ----------------------------------- 

There are four interesting things here:

Firstly, just by using the controller method name of postAlbumAction, FOSRESTBundle has configured a route for us that explicitly only matches incoming POST requests.

Secondly, FOSRESTBundle has guessed at our route name. This uses a different convention to the Symfony 4 standard annotation based route naming. I personally think the Symfony 4 format of app_, for our own App 'namespace', then healthcheck for our Controller, and _get for the route method is tidier.

Thirdly, the route path is pluralised, and has added an unusual, optional .{_format} placeholder.

We can 'fix' all of these things, but they aren't technically broken. This is the suggested convention from FOSRESTBundle. We can buy into all, some, or very little of it.

My Preferences

Personally, I don't like the controller method name of postAlbumAction.

In order to gain the benefit of FOSRESTBundle's automatic route generation, we'd need to have all our routes follow this naming convention:

  • getAlbumAction
  • putAlbumAction
  • deleteAlbumAction

And so on.

As we are already in the AlbumController, I'm fine with the resource being implied.

FOSRESTBundle allows us to remove the resource name from our controller method names by ensuring our controller class implements ClassResourceInterface:

<?php

namespace App\Controller;

use FOS\RestBundle\Controller\FOSRestController;
+use FOS\RestBundle\Routing\ClassResourceInterface;
use Symfony\Component\HttpFoundation\Request;

-class AlbumController extends FOSRestController
+class AlbumController extends FOSRestController implements ClassResourceInterface

With that simple change we can update our controller method name to my preferred standard:

class AlbumController extends FOSRestController implements ClassResourceInterface
{
-   public function postAlbumAction(
+   public function postAction(
        Request $request
    ) {
    }
}

Nice.

My next preference is for singular endpoints - /album, not /albums. I don't know why. I just prefer it.

Again, FOSRESTBundle has our backs here:

<?php

namespace App\Controller;

+use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\Routing\ClassResourceInterface;
use Symfony\Component\HttpFoundation\Request;

+/**
+* @Rest\RouteResource(
+*     "Album",
+*     pluralize=false
+* )
+*/
class AlbumController extends FOSRestController implements ClassResourceInterface
{
    public function postAction(
        Request $request
    ) {
    }
}

All these steps are explained here.

bin/console debug:router
 -------------------------- -------- -------- ------ ----------------------------------- 
  Name                       Method   Scheme   Host   Path                               
 -------------------------- -------- -------- ------ ----------------------------------- 
  post_album                 POST     ANY      ANY    /album.{_format}                   
 -------------------------- -------- -------- ------ ----------------------------------- 

I have removed the extra routes / noise for brevity.

Lastly, I'd like to get rid of the {_format}. The only format that will be supported will be JSON. If you need to support XML or something else, FOSRESTBundle can support this.

To 'fix' this, we need to alter config/packages/fos_rest.yaml. Note that this file was created for us automatically when we ran composer require friendsofsymfony/rest-bundle.

The current, default contents:

# Read the documentation: https://symfony.com/doc/master/bundles/FOSRestBundle/index.html
fos_rest: ~
#    param_fetcher_listener:  true
#    allowed_methods_listener:  true
#    routing_loader: true
#    view:
#        view_response_listener:  true
#    exception:
#        codes:
#            App\Exception\MyException: 403
#        messages:
#            App\Exception\MyException: Forbidden area.
#    format_listener:
#        rules:
#            - { path: ^/api, prefer_extension: true, fallback_format: json, priorities: [ json, html ] }

The squiggle / ~ (which means null in YAML) simply means accept the default config. The default config can be found the same way any Symfony config can be found:

bin/console config:dump-reference   

Available registered bundles with their extension alias if available
====================================================================

 -------------------------- --------------------- 
  Bundle name                Extension alias      
 -------------------------- --------------------- 
  DoctrineBundle             doctrine             
  DoctrineCacheBundle        doctrine_cache       
  DoctrineMigrationsBundle   doctrine_migrations  
  FOSRestBundle              fos_rest             
  FrameworkBundle            framework            
  MonologBundle              monolog              
  NelmioCorsBundle           nelmio_cors          
  TwigBundle                 twig                 
  WebProfilerBundle          web_profiler         
  WebServerBundle            web_server           
 -------------------------- --------------------- 

 // Provide the name of a bundle as the first argument of this command to dump its default configuration. (e.g.         
 // config:dump-reference FrameworkBundle)                                                                              
 //                                                                                                                     
 // For dumping a specific option, add its path as the second argument of this command. (e.g.                           
 // config:dump-reference FrameworkBundle profiler.matcher to dump the                                                  
 // framework.profiler.matcher configuration)                                                                           

And we just pass in the Bundle name, or the alias:

bin/console config:dump-reference FOSRestBundle

# Default configuration for "FOSRestBundle"
fos_rest:
    disable_csrf_role:    null
    access_denied_listener:
        enabled:              false
        service:              null
        formats:

            # Prototype
            name:                 ~
    unauthorized_challenge: null
    param_fetcher_listener:
        enabled:              false
        force:                false
        service:              null
    cache_dir:            '%kernel.cache_dir%/fos_rest'
    allowed_methods_listener:
        enabled:              false
        service:              null
    routing_loader:
        default_format:       null
        prefix_methods:       true
        include_format:       true
    body_converter:
        enabled:              false
        validate:             false
        validation_errors_argument: validationErrors
    service:
        router:               router
        templating:           templating
        serializer:           null
        view_handler:         fos_rest.view_handler.default
        inflector:            fos_rest.inflector.doctrine
        validator:            validator
    serializer:
        version:              null
        groups:               []
        serialize_null:       false
    zone:

        # Prototype
        -

            # use the urldecoded format
            path:                 null # Example: ^/path to resource/
            host:                 null
            methods:              []
            ips:                  []
    view:
        default_engine:       twig
        force_redirects:

            # Prototype
            name:                 ~
        mime_types:
            enabled:              false
            service:              null
            formats:

                # Prototype
                name:                 []
        formats:

            # Prototype
            name:                 ~
        templating_formats:

            # Prototype
            name:                 ~
        view_response_listener:
            enabled:              false
            force:                false
            service:              null
        failed_validation:    400
        empty_content:        204
        serialize_null:       false
        jsonp_handler:
            callback_param:       callback
            mime_type:            application/javascript+jsonp
    exception:
        enabled:              false
        exception_controller: null
        service:              null
        codes:

            # Prototype
            name:                 ~
        messages:

            # Prototype
            name:                 ~
        debug:                true
    body_listener:
        enabled:              true
        service:              null
        default_format:       null
        throw_exception_on_unsupported_content_type: false
        decoders:

            # Prototype
            name:                 ~
        array_normalizer:
            service:              null
            forms:                false
    format_listener:
        enabled:              false
        service:              null
        rules:

            # Prototype
            -

                # URL path info
                path:                 null

                # URL host name
                host:                 null

                # Method for URL
                methods:              null
                attributes:

                    # Prototype
                    name:                 ~
                stop:                 false
                prefer_extension:     true
                fallback_format:      html
                priorities:           []
    versioning:
        enabled:              false
        default_version:      null
        resolvers:
            query:
                enabled:              true
                parameter_name:       version
            custom_header:
                enabled:              true
                header_name:          X-Accept-Version
            media_type:
                enabled:              true
                regex:                '/(v|version)=(?P<version>[0-9\.]+)/'
        guessing_order:

            # Defaults:
            - query
            - custom_header
            - media_type

That's a lot of stuff.

You can find all of this in the Full Default Configuration reference also.

Hidden amongst all of this is the routing_loader, and by setting include_format to false, we can remove this unwanted .{_format} from the generated route:

# config/packages/fos_rest.yaml

fos_rest:
    routing_loader:
        include_format: false

Checking the router:

bin/console debug:router
 -------------------------- -------- -------- ------ ----------------------------------- 
  Name                       Method   Scheme   Host   Path                               
 -------------------------- -------- -------- ------ ----------------------------------- 
  post_album                 POST     ANY      ANY    /album                             
 -------------------------- -------- -------- ------ ----------------------------------- 

Ok, looking good.

Our config isn't quite done yet, but one thing at a time.

Running The POST Test

If we run the POST 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:29
    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:29

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

It's not unexpected that our test fails. We don't have any logic inside our postAction method.

The test simply proves that we can hit the /album endpoint with a POST request. If we couldn't, we'd see a 405 / method not allowed error.

That's the basic setup out of the way. In the next video we will continue with the implementation of our postAction method body.

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