Building RESTful APIs with Express

Module 22: Web Frameworks I (JavaScript) - Tuesday: Express.js Advanced

Introduction to RESTful APIs

RESTful APIs (Representational State Transfer) provide a standardized approach for creating web services that are scalable, maintainable, and easy to understand. Express makes it remarkably simple to implement RESTful principles in your applications.

graph LR A[Client] -->|HTTP Request| B[Express API] B -->|HTTP Response| A B <-->|CRUD Operations| C[Database] style B fill:#f9d71c,stroke:#333,stroke-width:2px

The Restaurant Analogy

Think of a RESTful API like a restaurant:

  • Resources (like users or products) are the menu items
  • HTTP Methods (GET, POST, PUT, DELETE) are how you place your order
  • Routes are like asking for specific sections of the menu (appetizers, desserts)
  • Responses are what the waiter brings back to your table

Just as a restaurant has a standardized way to take orders and serve dishes, RESTful APIs provide a consistent interface for applications to communicate.

REST Architectural Principles

REST is built on several key principles that guide API design:

Key Principles

Why REST Dominates Web APIs

REST has become the dominant approach for several reasons:

  • Simplicity: Straightforward to implement and understand
  • Scalability: Stateless nature makes horizontal scaling easier
  • Platform Independence: Works with any client technology (web, mobile, IoT)
  • Familiarity: Uses existing HTTP protocols developers already know
  • Evolution: APIs can evolve without breaking existing clients

Major platforms like Twitter, GitHub, Stripe, and countless others use RESTful APIs as their primary interface for developers.

Express for RESTful APIs

Express provides a robust framework for building RESTful APIs with minimal code:

flowchart TD A[Express App] --> B[Router] B --> C1[GET /users] B --> C2[POST /users] B --> C3[GET /users/:id] B --> C4[PUT /users/:id] B --> C5[DELETE /users/:id] C1 --> D[Controller Methods] C2 --> D C3 --> D C4 --> D C5 --> D D --> E[Database Interaction] style A fill:#f9d71c,stroke:#333,stroke-width:2px style B fill:#a1c2ff,stroke:#333,stroke-width:2px

Basic API Structure in Express


const express = require('express');
const app = express();

// Middleware for parsing JSON bodies
app.use(express.json());

// Resources (in a real app, these would come from a database)
let users = [
  { id: 1, name: 'Alice', email: 'alice@example.com' },
  { id: 2, name: 'Bob', email: 'bob@example.com' }
];

// GET all users
app.get('/api/users', (req, res) => {
  res.status(200).json(users);
});

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

// POST new user
app.post('/api/users', (req, res) => {
  const { name, email } = req.body;
  
  if (!name || !email) {
    return res.status(400).json({ message: 'Name and email are required' });
  }
  
  const newUser = {
    id: users.length + 1,
    name,
    email
  };
  
  users.push(newUser);
  res.status(201).json(newUser);
});

// PUT (update) user
app.put('/api/users/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const { name, email } = req.body;
  
  const userIndex = users.findIndex(user => user.id === id);
  
  if (userIndex === -1) {
    return res.status(404).json({ message: 'User not found' });
  }
  
  const updatedUser = {
    ...users[userIndex],
    name: name || users[userIndex].name,
    email: email || users[userIndex].email
  };
  
  users[userIndex] = updatedUser;
  res.status(200).json(updatedUser);
});

// DELETE user
app.delete('/api/users/:id', (req, res) => {
  const id = parseInt(req.params.id);
  
  const userIndex = users.findIndex(user => user.id === id);
  
  if (userIndex === -1) {
    return res.status(404).json({ message: 'User not found' });
  }
  
  users.splice(userIndex, 1);
  res.status(204).send();
});

app.listen(3000, () => {
  console.log('API server running on port 3000');
});
                

This example demonstrates a complete RESTful API with all CRUD operations (Create, Read, Update, Delete) for a user resource.

