REST Constraints and Resource Naming
REST Constraints and Resource Naming
REST Architectural Constraints
REST (Representational State Transfer) defines six constraints that form the foundation of well-designed APIs:
- Client-Server Separation: The client handles the UI while the server manages data storage. They evolve independently as long as the interface stays stable.
- Stateless: Every request from the client must contain all the information the server needs to fulfill it. The server stores no client context between requests.
- Cacheable: Responses must declare whether they can be cached. Proper caching reduces server load and improves client performance.
- Uniform Interface: Resources are identified by URIs, and clients interact with resources through standard HTTP methods. This is what makes REST "RESTful."
- Layered System: Clients cannot tell whether they are connected directly to the server or through intermediaries like load balancers or CDNs.
- Code on Demand (optional): Servers can temporarily extend client functionality by transferring executable code.
Resource Naming Conventions
Resources are nouns, not verbs. Use plural nouns for collections and nest only one level deep:
# Collection and member resources
GET /api/users # List all users
POST /api/users # Create a new user
GET /api/users/:id # Get a specific user
PUT /api/users/:id # Replace a user entirely
PATCH /api/users/:id # Partially update a user
DELETE /api/users/:id # Delete a user
# Nested resources (one level deep max)
GET /api/users/:id/posts # Get posts by a specific user
POST /api/users/:id/posts # Create a post for a user
# Sub-resources
GET /api/posts/:id/comments # List comments on a post
POST /api/posts/:id/comments # Add a comment to a post
DELETE /api/posts/:id/comments/:cid # Delete a specific comment
# Action endpoints (use when RESTful mapping is awkward)
POST /api/posts/:id/publish # Publish a draft post
POST /api/users/:id/follow # Follow a user
POST /api/auth/login # Authenticate
Naming Rules
- Use lowercase kebab-case for multi-word URIs:
/api/user-profilesnot/api/userProfiles - Never expose table names directly — use domain language:
/api/ordersnot/api/order_table - Use query parameters for filtering, not path changes:
/api/posts?author=alice - Avoid file extensions in URIs: use
Acceptheader instead of/api/users.json
HTTP Methods and Corresponding Status Codes
// Resource creation with proper status codes
router.post('/api/users', async (req, res, next) => {
try {
const user = await usersService.create(req.body);
// 201 Created with Location header pointing to new resource
res.status(201)
.location(`/api/users/${user.id}`)
.json(user);
} catch (error) {
if (error.code === 'DUPLICATE_EMAIL') {
// 409 Conflict for duplicate resource
return res.status(409).json({ error: 'Email already registered' });
}
next(error);
}
});
// Successful retrieval
router.get('/api/users/:id', async (req, res, next) => {
const user = await usersService.findById(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user); // 200 OK
});
// Successful deletion with no content
router.delete('/api/users/:id', async (req, res, next) => {
await usersService.remove(req.params.id);
res.status(204).end(); // 204 No Content
});
Uniform Interface in Practice
The uniform interface means every resource is accessed the same way. A User resource looks and behaves like a Post resource — both support CRUD through the same HTTP methods, accept and return JSON, use consistent error formats, and support pagination on list endpoints. This predictability lets clients interact with any resource without learning new patterns.
HTTP Methods and Status Codes
HTTP Methods and Status Codes
Method Semantics
Each HTTP method has a specific semantic meaning that maps to a CRUD operation:
| Method | CRUD | Idempotent | Safe | Request Body | Success Code |
|---|---|---|---|---|---|
| GET | Read | Yes | Yes | No | 200 OK |
| POST | Create | No | No | Yes | 201 Created |
| PUT | Replace | Yes | No | Yes | 200 OK |
| PATCH | Update | No* | No | Yes | 200 OK |
| DELETE | Delete | Yes | No | Optional | 204 No Content |
*PATCH can be idempotent if designed that way, but the spec does not guarantee it.
Idempotent means calling the method multiple times produces the same result as calling it once. GET, PUT, and DELETE are idempotent. POST is not — sending the same POST request twice creates two resources.
Safe means the method does not modify server state. Only GET is safe.
PATCH vs PUT
// PUT replaces the ENTIRE resource
// Client must send all fields
router.put('/api/users/:id', async (req, res) => {
const { name, email, bio } = req.body;
// All fields required — missing fields become null
const updated = await usersService.replace(req.params.id, {
name,
email,
bio: bio || null,
});
res.json(updated);
});
// PATCH updates only the PROVIDED fields
// Client sends only what changed
router.patch('/api/users/:id', async (req, res) => {
const allowedFields = ['name', 'email', 'bio'];
const updates = {};
for (const field of allowedFields) {
if (req.body[field] !== undefined) {
updates[field] = req.body[field];
}
}
const updated = await usersService.update(req.params.id, updates);
res.json(updated);
});
Status Code Decision Tree
Did the request succeed?
├── Yes
│ ├── GET → 200 OK
│ ├── POST → 201 Created (+ Location header)
│ ├── PUT/PATCH → 200 OK (updated resource) or 204 No Content
│ └── DELETE → 204 No Content
├── Client Error
│ ├── Malformed request → 400 Bad Request
│ ├── Not authenticated → 401 Unauthorized
│ ├── Authenticated but no permission → 403 Forbidden
│ ├── Resource not found → 404 Not Found
│ ├── Conflict (duplicate, version mismatch) → 409 Conflict
│ ├── Validation failed → 422 Unprocessable Entity
│ └── Rate limited → 429 Too Many Requests
└── Server Error
├── Unexpected failure → 500 Internal Server Error
└── Service unavailable → 503 Service Unavailable
Idempotency in Practice
// POST is NOT idempotent — each call creates a new order
router.post('/api/orders', async (req, res) => {
const order = await ordersService.create(req.body);
res.status(201).json(order);
});
// PUT IS idempotent — sending the same payload twice yields the same result
router.put('/api/orders/:id/status', async (req, res) => {
const order = await ordersService.setStatus(req.params.id, req.body.status);
res.json(order);
});
// DELETE is idempotent — deleting an already-deleted resource is still 204
router.delete('/api/orders/:id', async (req, res) => {
await ordersService.remove(req.params.id);
res.status(204).end();
});
Content Negotiation
Clients specify the response format using the Accept header. The server responds with the format it chose using Content-Type:
# Client request
GET /api/users/123
Accept: application/json
# Server response
200 OK
Content-Type: application/json
{"id": 123, "name": "Alice"}
If the server cannot produce the requested format, it returns 406 Not Acceptable. Most APIs default to JSON and do not strictly enforce content negotiation, but supporting it is good practice for public APIs.
Pagination, Filtering, and Sorting
Pagination, Filtering, and Sorting
Offset-Based Pagination
Offset pagination is simple and widely supported. The client requests a page number and page size:
// GET /api/posts?page=2&limit=10&sort=-createdAt
router.get('/', async (req, res, next) => {
try {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(100, parseInt(req.query.limit) || 20);
const sort = req.query.sort || '-createdAt';
// Parse sort string: '-createdAt' → { createdAt: 'desc' }
const orderField = sort.startsWith('-') ? sort.slice(1) : sort;
const orderDir = sort.startsWith('-') ? 'desc' : 'asc';
const offset = (page - 1) * limit;
const [posts, [{ total }]] = await Promise.all([
db.query(
`SELECT * FROM posts ORDER BY ${orderField} ${orderDir} LIMIT $1 OFFSET $2`,
[limit, offset]
),
db.query('SELECT COUNT(*) FROM posts'),
]);
res.json({
data: posts.rows,
meta: {
page,
limit,
total: parseInt(total),
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1,
},
});
} catch (error) {
next(error);
}
});
Drawback: Offset pagination becomes slow on large datasets because the database must scan and discard all rows before the offset. For a query with OFFSET 100000, the database reads 100,001 rows just to return 20.
Cursor-Based Pagination
Cursor pagination avoids the offset problem by using a bookmark (cursor) that points to the last item seen:
// GET /api/posts?cursor=abc123&limit=20
router.get('/', async (req, res, next) => {
try {
const { cursor, limit: limitStr = '20' } = req.query;
const limit = Math.min(100, parseInt(limitStr));
let query = 'SELECT * FROM posts';
const params = [];
if (cursor) {
const decodedCursor = Buffer.from(cursor, 'base64').toString('ascii');
query += ' WHERE created_at < $1';
params.push(decodedCursor);
}
query += ' ORDER BY created_at DESC LIMIT $' + (params.length + 1);
params.push(limit + 1); // fetch one extra to detect hasMore
const { rows } = await db.query(query, params);
const hasMore = rows.length > limit;
const data = hasMore ? rows.slice(0, limit) : rows;
const nextCursor = hasMore
? Buffer.from(data[data.length - 1].created_at.toISOString()).toString('base64')
: null;
res.json({
data,
meta: {
nextCursor,
hasMore,
limit,
},
});
} catch (error) {
next(error);
}
});
Cursor pagination is used by Twitter, GitHub, and Stripe. It guarantees consistent results even when new records are inserted between page fetches.
Filtering
Filtering lets clients narrow results using query parameters:
// GET /api/posts?status=published&author=alice&tag=react&from=2024-01-01
router.get('/', async (req, res, next) => {
try {
const { status, author, tag, from, to, search } = req.query;
const conditions = [];
const params = [];
let paramIndex = 1;
if (status) {
conditions.push(`status = $${paramIndex++}`);
params.push(status);
}
if (author) {
conditions.push(`author_id = $${paramIndex++}`);
params.push(author);
}
if (tag) {
conditions.push(`$${paramIndex++} = ANY(tags)`);
params.push(tag);
}
if (from) {
conditions.push(`created_at >= $${paramIndex++}`);
params.push(from);
}
if (to) {
conditions.push(`created_at <= $${paramIndex++}`);
params.push(to);
}
if (search) {
conditions.push(`(title ILIKE $${paramIndex} OR content ILIKE $${paramIndex})`);
params.push(`%${search}%`);
paramIndex++;
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const { rows } = await db.query(`SELECT * FROM posts ${where}`, params);
res.json({ data: rows });
} catch (error) {
next(error);
}
});
Sorting
// GET /api/posts?sort=-createdAt,title
// Supports multiple sort fields, - prefix for descending
router.get('/', async (req, res, next) => {
try {
const sortParam = req.query.sort || '-createdAt';
const allowedFields = ['createdAt', 'title', 'viewCount'];
const orderBy = sortParam.split(',').map(field => {
const desc = field.startsWith('-');
const name = desc ? field.slice(1) : field;
if (!allowedFields.includes(name)) {
throw new AppError(`Invalid sort field: ${name}`, 400);
}
return `${name} ${desc ? 'DESC' : 'ASC'}`;
}).join(', ');
const { rows } = await db.query(`SELECT * FROM posts ORDER BY ${orderBy}`);
res.json({ data: rows });
} catch (error) {
next(error);
}
});
Field Selection
Let clients request only the fields they need to reduce payload size:
// GET /api/users?fields=id,name,email
router.get('/', async (req, res, next) => {
try {
const allowedFields = ['id', 'name', 'email', 'role', 'createdAt'];
const requested = req.query.fields?.split(',') || allowedFields;
const fields = requested.filter(f => allowedFields.includes(f));
const { rows } = await db.query(
`SELECT ${fields.join(', ')} FROM users`
);
res.json({ data: rows });
} catch (error) {
next(error);
}
});
Field selection is used by GraphQL REST adapters and mobile APIs where bandwidth is limited.
Error Responses and Problem Details
Error Responses and Problem Details
Consistent Error Format
Every API should return errors in a predictable structure so clients can parse and display them reliably:
// Error response format
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The request body contains invalid fields",
"details": [
{
"field": "email",
"message": "Must be a valid email address",
"value": "not-an-email"
},
{
"field": "password",
"message": "Must be at least 8 characters",
"value": "123"
}
],
"requestId": "req_abc123"
}
}
Custom AppError Class
class AppError extends Error {
constructor(message, statusCode, code, details = null) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.details = details;
this.isOperational = true;
}
}
// Usage in controllers
router.post('/api/users', async (req, res, next) => {
try {
const { email, password, name } = req.body;
if (!email) {
throw new AppError('Email is required', 400, 'VALIDATION_ERROR', [
{ field: 'email', message: 'Email is required' }
]);
}
const existing = await usersService.findByEmail(email);
if (existing) {
throw new AppError('Email already registered', 409, 'CONFLICT');
}
const user = await usersService.create({ email, password, name });
res.status(201).json(user);
} catch (error) {
next(error);
}
});
Global Error Handler Middleware
function errorHandler(err, req, res, next) {
// Log unexpected errors
if (!err.isOperational) {
console.error('Unexpected error:', err);
}
const statusCode = err.statusCode || 500;
const response = {
error: {
code: err.code || 'INTERNAL_ERROR',
message: err.isOperational ? err.message : 'An unexpected error occurred',
requestId: req.id,
},
};
if (err.details) {
response.error.details = err.details;
}
res.status(statusCode).json(response);
}
// Handle specific error types
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
HTTP Status Code Usage
| Code | Meaning | When to Use |
|---|---|---|
| 400 | Bad Request | Malformed JSON, missing required fields |
| 401 | Unauthorized | No token or invalid token |
| 403 | Forbidden | Valid token but insufficient permissions |
| 404 | Not Found | Resource does not exist |
| 409 | Conflict | Duplicate email, version mismatch |
| 422 | Unprocessable Entity | Validation errors on well-formed request |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unexpected server failure |
| 503 | Service Unavailable | Temporary overload or maintenance |
RFC 7807 Problem Details
The industry standard for API error responses:
{
"type": "https://api.example.com/errors/validation-error",
"title": "Validation Error",
"status": 422,
"detail": "The request body contains 2 invalid fields",
"instance": "/api/users",
"errors": [
{ "field": "email", "reason": "invalid format" }
]
}
Content-Type: application/problem+json
This format is self-documenting — clients can use the type URI to look up error documentation automatically.
Rate Limiting, Caching, and Idempotency
Rate Limiting, Caching, and Idempotency
Rate Limiting with Sliding Window
Rate limiting protects your API from abuse and ensures fair resource usage:
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
async function rateLimiter(req, res, next) {
const key = `rate:${req.ip}`;
const windowMs = 60 * 1000; // 1 minute window
const maxRequests = 100;
try {
const current = await redis.multi()
.incr(key)
.pexpire(key, windowMs)
.exec();
const count = current[0][1];
const remaining = Math.max(0, maxRequests - count);
res.set('X-RateLimit-Limit', maxRequests);
res.set('X-RateLimit-Remaining', remaining);
res.set('X-RateLimit-Reset', Math.ceil(Date.now() / 1000) + 60);
if (count > maxRequests) {
return res.status(429).json({
error: {
code: 'RATE_LIMITED',
message: 'Too many requests. Please try again later.',
retryAfter: 60,
},
});
}
next();
} catch (error) {
next(error);
}
}
// Apply to all routes
app.use('/api', rateLimiter);
HTTP Caching Headers
// ETag-based conditional requests
router.get('/:id', async (req, res, next) => {
try {
const post = await postsService.findById(req.params.id);
if (!post) throw new AppError('Post not found', 404);
const etag = `"${post.id}-${post.updatedAt.getTime()}"`;
// Client has current version — return 304 with no body
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set('ETag', etag);
res.set('Cache-Control', 'private, max-age=0, must-revalidate');
res.json(post);
} catch (error) {
next(error);
}
});
// Public resources with cache duration
router.get('/public/posts', async (req, res) => {
const posts = await postsService.listPublished();
// Cache for 5 minutes, allow stale content for 1 minute while revalidating
res.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=60');
res.json(posts);
});
Redis Caching Middleware
function cacheMiddleware(keyFn, ttl = 300) {
return async (req, res, next) => {
const key = keyFn(req);
try {
const cached = await redis.get(key);
if (cached) {
return res.json(JSON.parse(cached));
}
} catch (err) {
console.error('Cache read error:', err);
}
// Intercept res.json to cache the response before sending
const originalJson = res.json.bind(res);
res.json = (data) => {
redis.setex(key, ttl, JSON.stringify(data)).catch(console.error);
return originalJson(data);
};
next();
};
}
// Usage on specific routes
router.get('/posts',
cacheMiddleware(req => `posts:list:${req.query.page}:${req.query.sort}`, 300),
postsController.list
);
Idempotency Keys
Idempotency keys prevent duplicate processing of non-idempotent requests like payments or order creation:
router.post('/orders', authenticate, async (req, res, next) => {
try {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) {
throw new AppError('Idempotency-Key header is required', 400);
}
// Check if this key was already processed
const cached = await redis.get(`idempotent:${idempotencyKey}`);
if (cached) {
// Return the original response — do not process again
return res.status(200).json(JSON.parse(cached));
}
// Process the request
const order = await ordersService.create(req.body);
// Store the response for 24 hours
await redis.setex(
`idempotent:${idempotencyKey}`,
86400,
JSON.stringify(order)
);
res.status(201).json(order);
} catch (error) {
next(error);
}
});
API Versioning Strategies
// URL path versioning (most common)
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// Header versioning
app.use('/api', (req, res, next) => {
const version = req.headers['accept-version'] || '1';
req.apiVersion = version;
next();
}, router);
URL path versioning is the most transparent and debuggable approach. Header versioning keeps URIs clean but makes testing harder.
Quiz
1. What is the difference between PUT and PATCH?
2. Why is cursor-based pagination preferred over offset pagination for large datasets?
3. What HTTP status code should you return when a request fails validation on a well-formed JSON body?
Flashcards
Question
What makes a REST API endpoint idempotent?
Click to reveal answer
Answer
An endpoint is idempotent when calling it multiple times with the same input produces the same result as calling it once. GET, PUT, and DELETE are idempotent by definition. POST is not idempotent because each call creates a new resource.
Question
What is the purpose of an idempotency key?
Click to reveal answer
Answer
An idempotency key is a unique identifier sent with a request (usually in a header) that lets the server detect and deduplicate retried requests. If the server has already processed a request with that key, it returns the cached response instead of processing it again. Critical for payment and order endpoints.
Question
When should you use cursor-based pagination instead of offset pagination?
Click to reveal answer
Answer
Use cursor pagination when the dataset is large, when records may be inserted or deleted between page fetches, or when consistent ordering matters. Offset pagination is simpler but becomes slow and unreliable with OFFSET values in the thousands because the database must scan and discard all preceding rows.
Revision Notes
Key Takeaways
- 1. REST resources are nouns (plural, lowercase) — use HTTP methods for verbs, not URIs
- 2. GET, PUT, DELETE are idempotent; POST and PATCH are not
- 3. Return 201 Created with a Location header when creating resources via POST
- 4. Cursor pagination avoids the offset problem and guarantees consistent results under concurrent writes
- 5. Use 422 Unprocessable Entity for validation errors on well-formed requests, 400 for malformed syntax
- 6. Idempotency keys prevent duplicate processing of non-idempotent requests like payments
- 7. ETag and Cache-Control headers enable efficient HTTP caching without application-level changes
Interview Tips
- • Explain when to use PUT vs PATCH and why PUT is idempotent
- • Describe the tradeoffs between offset and cursor pagination with real examples
- • Walk through how you would design a consistent error response format for a team
- • Explain how idempotency keys work and why they matter for payment APIs
- • Discuss REST vs GraphQL tradeoffs — when would you choose one over the other
Cheat Sheet
REST API Design Cheat Sheet
Resource Naming:
- Plural nouns:
/api/users,/api/posts/:id/comments - Nested one level deep max
- Use query params for filtering:
/api/posts?status=published
HTTP Methods:
- GET (read, safe, idempotent) → 200
- POST (create, not idempotent) → 201 + Location header
- PUT (replace, idempotent) → 200
- PATCH (partial update) → 200
- DELETE (remove, idempotent) → 204
Status Codes:
- 400 Bad Request (malformed syntax)
- 401 Unauthorized (no/invalid token)
- 403 Forbidden (insufficient permissions)
- 404 Not Found
- 409 Conflict (duplicate resource)
- 422 Unprocessable Entity (validation error)
- 429 Too Many Requests (rate limited)
- 500 Internal Server Error
Pagination:
- Offset:
?page=2&limit=20— simple, fast for small data - Cursor:
?cursor=abc&limit=20— stable, fast for large data
Caching:
- ETag + If-None-Match → 304 Not Modified
- Cache-Control:
public, max-age=300, stale-while-revalidate=60
Idempotency:
- Send
Idempotency-Keyheader for POST requests that must not duplicate - Server stores response for 24h and returns cached result on retry