Middleware, Slim and versioning
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 offunction($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
<?phpuse \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 herereturn $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 specifiedcurl -X POST -H 'Content-Type: application/json' http://localhost:8000/reverse -d '{"firstname": "Michael"}'# => {"firstname":"leahciM"}# Version 1 specifiedcurl -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 specifiedcurl -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.