HTTP Methods and their RESTful Uses

HTTP methods map directly to CRUD operations in a RESTful API:

HTTP Method CRUD Operation Description Example Endpoint Response Code
GET Read Retrieve resources, never modifies data GET /users or GET /users/42 200 OK
POST Create Create new resources POST /users 201 Created
PUT Update Update existing resources (full replacement) PUT /users/42 200 OK
PATCH Update (partial) Partially update existing resources PATCH /users/42 200 OK
DELETE Delete Remove resources DELETE /users/42 204 No Content

Adding PATCH Support for Partial Updates


// PATCH (partial update) user
app.patch('/api/users/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const updates = req.body;
  
  const userIndex = users.findIndex(user => user.id === id);
  
  if (userIndex === -1) {
    return res.status(404).json({ message: 'User not found' });
  }
  
  // Only update the fields provided in the request
  users[userIndex] = { ...users[userIndex], ...updates };
  
  res.status(200).json(users[userIndex]);
});
                

The difference between PUT and PATCH is that PUT replaces the entire resource, while PATCH applies partial modifications. PATCH is particularly useful for large resources where you only need to update a few fields.

URL Structure and Resource Naming

Well-designed resource naming is crucial for creating intuitive and maintainable APIs:

Best Practices

Hierarchical Resources Example


// Get all posts by a specific user
app.get('/api/users/:userId/posts', (req, res) => {
  const userId = parseInt(req.params.userId);
  const userPosts = posts.filter(post => post.userId === userId);
  
  res.status(200).json(userPosts);
});

// Get a specific post by a specific user
app.get('/api/users/:userId/posts/:postId', (req, res) => {
  const userId = parseInt(req.params.userId);
  const postId = parseInt(req.params.postId);
  
  const post = posts.find(p => p.userId === userId && p.id === postId);
  
  if (!post) {
    return res.status(404).json({ message: 'Post not found' });
  }
  
  res.status(200).json(post);
});

// Create a new post for a user
app.post('/api/users/:userId/posts', (req, res) => {
  const userId = parseInt(req.params.userId);
  const { title, content } = req.body;
  
  // Check if user exists
  const userExists = users.some(user => user.id === userId);
  
  if (!userExists) {
    return res.status(404).json({ message: 'User not found' });
  }
  
  if (!title || !content) {
    return res.status(400).json({ message: 'Title and content are required' });
  }
  
  const newPost = {
    id: posts.length + 1,
    userId,
    title,
    content,
    createdAt: new Date().toISOString()
  };
  
  posts.push(newPost);
  res.status(201).json(newPost);
});
                

This example shows how to structure related resources hierarchically, making the relationship between users and posts explicit in the URL.

Real-world API URL Examples

API Provider Example Resource URL What It Represents
GitHub /repos/:owner/:repo/issues All issues in a specific repository
Stripe /v1/customers/:id/sources Payment sources for a specific customer
Twitter /2/users/:id/tweets Tweets from a specific user
Spotify /v1/artists/:id/albums Albums by a specific artist

These real-world examples demonstrate how major API providers structure their resources in a clear, hierarchical way.

Response Formatting and Status Codes

Consistent response formatting and appropriate status codes are essential for creating user-friendly APIs:

Common HTTP Status Codes

Consistent Response Structure


// Utility for consistent success responses
const sendSuccessResponse = (res, statusCode, data, meta = {}) => {
  return res.status(statusCode).json({
    success: true,
    data,
    meta
  });
};

// Utility for consistent error responses
const sendErrorResponse = (res, statusCode, message, errors = null) => {
  const response = {
    success: false,
    error: {
      message
    }
  };
  
  if (errors) {
    response.error.details = errors;
  }
  
  return res.status(statusCode).json(response);
};

