PSR-15 middleware, Zend Expressive and versioning

25 Mar 2018 in Tech

As it turns out, I mis-remembered what Matthew said in his talk and yesterday's post with Slim isn't actually PSR-15 compatible. Slim currently uses function($request, $response, $next) whilst PSR-15 uses function($request, $handler).

To build a real PSR-15 middleware, I chose to use Zend Expressive, which supports PSR-15 as of version 3.

Bootstrapping our Zend Expressive application

Start by creating an example Expressive application using composer:

bash
composer create-project "zendframework/zend-expressive-skeleton:3.0.x-dev" psr15-demo --no-interaction

Change in to the psr15-demo directory, then run the built in PHP server:

bash
php -t public -S localhost:8000

We're going to add a new endpoint that reverses a string. Open up config/routes.php and add the following underneath $app->get('/api/ping'):

php
$app->post('/reverse', App\Handler\ReverseHandler::class, 'reverse');

As we've told Expressive to look for App\Handler\ReverseHandler we need to implement that too. Run the following to bootstrap this new handler:

bash
composer expressive handler:create "App\Handler\ReverseHandler"

Open up src/App/Handler/ReverseHandler.php, add use Zend\Diactoros\Response\JsonResponse; to your list of imports and replace the handle function with the following:

php
public function handle(ServerRequestInterface $request) : ResponseInterface
{
$body = $request->getParsedBody();
if (!isset($body['firstname'])) {
return new JsonResponse(['error' => 'Missing firstname']);
}
return new JsonResponse([
'firstname' => strrev($body['firstname'])
]);
}

Notice how the code is almost exactly the same as the Slim version? That's thanks to the fact that both project use PSR-7 request and response objects (standardisation!).

We need to make one more change to our Expressive config. By default it doesn't parse the request body on all requests, so open up config/pipeline.php and add the following use statement:

php
use Zend\Expressive\Helper\BodyParams\BodyParamsMiddleware;

Next, add the BodyParamsMiddleware to the pipeline after ServerUrlMiddleware:

php
$app->pipe(ServerUrlMiddleware::class);
$app->pipe(BodyParamsMiddleware::class);

Now, you should be able to make a request to your application and have the name provided reversed:

bash
curl -X POST -H 'Content-Type: application/json' "http://localhost:8000/reverse" -d '{"firstname": "Michael"}'
# => {"firstname":"leahciM"}

Create the middleware

We're now in a position to write some middleware!

Generate a new middleware using composer and the Expressive helper:

bash
composer expressive middleware:create "App\VersionMiddleware"

This will create src/App/VersionMiddleware.php which is a minimal middleware definition (it doesn't do anything yet!). Let's update it to translate from first_name to firstname and then firstname back to first_name like our Slim middleware:

php
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface
{
$isV2 = in_array('application/vnd.test.v2+json', $request->getHeader('Accept'));
if ($isV2) {
$body = $request->getParsedBody();
$body['firstname'] = $body['first_name'];
unset($body['first_name']);
$request = $request->withParsedBody($body);
}
$response = $handler->handle($request);
if ($isV2) {
$body = json_decode((string)$response->getBody(), true);
$body['first_name'] = $body['firstname'];
unset($body['firstname']);
$response->getBody()->rewind();
$response->getBody()->write(json_encode($body));
}
return $response;
}

Register the middleware on our route

Finally, we need to register our middleware with Expressive. As routes are already middlewares, we can update our config/routes.php to run App\VersionMiddleware before it runs App\Handler\ReverseHandler:

php
$app->post('/reverse', [App\VersionMiddleware::class, App\Handler\ReverseHandler::class], 'reverse');

At this point, we can make requests to both version 1 and version 2 of the API like we could with Slim.

bash
# No version specified
curl -X POST -H 'Content-Type: application/json' "http://localhost:8000/reverse" -d '{"firstname": "Michael"}'
# => {"firstname":"leahciM"}
# Version 1 specified
curl -X POST -H 'Content-Type: application/json' "http://localhost:8000/reverse" -d '{"firstname": "Michael"}' -H 'Accept: application/vnd.test.v1+json'
# => {"firstname":"leahciM"}
# Version 2 specified
curl -X POST -H 'Content-Type: application/json' "http://localhost:8000/reverse" -d '{"first_name": "Michael"}' -H 'Accept: application/vnd.test.v2+json'
# => {"first_name":"leahciM"}