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.