Work with XML, or JSON, or Both [FOSRESTBundle]
Towards the end of the previous video we saw that FOSRESTBundle comes with the concept of a View Layer. By using the View Layer we allow our front end / API consumer to send and receive data in a variety of formats:
json
xml
As far as we as FOSRESTBundle integrators are concerned, so long as we wrap our data - entities, arrays, even Symfony's Form
class - FOSRESTBundle can handle the process of serializing the data for us into JSON, or XML, or any other format we have configured.
This is awesome. This is a solid example of how using FOSRESTBundle for your Symfony 4 JSON API is going to save you a bunch of time.
But, also this won't just work right out of the box.
Right now, our fos_rest.yaml
config simply looks like this:
fos_rest:
routing_loader:
include_format: false
And as we have covered, this will be combined with the default configuration to come up with the active config that FOSRESTBundle will use when handling incoming requests.
The Problem
The problem right now is that we have not provided any more explicit configuration, and so we're falling back to the defaults.
Here's the 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 add a new Album # features/album.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.feature:29
1 scenario (1 failed)
5 steps (4 passed, 1 failed)
0m0.23s (10.47Mb)
Currently we're throwing a 500
error.
You can use the profiler for a nice view of what's going wrong:
Or you can rely on the log files:
tail -f var/log/dev.log
# ... other stuff
[2018-03-09 10:23:57] request.CRITICAL: Uncaught PHP Exception LogicException: "An instance of Symfony\Bundle\FrameworkBundle\Templating\EngineInterface must be injected in FOS\RestBundle\View\ViewHandler to render templates." at /tmp/symfony-4-fos-rest-api/vendor/friendsofsymfony/rest-bundle/View/ViewHandler.php line 346 {"exception":"[object] (LogicException(code: 0): An instance of Symfony\\Bundle\\FrameworkBundle\\Templating\\EngineInterface must be injected in FOS\\RestBundle\\View\\ViewHandler to render templates. at /tmp/symfony-4-fos-rest-api/vendor/friendsofsymfony/rest-bundle/View/ViewHandler.php:346)"} []
As we haven't configured our setup and further, we're falling back to HTML.
For a HTML response FOSRESTBundle wants to use Twig.
We actually do have Twig installed - implicitly - because we composer require profiler-pack
. However, templating is not enabled explicitly in our setup.
If we enable it, we get a new error:
# config/packages/framework.yaml
framework:
# other stuff removed
templating:
enabled: true
engines: twig
And now:
[2018-03-09 11:51:15] request.CRITICAL: Uncaught PHP Exception InvalidArgumentException: "Unable to find template "" (looked into: /tmp/symfony-4-fos-rest-api/templates, /tmp/symfony-4-fos-rest-api/templates, /tmp/symfony-4-fos-rest-api/vendor/symfony/twig-bridge/Resources/views/Form)." at /tmp/symfony-4-fos-rest-api/vendor/symfony/twig-bridge/TwigEngine.php line 127 {"exception":"[object] (InvalidArgumentException(code: 0): Unable to find template \"\" (looked into: /tmp/symfony-4-fos-rest-api/templates, /tmp/symfony-4-fos-rest-api/templates, /tmp/symfony-4-fos-rest-api/vendor/symfony/twig-bridge/Resources/views/Form). at /tmp/symfony-4-fos-rest-api/vendor/symfony/twig-bridge/TwigEngine.php:127, Twig_Error_Loader(code: 0): Unable to find template \"\" (looked into: /tmp/symfony-4-fos-rest-api/templates, /tmp/symfony-4-fos-rest-api/templates, /tmp/symfony-4-fos-rest-api/vendor/symfony/twig-bridge/Resources/views/Form). at /tmp/symfony-4-fos-rest-api/vendor/twig/twig/lib/Twig/Loader/Filesystem.php:226)"} []
'Unable to find template ""' being the interesting information.
But we don't want to work with HTML, so get rid of that framework.yaml
addition, if you added it.
Let The Consumer Dictate What We Do
FOSRESTBundle comes with a FormatListener
which allows our API consumer to send in data in any format we support, and the normalization process will be taken care of for us.
In other words, if the API consumer sends in XML:
<?xml version="1.0" encoding="UTF-8"?>
<root>
<release_date>2030-12-05T01:02:03+00:00</release_date>
<title>My great album</title>
<track_count>7</track_count>
</root>
Or JSON:
{
"title": "some fake album name",
"release_date": "2020-01-08T00:00:00+00:00",
"track_count": 12
}
Our API can handle both.
In order for this work, we need to configure the format_listener
with some rules
.
We only need one rule:
fos_rest:
routing_loader:
include_format: false
format_listener:
rules:
- { path: ^/, prefer_extension: false, fallback_format: xml, priorities: [ xml, json ] }
This rule saying that for any path starting with a slash (aka any path at all), then we will accept either XML, or JSON.
We won't allow the user to choose their return data type, however, we will always return XML.
Also, we could switch the priorities round and always return JSON. If doing this, update the fallback_format
to json
also.
fos_rest:
routing_loader:
include_format: true
format_listener:
rules:
- { path: ^/, prefer_extension: true, fallback_format: xml, priorities: [ xml, json ] }
By switching back to include_format: true
, we could allow incoming GET
requests of e.g.:
Examples
As a heads up, we haven't implemented the GET
request just yet. This is a preview of what's to come.
http://api.oursite.com:8000/album/1.json
{
"id": 1,
"title": "some fake album name",
"release_date": "2020-01-08T00:00:00+00:00",
"track_count": 12
}
http://api.oursite.com:8000/album/1.xml
<?xml version="1.0"?>
<response>
<id>1</id>
<title>some fake album name</title>
<release_date>2020-01-08T00:00:00+00:00</release_date>
<track_count>12</track_count>
</response>
http://api.oursite.com:8000/album/1
<?xml version="1.0"?>
<response>
<id>1</id>
<title>some fake album name</title>
<release_date>2020-01-08T00:00:00+00:00</release_date>
<track_count>12</track_count>
</response>
Here we fall back to the default - xml
- because no extension was requested in the URL.
This is pretty cool - we can support POST
ing in both XML and JSON, even though we don't really truly care about XML. It's a nice bonus.
Ok, here's my config so far:
fos_rest:
routing_loader:
include_format: true
format_listener:
rules:
- { path: ^/, prefer_extension: true, fallback_format: json, priorities: [ json ] }
If we run our test now, things pass:
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 add a new Album # features/album.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()
1 scenario (1 passed)
5 steps (5 passed)
0m0.14s (9.71Mb)
You can confirm this for yourself by looking at your database. There should now be 4 records in your albums
table. The three from the Background
, and the new one you just successfully posted in.
Nice.
At this point our error checking tests will be failing. This is expected. The representation FOSRESTBundle returns, versus the JSON we expect is slightly different. Not to worry, we will fix this, but first we will finish implementing the happy paths.