Module 16: JavaScript Backend

Custom Middleware Development in Express.js

Why Create Custom Middleware?

While Express comes with built-in middleware and the ecosystem offers many third-party options, there are still compelling reasons to create your own custom middleware:

Analogy: If built-in middleware is like standard furniture that comes with a new house, and third-party middleware is like furniture you buy from a store, then custom middleware is like custom-built furniture tailored exactly to your space, needs, and aesthetic preferences. It fits perfectly in your home because it was designed specifically for it.

graph TD A[Express Application] --> B{Is functionality already available?} B -->|Yes| C[Use Built-in or Third-party Middleware] B -->|No| D[Create Custom Middleware] D --> E[Application-specific Logic] D --> F[Unique Business Requirements] D --> G[Specialized Integration] D --> H[Optimized Performance] D --> I[Custom Security Measures]

Custom Middleware Structure

At its core, a custom middleware function follows the same pattern as any Express middleware:


// Basic custom middleware structure
function customMiddleware(req, res, next) {
  // Middleware logic goes here
  
  // Call next() to pass control to the next middleware
  next();
}

// Using the custom middleware
app.use(customMiddleware);
            

The middleware function can access and modify the request and response objects, perform operations, and then either pass control to the next middleware or end the request-response cycle.

Middleware with Configuration Options

More advanced middleware can accept configuration options by using a function that returns the middleware function:


// Configurable middleware pattern
function customMiddleware(options) {
  // Set default options
  const opts = {
    enabled: true,
    logLevel: 'info',
    ...options
  };
  
  // Return the actual middleware function
  return function(req, res, next) {
    // Skip middleware if disabled
    if (!opts.enabled) {
      return next();
    }
    
    // Middleware logic using options
    if (opts.logLevel === 'debug') {
      console.log('Debug info:', req.path);
    }
    
    // Continue to next middleware
    next();
  };
}

// Using the configurable middleware
app.use(customMiddleware({ 
  logLevel: 'debug' 
}));
            

This pattern allows users of your middleware to customize its behavior without modifying the middleware code itself.

flowchart LR subgraph Configuration A[Options Object] --> B[Default Options] B --> C[Merged Options] end subgraph Middleware D[Request] --> E{Enabled?} E -->|Yes| F[Apply Logic] E -->|No| G[Skip] F --> H[Call next()] G --> H H --> I[Next Middleware] end C --> E

Real-world example: The popular Node.js framework NestJS uses this configurable middleware pattern extensively. Their middleware often accepts option objects that allow developers to adjust behavior for different environments (development, testing, production) without writing different middleware code for each scenario.

Essential Custom Middleware Examples

Request Logging Middleware

Create a simple but powerful logging middleware that records request details:


// Request logger middleware
function requestLogger(options = {}) {
  const {
    logLevel = 'info',
    includeBody = false,
    includeHeaders = false,
    excludePaths = ['/health', '/favicon.ico']
  } = options;
  
  return (req, res, next) => {
    // Skip logging for excluded paths
    if (excludePaths.includes(req.path)) {
      return next();
    }
    
    // Capture start time
    const start = Date.now();
    
    // Log when the response finishes
    res.on('finish', () => {
      // Calculate duration
      const duration = Date.now() - start;
      
      // Build log entry
      const logEntry = {
        timestamp: new Date().toISOString(),
        method: req.method,
        path: req.path,
        query: req.query,
        statusCode: res.statusCode,
        duration: `${duration}ms`
      };
      
      // Conditionally include body and headers
      if (includeBody && req.body) {
        logEntry.body = req.body;
      }
      
      if (includeHeaders) {
        logEntry.headers = req.headers;
      }
      
      // Log based on status code
      if (res.statusCode >= 500) {
        console.error(logEntry);
      } else if (res.statusCode >= 400) {
        console.warn(logEntry);
      } else if (logLevel === 'debug') {
        console.debug(logEntry);
      } else {
        console.log(logEntry);
      }
    });
    
    next();
  };
}

// Usage
app.use(requestLogger({
  logLevel: 'debug',
  includeBody: true,
  excludePaths: ['/health', '/metrics', '/favicon.ico']
}));
            

