Introduction to REST
REST (Representational State Transfer) is an architectural style for designing networked applications. It uses simple HTTP requests to make calls between machines, making it ideal for web services.
What is REST?
REST is an architectural style, not a standard or a protocol. It was introduced by Roy Fielding in his 2000 doctoral dissertation and is built around several core principles. RESTful APIs use HTTP requests to perform CRUD operations (Create, Read, Update, Delete) on resources, represented as URLs. These operations are performed using HTTP methods (GET, POST, PUT, DELETE).
The Library Analogy
Think of a REST API like a well-organized library:
- Resources (books) are organized by topic (collection)
- Each book has a unique identifier (book ID)
- HTTP Methods are like the actions you can perform:
- GET: Browse books without changing them
- POST: Add a new book to the collection
- PUT: Replace an existing book with a new edition
- PATCH: Update specific information in a book
- DELETE: Remove a book from the collection
- Status Codes are like the responses from the librarian:
- "Here's the book you requested" (200 OK)
- "That book doesn't exist" (404 Not Found)
- "You're not allowed to access this restricted collection" (403 Forbidden)
- Query Parameters are like specifying additional criteria ("Show me only fiction books published after 2010")
Why Use REST?
- Simplicity: REST is easier to understand and implement than alternatives like SOAP
- Scalability: Stateless nature enables robust scaling
- Flexibility: Supports multiple data formats (JSON, XML, etc.)
- Portability: Can be used by virtually any programming language
- Visibility: Operations are explicit through HTTP methods
- Reliability: Stateless operations are easier to recover in case of failure
- Compatibility: Uses standard HTTP, so works with existing infrastructure
Core REST Principles
REST is built on six core architectural principles that guide its implementation.
1. Resource-Based
Everything is a resource, identified by a unique URL. Resources can be singleton (a specific user) or collections (all users).
Example:
- Collection:
/users - Singleton:
/users/123
2. Stateless
Each request contains all information necessary to complete it. The server doesn't store client state between requests.
Example: Authentication tokens are sent with each request rather than relying on server-side sessions.
3. Uniform Interface
A consistent, standardized way to communicate between clients and servers, including:
- Resource identification in requests
- Resource manipulation through representations
- Self-descriptive messages
- Hypermedia as the engine of application state (HATEOAS)
Example: Using standard HTTP methods consistently across all resources.
4. Client-Server
A clear separation of concerns between client and server, allowing them to evolve independently.
Example: The front-end UI can be completely redesigned without changing the API.
5. Cacheable
Responses must define themselves as cacheable or non-cacheable to prevent clients from reusing stale data.
Example: Using HTTP headers like Cache-Control and ETag.
6. Layered System
The client cannot tell whether it is connected directly to the end server or to an intermediary along the way.
Example: Load balancers, API gateways, and caching layers can be introduced without changing client code.
Resource Design
The foundation of a good RESTful API is a well-thought-out resource model that maps logically to your domain.
What is a Resource?
A resource is any information that can be named: a document, an image, a service, a collection of other resources, or a non-virtual object (like a person). In REST, each resource is identified by a unique URL.
Resource Naming Best Practices
| Good Practice | Bad Practice | Why? |
|---|---|---|
| Use nouns, not verbs | /getUserProfile |
/users/profile |
| Use plural for collections | /user |
/users (consistent with /users/123) |
| Use kebab-case or lowercase | /productCategories |
/product-categories or /productcategories |
| Hierarchical relationships | /authors/books (all books of all authors) |
/authors/{id}/books (books of a specific author) |
| Query parameters for filtering | /users/filter-by-age-and-region |
/users?minAge=25®ion=europe |
| Concrete names over abstract concepts | /items |
/products (if they are products) |
| Consistent naming convention | Mix of /users but /employee |
Stick with plural (/users, /employees) or singular everywhere |
Resource Relationships
Resources often have relationships with other resources. REST APIs handle these relationships through hierarchical URLs.
Example: Blog API Resource Design
/posts- All blog posts/posts/123- A specific post/posts/123/comments- All comments on post 123/posts/123/comments/456- Comment 456 on post 123/users- All users/users/789- User with ID 789/users/789/posts- All posts by user 789/categories- All categories/categories/tech- The "tech" category/categories/tech/posts- Posts in the "tech" category
Nesting Limit
Avoid deep nesting of resources. Instead of /authors/123/books/456/chapters/789/sections/10, consider flattening with query parameters: /sections?chapterId=789 or make the resource standalone /sections/10.
A good rule of thumb is to limit nesting to 2-3 levels deep to maintain readability and usability.
HTTP Methods for CRUD Operations
REST APIs use standard HTTP methods to perform operations on resources. This creates a uniform interface, making your API intuitive to use.
| HTTP Method | CRUD Operation | Collection (/users) |
Resource (/users/123) |
|---|---|---|---|
| GET | Read | List all users | Retrieve user 123 |
| POST | Create | Create a new user | N/A (typically) |
| PUT | Update/Replace | Bulk replace users | Replace user 123 entirely |
| PATCH | Update/Modify | Bulk update users | Partially update user 123 |
| DELETE | Delete | Delete all users | Delete user 123 |
Characteristics of HTTP Methods
| HTTP Method | Safe | Idempotent | Cacheable |
|---|---|---|---|
| GET | Yes | Yes | Yes |
| POST | No | No | Rarely |
| PUT | No | Yes | No |
| PATCH | No | No | No |
| DELETE | No | Yes | No |
Key Terms
- Safe: The operation doesn't change the resource state on the server (read-only).
- Idempotent: Multiple identical requests have the same effect as a single request.
- Cacheable: The response can be cached for future use.
HTTP Method Examples
// GET - Retrieve a list of users
GET /users
// GET - Retrieve a specific user
GET /users/123
// POST - Create a new user
POST /users
{
"name": "John Doe",
"email": "john@example.com",
"role": "admin"
}
// PUT - Replace user 123 completely
PUT /users/123
{
"name": "John Smith",
"email": "john.smith@example.com",
"role": "user"
}
// PATCH - Update specific fields of user 123
PATCH /users/123
{
"email": "updated.email@example.com"
}
// DELETE - Remove user 123
DELETE /users/123
PUT vs. PATCH
The key difference is that PUT replaces the entire resource, whereas PATCH updates only the specified fields.
- Use PUT when the client sends a complete representation of the resource.
- Use PATCH when the client sends a partial update to the resource.
If a client sends a PUT request with only some fields, the unspecified fields should be set to their default values, effectively erasing any previous values for those fields.
HTTP Status Codes
HTTP status codes provide standardized responses that indicate the result of a client's request. Using appropriate status codes helps API consumers understand what happened with their request.
2xx: Success
- 200 OK - Standard success response
- 201 Created - Resource created successfully
- 202 Accepted - Request accepted for processing
- 204 No Content - Success but no content to return
3xx: Redirection
- 301 Moved Permanently - Resource has a new URL
- 302 Found - Temporary redirection
- 304 Not Modified - Resource not modified since last request
4xx: Client Error
- 400 Bad Request - Malformed request syntax
- 401 Unauthorized - Authentication required
- 403 Forbidden - Authenticated but not authorized
- 404 Not Found - Resource doesn't exist
- 405 Method Not Allowed - HTTP method not supported
- 409 Conflict - Request conflicts with server state
- 422 Unprocessable Entity - Validation errors
- 429 Too Many Requests - Rate limit exceeded
5xx: Server Error
- 500 Internal Server Error - Unexpected server error
- 501 Not Implemented - Functionality not supported
- 502 Bad Gateway - Invalid response from upstream server
- 503 Service Unavailable - Server temporarily unavailable
- 504 Gateway Timeout - Upstream server timeout
Status Code Usage Examples
// 200 OK - Successful GET request
app.get('/users', (req, res) => {
const users = fetchUsers();
res.status(200).json(users);
});
// 201 Created - Successful resource creation
app.post('/users', (req, res) => {
const newUser = createUser(req.body);
res.status(201).json(newUser);
});
// 204 No Content - Successful request with no response body
app.delete('/users/:id', (req, res) => {
deleteUser(req.params.id);
res.status(204).end();
});
// 400 Bad Request - Invalid input
app.post('/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({
error: 'Bad Request',
message: 'Name and email are required fields'
});
}
// Process valid request...
});
// 401 Unauthorized - Missing authentication
app.get('/protected-resource', (req, res) => {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Authentication required'
});
}
// Process authenticated request...
});
// 403 Forbidden - Authenticated but not authorized
app.delete('/users/:id', (req, res) => {
const user = getCurrentUser(req);
const targetUser = getUserById(req.params.id);
if (user.role !== 'admin' && user.id !== targetUser.id) {
return res.status(403).json({
error: 'Forbidden',
message: 'You do not have permission to delete this user'
});
}
// Process authorized request...
});
// 404 Not Found - Resource doesn't exist
app.get('/users/:id', (req, res) => {
const user = getUserById(req.params.id);
if (!user) {
return res.status(404).json({
error: 'Not Found',
message: `User with ID ${req.params.id} not found`
});
}
res.json(user);
});
// 500 Internal Server Error - Unexpected error
app.get('/users', (req, res) => {
try {
const users = fetchUsers();
res.json(users);
} catch (error) {
console.error('Error fetching users:', error);
res.status(500).json({
error: 'Internal Server Error',
message: 'An unexpected error occurred'
});
}
});
Error Response Structure
It's a good practice to have a consistent error response structure:
{
"error": "Not Found", // Error type or title
"message": "User with ID 123 not found", // Human-readable message
"code": "RESOURCE_NOT_FOUND", // Optional application error code
"details": [...], // Optional additional information
"timestamp": "2025-05-04T12:34:56.789Z", // When the error occurred
"path": "/api/users/123" // The requested endpoint
}
Query Parameters, Filtering, and Pagination
Query parameters allow clients to modify the behavior of a request without changing the endpoint. They're commonly used for filtering, sorting, and pagination.
Filtering
Use query parameters to filter collections based on specific criteria.
// Basic filtering examples
GET /users?status=active
GET /products?category=electronics
GET /orders?customerId=123
GET /events?date=2025-05-01
// Multiple filters
GET /users?status=active&role=admin
GET /products?minPrice=10&maxPrice=50
// Advanced filtering
GET /users?name_contains=john
GET /products?createdAt_gte=2025-01-01
GET /logs?level_in=error,warning
Sorting
Allow clients to sort results by specific fields in ascending or descending order.
// Basic sorting
GET /users?sort=lastName
GET /products?sort=price
// Sort direction (ascending/descending)
GET /users?sort=lastName_desc
GET /orders?sort=createdAt_desc
// Multiple sort criteria
GET /users?sort=lastName_asc,firstName_asc
GET /products?sort=category_asc,price_desc
Pagination
Implement pagination to limit the number of results returned and improve performance for large data sets.
// Offset-based pagination
GET /users?limit=20&offset=0 // First page (0-19)
GET /users?limit=20&offset=20 // Second page (20-39)
// Page-based pagination
GET /users?page=1&perPage=20 // First page
GET /users?page=2&perPage=20 // Second page
// Cursor-based pagination
GET /posts?limit=20&afterId=abc123 // Get 20 posts after ID abc123
Pagination Response Example
Include pagination metadata in responses to help clients navigate through pages:
{
"data": [
{ "id": 1, "name": "John Doe", "email": "john@example.com" },
{ "id": 2, "name": "Jane Smith", "email": "jane@example.com" },
// ... more items
],
"pagination": {
"totalItems": 43,
"totalPages": 5,
"currentPage": 1,
"perPage": 10,
"hasNextPage": true,
"hasPrevPage": false
},
"links": {
"self": "/api/users?page=1&perPage=10",
"first": "/api/users?page=1&perPage=10",
"last": "/api/users?page=5&perPage=10",
"next": "/api/users?page=2&perPage=10",
"prev": null
}
}
Search
Implement search functionality to allow clients to find resources by specific terms.
// Basic search
GET /users?search=john
GET /products?search=wireless+headphones
// Field-specific search
GET /users?search=john&searchFields=name,email
GET /products?search=wireless&searchFields=name,description
Query Parameter Best Practices
- Use standardized naming: Keep parameter names consistent across endpoints
- Document all parameters: Make it clear what each parameter does and what values it accepts
- Provide defaults: Define sensible defaults for optional parameters
- Be flexible with formats: Accept various formats for dates, booleans, etc. (e.g., true/1/yes)
- Validate inputs: Ensure parameter values are valid and secure
- Return error messages: Provide clear error messages for invalid parameters
Response Formats and Content Negotiation
REST APIs can return resources in different formats to meet client requirements. Content negotiation allows clients to specify their preferred format.
Common Response Formats
- JSON (JavaScript Object Notation) - Most common format for RESTful APIs
- XML (eXtensible Markup Language) - More verbose but supports more complex structures
- CSV (Comma-Separated Values) - Simple format for tabular data
- HTML - For browser-friendly rendering of resources
- Plain Text - For simple string responses
Content Negotiation
Content negotiation is the process of selecting the best representation of a resource based on client preferences. It's typically done through the HTTP Accept header.
// Client requests JSON response
GET /users/123
Accept: application/json
// Client requests XML response
GET /users/123
Accept: application/xml
Implementing Content Negotiation with Express
// Using res.format() for content negotiation
app.get('/api/users/:id', (req, res) => {
const userId = req.params.id;
const user = getUserById(userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.format({
'application/json': () => {
res.json(user);
},
'application/xml': () => {
const xml = convertToXml(user);
res.type('application/xml').send(xml);
},
'text/csv': () => {
const csv = convertToCsv(user);
res.type('text/csv').send(csv);
},
'text/html': () => {
res.render('user', { user });
},
default: () => {
// Default to JSON
res.json(user);
}
});
});
JSON Response Structure
Design consistent, intuitive response structures for your API:
// Single resource response
{
"id": 123,
"name": "John Doe",
"email": "john@example.com",
"role": "admin",
"createdAt": "2025-01-01T12:00:00Z",
"updatedAt": "2025-05-04T15:30:00Z"
}
// Collection response
{
"data": [
{ "id": 1, "name": "John Doe", "email": "john@example.com" },
{ "id": 2, "name": "Jane Smith", "email": "jane@example.com" },
// ... more items
],
"meta": {
"totalItems": 43,
"totalPages": 5,
"currentPage": 1,
"perPage": 10
},
"links": {
"self": "/api/users?page=1&perPage=10",
"next": "/api/users?page=2&perPage=10"
}
}
// Error response
{
"error": "Not Found",
"message": "User with ID 123 not found",
"timestamp": "2025-05-04T15:30:00Z",
"path": "/api/users/123"
}
Response Envelope
Consider whether to use a response envelope (a wrapper object around your data) for consistency:
With envelope:
{
"success": true,
"data": { "id": 123, "name": "John Doe", ... },
"message": null,
"timestamp": "2025-05-04T15:30:00Z"
}
Without envelope (direct):
{ "id": 123, "name": "John Doe", ... }
Envelopes add consistency but increase response size. Choose based on your API's needs.
Versioning REST APIs
API versioning allows you to evolve your API without breaking existing client applications. There are several approaches to versioning, each with pros and cons.
Versioning Strategies
| Approach | Example | Pros | Cons |
|---|---|---|---|
| URL Path | /v1/users |
|
|
| Query Parameter | /users?version=1 |
|
|
| HTTP Header | Accept: application/vnd.company.v1+json |
|
|
| Custom Header | X-API-Version: 1 |
|
|
| Domain | api.v1.example.com/users |
|
|
Implementing Versioning in Express
// URL Path Versioning
const express = require('express');
const app = express();
// v1 routes
const v1UserRouter = require('./routes/v1/users');
app.use('/v1/users', v1UserRouter);
// v2 routes
const v2UserRouter = require('./routes/v2/users');
app.use('/v2/users', v2UserRouter);
// Query Parameter Versioning
app.get('/users', (req, res) => {
const version = req.query.version || '1'; // Default to v1
if (version === '1') {
return handleV1Request(req, res);
} else if (version === '2') {
return handleV2Request(req, res);
} else {
return res.status(400).json({ error: 'Unsupported API version' });
}
});
// HTTP Header Versioning (Accept header)
app.get('/users', (req, res) => {
const acceptHeader = req.get('Accept');
if (acceptHeader.includes('application/vnd.company.v1+json')) {
return handleV1Request(req, res);
} else if (acceptHeader.includes('application/vnd.company.v2+json')) {
return handleV2Request(req, res);
} else {
// Default to v1
return handleV1Request(req, res);
}
});
// Custom Header Versioning
app.get('/users', (req, res) => {
const apiVersion = req.get('X-API-Version') || '1'; // Default to v1
if (apiVersion === '1') {
return handleV1Request(req, res);
} else if (apiVersion === '2') {
return handleV2Request(req, res);
} else {
return res.status(400).json({ error: 'Unsupported API version' });
}
});
Versioning Best Practices
- Start with a version (even v1) from the beginning
- Make breaking changes in new versions, not existing ones
- Document changes between versions clearly
- Maintain older versions for a reasonable period
- Consider a deprecation policy with clear timelines
- Design with backward compatibility in mind to minimize the need for new versions
HATEOAS and API Discoverability
HATEOAS (Hypermedia as the Engine of Application State) is a constraint of REST that makes APIs self-documenting and more discoverable by including links to related resources.
What is HATEOAS?
HATEOAS allows clients to navigate API endpoints dynamically through hypermedia links provided in responses, rather than requiring knowledge of API structure upfront. This makes the API more self-documenting and flexible to changes.
HATEOAS Example
// Response without HATEOAS
{
"id": 123,
"name": "John Doe",
"email": "john@example.com",
"role": "admin"
}
// Response with HATEOAS
{
"id": 123,
"name": "John Doe",
"email": "john@example.com",
"role": "admin",
"_links": {
"self": { "href": "/api/users/123" },
"profile": { "href": "/api/users/123/profile" },
"orders": { "href": "/api/users/123/orders" },
"edit": { "href": "/api/users/123", "method": "PUT" },
"delete": { "href": "/api/users/123", "method": "DELETE" }
}
}
Benefits of HATEOAS
- Self-documenting: Clients can discover available actions
- Loose coupling: Clients don't need to hardcode API structure
- Evolvability: API can change without breaking clients
- State-driven UIs: Clients can adapt UI based on available actions
- Explorable APIs: Makes manual exploration easier
Implementing HATEOAS in Express
// Helper function to generate links
function generateUserLinks(user) {
return {
self: { href: `/api/users/${user.id}` },
profile: { href: `/api/users/${user.id}/profile` },
orders: { href: `/api/users/${user.id}/orders` },
// Include conditional links based on user state
...(user.isActive ? {
deactivate: { href: `/api/users/${user.id}/deactivate`, method: 'POST' }
} : {
activate: { href: `/api/users/${user.id}/activate`, method: 'POST' }
})
};
}
// Get a single user with HATEOAS links
app.get('/api/users/:id', (req, res) => {
const user = getUserById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json({
...user,
_links: generateUserLinks(user)
});
});
// Get a collection of users with HATEOAS links
app.get('/api/users', (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const users = getUsers(page, limit);
const totalUsers = countUsers();
const totalPages = Math.ceil(totalUsers / limit);
res.json({
data: users.map(user => ({
...user,
_links: generateUserLinks(user)
})),
_links: {
self: { href: `/api/users?page=${page}&limit=${limit}` },
first: { href: `/api/users?page=1&limit=${limit}` },
last: { href: `/api/users?page=${totalPages}&limit=${limit}` },
...(page > 1 ? { prev: { href: `/api/users?page=${page-1}&limit=${limit}` } } : {}),
...(page < totalPages ? { next: { href: `/api/users?page=${page+1}&limit=${limit}` } } : {})
},
meta: {
currentPage: page,
itemsPerPage: limit,
totalItems: totalUsers,
totalPages: totalPages
}
});
});
HATEOAS Design Tips
- Use consistent link relations: Standardize names like "self", "next", "prev", etc.
- Include HTTP methods: Helps clients understand how to interact with links
- Group links: Use a dedicated section like "_links" for all hypermedia controls
- Only show available actions: Links should reflect the current state and user permissions
- Consider using standards like HAL (Hypertext Application Language) or JSON:API
API Documentation
Good documentation is crucial for API adoption and proper usage. It should be clear, complete, and easy to navigate.
Documentation Components
- Overview: Introduction, purpose, and general concepts
- Authentication: How to authenticate requests
- Endpoints: List of all available endpoints
- Request formats: Required headers, parameters, and body structures
- Response formats: Expected response structures and status codes
- Error handling: Error formats and common error scenarios
- Examples: Sample requests and responses
- Rate limiting: Any usage limits and how they're enforced
- Versioning: How versioning works and what's changed between versions
API Documentation Tools
| Tool | Description | Best For |
|---|---|---|
| Swagger UI / OpenAPI | Interactive documentation based on the OpenAPI specification | Comprehensive, interactive API docs |
| ReDoc | Responsive, easy-to-read documentation for OpenAPI specs | Clean, modern documentation for complex APIs |
| API Blueprint | Markdown-based documentation format | Simple APIs with minimal setup |
| JSDoc | Documentation generator for JavaScript | Code-first approach for JS/Node.js APIs |
| Docusaurus | Documentation website generator by Facebook | Building full documentation sites |
| Postman | API client with documentation capabilities | Documentation with built-in testing |
Using Swagger/OpenAPI in Express
Swagger is one of the most popular tools for API documentation. Here's how to set it up with Express:
// 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();
// Swagger configuration
const swaggerOptions = {
definition: {
openapi: '3.0.0',
info: {
title: 'User Management API',
version: '1.0.0',
description: 'A REST API for managing users',
contact: {
name: 'API Support',
email: 'support@example.com'
}
},
servers: [
{
url: 'http://localhost:3000',
description: 'Development server'
},
{
url: 'https://api.example.com',
description: 'Production server'
}
]
},
apis: ['./routes/*.js'] // Path to the API docs
};
const swaggerDocs = swaggerJsdoc(swaggerOptions);
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocs));
// Example of documented route in routes/users.js:
/**
* @swagger
* /api/users:
* get:
* summary: Returns a list of users
* description: Retrieves a list of users with optional pagination
* parameters:
* - in: query
* name: page
* schema:
* type: integer
* default: 1
* description: Page number
* - 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
* example: 1
* name:
* type: string
* example: John Doe
* email:
* type: string
* example: john@example.com
* meta:
* type: object
* properties:
* totalItems:
* type: integer
* example: 50
* totalPages:
* type: integer
* example: 5
*/
app.get('/api/users', (req, res) => {
// Implementation
});
Documentation Best Practices
- Keep it updated: Documentation should evolve with your API
- Include real examples: Show actual request/response examples
- Provide context: Explain why and when to use specific endpoints
- Document errors: Cover all possible error scenarios
- Use clear language: Avoid jargon and be concise
- Test your docs: Ensure examples actually work
- Include authentication details: Make it clear how to authenticate
- Document rate limits: Explain any usage limitations
Best Practices for RESTful API Design
Here are some key best practices to follow when designing RESTful APIs:
Use HTTPS
Always use HTTPS to secure data in transit. This protects sensitive information and maintains user privacy.
Implement Proper Authentication
Use industry-standard authentication methods like OAuth 2.0, JWT, or API keys depending on your security requirements.
Validate All Input
Always validate, sanitize, and escape user input to prevent security vulnerabilities like SQL injection and XSS.
Use Rate Limiting
Implement rate limiting to protect your API from abuse, denial-of-service attacks, and to ensure fair usage.
Return Appropriate Status Codes
Use the right HTTP status codes to indicate success, client errors, and server errors to help clients understand what happened.
Provide Meaningful Error Messages
Error responses should include useful information to help clients diagnose and fix issues.
Keep URLs Simple and Readable
Create intuitive, consistent URL patterns that users can understand and predict.
Enable CORS as Needed
Configure Cross-Origin Resource Sharing (CORS) according to your security requirements.
Implement Caching
Use HTTP caching headers to improve performance and reduce server load for appropriate endpoints.
Enforce Pagination
Always paginate list endpoints to limit response size and improve performance.
Log API Activity
Keep detailed logs of API usage for debugging, monitoring, and security analysis.
Support Partial Updates
Use PATCH for partial updates rather than requiring the client to send the full resource.
Version Your API
Implement versioning from the start to allow for future changes without breaking existing clients.
Create Comprehensive Documentation
Document your API thoroughly with examples, explanations, and interactive tools.
Respond with JSON by Default
Use JSON as your default response format unless there's a specific reason not to.
Use Nouns, Not Verbs in URLs
Let the HTTP methods define the action, not the URL (e.g., POST /articles not POST /createArticle).
Practical Exercise
Exercise: Build a RESTful CRUD API for a Book Collection
Create a RESTful API for managing a collection of books. The API should follow REST principles and include all CRUD operations.
Step 1: Setup Project
mkdir book-api
cd book-api
npm init -y
npm install express morgan cors
Step 2: Create the Main Application File
Create a file named app.js:
const express = require('express');
const morgan = require('morgan');
const cors = require('cors');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
// In-memory data store
let books = [
{
id: '1',
title: 'To Kill a Mockingbird',
author: 'Harper Lee',
genre: 'Fiction',
publishedYear: 1960,
isbn: '978-0446310789',
available: true
},
{
id: '2',
title: '1984',
author: 'George Orwell',
genre: 'Science Fiction',
publishedYear: 1949,
isbn: '978-0451524935',
available: true
},
{
id: '3',
title: 'The Great Gatsby',
author: 'F. Scott Fitzgerald',
genre: 'Fiction',
publishedYear: 1925,
isbn: '978-0743273565',
available: false
}
];
// Generate a new ID for books
function generateId() {
return Date.now().toString();
}
// Root route
app.get('/', (req, res) => {
res.json({
message: 'Welcome to the Book Collection API',
endpoints: {
books: '/api/books',
bookById: '/api/books/{id}'
}
});
});
// GET all books with optional filtering and pagination
app.get('/api/books', (req, res) => {
// Extract query parameters for filtering
const {
genre,
author,
available,
minYear,
maxYear,
sort,
page = 1,
limit = 10
} = req.query;
// Start with all books
let filteredBooks = [...books];
// Apply filters if provided
if (genre) {
filteredBooks = filteredBooks.filter(book =>
book.genre.toLowerCase() === genre.toLowerCase()
);
}
if (author) {
filteredBooks = filteredBooks.filter(book =>
book.author.toLowerCase().includes(author.toLowerCase())
);
}
if (available !== undefined) {
const isAvailable = available === 'true';
filteredBooks = filteredBooks.filter(book => book.available === isAvailable);
}
if (minYear) {
filteredBooks = filteredBooks.filter(book =>
book.publishedYear >= parseInt(minYear)
);
}
if (maxYear) {
filteredBooks = filteredBooks.filter(book =>
book.publishedYear <= parseInt(maxYear)
);
}
// Apply sorting
if (sort) {
const [field, direction] = sort.split('_');
const isDesc = direction === 'desc';
filteredBooks.sort((a, b) => {
if (a[field] < b[field]) return isDesc ? 1 : -1;
if (a[field] > b[field]) return isDesc ? -1 : 1;
return 0;
});
}
// Calculate pagination
const startIndex = (parseInt(page) - 1) * parseInt(limit);
const endIndex = startIndex + parseInt(limit);
const paginatedBooks = filteredBooks.slice(startIndex, endIndex);
// Prepare pagination links
const totalBooks = filteredBooks.length;
const totalPages = Math.ceil(totalBooks / parseInt(limit));
const currentPage = parseInt(page);
// Build base URL for pagination links (preserving filters)
const baseUrl = '/api/books?';
const queryParams = new URLSearchParams(req.query);
// Create pagination metadata and links
const paginationMeta = {
totalItems: totalBooks,
itemsPerPage: parseInt(limit),
currentPage: currentPage,
totalPages: totalPages
};
const links = {
self: `${baseUrl}${queryParams.toString()}`
};
if (currentPage < totalPages) {
queryParams.set('page', currentPage + 1);
links.next = `${baseUrl}${queryParams.toString()}`;
}
if (currentPage > 1) {
queryParams.set('page', currentPage - 1);
links.prev = `${baseUrl}${queryParams.toString()}`;
}
queryParams.set('page', 1);
links.first = `${baseUrl}${queryParams.toString()}`;
queryParams.set('page', totalPages);
links.last = `${baseUrl}${queryParams.toString()}`;
// Enhance books with HATEOAS links
const booksWithLinks = paginatedBooks.map(book => ({
...book,
_links: {
self: { href: `/api/books/${book.id}` },
update: { href: `/api/books/${book.id}`, method: 'PUT' },
partialUpdate: { href: `/api/books/${book.id}`, method: 'PATCH' },
delete: { href: `/api/books/${book.id}`, method: 'DELETE' }
}
}));
res.json({
data: booksWithLinks,
_meta: paginationMeta,
_links: links
});
});
// GET a specific book by ID
app.get('/api/books/:id', (req, res) => {
const book = books.find(b => b.id === req.params.id);
if (!book) {
return res.status(404).json({
error: 'Not Found',
message: `Book with ID ${req.params.id} not found`,
timestamp: new Date().toISOString(),
path: `/api/books/${req.params.id}`
});
}
// Add HATEOAS links
const bookWithLinks = {
...book,
_links: {
self: { href: `/api/books/${book.id}` },
collection: { href: '/api/books' },
update: { href: `/api/books/${book.id}`, method: 'PUT' },
partialUpdate: { href: `/api/books/${book.id}`, method: 'PATCH' },
delete: { href: `/api/books/${book.id}`, method: 'DELETE' }
}
};
res.json(bookWithLinks);
});
// POST a new book
app.post('/api/books', (req, res) => {
const { title, author, genre, publishedYear, isbn } = req.body;
// Validate required fields
if (!title || !author) {
return res.status(400).json({
error: 'Bad Request',
message: 'Title and author are required fields',
timestamp: new Date().toISOString(),
path: '/api/books'
});
}
// Create new book
const newBook = {
id: generateId(),
title,
author,
genre: genre || 'Uncategorized',
publishedYear: publishedYear ? parseInt(publishedYear) : null,
isbn: isbn || null,
available: true, // Default to available
createdAt: new Date().toISOString()
};
// Add to collection
books.push(newBook);
// Add HATEOAS links
const bookWithLinks = {
...newBook,
_links: {
self: { href: `/api/books/${newBook.id}` },
collection: { href: '/api/books' }
}
};
res.status(201).json(bookWithLinks);
});
// PUT (replace) a book
app.put('/api/books/:id', (req, res) => {
const { title, author, genre, publishedYear, isbn, available } = req.body;
// Validate required fields
if (!title || !author) {
return res.status(400).json({
error: 'Bad Request',
message: 'Title and author are required fields',
timestamp: new Date().toISOString(),
path: `/api/books/${req.params.id}`
});
}
// Check if book exists
const index = books.findIndex(b => b.id === req.params.id);
if (index === -1) {
return res.status(404).json({
error: 'Not Found',
message: `Book with ID ${req.params.id} not found`,
timestamp: new Date().toISOString(),
path: `/api/books/${req.params.id}`
});
}
// Create updated book (complete replacement)
const updatedBook = {
id: req.params.id,
title,
author,
genre: genre || 'Uncategorized',
publishedYear: publishedYear ? parseInt(publishedYear) : null,
isbn: isbn || null,
available: available !== undefined ? Boolean(available) : true,
updatedAt: new Date().toISOString()
};
// Update collection
books[index] = updatedBook;
// Add HATEOAS links
const bookWithLinks = {
...updatedBook,
_links: {
self: { href: `/api/books/${updatedBook.id}` },
collection: { href: '/api/books' }
}
};
res.json(bookWithLinks);
});
// PATCH (partially update) a book
app.patch('/api/books/:id', (req, res) => {
// Check if book exists
const index = books.findIndex(b => b.id === req.params.id);
if (index === -1) {
return res.status(404).json({
error: 'Not Found',
message: `Book with ID ${req.params.id} not found`,
timestamp: new Date().toISOString(),
path: `/api/books/${req.params.id}`
});
}
// Get the existing book
const existingBook = books[index];
// Update only the fields that are provided
const updatedBook = {
...existingBook,
...req.body,
id: req.params.id, // Ensure ID doesn't change
updatedAt: new Date().toISOString()
};
// Convert numeric fields if they were provided
if (req.body.publishedYear) {
updatedBook.publishedYear = parseInt(req.body.publishedYear);
}
// Convert boolean fields if they were provided
if (req.body.available !== undefined) {
updatedBook.available = Boolean(req.body.available);
}
// Update collection
books[index] = updatedBook;
// Add HATEOAS links
const bookWithLinks = {
...updatedBook,
_links: {
self: { href: `/api/books/${updatedBook.id}` },
collection: { href: '/api/books' }
}
};
res.json(bookWithLinks);
});
// DELETE a book
app.delete('/api/books/:id', (req, res) => {
// Check if book exists
const index = books.findIndex(b => b.id === req.params.id);
if (index === -1) {
return res.status(404).json({
error: 'Not Found',
message: `Book with ID ${req.params.id} not found`,
timestamp: new Date().toISOString(),
path: `/api/books/${req.params.id}`
});
}
// Remove from collection
books.splice(index, 1);
// Return 204 No Content
res.status(204).end();
});
// Handle 404 - Route not found
app.use((req, res) => {
res.status(404).json({
error: 'Not Found',
message: `Route ${req.originalUrl} not found`,
timestamp: new Date().toISOString(),
path: req.originalUrl
});
});
// Global error handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: 'Internal Server Error',
message: 'Something went wrong on the server',
timestamp: new Date().toISOString(),
path: req.originalUrl
});
});
// Start the server
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
module.exports = app;
Step 3: Test Your API
- Start your server:
node app.js - Test the various endpoints using a tool like Postman, curl, or a web browser:
- Get all books:
GET http://localhost:3000/api/books - Get books with filters:
GET http://localhost:3000/api/books?genre=Fiction&minYear=1950 - Get a specific book:
GET http://localhost:3000/api/books/1 - Create a new book:
POST http://localhost:3000/api/bookswith a JSON body:{ "title": "The Hobbit", "author": "J.R.R. Tolkien", "genre": "Fantasy", "publishedYear": 1937, "isbn": "978-0547928227" } - Update a book completely:
PUT http://localhost:3000/api/books/1with a JSON body containing all fields - Update a book partially:
PATCH http://localhost:3000/api/books/1with a JSON body containing only the fields to update:{ "available": false } - Delete a book:
DELETE http://localhost:3000/api/books/3
- Get all books:
Bonus Challenges
- Implement Authentication: Add JWT authentication to protect write operations
- Add Swagger Documentation: Implement OpenAPI/Swagger documentation for the API
- Implement Caching: Add HTTP caching headers for read operations
- Add Validation: Use a validation library like Joi or express-validator for more robust input validation
- Write Tests: Create unit and integration tests for your API endpoints
Summary
- REST is an architectural style for designing networked applications using HTTP methods to perform operations on resources
- Core REST Principles include being resource-based, stateless, having a uniform interface, client-server separation, cacheability, and using a layered system
- Resource Design focuses on using nouns, appropriate naming conventions, and representing collections and items properly
- HTTP Methods (GET, POST, PUT, PATCH, DELETE) map to CRUD operations and have different characteristics regarding safety, idempotence, and cacheability
- HTTP Status Codes standardize responses to indicate success, redirect, client error, or server error conditions
- Query Parameters are used for filtering, sorting, pagination, and searching resources
- Response Formats should be consistent, with JSON being the most common format, and content negotiation allowing for different formats
- API Versioning enables evolution of your API without breaking existing clients
- HATEOAS adds hypermedia controls to responses, making APIs more self-documenting and discoverable
- Documentation is crucial for API adoption and proper usage, with tools like Swagger/OpenAPI being popular choices
Further Reading
- REST APIs must be hypertext-driven by Roy Fielding
- OpenAPI Specification
- Fielding's Dissertation on REST
- Richardson Maturity Model by Martin Fowler
- REST API Design Rulebook by Mark Masse
Next Lesson Preview
In our next session, we'll dive into HTTP methods and status codes in more detail. We'll explore the nuances of each HTTP method, when to use them appropriately, and how to handle responses with the correct status codes. You'll learn best practices for implementing RESTful APIs that communicate effectively with clients and handle errors gracefully.