Middleware, Slim and versioning

24 Mar 2018 in Tech

This morning at #GrumpyConf I watched Matthew Weier O'Phinney give a talk on PSR-15 and middleware and it got me thinking about how useful it can be for transforming API requests and responses across different API versions.

Slim isn't PSR-15 compatible - it uses function ($req, $res, $next) instead of function($req, $handler). For a PSR-15 example, see this post on PSR-15 middleware with Zend Expressive

Taking a contrived example, here's an API that reverses the name provided to it:

php
<?php
use \Psr\Http\Message\ServerRequestInterface as Request;
use \Psr\Http\Message\ResponseInterface as Response;
require 'vendor/autoload.php';
$app = new \Slim\App;
$app->post('/reverse', function (Request $request, Response $response) {
$body = $request->getParsedBody();
if (!isset($body['firstname'])) {
return $response->withJson(['error' => 'Missing firstname']);
}
return $response->withJson([
'firstname' => strrev($body['firstname'])
]);
});
$app->run();

This is version one of the API, and we've decided that firstname needs an underscore in it. As it's a breaking change, this will form version two of the API.

Instead of adding if statements to our endpoint and check different keys depending on the API version, we can use middleware to rename first_name to firstname and our endpoint never has to change.

Writing middleware

PSR-15 middleware receives a Request, Response and $next as parameters. We can modify the incoming request before we call $next and then modify the outgoing response after we call $next.

php
$versionMiddleware = function ($request, $response, $next) {
// Edit $request here
$response = $next($request, $response);
// Edit $response here
return $response;
};

The first thing we need to do is work out if the incoming request has specified that they want version 2 of the API

php
$versionMiddleware = function ($request, $response, $next) {
$isV2 = in_array('application/vnd.test.v2+json', $request->getHeader('Accept'));
$response = $next($request, $response);
return $response;
};

Once we know that, we can fetch the request body and rename from first_name to firstname in $request before we hand off the request to $next.

php
$versionMiddleware = function ($request, $response, $next) {
$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 = $next($request, $response);
return $response;
};

At this point our users can send a request with the Accept: application/vnd.test.v2+json header and we'll accept a first_name parameter from them and our code will return a firstname key with the string reversed. Whilst this works it's not a great experience - we're accepting one parameter and returning another. We need to modify the response to rename the key back to first_name on the way out using middleware.

php
$versionMiddleware = function ($request, $response, $next) {
$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 = $next($request, $response);
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;
};

Attach the middleware to a route

Using this middleware, our consumers can send and receive first_name so long as they send an Accept: application/vnd.test.v2+json header. The final thing for us to do is register this middleware with our route:

php
$app->post('/reverse', function (Request $request, Response $response) {
$body = $request->getParsedBody();
if (!isset($body['firstname'])) {
return $response->withJson(['error' => 'Missing firstname']);
}
return $response->withJson([
'firstname' => strrev($body['firstname'])
]);
})->add($versionMiddleware);

Test your API

Run the server with php -t . -S localhost:8000 and start making requests! We can make requests to this API with different request formats and receive different responses. If we don't specify an Accept header, it defaults to version 1.

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"}

Conclusion

Using middleware, we can modify the request/response format to provide a different interface for our consumers without having to ever touch our application's endpoint.

Whilst we put everything in one file for this example, we'd extract the middleware in to it's own class to make it more maintainable. Once it's in it's own class, we can register multiple middlewares for each route (potentially supporting even more API versions by translating from v3 to v2 to v1 and back again!)

If you'd like to learn more about middleware with Slim, you can read the Slim middleware docs.