Analogy: This request logging middleware is like a security camera system in a building. It records who enters (the request), what they do (the processing), and when they leave (the response). The configuration options let you adjust which cameras are active, how much detail to record, and which areas don't need monitoring.

Authentication Middleware

Create a flexible authentication middleware to protect routes:


// Authentication middleware
function authenticate(options = {}) {
  const {
    tokenType = 'Bearer',
    authHeader = 'authorization',
    tokenSecret = process.env.JWT_SECRET || 'default-secret',
    credentialsRequired = true
  } = options;
  
  return async (req, res, next) => {
    try {
      // Get the token from the header
      const header = req.headers[authHeader];
      
      if (!header) {
        if (credentialsRequired) {
          return res.status(401).json({ 
            error: 'Authentication required' 
          });
        }
        return next();
      }
      
      // Check if token type matches
      if (tokenType && !header.startsWith(`${tokenType} `)) {
        return res.status(401).json({ 
          error: `${tokenType} token required` 
        });
      }
      
      // Extract the token
      const token = header.split(' ')[1];
      
      if (!token) {
        return res.status(401).json({ 
          error: 'Token is missing' 
        });
      }
      
      // Verify the token (using a hypothetical verifyToken function)
      // In a real app, you'd use a library like jsonwebtoken
      const decoded = await verifyToken(token, tokenSecret);
      
      // Attach the user data to the request
      req.user = decoded;
      
      next();
    } catch (error) {
      if (error.name === 'TokenExpiredError') {
        return res.status(401).json({ 
          error: 'Token has expired' 
        });
      }
      
      if (error.name === 'JsonWebTokenError') {
        return res.status(401).json({ 
          error: 'Invalid token' 
        });
      }
      
      // For other errors
      next(error);
    }
  };
}

// Usage
// Protect all routes
app.use(authenticate());

// Protect specific routes
app.get('/api/protected',
  authenticate({ credentialsRequired: true }),
  (req, res) => {
    res.json({ message: 'Protected data', user: req.user });
  }
);

// Optional authentication
app.get('/api/public',
  authenticate({ credentialsRequired: false }),
  (req, res) => {
    if (req.user) {
      res.json({ message: 'Welcome back', user: req.user });
    } else {
      res.json({ message: 'Public data' });
    }
  }
);
            

Role-Based Authorization Middleware

Building on the authentication middleware, we can create a role-based authorization middleware:


// Role-based authorization middleware
function authorize(requiredRoles = []) {
  // Convert string to array for convenience
  if (typeof requiredRoles === 'string') {
    requiredRoles = [requiredRoles];
  }
  
  return (req, res, next) => {
    // Make sure user is authenticated first
    if (!req.user) {
      return res.status(401).json({ 
        error: 'Authentication required' 
      });
    }
    
    // If no roles required, allow access
    if (requiredRoles.length === 0) {
      return next();
    }
    
    // Get user roles from the user object
    const userRoles = req.user.roles || [];
    
    // Check if user has any of the required roles
    const hasRequiredRole = requiredRoles.some(role => 
      userRoles.includes(role)
    );
    
    if (!hasRequiredRole) {
      return res.status(403).json({
        error: 'Insufficient permissions'
      });
    }
    
    next();
  };
}

// Usage
// Admin-only route
app.get('/api/admin',
  authenticate(),
  authorize('admin'),
  (req, res) => {
    res.json({ message: 'Admin dashboard' });
  }
);

// Multiple roles allowed
app.get('/api/reports',
  authenticate(),
  authorize(['admin', 'manager', 'analyst']),
  (req, res) => {
    res.json({ message: 'Reports data' });
  }
);
            

Real-world example: Content management systems like WordPress use similar authentication and authorization middleware to control access to the admin dashboard. Different user roles (admin, editor, author, contributor) have different permissions, and the middleware ensures users can only access features appropriate for their role.

Error Handling Middleware

Create a comprehensive error handling middleware that provides appropriate responses based on error types:


// Error definitions
class AppError extends Error {
  constructor(message, statusCode, errorCode) {
    super(message);
    this.statusCode = statusCode;
    this.errorCode = errorCode;
    this.isOperational = true;
    
    Error.captureStackTrace(this, this.constructor);
  }
}

