Skip to main content

Express API Gateway

Introduction

An API Gateway serves as a single entry point for all client requests in a microservices architecture or complex backend system. In Express.js applications, implementing an API gateway pattern allows you to centralize cross-cutting concerns such as authentication, logging, rate limiting, and request routing. This approach simplifies client interactions with your backend services and provides a unified API surface.

In this tutorial, we'll explore how to build an API gateway using Express.js, understand its benefits, and implement common gateway functionalities step by step.

What is an API Gateway?

An API Gateway is a server that acts as an intermediary between clients and your backend services. It handles:

  1. Request routing - directing requests to appropriate services
  2. Authentication and authorization - verifying user identity and permissions
  3. Rate limiting - preventing abuse through request throttling
  4. Request/response transformation - modifying requests or responses as needed
  5. Logging and monitoring - tracking API usage and performance
  6. Caching - improving performance by storing responses

API Gateway Architecture

Setting Up a Basic API Gateway

Let's create a simple API gateway that routes requests to different backend services.

Step 1: Project Setup

First, let's set up our Express application:

bash
mkdir express-api-gateway
cd express-api-gateway
npm init -y
npm install express http-proxy-middleware morgan dotenv cors helmet

Step 2: Create the Gateway Server

Create a file named gateway.js:

javascript
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const morgan = require('morgan');
const cors = require('cors');
const helmet = require('helmet');

const app = express();
const PORT = process.env.PORT || 3000;

// Middleware for security and logging
app.use(helmet());
app.use(cors());
app.use(morgan('combined'));
app.use(express.json());

// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'OK', message: 'API Gateway is running' });
});

// Service routes configuration
const servicesConfig = {
users: {
url: 'http://localhost:3001',
pathRewrite: {
'^/api/users': '/users' // Rewrite path
}
},
products: {
url: 'http://localhost:3002',
pathRewrite: {
'^/api/products': '/products'
}
}
};

// Setup proxy routes
Object.keys(servicesConfig).forEach(service => {
const config = servicesConfig[service];
app.use(`/api/${service}`, createProxyMiddleware({
target: config.url,
changeOrigin: true,
pathRewrite: config.pathRewrite
}));
});

// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Not Found', message: 'The requested resource does not exist' });
});

// Error handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal Server Error', message: err.message });
});

app.listen(PORT, () => {
console.log(`API Gateway running on port ${PORT}`);
});

Adding Authentication to the Gateway

A key responsibility of an API gateway is handling authentication. Let's add JWT authentication:

javascript
const jwt = require('jsonwebtoken');

// Secret key (in production, use environment variables)
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';

// Authentication middleware
const authenticate = (req, res, next) => {
// Skip authentication for certain paths
if (req.path === '/health' || req.path === '/api/auth/login') {
return next();
}

const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized', message: 'Authentication token required' });
}

const token = authHeader.split(' ')[1];

try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Unauthorized', message: 'Invalid token' });
}
};

// Add authentication middleware before routes
app.use(authenticate);

Implementing Rate Limiting

Rate limiting prevents API abuse. Let's add a simple rate limiter:

javascript
const rateLimit = require('express-rate-limit');

// Rate limiting middleware
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: {
error: 'Too Many Requests',
message: 'You have exceeded the rate limit. Please try again later.'
}
});

// Apply rate limiter to all routes
app.use(apiLimiter);

Service Discovery Integration

In a real-world scenario, you might want to integrate with a service registry for dynamic service discovery:

javascript
const consul = require('consul')();

// Function to get service URL from consul
async function getServiceUrl(serviceName) {
try {
const result = await consul.catalog.service.nodes(serviceName);
if (result && result.length > 0) {
const service = result[0];
return `http://${service.ServiceAddress}:${service.ServicePort}`;
}
throw new Error(`Service ${serviceName} not found`);
} catch (error) {
console.error(`Error discovering service ${serviceName}:`, error);
return null;
}
}

// Dynamic proxy middleware
async function createDynamicProxy(req, res, next) {
const serviceName = req.params.service;
const serviceUrl = await getServiceUrl(serviceName);

if (!serviceUrl) {
return res.status(503).json({
error: 'Service Unavailable',
message: `The requested service '${serviceName}' is not available`
});
}

createProxyMiddleware({
target: serviceUrl,
changeOrigin: true,
pathRewrite: (path) => path.replace(`/api/${serviceName}`, '')
})(req, res, next);
}

// Dynamic service route
app.use('/api/:service/*', createDynamicProxy);

Response Caching

To improve performance, let's add response caching:

javascript
const apicache = require('apicache');

// Initialize cache
const cache = apicache.middleware;

// Cache all GET requests for 5 minutes
app.use(cache('5 minutes', (req, res) => {
return req.method === 'GET';
}));

Real-World Example: Complete E-Commerce API Gateway

Let's put everything together in a complete example for an e-commerce application:

javascript
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const morgan = require('morgan');
const helmet = require('helmet');
const cors = require('cors');
const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');
const apicache = require('apicache');

// Initialize Express app
const app = express();
const PORT = process.env.PORT || 3000;
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';

// Middleware setup
app.use(helmet());
app.use(cors());
app.use(express.json());
app.use(morgan('combined'));

// Rate limiter setup
const rateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
message: { error: 'Too Many Requests' }
});

app.use(rateLimiter);