// Example usage in routes
app.get('/api/users', (req, res) => {
  try {
    // Add pagination
    const page = parseInt(req.query.page) || 1;
    const limit = parseInt(req.query.limit) || 10;
    const startIndex = (page - 1) * limit;
    const endIndex = page * limit;
    
    const paginatedUsers = users.slice(startIndex, endIndex);
    
    // Meta information for pagination
    const meta = {
      total: users.length,
      page,
      limit,
      pages: Math.ceil(users.length / limit)
    };
    
    // Add links for pagination if needed
    if (page < meta.pages) {
      meta.next = `/api/users?page=${page + 1}&limit=${limit}`;
    }
    
    if (page > 1) {
      meta.prev = `/api/users?page=${page - 1}&limit=${limit}`;
    }
    
    return sendSuccessResponse(res, 200, paginatedUsers, meta);
  } catch (error) {
    return sendErrorResponse(res, 500, 'Server error', error.message);
  }
});

app.post('/api/users', (req, res) => {
  const { name, email } = req.body;
  
  // Basic validation
  const errors = [];
  if (!name) errors.push({ field: 'name', message: 'Name is required' });
  if (!email) errors.push({ field: 'email', message: 'Email is required' });
  
  if (errors.length > 0) {
    return sendErrorResponse(res, 400, 'Validation failed', errors);
  }
  
  try {
    const newUser = {
      id: users.length + 1,
      name,
      email,
      createdAt: new Date().toISOString()
    };
    
    users.push(newUser);
    return sendSuccessResponse(res, 201, newUser);
  } catch (error) {
    return sendErrorResponse(res, 500, 'Server error', error.message);
  }
});
                

This example demonstrates how to create utility functions for consistent response formatting, including meta information for pagination and detailed error messages.

Real-world Response Examples

Let's look at how some APIs structure their responses:

GitHub API (Success Response)


// GET /repos/octocat/hello-world/issues
{
  "total_count": 1,
  "items": [
    {
      "id": 1,
      "number": 1347,
      "title": "Found a bug",
      "state": "open",
      "user": {
        "login": "octocat",
        "id": 1
      },
      "created_at": "2011-04-22T13:33:48Z",
      "updated_at": "2011-04-22T13:33:48Z"
    }
  ]
}
                

Stripe API (Error Response)


{
  "error": {
    "code": "resource_missing",
    "doc_url": "https://stripe.com/docs/error-codes/resource-missing",
    "message": "No such customer: 'cus_nonexistent'",
    "param": "id",
    "type": "invalid_request_error"
  }
}
                

These examples show how professional APIs maintain consistent response structures, which makes integration easier for developers.

Implementing Pagination and Filtering

As your API resources grow, pagination and filtering become essential for performance and usability:

Pagination Implementation


// GET /api/products with pagination and filtering
app.get('/api/products', (req, res) => {
  try {
    let filteredProducts = [...products]; // Clone the array
    
    // Apply filters if provided
    if (req.query.category) {
      filteredProducts = filteredProducts.filter(p => 
        p.category === req.query.category
      );
    }
    
    if (req.query.minPrice) {
      const minPrice = parseFloat(req.query.minPrice);
      filteredProducts = filteredProducts.filter(p => p.price >= minPrice);
    }
    
    if (req.query.maxPrice) {
      const maxPrice = parseFloat(req.query.maxPrice);
      filteredProducts = filteredProducts.filter(p => p.price <= maxPrice);
    }
    
    // Search by name
    if (req.query.search) {
      const searchTerm = req.query.search.toLowerCase();
      filteredProducts = filteredProducts.filter(p => 
        p.name.toLowerCase().includes(searchTerm)
      );
    }
    
    // Apply sorting
    if (req.query.sort) {
      const sortField = req.query.sort;
      const sortOrder = req.query.order === 'desc' ? -1 : 1;
      
      filteredProducts.sort((a, b) => {
        if (a[sortField] < b[sortField]) return -1 * sortOrder;
        if (a[sortField] > b[sortField]) return 1 * sortOrder;
        return 0;
      });
    }
    
    // Pagination
    const page = parseInt(req.query.page) || 1;
    const limit = parseInt(req.query.limit) || 10;
    const startIndex = (page - 1) * limit;
    const endIndex = page * limit;
    
    // Count total items before pagination
    const totalItems = filteredProducts.length;
    
    // Apply pagination
    const paginatedProducts = filteredProducts.slice(startIndex, endIndex);
    
    // Create response with pagination metadata
    const response = {
      data: paginatedProducts,
      meta: {
        total: totalItems,
        page,
        limit,
        pages: Math.ceil(totalItems / limit)
      }
    };
    
    // Add links for navigation
    if (page < response.meta.pages) {
      response.meta.next = `${req.path}?page=${page + 1}&limit=${limit}`;
    }
    
    if (page > 1) {
      response.meta.prev = `${req.path}?page=${page - 1}&limit=${limit}`;
    }
    
    res.status(200).json(response);
  } catch (error) {
    res.status(500).json({ 
      success: false,
      error: {
        message: 'Server error',
        details: error.message
      }
    });
  }
});
                