// Custom error types
class ValidationError extends AppError {
  constructor(message, details = []) {
    super(message, 400, 'VALIDATION_ERROR');
    this.details = details;
  }
}

class NotFoundError extends AppError {
  constructor(resource = 'Resource') {
    super(`${resource} not found`, 404, 'NOT_FOUND');
  }
}

class UnauthorizedError extends AppError {
  constructor(message = 'Unauthorized access') {
    super(message, 401, 'UNAUTHORIZED');
  }
}

// Error handling middleware
function errorHandler(options = {}) {
  const {
    logErrors = true,
    includeStackTrace = process.env.NODE_ENV !== 'production',
    fallbackMessage = 'Something went wrong'
  } = options;
  
  return (err, req, res, next) => {
    // Set default status code if not set
    const statusCode = err.statusCode || 500;
    
    // Log the error
    if (logErrors) {
      console.error(
        `[ERROR] ${req.method} ${req.path} - ${statusCode}:`,
        err.isOperational ? err.message : err
      );
      
      // Log stack trace for non-operational errors
      if (!err.isOperational) {
        console.error(err.stack);
      }
    }
    
    // Prepare error response
    const errorResponse = {
      error: {
        message: err.message || fallbackMessage,
        code: err.errorCode || 'INTERNAL_ERROR'
      }
    };
    
    // Add validation details if available
    if (err instanceof ValidationError && err.details) {
      errorResponse.error.details = err.details;
    }
    
    // Include stack trace for debugging if enabled
    if (includeStackTrace && err.stack) {
      errorResponse.error.stack = err.stack.split('\n');
    }
    
    // Send response
    res.status(statusCode).json(errorResponse);
  };
}

// Usage
// In route handlers, you can throw custom errors
app.get('/api/users/:id', (req, res, next) => {
  try {
    const user = findUser(req.params.id);
    
    if (!user) {
      throw new NotFoundError('User');
    }
    
    res.json(user);
  } catch (error) {
    next(error);
  }
});

// Apply error handler as the last middleware
app.use(errorHandler({
  includeStackTrace: process.env.NODE_ENV === 'development'
}));
            
flowchart TD A[Route Handler] -->|Throws Error| B{Error Type?} B -->|Validation Error| C[400 Bad Request] B -->|Not Found Error| D[404 Not Found] B -->|Unauthorized Error| E[401 Unauthorized] B -->|Other Error| F[500 Internal Server Error] C & D & E & F --> G[Format Error Response] G --> H{Is Production?} H -->|Yes| I[Remove Sensitive Details] H -->|No| J[Include Debug Info] I & J --> K[Send Error Response]

Analogy: This error handling middleware is like an emergency response system. When something goes wrong (an error occurs), it categorizes the emergency (error type), dispatches the appropriate responders (status codes), and communicates clearly with those affected (error response). In a high-security facility (production), it's careful not to reveal sensitive information in its communications.

Data Validation Middleware

Create a flexible validation middleware using a schema validation library (like Joi):


// Assuming Joi is installed: npm install joi
const Joi = require('joi');

// Validation middleware
function validate(schema, property = 'body') {
  return (req, res, next) => {
    const data = req[property];
    
    const { error, value } = schema.validate(data, {
      abortEarly: false,  // Return all errors, not just the first one
      stripUnknown: true, // Remove unknown fields
      errors: {
        wrap: {
          label: ''  // Don't wrap field names in quotes
        }
      }
    });
    
    if (error) {
      // Extract and format validation errors
      const details = error.details.map(detail => ({
        field: detail.path.join('.'),
        message: detail.message
      }));
      
      // Return validation error response
      return res.status(400).json({
        error: {
          message: 'Validation failed',
          code: 'VALIDATION_ERROR',
          details
        }
      });
    }
    
    // Replace validated data
    req[property] = value;
    
    next();
  };
}

