A Real World PHP Lambda App Part 2: Routing and a Front Controller

Submitted by nigel on Saturday 27th January 2018

Since we are making a PHP application we really should stick to the recognised right way of doing this; the MVC pattern is the established best practice. Sitting in front of this pattern is the Front Controller which will act as a controller to determine which C of the MVC will actually be called, and to do a bit of the routing job, and to act as the layer between our actual code and the hander,js / handler.php shim that comes with serverless-php.

In fact we get a starter Front Controller in the serverless-php repo already although it is very basic and will need a little extending to serve a purpose in a MVC web app. 

The name of the game is to keep whatever we develop to be very lightweight. Whilst it would be good to get Symfony / Zend / Laravel running under serverless-php, that will come at a cost of execution time and development time. 

So the 'Front Controller' is called HelloHandler.php in the src/ directory but you should've renamed it VoteHandler.php already and changed the name of the class it contains.

The second consideration is the AWS API Gateway - that too needs to know which routes we are defining. We can accomplish that by configuration in the serverless.yml file.

As previously mentioned our voting app is going to present a form (HTTP GET), then when submitted (HTTP POST) it will cache the name of the voter and that person's choice in a NoSQL data store, then redirect to a thank you page (HTTP POST). Note that the redirect must be a POST since Google don't take kindly to URL parameters containing personal data, and we want to display the voter's name on the thank you page. 

serverless.yml
We need to define the functions - one for each of the transactions. Present the form; Submit the form; Redirect to thank_you. Note that since we are always going to channel the code through our Front Controller and therefore we are going to use the same service.
functions:
  vote_get:
    handler: handler.handle
    environment:
      HANDLER: handler.vote  # This is the service name which will be used (from services.yml)
    events:
      - http:
          path: /
          method: get
  vote_post:
    handler: handler.handle
    environment:
      HANDLER: handler.vote  # This is the service name which will be used (from services.yml)
    events:
      - http:
          path: /
          method: post
  thank_you:
    handler: handler.handle
    environment:
      HANDLER: handler.vote  # This is the service name which will be used (from services.yml)
    events:
      - http:
          path: /thank_you
          method: post
Now lets deploy to AWS
$ sls deploy
Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service .zip file to S3 (9.96 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
.....................................................................
Serverless: Stack update finished...
Service Information
service: vote
stage: dev
region: eu-west-1
stack: vote-dev
api keys:
  None
endpoints:
  GET - https://n4kofna4l1.execute-api.eu-west-1.amazonaws.com/dev/
  POST - https://n4kofna4l1.execute-api.eu-west-1.amazonaws.com/dev/
  POST - https://n4kofna4l1.execute-api.eu-west-1.amazonaws.com/dev/thank_you
functions:
  vote_get: vote-dev-vote_get
  vote_post: vote-dev-vote_post
  thank_you: vote-dev-thank_you
API Gateway

If we log into the AWS Console and navigate to the API Gateway we can see our functions are declared correctly. 

Front Controller
Below is the front controller src/VoteHandler.php. Most of it is quite self-explanatory but a few points are worthy of commentary. The $routing is hard-coded since we need to match a path / method against a controller. The $event is populated with the serverless runtime such as command line data, HTTP method and the route. In the main handle function we are returning whatever the controller passes back to us in terms of HTTP status code, headers and body. It is up to the controllers to determine this. Finally the function createController is used to avoid having a new operator in the handle function which would make it impossible to be unit tested,

<?php

namespace Raines\Serverless;

use 
Psr\Log\LoggerInterface;

class 
VoteHandler implements Handler
{
    
/**
     * Routing table
     */
    
protected $routing = [
        [
            
'path' => '/',
            
'method' => 'GET',
            
'controller' => 'Form',
            
'action' => 'index'
        
],
        [
            
'path' => '/',
            
'method' => 'POST',
            
'controller' => 'Form',
            
'action' => 'save'
        
],
        [
            
'path' => '/thank_you',
            
'method' => 'POST',
            
'controller' => 'Thankyou',
            
'action' => 'index'
        
],
    ];


    
/**
     * {@inheritdoc}
     */
    
public function handle(array $eventContext $context)
    {
        
$logger $context->getLogger();
        
$logger->notice('Got event'$event);

        
// get the controller and action based on the path and method
        
$route = [];
        foreach(
$this->routing as $k => $r) {
            if (
$r['path'] == $event['path'] && $r['method'] == $event['httpMethod']) {
                
$route[] = $this->routing[$k];
            }
        }

        try {
            if (
count($route) != 1) {
                throw new \
Exception('Inconsistent routing provided');
            }

            
// Construct the controller name
            
$classname "Vote\Controllers\\{$route[0]['controller']}Controller";

            
// See if classname and action both exist
            
if (!class_exists($classname) || !method_exists($classname$route[0]['action'])) {
                throw new \
Exception('Unknown classname or action provided');
            }

        } catch (\
Exception $e) {
            return [
                
'headers' => ['Content-Type' => 'application/json'],
                
'statusCode' => 500,
                
'body' => json_encode($e->getMessage(), JSON_HEX_QUOT),
            ];
        }

        
// Create the controller
        
$controller $this->createController($classname$logger$event);

        
// Execute the action
        
$output $controller->{$route[0]['action']}();

        return [
            
'headers' => $controller->__get('headers'),
            
'statusCode' => $controller->__get('statusCode'),
            
'body' => $output,
        ];
    }


    
/**
     * Instantiate the controller
     *
     * @param string $classname
     * @param LoggerInterface $logger
     * @param array $event
     *
     * @return object
     */
    
private function createController(string $classnameLoggerInterface $logger, array $event)
    {
        return new 
$classname($logger$event);
    }
}
?>
We also need to make a small change to handler.php, In the final line we need to make sure the json_encode() call is passed the JSON_HEX_QUOT parameter to ensure correct escaping.

<?php

$loader 
= require __DIR__ '/vendor/autoload.php';

use 
Raines\Serverless\Context;
use 
Symfony\Component\Config\FileLocator;
use 
Symfony\Component\DependencyInjection\ContainerBuilder;
use 
Symfony\Component\DependencyInjection\Loader\YamlFileLoader;

// Set up service container
$container = new ContainerBuilder();
$loader = new YamlFileLoader($container, new FileLocator(__DIR__ '/config'));
$loader->load('services.yml');

// Get event data and context object
$event json_decode($argv[1], true) ?: [];
$logger $container->get('logger');
$fd fopen('php://fd/3''r+');
$context = new Context($logger$argv[2], $fd);

// Get the handler service and execute
$handler $container->get(getenv('HANDLER'));
$response $handler->handle($event$context);

// Send data back to shim
printf(json_encode($responseJSON_HEX_QUOT));
?>
Runtime Invocation

We are now ready to run our code from the command line locally. Of course it isn't going to do too much since we've not added the MVC part of the code yet! However I would expect it to get to the point it tries to instantiate a controller before throwing an exception. 

The syntax for local invocation is below. Note the JSON structure passed using the flag --data - this will be interpreted by our Front Controller in the populated $event array. The -f flag denotes the Lambda function we are running. 

$ sls invoke local -f vote_get --data '{"path":"/","httpMethod":"POST"}'
Got event {"path":"/","httpMethod":"POST"} []
{
    "headers": {
        "Content-Type": "application/json"
    },
    "statusCode": 500,
    "body": "Unknown classname or action provided"
}
Ok - an error but totally expected. Progress is being made!