Understanding REST Architecture
REST (Representational State Transfer) is an architectural style for designing networked applications. It relies on a stateless, client-server communication protocol, typically HTTP, and treats server objects as resources that can be created, read, updated, or deleted.
Analogy: Think of a RESTful API as a well-organized library. Resources (books) are organized by category (endpoints), each with a unique identifier (URI). You can browse the catalog (GET), add new books (POST), update book information (PUT/PATCH), or remove books (DELETE). The library maintains a consistent system for all these operations, regardless of the type of book, making it intuitive for anyone to use.
Key Principles of REST
- Resource-Based: Everything is a resource, identified by a unique URI
- Stateless: Server doesn't store client state between requests
- Client-Server: Separation of concerns between client and server
- Uniform Interface: Consistent way to interact with resources
- Cacheable: Responses can be cached to improve performance
- Layered System: Client can't tell if it's connected directly to the server
Real-world example: Major platforms like Twitter, GitHub, and Stripe use RESTful APIs to expose their services. For instance, GitHub's API lets you access repositories, issues, and pull requests as resources with URIs like /repos/:owner/:repo or /repos/:owner/:repo/issues.
HTTP Methods in REST
RESTful APIs use HTTP methods to define the operation being performed on resources:
| Method | Operation | Description | Example |
|---|---|---|---|
| GET | Read | Retrieve resources without modifying them | GET /users |
| POST | Create | Create new resources | POST /users |
| PUT | Update | Replace an entire resource | PUT /users/123 |
| PATCH | Update | Partially update a resource | PATCH /users/123 |
| DELETE | Delete | Remove resources | DELETE /users/123 |
HTTP Method Semantics
// GET - Retrieve all users
app.get('/api/users', (req, res) => {
// Implementation to fetch users
res.json(users);
});
// GET - Retrieve a specific user
app.get('/api/users/:id', (req, res) => {
// Implementation to fetch a specific user
const user = findUserById(req.params.id);
res.json(user);
});
// POST - Create a new user
app.post('/api/users', (req, res) => {
// Implementation to create a user
const newUser = createUser(req.body);
res.status(201).json(newUser);
});
// PUT - Replace a user completely
app.put('/api/users/:id', (req, res) => {
// Implementation to replace a user
const updatedUser = replaceUser(req.params.id, req.body);
res.json(updatedUser);
});
// PATCH - Update a user partially
app.patch('/api/users/:id', (req, res) => {
// Implementation to update specific fields
const updatedUser = updateUser(req.params.id, req.body);
res.json(updatedUser);
});
// DELETE - Remove a user
app.delete('/api/users/:id', (req, res) => {
// Implementation to delete a user
deleteUser(req.params.id);
res.status(204).send();
});
PUT vs PATCH
Understanding the difference between PUT and PATCH is important:
- PUT replaces an entire resource. Any fields not included are set to their default values or null.
- PATCH modifies only the specified fields, leaving others unchanged.
// Example: Original user
// { "id": 123, "name": "John", "email": "john@example.com", "role": "user" }
// PUT request - Replaces the entire resource
// Request body: { "name": "John Updated", "email": "john.updated@example.com" }
// Result: { "id": 123, "name": "John Updated", "email": "john.updated@example.com", "role": null }
// PATCH request - Updates only specified fields
// Request body: { "name": "John Updated", "email": "john.updated@example.com" }
// Result: { "id": 123, "name": "John Updated", "email": "john.updated@example.com", "role": "user" }
Resource URI Design
URI (Uniform Resource Identifier) design is crucial for a clean, intuitive API. Follow these principles:
Resource Naming
- Use nouns, not verbs (e.g.,
/usersnot/getUsers) - Use plural nouns for collections (e.g.,
/usersnot/user) - Use concrete names instead of abstract concepts
- Keep resource names lowercase and use hyphens for multi-word resources
// Good URI design
app.get('/api/users', getAllUsers);
app.get('/api/users/:id', getUserById);
app.get('/api/blog-posts', getAllBlogPosts);
app.get('/api/blog-posts/:id/comments', getPostComments);
// Poor URI design - avoid these
app.get('/api/getUsers', getUsers); // Uses verb
app.get('/api/user/:id', getUserById); // Uses singular
app.get('/api/userManagement', manageUsers); // Abstract concept
app.get('/api/blogPosts', getBlogPosts); // Uses camelCase
Hierarchical Relationships
Express nested relationships using hierarchical URIs:
// Parent-child relationships
app.get('/api/users/:userId/orders', getUserOrders);
app.get('/api/users/:userId/orders/:orderId', getUserOrder);
app.post('/api/users/:userId/orders', createUserOrder);
// Alternative: Using query parameters for filtering
app.get('/api/orders?userId=123', getOrdersByUser);
Query Parameters vs. Path Parameters
Use path parameters for identifying resources and query parameters for filtering, sorting, pagination, etc.:
// Path parameters for resource identification
app.get('/api/users/:id', getUserById);
// Query parameters for filtering, sorting, pagination
app.get('/api/users', (req, res) => {
const { role, status, sort, page, limit } = req.query;
// Filter users by role and status
let filteredUsers = users;
if (role) {
filteredUsers = filteredUsers.filter(user => user.role === role);
}
if (status) {
filteredUsers = filteredUsers.filter(user => user.status === status);
}
// Sort users
if (sort) {
const [field, order] = sort.split(':');
filteredUsers.sort((a, b) => {
return order === 'desc'
? b[field].localeCompare(a[field])
: a[field].localeCompare(b[field]);
});
}
// Paginate results
const pageNum = parseInt(page) || 1;
const limitNum = parseInt(limit) || 10;
const startIndex = (pageNum - 1) * limitNum;
const paginatedUsers = filteredUsers.slice(startIndex, startIndex + limitNum);
res.json({
data: paginatedUsers,
meta: {
total: filteredUsers.length,
page: pageNum,
limit: limitNum,
pages: Math.ceil(filteredUsers.length / limitNum)
}
});
});
Analogy: Think of URI design like a postal address system. Path parameters are like the building number and street name, uniquely identifying a location. Query parameters are like additional instructions for the mail carrier (e.g., "Leave package at back door" or "Signature required"). They modify how the delivery happens but don't change the address itself.
HTTP Status Codes
Proper HTTP status codes communicate the result of requests clearly:
Common Status Codes in REST APIs
| Code | Message | Usage |
|---|---|---|
| 200 | OK | Successful request |
| 201 | Created | Resource created successfully |
| 204 | No Content | Successful request with no response body (e.g., DELETE) |
| 400 | Bad Request | Invalid request format or parameters |
| 401 | Unauthorized | Authentication required |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Resource not found |
| 409 | Conflict | Request conflicts with current state (e.g., duplicate entry) |
| 422 | Unprocessable Entity | Validation errors |
| 500 | Internal Server Error | Server-side error |
Using Status Codes in Express
// Successful responses
app.get('/api/users', (req, res) => {
// Success with data
res.status(200).json(users);
});
app.post('/api/users', (req, res) => {
// Resource created
const newUser = createUser(req.body);
res.status(201).json(newUser);
});
app.delete('/api/users/:id', (req, res) => {
// Success, no content to return
deleteUser(req.params.id);
res.status(204).send();
});
// Client error responses
app.post('/api/users', (req, res) => {
// Validation error
if (!req.body.email) {
return res.status(400).json({ error: 'Email is required' });
}
// Email already exists
if (userExists(req.body.email)) {
return res.status(409).json({ error: 'Email already in use' });
}
// Create user...
});
app.get('/api/users/:id', (req, res) => {
const user = findUserById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});
app.put('/api/admin/settings', (req, res) => {
if (!req.user.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
// Update settings...
});
// Server error response
app.get('/api/reports', (req, res) => {
try {
const reports = generateReports();
res.json(reports);
} catch (error) {
console.error('Report generation error:', error);
res.status(500).json({ error: 'Failed to generate reports' });
}
});
Real-world example: The Stripe API uses HTTP status codes extensively to communicate different error conditions. They return 400 errors for invalid parameters, 401 for authentication failures, 402 for payment failures, and 429 for rate limiting. Their detailed error responses make it easy for developers to debug issues and provide clear messages to end users.
Request and Response Formatting
Consistent request and response formats improve API usability and developer experience.
Content Negotiation
Use content negotiation to support multiple formats:
app.get('/api/users/:id', (req, res) => {
const user = findUserById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Check Accept header to determine response format
const acceptHeader = req.get('Accept');
if (acceptHeader === 'application/xml') {
// Convert user to XML and send
res.set('Content-Type', 'application/xml');
res.send(convertToXML(user));
} else {
// Default to JSON
res.json(user);
}
});
Standard Response Structure
Use a consistent structure for all responses:
// Success response
{
"data": [...],
"meta": {
"pagination": {
"page": 1,
"limit": 10,
"total": 100,
"pages": 10
}
}
}
// Error response
{
"error": {
"message": "Resource not found",
"code": "NOT_FOUND",
"details": [...]
}
}
Response Formatting Middleware
// middleware/response-formatter.js
const formatResponse = (req, res, next) => {
// Store the original json method
const originalJson = res.json;
// Override the json method
res.json = function(body) {
// Skip if already formatted
if (body && (body.data || body.error)) {
return originalJson.call(this, body);
}
// Format the response
const formattedBody = {
data: body,
meta: {
timestamp: new Date().toISOString(),
// Include other metadata...
}
};
// Call the original json method with the formatted body
return originalJson.call(this, formattedBody);
};
next();
};
// Use the middleware
app.use(formatResponse);
Error Response Formatting
// middleware/error-handler.js
const errorHandler = (err, req, res, next) => {
// Set default status code
const statusCode = err.statusCode || 500;
const errorCode = err.errorCode || 'INTERNAL_ERROR';
// Log the error
console.error(`Error ${statusCode}: ${err.message}`);
if (statusCode === 500) {
console.error(err.stack);
}
// Format the error response
const errorResponse = {
error: {
message: err.message || 'An unexpected error occurred',
code: errorCode
}
};
// Add validation errors if available
if (err.errors) {
errorResponse.error.details = err.errors;
}
// Include stack trace in development
if (process.env.NODE_ENV === 'development' && err.stack) {
errorResponse.error.stack = err.stack;
}
res.status(statusCode).json(errorResponse);
};
// Add the error handler as the last middleware
app.use(errorHandler);
Analogy: Standardized response formatting is like a well-designed report template in a corporation. No matter which department submits a report, it follows the same structure—making it immediately familiar to anyone who reads it. The reader always knows where to find the summary, detailed data, and conclusions, regardless of the report's content.
HATEOAS and API Discoverability
HATEOAS (Hypermedia as the Engine of Application State) makes APIs self-documenting by including relevant links with responses.
Adding Resource Links
app.get('/api/users/:id', (req, res) => {
const user = findUserById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Add relevant links
const links = {
self: `${req.protocol}://${req.get('host')}/api/users/${user.id}`,
orders: `${req.protocol}://${req.get('host')}/api/users/${user.id}/orders`,
update: `${req.protocol}://${req.get('host')}/api/users/${user.id}`,
delete: `${req.protocol}://${req.get('host')}/api/users/${user.id}`
};
res.json({
data: user,
links
});
});
app.get('/api/users', (req, res) => {
// Pagination logic...
// Add pagination links
const links = {
self: `${req.protocol}://${req.get('host')}/api/users?page=${page}&limit=${limit}`,
first: `${req.protocol}://${req.get('host')}/api/users?page=1&limit=${limit}`,
last: `${req.protocol}://${req.get('host')}/api/users?page=${totalPages}&limit=${limit}`
};
if (page > 1) {
links.prev = `${req.protocol}://${req.get('host')}/api/users?page=${page - 1}&limit=${limit}`;
}
if (page < totalPages) {
links.next = `${req.protocol}://${req.get('host')}/api/users?page=${page + 1}&limit=${limit}`;
}
res.json({
data: users,
meta: {
pagination: { page, limit, total, totalPages }
},
links
});
});
Link Header Approach
An alternative approach is using the Link header:
app.get('/api/users', (req, res) => {
// Pagination logic...
// Set Link headers for pagination
const linkHeader = [];
linkHeader.push(`<${req.protocol}://${req.get('host')}/api/users?page=${page}&limit=${limit}>; rel="self"`);
linkHeader.push(`<${req.protocol}://${req.get('host')}/api/users?page=1&limit=${limit}>; rel="first"`);
linkHeader.push(`<${req.protocol}://${req.get('host')}/api/users?page=${totalPages}&limit=${limit}>; rel="last"`);
if (page > 1) {
linkHeader.push(`<${req.protocol}://${req.get('host')}/api/users?page=${page - 1}&limit=${limit}>; rel="prev"`);
}
if (page < totalPages) {
linkHeader.push(`<${req.protocol}://${req.get('host')}/api/users?page=${page + 1}&limit=${limit}>; rel="next"`);
}
res.set('Link', linkHeader.join(', '));
res.json({ data: users });
});
Real-world example: GitHub's API implements HATEOAS by including navigation links with responses. When you fetch a list of repositories, the response includes URLs for the next, previous, first, and last pages, allowing client applications to navigate the results without having to construct URLs manually. This makes their API more discoverable and self-documenting.
Versioning RESTful APIs
API versioning helps evolve your API without breaking existing clients. There are several approaches:
URI Path Versioning
// Version in the URI path
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// v1Router.js
const express = require('express');
const router = express.Router();
router.get('/users', (req, res) => {
// v1 implementation
res.json(users);
});
module.exports = router;
// v2Router.js
const express = require('express');
const router = express.Router();
router.get('/users', (req, res) => {
// v2 implementation with enhanced data
res.json({
data: users,
meta: { count: users.length }
});
});
module.exports = router;
Query Parameter Versioning
app.get('/api/users', (req, res) => {
const version = req.query.version || '1';
if (version === '1') {
// v1 implementation
return res.json(users);
} else if (version === '2') {
// v2 implementation
return res.json({
data: users,
meta: { count: users.length }
});
} else {
return res.status(400).json({ error: 'Unsupported API version' });
}
});
Header Versioning
app.get('/api/users', (req, res) => {
const version = req.get('Accept-Version') || '1';
if (version === '1') {
// v1 implementation
return res.json(users);
} else if (version === '2') {
// v2 implementation
return res.json({
data: users,
meta: { count: users.length }
});
} else {
return res.status(400).json({ error: 'Unsupported API version' });
}
});
Content Type Versioning
app.get('/api/users', (req, res) => {
const acceptHeader = req.get('Accept');
if (acceptHeader === 'application/vnd.myapi.v1+json') {
// v1 implementation
return res.json(users);
} else if (acceptHeader === 'application/vnd.myapi.v2+json') {
// v2 implementation
return res.json({
data: users,
meta: { count: users.length }
});
} else {
// Default to latest version
return res.json({
data: users,
meta: { count: users.length }
});
}
});
Analogy: API versioning is like product model numbers. When a manufacturer releases a new version of a product with significant changes, they increment the model number rather than changing the original. This allows customers with the older model to continue using it while clearly indicating which version they have. Similarly, API versioning lets clients continue using the API version they're compatible with while allowing new clients to use the latest features.
Express Implementation of a RESTful API
Let's put everything together in a complete Express implementation of a RESTful API:
Project Structure
project/
├── app.js # Main application file
├── routes/
│ ├── index.js # Route aggregator
│ ├── user.routes.js # User routes
│ └── product.routes.js # Product routes
├── controllers/
│ ├── user.controller.js # User controller
│ └── product.controller.js # Product controller
├── models/
│ ├── user.model.js # User model
│ └── product.model.js # Product model
├── middleware/
│ ├── auth.js # Authentication middleware
│ ├── error-handler.js # Error handling middleware
│ └── response-formatter.js # Response formatting middleware
└── config/
└── index.js # Configuration settings
Entry Point (app.js)
// app.js
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
const routes = require('./routes');
const errorHandler = require('./middleware/error-handler');
const responseFormatter = require('./middleware/response-formatter');
const config = require('./config');
// Create Express app
const app = express();
// Connect to database
mongoose.connect(config.dbUri)
.then(() => console.log('Connected to database'))
.catch(err => console.error('Database connection error:', err));
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Add response formatter
app.use(responseFormatter);
// Mount routes
app.use('/api', routes);
// Handle 404 errors
app.use((req, res) => {
res.status(404).json({
error: {
message: 'Resource not found',
code: 'NOT_FOUND'
}
});
});
// Error handling middleware
app.use(errorHandler);
// Start server
const PORT = config.port || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
module.exports = app;
Routes (routes/user.routes.js)
// routes/user.routes.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/user.controller');
const { authenticate, authorize } = require('../middleware/auth');
// Public routes
router.get('/', userController.getAllUsers);
router.get('/:id', userController.getUserById);
// Protected routes
router.post('/', authenticate, userController.createUser);
router.put('/:id', authenticate, authorize('admin'), userController.updateUser);
router.patch('/:id', authenticate, authorize('admin'), userController.updateUserPartially);
router.delete('/:id', authenticate, authorize('admin'), userController.deleteUser);
// Nested routes
router.get('/:id/orders', authenticate, userController.getUserOrders);
module.exports = router;
Controller (controllers/user.controller.js)
// controllers/user.controller.js
const User = require('../models/user.model');
const Order = require('../models/order.model');
const AppError = require('../utils/app-error');
// Helper function to build resource links
const buildUserLinks = (req, userId) => {
const baseUrl = `${req.protocol}://${req.get('host')}/api/users`;
return {
self: `${baseUrl}/${userId}`,
orders: `${baseUrl}/${userId}/orders`,
update: `${baseUrl}/${userId}`,
delete: `${baseUrl}/${userId}`
};
};
exports.getAllUsers = async (req, res, next) => {
try {
// Get query parameters
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const skip = (page - 1) * limit;
// Get users with pagination
const [users, total] = await Promise.all([
User.find().skip(skip).limit(limit),
User.countDocuments()
]);
// Calculate pagination data
const totalPages = Math.ceil(total / limit);
// Build pagination links
const baseUrl = `${req.protocol}://${req.get('host')}/api/users`;
const links = {
self: `${baseUrl}?page=${page}&limit=${limit}`,
first: `${baseUrl}?page=1&limit=${limit}`,
last: `${baseUrl}?page=${totalPages}&limit=${limit}`
};
if (page > 1) {
links.prev = `${baseUrl}?page=${page - 1}&limit=${limit}`;
}
if (page < totalPages) {
links.next = `${baseUrl}?page=${page + 1}&limit=${limit}`;
}
// Send response
res.status(200).json({
data: users,
meta: {
pagination: { page, limit, total, totalPages }
},
links
});
} catch (error) {
next(error);
}
};
exports.getUserById = async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
return next(new AppError('User not found', 404, 'NOT_FOUND'));
}
// Build links
const links = buildUserLinks(req, user.id);
res.status(200).json({
data: user,
links
});
} catch (error) {
next(error);
}
};
exports.createUser = async (req, res, next) => {
try {
const newUser = await User.create(req.body);
// Build links
const links = buildUserLinks(req, newUser.id);
res.status(201).json({
data: newUser,
links
});
} catch (error) {
next(error);
}
};
exports.updateUser = async (req, res, next) => {
try {
const user = await User.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true }
);
if (!user) {
return next(new AppError('User not found', 404, 'NOT_FOUND'));
}
// Build links
const links = buildUserLinks(req, user.id);
res.status(200).json({
data: user,
links
});
} catch (error) {
next(error);
}
};
exports.updateUserPartially = async (req, res, next) => {
try {
const user = await User.findByIdAndUpdate(
req.params.id,
{ $set: req.body },
{ new: true, runValidators: true }
);
if (!user) {
return next(new AppError('User not found', 404, 'NOT_FOUND'));
}
// Build links
const links = buildUserLinks(req, user.id);
res.status(200).json({
data: user,
links
});
} catch (error) {
next(error);
}
};
exports.deleteUser = async (req, res, next) => {
try {
const user = await User.findByIdAndDelete(req.params.id);
if (!user) {
return next(new AppError('User not found', 404, 'NOT_FOUND'));
}
res.status(204).send();
} catch (error) {
next(error);
}
};
exports.getUserOrders = async (req, res, next) => {
try {
// Check if user exists
const user = await User.findById(req.params.id);
if (!user) {
return next(new AppError('User not found', 404, 'NOT_FOUND'));
}
// Get user orders
const orders = await Order.find({ user: req.params.id });
// Build links
const links = {
self: `${req.protocol}://${req.get('host')}/api/users/${req.params.id}/orders`,
user: `${req.protocol}://${req.get('host')}/api/users/${req.params.id}`
};
res.status(200).json({
data: orders,
links
});
} catch (error) {
next(error);
}
};
Real-world example: The Express-based API that powers Airbnb follows these RESTful design principles. Their API uses resource-oriented URLs like /listings/:id and /users/:id/reviews, consistent HTTP methods, appropriate status codes, and includes links to related resources in responses. This approach allows them to handle millions of listings and bookings while providing a consistent, intuitive interface for both web and mobile clients.
Practice Activities
Activity 1: Basic RESTful API
Create a RESTful API for a blog with the following features:
- Resource endpoints for posts and comments
- Proper use of HTTP methods for CRUD operations
- Appropriate status codes for different scenarios
- Query parameters for filtering and pagination
- Consistent response format for success and errors
Test your API using tools like Postman or curl.
Activity 2: Advanced RESTful Features
Enhance your blog API with more advanced RESTful features:
- HATEOAS links in responses
- Versioning with at least two versions of endpoints
- Content negotiation (JSON and XML formats)
- Authentication for protected resources
- Rate limiting for API endpoints
Create a simple client that consumes your API to demonstrate its functionality.
Activity 3: Complete RESTful API Project
Build a complete RESTful API for an e-commerce system with:
- Multiple related resources (products, categories, users, orders)
- Nested resources and relationships
- Full CRUD operations with proper validations
- Search, filter, sort, and pagination capabilities
- Comprehensive error handling
- Authentication and authorization
- API documentation using Swagger or a similar tool
Implement middleware for logging, response formatting, and security.
Key Takeaways
- REST is an architectural style that uses HTTP methods to operate on resources
- RESTful APIs use HTTP methods (GET, POST, PUT, PATCH, DELETE) for CRUD operations
- Resource URIs should use nouns, not verbs, and follow consistent naming conventions
- Use path parameters for resource identification and query parameters for filtering and pagination
- HTTP status codes communicate the result of requests clearly to clients
- Consistent response formatting improves API usability and developer experience
- HATEOAS makes APIs self-documenting by including relevant links with responses
- API versioning helps evolve your API without breaking existing clients
- Express.js provides a flexible framework for implementing RESTful APIs
- Well-designed REST APIs are intuitive, maintainable, and scalable
In the next lecture, we'll explore request validation and sanitization to ensure data integrity in your RESTful APIs.