// Usage
// Define validation schemas
const schemas = {
  createUser: Joi.object({
    username: Joi.string().alphanum().min(3).max(30).required(),
    email: Joi.string().email().required(),
    password: Joi.string().min(8).required(),
    age: Joi.number().integer().min(18).max(120)
  }),
  
  updateUser: Joi.object({
    username: Joi.string().alphanum().min(3).max(30),
    email: Joi.string().email(),
    age: Joi.number().integer().min(18).max(120)
  }),
  
  loginUser: Joi.object({
    email: Joi.string().email().required(),
    password: Joi.string().required()
  })
};

// Apply validation to routes
app.post('/api/users',
  validate(schemas.createUser),
  (req, res) => {
    // req.body contains validated and sanitized data
    res.status(201).json({ message: 'User created', user: req.body });
  }
);

app.put('/api/users/:id',
  validate(schemas.updateUser),
  (req, res) => {
    res.json({ message: 'User updated', user: req.body });
  }
);

// Validate query parameters
app.get('/api/users',
  validate(Joi.object({
    page: Joi.number().integer().min(1).default(1),
    limit: Joi.number().integer().min(1).max(100).default(20),
    sortBy: Joi.string().valid('username', 'email', 'createdAt').default('createdAt'),
    order: Joi.string().valid('asc', 'desc').default('desc')
  }), 'query'),
  (req, res) => {
    // req.query contains validated and sanitized query parameters
    // with default values applied
    const { page, limit, sortBy, order } = req.query;
    res.json({ users: [], pagination: { page, limit, totalPages: 10 } });
  }
);
            

Real-world example: Payment processing systems like Stripe use extensive request validation to ensure all transaction data meets requirements before processing. Their APIs reject requests with invalid credit card numbers, incomplete addresses, or improper currency formats, providing detailed validation errors to help developers fix their requests.

Rate Limiting Middleware

Create a rate limiting middleware to protect your API from abuse:


// Simple in-memory rate limiter
function rateLimiter(options = {}) {
  const {
    windowMs = 60 * 1000, // 1 minute
    maxRequests = 100,    // 100 requests per windowMs
    message = 'Too many requests, please try again later',
    statusCode = 429,     // Too Many Requests
    keyGenerator = (req) => req.ip, // Identify clients by IP
    skip = () => false    // Function to skip rate limiting
  } = options;
  
  // Store for tracking requests
  const requestCounts = new Map();
  
  // Cleanup function to remove old entries
  const cleanup = () => {
    const now = Date.now();
    for (const [key, data] of requestCounts.entries()) {
      if (now - data.startTime > windowMs) {
        requestCounts.delete(key);
      }
    }
  };
  
  // Run cleanup every windowMs
  setInterval(cleanup, windowMs);
  
  // Return middleware function
  return (req, res, next) => {
    // Skip rate limiting if skip function returns true
    if (skip(req, res)) {
      return next();
    }
    
    // Get client identifier
    const key = keyGenerator(req);
    
    // Get current time
    const now = Date.now();
    
    // Initialize or update client data
    if (!requestCounts.has(key)) {
      requestCounts.set(key, {
        count: 1,
        startTime: now
      });
      return next();
    }
    
    const data = requestCounts.get(key);
    
    // Reset if window has expired
    if (now - data.startTime > windowMs) {
      data.count = 1;
      data.startTime = now;
      return next();
    }
    
    // Increment request count
    data.count += 1;
    
    // Check if over limit
    if (data.count > maxRequests) {
      // Add rate limit headers
      res.setHeader('Retry-After', Math.ceil(windowMs / 1000));
      res.setHeader('X-RateLimit-Limit', maxRequests);
      res.setHeader('X-RateLimit-Remaining', 0);
      res.setHeader('X-RateLimit-Reset', Math.ceil((data.startTime + windowMs) / 1000));
      
      // Send rate limit error
      return res.status(statusCode).json({
        error: {
          message,
          code: 'RATE_LIMIT_EXCEEDED'
        }
      });
    }
    
    // Add rate limit headers
    res.setHeader('X-RateLimit-Limit', maxRequests);
    res.setHeader('X-RateLimit-Remaining', maxRequests - data.count);
    res.setHeader('X-RateLimit-Reset', Math.ceil((data.startTime + windowMs) / 1000));
    
    next();
  };
}

