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:

symfony-4-fosrestbundle-serialized-form-error

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 POSTing 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.

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