This example demonstrates a comprehensive API endpoint with pagination, filtering, searching, and sorting capabilities.

Common Query Parameters in Production APIs

Parameter Purpose Example
page, limit Pagination control ?page=2&limit=20
sort, order Sorting results ?sort=price&order=desc
fields Field selection (sparse fieldsets) ?fields=id,name,price
search, q Full text search ?search=wireless
filter[field] Field-specific filtering ?filter[category]=electronics

Companies like GitHub, Shopify, and Stripe all implement these patterns in their APIs to provide flexible data retrieval options.

API Versioning Strategies

API versioning is crucial for evolving your API without breaking existing client applications:

Common Versioning Approaches

URL Path Versioning in Express


// Create separate routers for each version
const express = require('express');
const app = express();

// Import version-specific routers
const v1UserRouter = require('./routes/v1/users');
const v2UserRouter = require('./routes/v2/users');

// Apply version prefixes
app.use('/api/v1/users', v1UserRouter);
app.use('/api/v2/users', v2UserRouter);

// Example v1 router (routes/v1/users.js)
const v1Router = express.Router();

v1Router.get('/', (req, res) => {
  // Old version returns minimal user data
  const basicUserData = users.map(u => ({
    id: u.id,
    name: u.name,
    email: u.email
  }));
  
  res.status(200).json(basicUserData);
});

// Example v2 router (routes/v2/users.js)
const v2Router = express.Router();

v2Router.get('/', (req, res) => {
  // New version returns enhanced user data
  const enhancedUserData = users.map(u => ({
    id: u.id,
    name: u.name,
    email: u.email,
    role: u.role,
    createdAt: u.createdAt,
    lastLogin: u.lastLogin,
    settings: u.settings
  }));
  
  res.status(200).json({
    data: enhancedUserData,
    meta: {
      total: enhancedUserData.length,
      version: 'v2'
    }
  });
});
                

This example demonstrates how to implement URL path versioning by mounting different routers for each API version, allowing you to maintain backward compatibility while evolving your API.

Versioning Strategies of Major APIs

API Provider Versioning Strategy Example
Stripe URL Path Versioning /v1/customers
GitHub Content Negotiation Accept: application/vnd.github.v3+json
Twitter URL Path Versioning /2/users/:id
Salesforce URL Path Versioning /services/data/v53.0/query

Most major API providers maintain multiple versions simultaneously to support their diverse user base.

Documentation and OpenAPI/Swagger

Good documentation is critical for API adoption. OpenAPI (formerly Swagger) provides a standard format for API documentation:

Express with Swagger Documentation


// Install required packages
// npm install swagger-jsdoc swagger-ui-express

const express = require('express');
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerUi = require('swagger-ui-express');

const app = express();
app.use(express.json());