// Usage
// Apply rate limiter to all routes
app.use(rateLimiter({ maxRequests: 100, windowMs: 60 * 1000 }));

// Apply stricter rate limiting to login route
app.post('/api/login',
  rateLimiter({
    windowMs: 15 * 60 * 1000, // 15 minutes
    maxRequests: 5,            // 5 attempts per 15 minutes
    message: 'Too many login attempts, please try again later'
  }),
  (req, res) => {
    // Login logic
  }
);

// Different rate limits for different APIs
app.use('/api/public',
  rateLimiter({ maxRequests: 500, windowMs: 60 * 1000 })
);

app.use('/api/private',
  rateLimiter({ maxRequests: 200, windowMs: 60 * 1000 })
);
            

Analogy: Rate limiting middleware is like a bouncer at a popular nightclub. It tracks how many times each person (client) enters (makes requests) in a given time period. If someone tries to enter too many times too quickly, the bouncer stops them and asks them to wait. This ensures the club doesn't get overcrowded and everyone has a fair chance to enjoy the services.

Response Transformation Middleware

Create middleware to standardize API responses:


// Response transformation middleware
function responseFormatter(options = {}) {
  const {
    envelope = true,     // Wrap responses in a data property
    timestamps = true,   // Add timestamps to responses
    version = '1.0',     // API version
    successKey = 'data', // Property for successful responses
    metaKey = 'meta'     // Property for metadata
  } = options;
  
  return (req, res, next) => {
    // Store the original res.json method
    const originalJson = res.json;
    
    // Override res.json method
    res.json = function(body) {
      // Skip if already formatted or is an error response
      if (body && (body.error || body[successKey])) {
        return originalJson.call(this, body);
      }
      
      // Format the response
      let formattedResponse = {};
      
      if (envelope) {
        // Wrap the response in the success key
        formattedResponse[successKey] = body;
      } else {
        // Merge the body directly
        formattedResponse = { ...body };
      }
      
      // Add metadata
      formattedResponse[metaKey] = {
        ...(formattedResponse[metaKey] || {})
      };
      
      // Add timestamps if enabled
      if (timestamps) {
        formattedResponse[metaKey].timestamp = new Date().toISOString();
      }
      
      // Add API version if provided
      if (version) {
        formattedResponse[metaKey].version = version;
      }
      
      // Add request ID if available
      if (req.id) {
        formattedResponse[metaKey].requestId = req.id;
      }
      
      // Call the original json method with the formatted response
      return originalJson.call(this, formattedResponse);
    };
    
    next();
  };
}

// Usage
app.use(responseFormatter({
  version: '2.0',
  timestamps: true
}));

// Example route that returns data
app.get('/api/users', (req, res) => {
  const users = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
  
  // This will be automatically formatted
  res.json(users);
  
  // Output:
  // {
  //   "data": [
  //     { "id": 1, "name": "Alice" },
  //     { "id": 2, "name": "Bob" }
  //   ],
  //   "meta": {
  //     "timestamp": "2023-08-15T12:34:56.789Z",
  //     "version": "2.0"
  //   }
  // }
});

// Example route with pagination metadata
app.get('/api/products', (req, res) => {
  const products = [/* product data */];
  const total = 100;
  const page = parseInt(req.query.page) || 1;
  const limit = parseInt(req.query.limit) || 20;
  
  res.json({
    data: products,
    meta: {
      pagination: {
        total,
        page,
        limit,
        pages: Math.ceil(total / limit)
      }
    }
  });
  
  // The middleware will preserve and enhance the existing meta object
});
            

Real-world example: Financial data APIs like Bloomberg or Reuters use response formatting middleware to ensure their data is consistently presented across all endpoints. This includes standardized timestamp formats, data versioning information, and consistent field naming—critical for systems that process market data in real-time.

Request Context Middleware

Create middleware that establishes a request context with useful properties and utilities:


// UUID package for request IDs
const { v4: uuidv4 } = require('uuid');

