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:
- Request routing - directing requests to appropriate services
- Authentication and authorization - verifying user identity and permissions
- Rate limiting - preventing abuse through request throttling
- Request/response transformation - modifying requests or responses as needed
- Logging and monitoring - tracking API usage and performance
- Caching - improving performance by storing responses
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:
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
:
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:
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:
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:
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:
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:
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)
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)
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:
# 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
- Simplified client code: Clients interact with a single endpoint rather than multiple services
- Centralized cross-cutting concerns: Authentication, logging, and monitoring in one place
- Enhanced security: Single point to implement security policies
- Reduced client-server traffic: Aggregation of multiple calls into one
- Protocol translation: Handle different protocols between client and services
- 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.
// 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:
- Setting up a basic API gateway using Express and http-proxy-middleware
- Adding critical functionality like authentication, rate limiting, and caching
- Creating mock services for testing the gateway
- 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
- Express.js Documentation
- http-proxy-middleware Documentation
- Microservices.io - API Gateway Pattern
- Kong API Gateway - A popular open-source API gateway
Exercises
- Extend the API gateway to include request validation using a library like Joi or express-validator
- Implement a circuit breaker pattern to handle service failures gracefully
- Add metrics collection to monitor API usage and performance
- Create a dashboard to visualize API traffic and response times
- 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! :)