Kyle Edwards

Simulating API Gateways for Local Microservices

When composing an API with microservices and serverless functions, it’s a fairly common practice to place them behind an API Gateway to provide a single hostname and route incoming HTTP requests to their appropriate service based on its URL path. Clients can remain agnostic to the implementation details of the microservice architecture and can treat it as a single resource.

Developing against these microservices can introduce pain points, especially when you need to run some subset of the services locally while your client application or other services need to communicate, or if your apps rely on an auto-generated SDK such as those created by Swagger Codegen. In these cases I’ve found myself juggling environment variables and configuration files to ensure I’m hitting the right environment for my needs.

While there are methods for emulating provider-specific gateways locally, I’ve found that they’re primarily geared towards serverless entirely (see AWS SAM or serverless-offline), and these tools are lacking for this particular use case of an API gateway hitting servers either remotely on a cloud compute instance or running on your local machine. The feature I’ve been really looking for is the flexibility to failover to a remote source if I happen to not have a service or function cloned locally (or if I’m simply too lazy to run it).

How It Works

The gist of how it works is that you maintain a mapping of gateway routes to your local services’ port numbers. Say you have a users service running behind a remote gateway at https://dev-api.example.com/users, but you develop locally on port 8080, as well as a documents service at https://dev-api.example.com/documents remotely, 8085 locally. You would configure your mapping as:

{
  "/users": 8080,
  "/documents": 8085,
}

When your local gateway spins up, it begins listening for an incoming request, and if it matches a mapped service route, it attempts to forward the request to your local service. If the local service responds, it returns the response back to the requester. However, if the request fails with an ECONNREFUSED error or if the route is not mapped, the gateway then attempts to forward the request on to your remote server behind https://dev-api.example.com/{path}.

Below is the full code in right around 100 lines of JavaScript.

// Imports
const bodyParser = require('body-parser');
const express = require('express');
const request = require('request');
const config = require('config');

// Constants
const API = config.get('baseApi');
const SERVICES = config.get('services');
const PORT = config.get('port');

// Sanitize and send response.
const send = (res, response) => {
  try {
    let statusCode = 404;
    let body = '';
    let headers = {};
    if (response) {
      ({ statusCode, body, headers } = response);
    }
    // Express will specify these headers so they can be removed.
    delete headers['content-length'];
    delete headers.date;
    delete headers['content-encoding'];
    delete headers['content-type'];
    delete headers['transfer-encoding'];
    delete headers.vary;
    let json = false;
    try {
      body = JSON.parse(response.body);
      json = true;
    } catch (e) { } // eslint-disable-line
    res.set(headers);
    res.status(statusCode)[json ? 'json' : 'send'](body);
  } catch (e) {
    res.status(502).send('Bad Gateway');
  }
};

// Convert incoming request objects into outgoing request parameters.
const spoof = ({ headers: { host, ...headers }, method, body, query }) => {
  // Remove the content length header as the outgoing request will recalculate it correctly.
  delete headers['content-length']; // eslint-disable-line
  const opts = { headers, method, query };
  if (method !== 'GET' && method !== 'OPTIONS') {
    opts.body = body;
    if (typeof body !== 'string') {
      opts.json = true;
    }
  }
  // Continue gzip compression if necessary.
  if (headers['accept-encoding'].includes('gzip')) {
    opts.gzip = true;
  }
  return opts;
};

// Create an HTTP-based listener.
const httpListener = (port) => {
  return (req, res) => {
    const opts = spoof(req);
    const url = `http://localhost:${port}${req.url}`;
    // Call the local URL first on the designated port...
    request({ ...opts, url }, (err, response) => {
      if (err && err.errno === 'ECONNREFUSED') {
        // If the request fails because a local service is not running, forward to the remote API...
        return request({ ...spoof(req), url: `${API}${req.originalUrl}` }, (err, response) => {
          send(res, response);
        });
      } else if (err) {
        // Other errors fail with a 502.
        return res.status(502).send('Bad Gateway');
      }
      send(res, response); // If valid, send the results back to the requester.
    });
  };
};

// Create the server.
const app = express();
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());

// Create handlers for each configured service.
Object.entries(SERVICES).forEach(([service, port]) => {
  app.use(`/v1/${service}`, httpListener(port));
});

// Any unhandled routes can be forwarded to the remote gateway.
app.use((req, res) => {
  request({ ...spoof(req), url: `${API}${req.originalUrl}` }, (err, response) => {
    send(res, response);
  });
});

// Begin listening.
app.listen(PORT);

Extensions

Many cloud-provided API gateways include other features such as trace headers and serverless function authorizers, both of which would not be too difficult to retrofit into this solution.