// Request context middleware
function requestContext(options = {}) {
  const {
    generateId = () => uuidv4(),
    includeTimestamp = true,
    contextKey = 'context'
  } = options;
  
  return (req, res, next) => {
    // Generate a unique request ID
    req.id = generateId();
    
    // Add request ID header to response
    res.setHeader('X-Request-ID', req.id);
    
    // Create context object
    req[contextKey] = {
      id: req.id,
      startTime: includeTimestamp ? Date.now() : undefined,
      
      // Utility to calculate elapsed time
      getElapsedTime: () => {
        if (!includeTimestamp) return undefined;
        return Date.now() - req[contextKey].startTime;
      },
      
      // Store arbitrary data in context
      set: (key, value) => {
        req[contextKey][key] = value;
        return value;
      },
      
      // Retrieve data from context
      get: (key, defaultValue) => {
        return req[contextKey][key] !== undefined 
          ? req[contextKey][key] 
          : defaultValue;
      },
      
      // Log within request context
      log: (level, message, data = {}) => {
        console[level](`[${req.id}] ${message}`, {
          requestId: req.id,
          timestamp: new Date().toISOString(),
          path: req.path,
          method: req.method,
          ...data
        });
      }
    };
    
    // Add convenience methods
    req.getElapsedTime = req[contextKey].getElapsedTime;
    req.log = req[contextKey].log;
    
    next();
  };
}

// Usage
app.use(requestContext());

app.get('/api/process', (req, res) => {
  // Log with request context
  req.log('info', 'Processing request', { step: 'start' });
  
  // Store data in context
  req.context.set('processId', 12345);
  
  // Perform some operation
  // ...
  
  // Log again with elapsed time
  req.log('info', 'Request processed', {
    processId: req.context.get('processId'),
    duration: req.getElapsedTime() + 'ms'
  });
  
  res.json({ success: true });
});
            

Analogy: The request context middleware is like a personal assistant assigned to each visitor (request) that enters your office building. This assistant follows the visitor throughout their journey, taking notes on timing, locations visited, and important interactions. When the visitor leaves, the assistant provides a complete record of the visit, making it easier to track and analyze what happened.

Middleware Composition Techniques

As you develop more custom middleware, you can use several techniques to compose them effectively:

Middleware Factory Pattern

Create factories that generate specialized middleware:


// Middleware factory for resource access control
function resourceACL(resourceType) {
  // Return middleware specific to resource type
  return (req, res, next) => {
    // Implementation specific to resourceType
    // ...
    next();
  };
}

// Usage
app.use('/api/users', resourceACL('users'));
app.use('/api/products', resourceACL('products'));
            

Middleware Pipelines

Create reusable middleware pipelines for common scenarios:


// Middleware pipeline for API endpoints
function apiPipeline(validationSchema) {
  return [
    requestLogger(),
    rateLimiter({ windowMs: 60 * 1000, maxRequests: 100 }),
    authenticate(),
    validationSchema ? validate(validationSchema) : (req, res, next) => next(),
    responseFormatter()
  ];
}

// Usage
app.get('/api/users',
  apiPipeline(userListSchema),
  (req, res) => {
    // Handler logic
  }
);
            

Conditional Middleware

Apply middleware conditionally based on environment or other factors:


// Conditional middleware application
if (process.env.NODE_ENV === 'development') {
  app.use(requestLogger({ includeBody: true, logLevel: 'debug' }));
} else {
  app.use(requestLogger({ excludePaths: ['/health', '/metrics'] }));
}

// Function that returns appropriate middleware
function getSecurityMiddleware() {
  if (process.env.NODE_ENV === 'production') {
    return [
      helmet(),
      rateLimiter({ windowMs: 15 * 60 * 1000, maxRequests: 100 }),
      csrf()
    ];
  } else {
    return [
      helmet({ contentSecurityPolicy: false }), // Relaxed in development
      (req, res, next) => next() // Skip rate limiting in development
    ];
  }
}

// Apply conditional middleware
app.use(getSecurityMiddleware());
            
flowchart TD A[Request] --> B{Development?} B -->|Yes| C[Development Middleware Stack] B -->|No| D[Production Middleware Stack] C --> C1[Verbose Logging] C --> C2[Relaxed Security] C --> C3[Debug Tools] D --> D1[Minimal Logging] D --> D2[Strict Security] D --> D3[Performance Optimizations] C1 & C2 & C3 --> E[Application Logic] D1 & D2 & D3 --> E E --> F[Response]

