Module 5: Web Frameworks & RESTful APIs (Express.js)

Express Basics

Express is an unopinionated, minimalist routing and middleware framework that abstracts raw node:http servers into structured request pipelines.

Incoming Request ──▶ [ express.json() ] ──▶ [ Custom Middleware ] ──▶ [ Route Handler ] ──▶ Response

Application Setup & Request/Response Cycle

An Express application manages an internal stack of handler functions. Every incoming HTTP request flows sequentially through this stack until a terminal handler writes headers and calls res.send(), res.json(), or res.end().

JavaScript

import express from 'express';

const app = express();

// Body parsers: transforms raw incoming byte streams into JavaScript objects
app.use(express.json({ limit: '10kb' })); // Parse application/json
app.use(express.urlencoded({ extended: true })); // Parse application/x-www-form-urlencoded

// Basic Route Definition
app.get('/health', (req, res) => {
  res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
});

app.listen(3000, () => {
  console.log('Server listening on port 3000');
});

Parsing URL Parameters, Query Strings, and Bodies

  • Route Parameters (req.params): Named URL segments used to identify a specific resource.
  • Query Strings (req.query): Key-value pairs appended after ? in the URL, used for filtering, pagination, and sorting.
  • Request Body (req.body): Payload submitted in POST/PUT/PATCH requests (populated by body-parsing middleware).
  • Headers (req.headers): Access headers like authorization or content-type.

JavaScript

// GET /api/v1/products/prod_99?sort=desc&limit=5
app.get('/api/v1/products/:id', (req, res) => {
  const { id } = req.params;            // 'prod_99'
  const { sort, limit = '10' } = req.query; // sort: 'desc', limit: '5'

  res.status(200).json({ productId: id, sort, limit: parseInt(limit, 10) });
});

// POST /api/v1/products
app.post('/api/v1/products', (req, res) => {
  const { name, price } = req.body; // Extracted from parsed JSON body
  res.status(201).json({ created: { name, price } });
});

Middleware Architecture

A middleware function has access to the request object (req), the response object (res), and the next function in the application’s request-response cycle.

JavaScript

function middleware(req, res, next) {
  // 1. Execute custom logic
  // 2. Mutate req or res objects if needed
  // 3. Pass control to the next middleware:
  next();
  // ...or terminate the request immediately:
  // res.status(403).json({ error: 'Forbidden' });
}

The 5 Types of Express Middleware

  1. Application-Level: Bound directly to an instance of app using app.use() or app.METHOD(). Runs on every matching path.
  2. Router-Level: Bound to an instance of express.Router(). Confines middleware execution to a specific domain or resource subset.
  3. Built-in: Bundled with Express: express.json(), express.urlencoded(), express.static('public').
  4. Third-Party: Maintained in the npm ecosystem:
    • cors: Configures Cross-Origin Resource Sharing headers.
    • morgan: HTTP request logger.
    • helmet: Sets secure HTTP response headers.

JavaScript

import cors from 'cors';
import morgan from 'morgan';

// Application-level third-party middleware
app.use(cors({ origin: 'https://trusted-client.com', methods: ['GET', 'POST'] }));
app.use(morgan('combined'));
  1. Error-Handling Middleware: Defined with strictly four arguments: (err, req, res, next). Express identifies this signature via fn.length === 4.

JavaScript

// Centralized Error-Handling Middleware (Must be declared AFTER all routes)
app.use((err, req, res, next) => {
  const statusCode = err.statusCode || 500;
  const message = err.isOperational ? err.message : 'Internal Server Error';

  console.error(`[Error] ${req.method} ${req.url}:`, err);

  res.status(statusCode).json({
    status: 'error',
    message,
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
  });
});

Router-Level Isolation

JavaScript

// routes/users.routes.js
import { Router } from 'express';

const router = Router();

// Router-specific middleware: applies only to routes mounted on this router
router.use((req, res, next) => {
  console.log(`User Router access: ${req.ip}`);
  next();
});

router.get('/', (req, res) => res.json({ users: [] }));
router.get('/:id', (req, res) => res.json({ user: req.params.id }));