// Swagger definition
const swaggerOptions = {
  definition: {
    openapi: '3.0.0',
    info: {
      title: 'User API',
      version: '1.0.0',
      description: 'A simple Express API for user management'
    },
    servers: [
      {
        url: 'http://localhost:3000'
      }
    ]
  },
  apis: ['./routes/*.js']  // Path to the API routes files
};

const swaggerSpec = swaggerJsdoc(swaggerOptions);
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));

// Example of annotated route for Swagger
/**
 * @swagger
 * /api/users:
 *   get:
 *     summary: Returns a list of users
 *     description: Retrieve a list of all users from the database
 *     parameters:
 *       - in: query
 *         name: page
 *         schema:
 *           type: integer
 *           default: 1
 *         description: Page number for pagination
 *       - in: query
 *         name: limit
 *         schema:
 *           type: integer
 *           default: 10
 *         description: Number of items per page
 *     responses:
 *       200:
 *         description: A list of users
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 data:
 *                   type: array
 *                   items:
 *                     type: object
 *                     properties:
 *                       id:
 *                         type: integer
 *                       name:
 *                         type: string
 *                       email:
 *                         type: string
 *                 meta:
 *                   type: object
 *                   properties:
 *                     total:
 *                       type: integer
 *                     page:
 *                       type: integer
 *                     limit:
 *                       type: integer
 *                     pages:
 *                       type: integer
 */
app.get('/api/users', (req, res) => {
  // Implementation as before...
});

/**
 * @swagger
 * /api/users/{id}:
 *   get:
 *     summary: Get a user by ID
 *     parameters:
 *       - in: path
 *         name: id
 *         required: true
 *         schema:
 *           type: integer
 *         description: User ID
 *     responses:
 *       200:
 *         description: User details
 *       404:
 *         description: User not found
 */
app.get('/api/users/:id', (req, res) => {
  // Implementation as before...
});

// Start the server
app.listen(3000, () => {
  console.log('API documentation available at http://localhost:3000/api-docs');
});
                

This example shows how to integrate Swagger documentation into an Express API using JSDoc comments to describe endpoints, parameters, and responses.

Benefits of API Documentation

  • Reduces Onboarding Time: New developers can understand and use your API faster
  • Decreases Support Burden: Self-service documentation reduces support requests
  • Enables Client Generation: OpenAPI specs can auto-generate client libraries
  • Serves as a Contract: Documentation acts as a clear contract between frontend and backend teams
  • Facilitates Testing: Swagger UI allows direct testing of endpoints

Companies like Stripe, Twilio, and Shopify are known for their excellent API documentation, which has been a key factor in their developer adoption.

Practical Exercise: Building a RESTful API

Let's create a complete e-commerce API with Express. This exercise will help you practice RESTful principles in a real-world scenario.

E-commerce API Exercise

Objective: Create a RESTful API for an e-commerce platform with products, categories, and orders.

Requirements:

  1. Set up an Express project with appropriate middleware
  2. Implement the following resources:
    • Products (CRUD operations)
    • Categories (CRUD operations)
    • Orders (Create, Read, Update status)
  3. Implement the following features:
    • Filtering products by category, price range
    • Sorting products by price, name, or date added
    • Pagination for product listings
    • Consistent response formatting
    • Appropriate HTTP status codes
  4. Add basic validation for all endpoints
  5. Document your API with OpenAPI/Swagger

Project Structure:


e-commerce-api/
├── package.json
├── server.js
├── routes/
│   ├── products.js
│   ├── categories.js
│   └── orders.js
├── controllers/
│   ├── productController.js
│   ├── categoryController.js
│   └── orderController.js
├── models/  // In-memory data for this exercise
│   ├── Product.js
│   ├── Category.js
│   └── Order.js
├── middleware/
│   └── validation.js
└── util/
    └── apiResponse.js
                

Bonus Challenge: Implement filtering products by multiple categories, search functionality, and order details including product items.

Further Resources