Real-world example: The Node.js framework NestJS uses middleware composition extensively in their authentication system. They compose multiple middleware components (JWT verification, role checking, throttling) into reusable "guards" that can be applied to routes as a unit, making security implementation consistent across the application.

Testing Custom Middleware

Testing middleware is crucial to ensure it behaves as expected in different scenarios. Here's an approach using popular testing tools:


// Example using Jest and Supertest
const request = require('supertest');
const express = require('express');

// Import middleware to test
const authenticate = require('../middleware/authenticate');

describe('Authentication Middleware', () => {
  let app;
  
  beforeEach(() => {
    // Create a fresh Express app for each test
    app = express();
    
    // Configure the app with the middleware and a test route
    app.use(authenticate({
      tokenSecret: 'test-secret'
    }));
    
    app.get('/protected', (req, res) => {
      res.json({ user: req.user });
    });
  });
  
  test('should return 401 if no token is provided', async () => {
    const response = await request(app)
      .get('/protected');
    
    expect(response.status).toBe(401);
    expect(response.body.error).toBeDefined();
  });
  
  test('should return 401 if token is invalid', async () => {
    const response = await request(app)
      .get('/protected')
      .set('Authorization', 'Bearer invalid-token');
    
    expect(response.status).toBe(401);
    expect(response.body.error).toBeDefined();
  });
  
  test('should allow access with valid token', async () => {
    // Create a valid token for testing
    const validToken = createTestToken({ id: 1, username: 'testuser' });
    
    const response = await request(app)
      .get('/protected')
      .set('Authorization', `Bearer ${validToken}`);
    
    expect(response.status).toBe(200);
    expect(response.body.user).toBeDefined();
    expect(response.body.user.username).toBe('testuser');
  });
});

// Isolated middleware unit testing
describe('Authentication Middleware Unit Tests', () => {
  test('should call next() with valid token', () => {
    // Mock Express request and response
    const req = {
      headers: {
        authorization: 'Bearer valid-token'
      }
    };
    const res = {
      status: jest.fn().mockReturnThis(),
      json: jest.fn()
    };
    const next = jest.fn();
    
    // Mock token verification
    jest.mock('../utils/verifyToken', () => ({
      verifyToken: jest.fn().mockResolvedValue({ id: 1, username: 'testuser' })
    }));
    
    // Execute middleware with mocks
    authenticate()(req, res, next);
    
    // Verify next was called
    expect(next).toHaveBeenCalled();
    // Verify user was attached to request
    expect(req.user).toBeDefined();
    expect(req.user.username).toBe('testuser');
  });
});
            

Analogy: Testing middleware is like quality control in a factory. Before putting a component into full production, you test it in isolation with different inputs to verify it behaves correctly. You also test it integrated with other components to ensure they work together properly. This prevents defective parts from causing problems in the final product.

Practice Activities

Activity 1: Advanced Logging Middleware

Extend the request logging middleware to include:

  • Color-coded console output based on status code
  • Log rotation to create daily log files
  • Different log formats for development and production
  • Performance metrics for slow requests (over 1 second)

Test the middleware with various request types and error scenarios.

Activity 2: Complete Authentication System

Build a set of authentication and authorization middleware that includes:

  • JWT authentication middleware with token refresh capability
  • Role-based access control middleware
  • Permission-based authorization middleware
  • Rate limiting middleware specifically for authentication routes

Create an Express application that uses these middleware components to protect different routes.

Activity 3: Middleware Composition Framework

Create a middleware composition framework that:

  • Defines standard middleware pipelines for different route types (public, authenticated, admin)
  • Allows conditional middleware application based on environment
  • Enables middleware ordering optimization for performance
  • Provides a clean API for applying middleware stacks to routes

Use the framework to implement a complete API with at least 10 endpoints using different middleware combinations.

Key Takeaways

With a solid understanding of middleware architecture and development, you can now create Express applications with sophisticated request processing pipelines tailored to your specific needs.