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.