Setup - Bundles & Config


In this video we are going to take an overview of the code and configuration in place at the start of this project.

If you have completed the FOSRESTBundle and / or FOSUserBundle courses here at CodeReviewVideos, or you have worked on projects that use one or both of these bundles, then you will hopefully be comfortable with the configuration presented here. No problem if not, as we will cover it again all the same.

We are going to use Behat for our testing so will need a few files in place to work from. Again, we will cover these momentarily.

Lastly, we will be creating a brand new Symfony environment in which we will run our Acceptance tests. If you have used Symfony before you will very likely be familiar with app.php, and app_dev.php. In this instance we will create an app_acceptance.php, and use this when running our Behat tests.

Getting Started

To begin with we need to pull in the various bundles that will make this project work.

You are free to add each in turn via composer require, but this may lead to versioning inconsistencies.

If you wish to have exactly the same dependencies as used in this series, I would advise pulling the 'start' tag from GitHub. Alternatively you can copy in the following sections to your composer.json, but this won't guarantee the exact versions - for that you need to use the same composer.lock as I did, which as mentioned, can be found in the 'start' tag.

    "require": {
        "php": ">=5.5.9",
        "symfony/symfony": "3.1.*",
        "doctrine/orm": "^2.5",
        "doctrine/doctrine-bundle": "^1.6",
        "doctrine/doctrine-cache-bundle": "^1.2",
        "symfony/swiftmailer-bundle": "^2.3",
        "symfony/monolog-bundle": "^2.8",
        "symfony/polyfill-apcu": "^1.0",
        "sensio/distribution-bundle": "^5.0",
        "sensio/framework-extra-bundle": "^3.0.2",
        "incenteev/composer-parameter-handler": "^2.0",

        "nelmio/cors-bundle": "^1.4",
        "nelmio/api-doc-bundle": "^2.13",
        "friendsofsymfony/rest-bundle": "^2.0@dev",
        "jms/serializer-bundle": "^1.1",
        "friendsofsymfony/user-bundle": "~2.0@dev",
        "lexik/jwt-authentication-bundle": "^1.7"
    },
    "require-dev": {
        "sensio/generator-bundle": "^3.0",
        "symfony/phpunit-bridge": "^3.0",

        "behat/behat": "^3.1",
        "behat/symfony2-extension": "^2.1",
        "phpunit/phpunit": "^5.5",
        "guzzlehttp/guzzle": "^6.2",
        "csa/guzzle-bundle": "^2.1"
    },

I have put a space between the default dependencies provided by the Symfony framework standard installation, and the ones I have added in each section.

We will cover why we need each of these bundles in more depth as we go through this series.

Go ahead and run composer install now to bring in all the extra files to your /vendor directory.

Once the files are downloaded, we need to enable them in AppKernel.php:

<?php

// /app/AppKernel.php

use Symfony\Component\HttpKernel\Kernel;
use Symfony\Component\Config\Loader\LoaderInterface;

class AppKernel extends Kernel
{
    public function registerBundles()
    {
        $bundles = [
            new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),
            new Symfony\Bundle\SecurityBundle\SecurityBundle(),
            new Symfony\Bundle\TwigBundle\TwigBundle(),
            new Symfony\Bundle\MonologBundle\MonologBundle(),
            new Symfony\Bundle\SwiftmailerBundle\SwiftmailerBundle(),
            new Doctrine\Bundle\DoctrineBundle\DoctrineBundle(),
            new Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle(),

            new FOS\UserBundle\FOSUserBundle(),
            new FOS\RestBundle\FOSRestBundle(),
            new Nelmio\CorsBundle\NelmioCorsBundle(),
            new Nelmio\ApiDocBundle\NelmioApiDocBundle(),
            new Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle(),

            new AppBundle\AppBundle(),
        ];
        if (in_array($this->getEnvironment(), ['dev', 'test', 'acceptance'], true)) {
            $bundles[] = new Symfony\Bundle\DebugBundle\DebugBundle();
            $bundles[] = new Symfony\Bundle\WebProfilerBundle\WebProfilerBundle();
            $bundles[] = new Sensio\Bundle\DistributionBundle\SensioDistributionBundle();
            $bundles[] = new Sensio\Bundle\GeneratorBundle\SensioGeneratorBundle();

            $bundles[] = new Csa\Bundle\GuzzleBundle\CsaGuzzleBundle();
        }
        return $bundles;
    }

    // * snip *