// Cache middleware
const cache = apicache.middleware;
const cacheRoutes = cache('5 minutes');

// Authentication middleware
const authenticate = (req, res, next) => {
// Public routes that don't need authentication
const publicPaths = ['/health', '/api/auth/login', '/api/auth/register'];
if (publicPaths.includes(req.path)) {
return next();
}

const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}

const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
};

// Apply authentication middleware
app.use(authenticate);

// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'OK' });
});

// Service routes configuration
const services = {
auth: {
url: 'http://auth-service:3001',
public: true
},
users: {
url: 'http://user-service:3002'
},
products: {
url: 'http://product-service:3003'
},
orders: {
url: 'http://order-service:3004'
},
payments: {
url: 'http://payment-service:3005'
}
};

// Request logger middleware
const requestLogger = (req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
next();
};

app.use(requestLogger);

// Setup service proxies
Object.keys(services).forEach(service => {
const { url, public } = services[service];
const path = `/api/${service}`;

// Apply cache only to GET requests for product service
if (service === 'products' && req.method === 'GET') {
app.use(path, cacheRoutes, createProxyMiddleware({
target: url,
changeOrigin: true,
pathRewrite: { [`^${path}`]: '' }
}));
} else {
app.use(path, createProxyMiddleware({
target: url,
changeOrigin: true,
pathRewrite: { [`^${path}`]: '' }
}));
}
});

// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Not Found', message: 'The requested resource does not exist' });
});

// Error handler
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: 'Internal Server Error' });
});

// Start server
app.listen(PORT, () => {
console.log(`E-commerce API Gateway running on port ${PORT}`);
});

Creating a Mock Backend Services for Testing

To test our API gateway, let's create a couple of mock services:

User Service (users-service.js)

javascript
const express = require('express');
const app = express();
const PORT = 3001;

app.use(express.json());

const users = [
{ id: 1, name: 'John Doe', email: '[email protected]' },
{ id: 2, name: 'Jane Smith', email: '[email protected]' }
];

app.get('/users', (req, res) => {
res.json(users);
});

app.get('/users/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});

app.listen(PORT, () => {
console.log(`User service running on port ${PORT}`);
});

Product Service (products-service.js)

javascript
const express = require('express');
const app = express();
const PORT = 3002;

app.use(express.json());

const products = [
{ id: 1, name: 'Laptop', price: 999.99 },
{ id: 2, name: 'Smartphone', price: 699.99 },
{ id: 3, name: 'Headphones', price: 149.99 }
];

app.get('/products', (req, res) => {
res.json(products);
});

app.get('/products/:id', (req, res) => {
const product = products.find(p => p.id === parseInt(req.params.id));
if (!product) {
return res.status(404).json({ error: 'Product not found' });
}
res.json(product);
});

app.listen(PORT, () => {
console.log(`Product service running on port ${PORT}`);
});

Testing the API Gateway

Once you have all services running, you can test the gateway:

bash
# Start the gateway and mock services
node gateway.js
node users-service.js
node products-service.js

# Test the services through the gateway
curl http://localhost:3000/api/users
curl http://localhost:3000/api/products
curl http://localhost:3000/api/products/1

Benefits of Using an API Gateway

  1. Simplified client code: Clients interact with a single endpoint rather than multiple services
  2. Centralized cross-cutting concerns: Authentication, logging, and monitoring in one place
  3. Enhanced security: Single point to implement security policies
  4. Reduced client-server traffic: Aggregation of multiple calls into one
  5. Protocol translation: Handle different protocols between client and services
  6. Load balancing: Distribute traffic across multiple service instances

Common Challenges and Solutions

Challenge: High Latency

Solution: Implement caching strategies for frequently accessed data and use connection pooling.

Challenge: Single Point of Failure

Solution: Deploy multiple gateway instances behind a load balancer for high availability.

Challenge: Service Discovery

Solution: Integrate with service registry tools like Consul, Etcd, or Eureka.

javascript
// Example of service discovery integration with Consul
const Consul = require('consul');
const consul = new Consul();

// Get service details from Consul
function getServiceDetails(serviceName) {
return new Promise((resolve, reject) => {
consul.catalog.service.nodes(serviceName, (err, result) => {
if (err) return reject(err);
if (!result || result.length === 0) {
return reject(new Error(`Service ${serviceName} not found`));
}
const service = result[0];
resolve({
host: service.ServiceAddress,
port: service.ServicePort
});
});
});
}

Summary

In this tutorial, we've learned how to create an Express-based API gateway that serves as a unified entry point for client applications. We've covered:

  1. Setting up a basic API gateway using Express and http-proxy-middleware
  2. Adding critical functionality like authentication, rate limiting, and caching
  3. Creating mock services for testing the gateway
  4. Understanding the benefits and challenges of the API gateway pattern

An API gateway is an essential architectural component in modern microservice applications. It helps centralize common functionality, simplifies client interactions, and provides a more secure and maintainable API surface.

Additional Resources

Exercises

  1. Extend the API gateway to include request validation using a library like Joi or express-validator
  2. Implement a circuit breaker pattern to handle service failures gracefully
  3. Add metrics collection to monitor API usage and performance
  4. Create a dashboard to visualize API traffic and response times
  5. Implement service-specific rate limiting (different limits for different endpoints)

By implementing an API gateway in your Express applications, you gain more control over your API endpoints, enhance security, and provide a better developer experience for API consumers.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)