export default router;

// app.js
import userRouter from './routes/users.routes.js';
app.use('/api/v1/users', userRouter);

REST API Design Principles

Representational State Transfer (REST) structures APIs around resources (nouns, not verbs) manipulated using standard HTTP protocols.

HTTP Verbs & Standard Status Codes

VerbSemanticsSuccess CodeIdempotentSafe
GETRead resource(s)200 OKYesYes
POSTCreate resource or execute arbitrary action201 CreatedNoNo
PUTReplace a resource completely200 OK / 204 No ContentYesNo
PATCHApply partial modifications to a resource200 OKNo (in spec)No
DELETERemove a resource200 OK / 204 No ContentYesNo
  • Safe: Does not modify server state (read-only).
  • Idempotent: Making multiple identical requests results in the same server state as a single request. DELETE /users/1 is idempotent (the record remains gone whether called once or five times), whereas POST /users creates a new record on each invocation.

Common HTTP Status Codes

  • 2xx Success: 200 OK, 201 Created, 204 No Content
  • 3xx Redirection: 301 Moved Permanently, 304 Not Modified (caching)
  • 4xx Client Error: 400 Bad Request, 401 Unauthorized (no/invalid auth token), 403 Forbidden (authenticated, but insufficient rights), 404 Not Found, 409 Conflict (e.g., duplicate unique field), 422 Unprocessable Entity (validation errors), 429 Too Many Requests
  • 5xx Server Error: 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable

Resource URI Conventions & Versioning

  • Use plural nouns: /api/v1/orders (not /api/v1/getAllOrders).
  • Model relationships hierarchically: /api/v1/users/42/orders.
  • Maintain backwards compatibility through URI versioning: /api/v1/... vs. /api/v2/....

Layered Architecture (Controller-Service-Repository)

Separating concerns isolates HTTP protocol details from core business logic and persistence layers, simplifying automated testing and maintenance.

Request ──▶ Controller (HTTP/Parsing) ──▶ Service (Business Logic) ──▶ Repository (Database)
src/
├── controllers/    # Parses req, validates, delegates, formats res
│   └── user.controller.js
├── services/       # Domain business rules, transactions, orchestration
│   └── user.service.js
├── repositories/   # Direct DB operations (Prisma, Mongoose, SQL)
│   └── user.repository.js
├── routes/         # Express Router mapping paths to controllers
│   └── user.routes.js
└── errors/         # Custom operational error classes
    └── AppError.js

1. Custom Error Definition

JavaScript

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

2. Repository Layer (Data Access Only)

JavaScript

// repositories/user.repository.js
const mockDb = new Map();

export const userRepository = {
  async findByEmail(email) {
    for (const user of mockDb.values()) {
      if (user.email === email) return user;
    }
    return null;
  },

  async create(userData) {
    const id = `usr_${Date.now()}`;
    const record = { id, ...userData, createdAt: new Date() };
    mockDb.set(id, record);
    return record;
  }
};

3. Service Layer (Business Logic Only)

JavaScript

// services/user.service.js
import { userRepository } from '../repositories/user.repository.js';
import { AppError } from '../errors/AppError.js';

export const userService = {
  async registerUser({ email, name, password }) {
    const existing = await userRepository.findByEmail(email);
    if (existing) {
      throw new AppError('A user with this email already exists', 409);
    }

    // Business rule: scrub or transform before persisting
    const newUser = await userRepository.create({ email, name, password });
    
    // Return sanitized domain model
    const { password: _, ...sanitized } = newUser;
    return sanitized;
  }
};

4. Controller Layer (HTTP Protocol Adaptation)

JavaScript

// controllers/user.controller.js
import { userService } from '../services/user.service.js';

export const userController = {
  async register(req, res, next) {
    try {
      const user = await userService.registerUser(req.body);
      res.status(201).json({ status: 'success', data: { user } });
    } catch (err) {
      next(err); // Hands errors off to centralized error middleware
    }
  }
};

Data Validation & Sanitization with Zod