Again, I have placed a space between the Symfony framework supplied configuration, and the configuration that is specific to this implementation.

Enabling the various bundles is fairly standard, so hopefully nothing new to you at this point in the $bundles = [...] section at least.

Notice here that I have added acceptance the list of environments:

if (in_array($this->getEnvironment(), ['dev', 'test', 'acceptance'], true)) {

As mentioned above, we will be creating a new environment called acceptance, and in this environment I want additional bundles that only have any relevance during development and testing:

$bundles[] = new Csa\Bundle\GuzzleBundle\CsaGuzzleBundle();

Essentially this says Guzzle will only be available in our dev, test, and crucially, acceptance environments. This is fine as we don't need to use Guzzle in the production code for our API. We will use Guzzle to allow our Behat-managed test client to send HTTP requests to our various controllers / endpoints.

Core Configuration

We've added all these extra bundles to our project but if you now try to load your site, it will be broken due to lack of configuration.

This is fine, we need to provide some - well, quite a lot - of configuration.

Here is the relevant parts of config.yml in full, and then we will break each stage down below:

## /app/config/config.yml

# FOS User
fos_user:
    db_driver: orm
    firewall_name: api
    user_class: AppBundle\Entity\User

# Nelmio CORS
nelmio_cors:
    defaults:
        allow_origin:  ["%cors_allow_origin%"]
        allow_methods: ["POST", "PUT", "GET", "DELETE", "OPTIONS"]
        allow_headers: ["content-type", "authorization"]
        max_age:       3600
    paths:
        '^/': ~

# Nelmio API Doc
nelmio_api_doc: ~

# FOS REST Bundle
fos_rest:
    body_listener: true
    param_fetcher_listener: force
    format_listener:
        enabled: true
        rules:
            - { path: ^/, priorities: [ json ], fallback_format: json, prefer_extension: true }
    view:
        view_response_listener: 'force'
        formats:
            json: true
            xml: false
            rss: false
        mime_types:
            json: ['application/json', 'application/x-json']
    routing_loader:
        default_format:  json
        include_format:  false
    exception:
        enabled: true

#JMS Serializer
jms_serializer: ~

# CSA Guzzle
csa_guzzle:
    profiler: "%kernel.debug%"

# Lexik JWT Bundle
lexik_jwt_authentication:
    private_key_path: "%jwt_private_key_path%"
    public_key_path:  "%jwt_public_key_path%"
    pass_phrase:      "%jwt_key_pass_phrase%"
    token_ttl:        "%jwt_token_ttl%"

That's a lot of config, and we haven't yet added in the config for our acceptance environment.

Ok, let's break this down.

FOS User Bundle

Firstly, our FOSUserBundle config:

# FOS User
fos_user:
    db_driver: orm
    firewall_name: api
    user_class: AppBundle\Entity\User

We're going to be using Doctrine, so our db_driver is set to orm.

We haven't yet created our User entity, but when we do so it will live in AppBundle\Entity\User.

We will create a Symfony firewall called api inside security.yml. However, the truth is, this won't quite behave as you might expect.

The firewall_name tells FOSUserBundle which Symfony firewall to authenticate our user with upon successful registration. In our case, we don't have persistent sessions so this won't behave as expected.

Instead, we will add functionality to our registration controller to return a JWT / JSON Web Token as part of the successful registration response.

CORS

Onwards, to the configuration for CORS / Cross-Origin Resource Sharing:

# Nelmio CORS
nelmio_cors:
    defaults:
        allow_origin:  ["%cors_allow_origin%"]
        allow_methods: ["POST", "PUT", "GET", "DELETE", "OPTIONS"]
        allow_headers: ["content-type", "authorization"]
        max_age:       3600
    paths:
        '^/': ~

As our front end code could potentially live on a different domain to our back-end API, we need to 'whitelist' certain sites to ensure they have access.

Notice here that we use Symfony parameter syntax: "%cors_allow_origin%". This means we need a corresponding entry in our parameters.yml (by default), and also don't forget to add an entry in parameters.yml.dist.

During testing this value simply needs to be something - so I'm going to set mine to localhost:3000, which my React setup would use:

# /app/config/parameters.yml

parameters:
    # nelmio cors
    cors_allow_origin: 'http://localhost:3000'

The allow_methods option tells our CORS setup which HTTP verbs to allow through. The first four are fairly common. We may wish to add "PATCH" here too.

The inclusion of "OPTIONS" is a little strange, but it's required for CORS preflight requests. This is the sort of thing that can cost you an afternoon of debugging, should you miss it from your config :)

The allow_headers option specifies which HTTP headers we can include when sending in requests. We will need to send in Content-type: application/json, and also when authenticated, we will get back a JWT which we add to our headers under the authorization key.

The max_age option specifies how long our preflight requests can be cached. If in doubt, leave it at 3600. This value is in seconds.

Finally, the paths section states that our configuration applies to everything (any route starting with a /), and we are accepting the default configuration.

Nelmio API Docs

Documentation is really helpful, so let's add some:

# Nelmio API Doc
nelmio_api_doc: ~

All we want to do here is accept the bundle defaults. We must add at least this configuration in.

Our actual documentation will live as annotations on the controller actions themselves.

FOS REST Bundle

There's a fair chunk happening here. If you are fairly new to FOSRESTBundle then consider reviewing this course where we cover the bundle in more depth.

# FOS REST Bundle
fos_rest:
    body_listener: true
    param_fetcher_listener: force
    format_listener:
        enabled: true
        rules:
            - { path: ^/, priorities: [ json ], fallback_format: json, prefer_extension: true }
    view:
        view_response_listener: 'force'
        formats:
            json: true
            xml: false
            rss: false
        mime_types:
            json: ['application/json', 'application/x-json']
    routing_loader:
        default_format:  json
        include_format:  false
    exception:
        enabled: true

Firstly, the listeners, with the exception of the mime_type listener, all are disabled by default.

We will be sending in body content in our POST, and PUT requests, so the body_listener will decode those for us nicely. Essentially this ensures the data we send in (such as our username and password) as JSON gets converted back to data that is PHP compatible.

One really nice part of FOSRESTBundle is the addition of the ParamFetcher annotations. We covered the reasoning behind why these are so useful in this video. We can turn them on by using param_fetcher_listener: force. The gist is - annotations for query params.

The format_listener stops us from having to have .json at the end of all our routes, which would look naff. However, if we do add in the extension then it should still work, and we can use the extension to overrule the json default.

Under the view section, the view_response_listener makes it possible for us to return a View instance, rather than the typical Response (or some extension thereof, e.g. JsonResponse) simply by returning some data. In other words, our controller can return an entity or object, and FOSRESTBundle will wrap it for us in a View. Easy.

Under formats we are being explicit that only json is allowable.

The mime_types section specifies what kind of content we allow. This would dictate what kind of Content-type header is allowed on incoming requests.

In the routing_loader section we explicitly specify json, so that we do not need to add .json as a suffix, for example when requesting our profile, it's /users/profile/2, rather than /users/profile/2.json.

Lastly, enabling exception means any exceptions - authentication problems being the most prominent - will generate a nice JSON error output, rather than a plain old HTML blow up.

Serializer

We must enable a serializer as part of FOSRESTBundle's installation requirements. JMSSerializer isn't perfect (it doesn't appear to be actively maintained afaik), but it is the current best option - in terms of features and StackOverflow help answers. Your milage my vary.

We just use the default config:

#JMS Serializer
jms_serializer: ~

Guzzle

We will only be using Guzzle in our testing environment. I have therefore added the minimum config to get the bundle to load:

# CSA Guzzle
csa_guzzle:
    profiler: '%kernel.debug%'

Lexik JWT

Lastly, you will need to run through the installation and configuration steps for LexikJWTBundle. I can't do anything about this, even with this starter setup.

Follow the guidance here. The configuration I have is the minimum required. You are free to customise further as needed:

# Lexik JWT Bundle
lexik_jwt_authentication:
    private_key_path: "%jwt_private_key_path%"
    public_key_path:  "%jwt_public_key_path%"
    pass_phrase:      "%jwt_key_pass_phrase%"
    token_ttl:        "%jwt_token_ttl%"

Again, as these are Symfony parameters, be sure to update parameters.yml, and parameters.yml.dist as per the docs.

Phew, that was a ton of config. Let's keep going, we aren't done yet.

Adding the acceptance Environment

Creating a new environment is fairly straightforward.

Firstly, we need to create a new file in our web directory:

<?php

// /web/app_acceptance.php

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Debug\Debug;

// This check prevents access to debug front controllers that are deployed by accident to production servers.
// Feel free to remove this, extend it, or make something more sophisticated.
if (isset($_SERVER['HTTP_CLIENT_IP'])
    || isset($_SERVER['HTTP_X_FORWARDED_FOR'])
    || !(in_array(@$_SERVER['REMOTE_ADDR'], ['127.0.0.1', 'fe80::1', '::1']) || php_sapi_name() === 'cli-server')
) {
    header('HTTP/1.0 403 Forbidden');
    exit('You are not allowed to access this file. Check '.basename(__FILE__).' for more information.');
}

/**
 * @var Composer\Autoload\ClassLoader $loader
 */
$loader = require __DIR__.'/../app/autoload.php';
Debug::enable();

$kernel = new AppKernel('acceptance', true);

$kernel->loadClassCache();
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);

I've put a line break between the single most important line in this section:

$kernel = new AppKernel('acceptance', true);

We could call this environment anything we want. We just need to make sure the app_*.php file name matches, and the corresponding config_*.yml file also matches.

Let's add that in now, too:

# /app/config/config_acceptance.yml

imports:
    - { resource: config_test.yml }

framework:
    profiler:
        only_exceptions: false
        collect: true

web_profiler:
    toolbar: true

csa_guzzle:
    logger: true
    clients:
        local_test_api:
            config:
                base_uri: "http://api.rest-user-api.dev/app_acceptance.php/"

parameters:
    database_name: "db_acceptance"

The two important pieces here are the configuration for Guzzle - we add in our local_test_api client and ensure ith as the base_url that matches the hostname of our local web server configuration. We could get fancier with this, but this works for a single box development scenario.

If you're unsure on Guzzle, this video series covered it in greater depth.

Also, we add in the database_name parameter here, to override the database name for the acceptance environment.

This means we can test, test, and re-test and not muck up the development or production databases at all.

Basic User Entity

At this stage the most basic of user entities will do. All we need is the example User entity from the FOSUserBundle documentation:

<?php

// src/AppBundle/Entity/User.php

namespace AppBundle\Entity;

use FOS\UserBundle\Model\User as BaseUser;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="fos_user")
 */
