An approach to API versioning using semver

Working on a small HTTP server side project, and wanted to add versioning up-front. Usually I would just add different routes for each version directly to my server router implementation. For example with Deno and Oak (very similar to Koa in NodeJS) I might do something like:

import { handleExampleV1 } from './handlers/example-v1.ts';
import { handleExampleV2 } from './handlers/example-v2.ts';

...

router.get('/v1/example', handleExampleV1);
router.get('/v2/example', handleExampleV2);

However, adding a route for every version can be a lot of duplication. If we had, say, a dozen different routes, and the next API version only really changes a couple of those, then we still have to define v2/ routes for all of them, even if they are unchanged.

Much like doing ‘code katas’ or other coding exercises, part of the point of solving the same problem multiple times is seeing how your solutions evolve. This time around, I decided to try to use an HTTP header for the version instead of part of the path, and had the idea to use semver (semantic versioning) to avoid having to duplicate paths across versions.

Defining a request handler and its assigned version range

The idea is that instead of the server (express, koa, oak, etc) setup defining the versions in paths, we shift the version ranges into the handlers. First let’s define what a “handler” will look like:

// handlers/example-v1-v2.js
export const handleExample = {
  // A semver range indicating which API versions this handler supports.
  version: '^1.0.0 || ^2.0.0',

  handler: async function(ctx) {
    ctx.response.status = 200;
    ctx.response.body = 'v1 handler';
  }
};

// handlers/example-v3.js
export const handleExample = {
  // A semver range indicating which API versions this handler supports.
  version: '>=3.0.0 <=3.5.0',

  handler: async function(ctx) {
    ctx.response.status = 200;
    ctx.response.body = 'v3 handler';
  }
};

Here I defined 2 handlers, one of which will support all API versions between 1.* and 2.*. A 2nd handler will support API versions between 3.0 and 3.5. The thing I like about this is we now locate the api version range directly alongside the request handler to which it belongs. It also opens up the possibility to define an entire range of api versions that this handler can be used for.

Finding request handlers for an API version with a factory

Next we need a piece of code to check the API version header from the requests and find the correct handler. For this, we will use the factory pattern.

// handlers/versionFactory.js

import * as semver from 'semver';

// A default handler to use if no other handler is found for the requested API version
const unsupportedApiVersionHandler = async (ctx) => {
  ctx.response.status = 400;
  ctx.response.headers.set('Content-Type', 'application/json');
  ctx.response.body = JSON.stringify({error: 'Unsupported API version'});
};

// A default handler to use if the api version http header is missing
const missingApiVersionHandler = async (ctx) => {
  ctx.response.status = 400;
  ctx.response.headers.set('Content-Type', 'application/json');
  ctx.response.body = JSON.stringify({error: 'Missing API version header'});
};

export function versionFactory(...handlers) {
  return async (ctx) => {
    const apiVersion = ctx.request.headers.get('x-api-version');
    if(!apiVersion) {
      return await missingApiVersionHandler(ctx);
    }

    const handlerForApiVersion = handlers.find(h => semver.satisfies(apiVersion, h.version));
    if (handlerForApiVersion) {
      await handlerForApiVersion.handler(ctx);
    } else {
      await unsupportedApiVersionHandler(ctx);
    }
}

This factory takes the HTTP x-api-version header, then loops over the passed in potential request handlers, looking for one whose semver range matches the header. If one is not found, we return an HTTP 400 Bad Request.

Modifying the server logic to use the factory

Now we can add the use of our factory and request handlers to the server logic.

import { versionFactory } from 'handlers/versionFactory';
import { handleExample as handleExampleV1 } from 'handlers/example-v1-v2';
import { handleExample as handleExampleV3 } from 'handlers/example-v3';

...

router.get('/example', versionFactory(
  handleExampleV3,
  handleExampleV1,
));

Note that we no longer specify the version in the path. Instead, we rely on the versionFactory to find the right request handler for the API version requested in the header.

Improved performance with memoization

Our version factory works at this point, however looping over handlers to calculate semver matches for every request isn’t very efficient, since the behavior is deterministic (the same handler will always be returned for a given api version). Because of this, we can memoize the request handler the first time it is found for any given api version.

export function versionFactory(...handlers) {
  const knownVersionHandlers = {}; // Store api version to handler mappings as they are resolved.

  return async (ctx) => {
    const apiVersion = ctx.request.headers.get('x-api-version');
    if(!apiVersion) {
      return await missingApiVersionHandler(ctx);
    }

    if (!knownVersionHandlers[apiVersion]) { // if we already know the handler for this API version, avoid resolving it again.
      const handlerForApiVersion = handlers.find(h => semver.satisfies(apiVersion, h.version));
      if (handlerForApiVersion) {
        knownVersionHandlers[apiVersion] = handlerForApiVersion.handler;
      } else {
        knownVersionHandlers[apiVersion] = unsupportedApiVersionHandler;
      }
    }

    await knownVersionHandlers[apiVersion](ctx);
}
Tagged with: ,
Posted in JavaScript, Programming