Never trust client payloads. Validate schema contracts before incoming data reaches services or database layers.

Bash

npm install zod

Validation Middleware Factory

JavaScript

// middleware/validate.js
import { ZodError } from 'zod';

export const validate = (schema) => async (req, res, next) => {
  try {
    // Validates and replaces req properties with cleaned, parsed data
    const parsed = await schema.parseAsync({
      body: req.body,
      query: req.query,
      params: req.params,
    });

    req.body = parsed.body ?? req.body;
    req.query = parsed.query ?? req.query;
    req.params = parsed.params ?? req.params;
    next();
  } catch (err) {
    if (err instanceof ZodError) {
      return res.status(422).json({
        status: 'fail',
        errors: err.errors.map((e) => ({
          field: e.path.join('.'),
          message: e.message,
        })),
      });
    }
    next(err);
  }
};

Defining Schemas and Binding Routes

JavaScript

// schemas/user.schema.js
import { z } from 'zod';

export const registerUserSchema = z.object({
  body: z.object({
    name: z.string().trim().min(2, 'Name must be at least 2 characters'),
    email: z.string().trim().email('Invalid email address').toLowerCase(),
    password: z.string().min(8, 'Password must be at least 8 characters'),
    age: z.coerce.number().int().positive().optional(),
  }),
});

// routes/user.routes.js
import { Router } from 'express';
import { userController } from '../controllers/user.controller.js';
import { validate } from '../middleware/validate.js';
import { registerUserSchema } from '../schemas/user.schema.js';

const router = Router();
router.post('/register', validate(registerUserSchema), userController.register);

export default router;

File Handling: Uploads with Multer

Browsers and clients upload files via multipart/form-data. Because express.json() handles only JSON, processing multi-part binary streams requires dedicated middleware like multer.

Bash

npm install multer

Storage Engines: Disk vs. Memory

  • memoryStorage(): Stores files as raw Buffer instances in RAM (req.file.buffer). Recommended when streaming directly to cloud object storage (AWS S3, Google Cloud Storage, Cloudinary). Large uploads can cause memory spikes.
  • diskStorage(): Streams files directly to the local file system. Keeps memory consumption low, but requires managing temporary files and disk cleanups.

Production Multer Configuration

JavaScript

// config/multer.js
import multer from 'multer';
import path from 'node:path';
import crypto from 'node:crypto';
import { AppError } from '../errors/AppError.js';

// Configure storage destination and collision-resistant file names
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, 'uploads/');
  },
  filename: (req, file, cb) => {
    const uniqueSuffix = crypto.randomBytes(16).toString('hex');
    const ext = path.extname(file.originalname).toLowerCase();
    cb(null, `${file.fieldname}-${Date.now()}-${uniqueSuffix}${ext}`);
  },
});

// Strict MIME type validation
const fileFilter = (req, file, cb) => {
  const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];

  if (allowedMimeTypes.includes(file.mimetype)) {
    cb(null, true);
  } else {
    cb(new AppError('Invalid file format. Only JPEG, PNG, and WebP are allowed.', 400), false);
  }
};

export const upload = multer({
  storage,
  fileFilter,
  limits: {
    fileSize: 5 * 1024 * 1024, // 5 Megabytes maximum
    files: 1,                  // Restrict to a single file per request
  },
});

Route Implementation

JavaScript

// routes/upload.routes.js
import { Router } from 'express';
import { upload } from '../config/multer.js';

const router = Router();

// Single file upload on field 'avatar'
router.post('/avatar', upload.single('avatar'), (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'Please provide a file' });
  }

  // req.file contains metadata
  const { path: diskPath, filename, size, mimetype } = req.file;

  res.status(200).json({
    status: 'success',
    file: { filename, path: diskPath, size, mimetype },
  });
});

// Multiple files upload on field 'documents' (up to 5)
router.post('/documents', upload.array('documents', 5), (req, res) => {
  // req.files is an array of uploaded file objects
  res.status(200).json({
    uploadedCount: req.files.length,
    files: req.files.map((f) => f.filename),
  });
});

export default router;