Building API Gateway in Node.js: Part I — Overview & Routing
Understanding the Basics of API Gateway Architecture
API Gateway is an important part of modern microservices architecture. It serves as an entry point for all clients' requests, managing, validating, sanitizing, and routing them to the appropriate service. In this series, I’ll focus on essential parts of API Gateway and try to implement them with Node.js. In this part, I will provide an overview of API Gateway, discuss its benefits and drawbacks, and focus on implementing the routing component in my own API Gateway solution.
Overview
API Gateway is a server that acts as an entry point for all client’s requests. It might be responsible for:
- routing: it routes incoming client requests to the correct microservice based on the request path, method, headers, and other parameters;
- security: it may enforce specific security policies such as authentication and authorization, ensuring that only authorized clients can access certain services or endpoints;
- rate-limiting: it may limit the number of requests a client can make in a given period protecting microservices from being overwhelmed by too many requests;
- caching: it can cache responses from microservices, reducing the need to process repeated requests;
- monitoring & logging: it may provide detailed monitoring & logging for API calls being made, giving valuable insights into the usage pattern, performance, and health of microservices.
To name a few benefits of using API Gateway:
- simplified client communication: instead of communicating with multiple services, clients communicate only with API Gateway, which reduces integration complexity;
- centralized security: you ensure authentication and authorization as it is implemented in a single place, which makes it easier to manage new policies;
- service abstraction: API Gateway hides the complexity of underlying services, allowing easier modification and upgrades to individual services;
- protocol agnostic: underlying services can communicate using various protocols (e.g., GraphQL, gRPC), but the API Gateway provides a unified HTTP API, simplifying the integration process.
However, API Gateway has some drawbacks, to name a few:
- single point of failure: if the API Gateway fails, it can bring down the entire API service;
- increased latency: adding an additional layer in the request-response path may introduce latency;
- development overhead: developing custom routing, security, and other policies can require significant effort and expertise.
So, you need to be careful and apply special architectural practices to guarantee the high availability, scalability, and elasticity of API Gateway, a central component of the API you’re exposing.
If you’re not ready to handle the development overhead of building your own API Gateway, you might consider using one of the following products:
- AWS API Gateway: managed Amazon Web Service that allows users to create, maintain, and publish API Gateway on any scale;
- Kong: open-source API Gateway and services management tool that provides balancing, monitoring, logging, and authentication;
- Nginx: a high-performance open-source web server that can be used as API Gateway;
- Traefik: HTTP reverse proxy and load balancer that also might be used as API Gateway.
To demonstrate how an API Gateway works, I will create a simple implementation showcasing various features. We’ll begin by focusing on the primary responsibility of an API Gateway: routing.
Routing
API Gateway incoming traffic should be routed to various microservices, and this is the main responsibility of API Gateway. To know how to route incoming traffic properly, API Gateway needs a configuration. There are two options for how you can configure API Gateway:
- static configuration: providing the configuration as a file in formats such as JSON or YAML.
- dynamic configuration: this approach allows the API gateway to fetch configuration in real-time from a database or some centralized service (e.g. service discovery).
Dynamic configuration is beneficial for environments that frequently change. However, it can introduce additional complexity in API Gateway development and potential performance overhead as API Gateway must regularly fetch and apply configuration changes.
To make things simple, I’m going to use a simple JSON file for API Gateway routing configuration.
Inspired by Webpack’s proxy configuration, I’ve defined the next API Gateway configuration:
[
{
"name": "user-service",
"context": ["/users"],
"target": "http://localhost:3001",
"pathRewrite": {}
}
]
Configuration is an array of routes. Each route has next attributes:
name
: the unique identifier of the route that might be used for tracing purposes;context
: non-empty array of the specific routes for which API Gateway will listen. When a request is made to a URL that starts with/users
, the API Gateway will route it to the corresponding target service;target
: defines the backend service URL to which the API Gateway should forward the requests that match the specified context;pathRewrite
: property allows he modification of the request URL path before it is forwarded to the target service. If the configuration is defined as an empty object ({}
) it means no transformation is needed. But configuration{‘^/test’: ‘/api’,}
means that the path/test
will be replaced with/api
.
To implement API Gateway with Node.js I will use Express web server. It’s a flexible, simple, and powerful web server that is a perfect choice for building API Gateway.
Based on the configuration, API Gateway might listen for only specific routes that are defined in the config file. However, I will configure it to listen for all incoming traffic. If a route is not defined in the config, the Gateway will return a 404 Not Found
response with an explanation.
Let’s develop an incoming request handler:
const fetch = require('node-fetch')
const routes = require('../routes/routes.json')
const errors = {
ROUTE_NOT_FOUND: 'ROUTE_NOT_FOUND',
}
const defaultTimeout = parseInt(process.env.HTTP_DEFAULT_TIMEOUT) || 5000
const nonBodyMethods = ['GET', 'HEAD']
/**
* @typedef {import('node-fetch').Response} Response
* @typedef Result
* @property {string} error
* @property {string} message
* @property {Response} response
*/
/**
* Method proxies the request to the appropriate service
* @typedef {import('express').Request} Request
* @param {Request} req
* @returns {Promise<Result>}
*/
async function handler(req) {
const { path, method, headers, body, ip } = req
const route = routes.find((route) =>
route.context.some((c) => path.startsWith(c))
)
if (!route) {
return {
error: errors.ROUTE_NOT_FOUND,
message: 'route not found',
}
}
const servicePath = Object.entries(route.pathRewrite).reduce(
(acc, [key, value]) => acc.replace(new RegExp(key), value),
path
)
const url = `${route.target}${servicePath}`
const reqHeaders = {
...headers,
'X-Forwarded-For': ip,
'X-Forwarded-Proto': req.protocol,
'X-Forwarded-Port': req.socket.localPort,
'X-Forwarded-Host': req.hostname,
'X-Forwarded-Path': req.baseUrl,
'X-Forwarded-Method': method,
'X-Forwarded-Url': req.originalUrl,
'X-Forfarded-By': 'api-gateway',
'X-Forwarded-Name': route.name,
'X-Request-Id': req.id,
}
const reqBody = nonBodyMethods.includes(method) ? undefined : body
const response = await fetch(url, {
method,
headers: reqHeaders,
body: reqBody,
follow: 0,
timeout: route?.timeout || defaultTimeout,
})
return {
response,
}
}
module.exports = handler
module.exports.errors = errors
The first step in the handler is to find the configuration that matches the incoming path. If the route is not defined in the configuration, an error is returned. Another handler (which I will demonstrate later) will then send a 404 Not Found
response to the user.
Next, the API Gateway applies path rewrite rules and prepares the HTTP request to be forwarded to the target service. During this process, the API Gateway also adds custom headers that define the proxy context. These headers may include information about the original request, such as the client’s IP address, and any other metadata required by the target service.
HTTP response from the target service is returned to the Express request handler:
const service = require('../services/proxy')
/**
* Proxy handler
* @typedef {import('express').Request} Request
* @typedef {import('express').Response} Response
* @typedef {import('express').NextFunction
* @param {Request} req
* @param {Response} res
* @returns {void}
*/
module.exports = async (req, res, next) => {
try {
const { error, message, response } = await service(req)
if (error) {
if (error === service.errors.ROUTE_NOT_FOUND) {
res.status(404).json({ error, message }).send()
} else {
res.status(500).json({ error, message }).send()
}
return
}
res.status(response.status)
response.headers.forEach((value, key) => res.setHeader(key, value))
const content = await response.buffer()
res.send(content)
} catch (err) {
next(err)
}
}
If an error is returned, the handler analyzes it and sends an appropriate response to the end user. Otherwise, it forwards the response from the target service back to the end user.
From the beginning, I also implemented several middlewares:
- error handler: this middleware logs any errors that occur during request processing;
- logger: this middleware logs information about incoming traffic;
- request ID: this middleware adds a unique identifier to each incoming request for tracing purposes.
The API Gateway isn’t limited to handling JSON only. It can also accept and return various data formats, such as XML, HTML, plain text, and binary data. To ensure the API Gateway works with any content type, I have configured Express’s raw middleware to handle all content types.
Additionally, I covered the proxy handler with unit tests to ensure its reliability and correctness. I also implemented a CI process using GitHub Actions to automate testing. This setup ensures that any changes to the codebase are automatically tested, maintaining high code quality.
To test API Gateway I implemented a simple users service:
const express = require('express');
const app = express();
const users = []
app.use(express.json());
app.get('/users/:id', (req, res) => {
const id = req.params.id;
const user = users.find(user => user.id === id);
res.json(user);
});
app.post('/users', (req, res) => {
const user = req.body;
users.push(user);
res.json(user);
})
app.listen(3001, () => {
console.log('Server is running on port 3001');
})
You can reach users service through API Gateway if you execute the following requests:
curl --location 'localhost:3000/users' \
--header 'Content-Type: application/json' \
--data '{
"id": "42",
"name": "Dmytro"
}'
curl --location 'localhost:3000/users/42'
Application Code
You can find the application code in the next repository:
Don’t forget to start the repository if you like it!
Support Me
If you found joy or value in my article, and you’d like to show some appreciation, you can now donate with ‘Buy Me A Coffee’! Your support will go a long way in helping me continue to share more stories, insights, and content that resonates with you.
Conclusions
API Gateway plays a crucial role in modern microservices development by acting as a single entry point for all client incoming traffic, routing them to appropriate services, and managing different important aspects such as security and rate limiting.
In this post, I’ve started developing an API Gateway with Node.js and Express. By leveraging middleware and proper configuration, we’ve created a flexible and scalable gateway that simplifies client interactions with backend services. Additionally, implementing unit tests and CI processes ensures our API Gateway remains reliable and maintains high code quality.
Future posts will explore other API Gateway aspects, such as security, monitoring, rate limiting, etc. Stay tuned for more insights and practical examples to help you master API Gateway development with Node.js.