class User extends BaseUser
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    public function __construct()
    {
        parent::__construct();
        // your own logic
    }
}

We will change this up as we progress through, but for now all we need is this 'shell'.

Basic security.yml Setup

To get our application back into a basic working state, the last thing we need to do is to add in the core configuration to our security.yml file.

Here's the base config we will start from:

# /app/config/security.yml

security:
    encoders:
        FOS\UserBundle\Model\UserInterface: bcrypt

    role_hierarchy:
        ROLE_ADMIN:       ROLE_USER
        ROLE_SUPER_ADMIN: ROLE_ADMIN

    providers:
        fos_userbundle:
            id: fos_user.user_provider.username_email

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false

        api:
            pattern:   ^/
            stateless: true
            lexik_jwt: ~

    access_control:
        - { path: ^/,                 role: IS_AUTHENTICATED_FULLY }

We've set the password encoder to be bcrypt for our user entity. This is the default provided in the FOSUserBundle documentation.

Our role_hierarchy is also the default provided in the FOSUserBundle documentation.

We've changed the providers.fos_userbundle.id to allow our users to log in with both their username or their email address. You may want to restrict this down, but for me, ease of use is preferable. We will add tests to cover login with both email address and username.

We must also add in a firewall. If we don't, we will get an exception that we haven't added an authentication provider.

I've called the firewall api, though you are free to use any name. If you do change it though, be sure to update your fos_user.firewall_name in config.yml.

We use a pattern of ^/ to indicate this firewall applies to anything starting with a slash. In other words, if it didn't match the routes under the dev firewall above, then this will be the catch all firewall.

By adding stateless: true, we tell Symfony not to store authentication information in a session. We won't need a session as our users will be sending their token value with every request.

Lastly, setting lexik_jwt: ~ ensures that the Authorization header will be checked, looking for a value of Bearer {token}. We will need to follow this format when sending in anything other than login requests.

Testing Our Config

At this stage, our Symfony application should work again without errors.

We should be able to run the commands to generate our database, and update its schema:

php bin/console doctrine:database:create
php bin/console doctrine:database:create --env=acceptance

php bin/console doctrine:schema:update --force
php bin/console doctrine:schema:update --force --env=acceptance

And whilst there's not a great deal to see, we should be good to start configuring our test environment.

Which is exactly what we shall do, starting in the very next video.

Code For This Course

Get the code for this course.

Episodes