REST API Patterns¶
REST isn't a protocol. It's a set of conventions. Break them consistently if you must , but don't be inconsistent
The best API is the one your consumers can predict. Resource naming , status codes , error format , pagination - if every endpoint follows the same pattern , your API is usable. If every endpoint does its own thing , your API is a puzzle
resource naming¶
Nouns , not verbs. Plural , not singular
GOOD:
GET /users # list users
GET /users/:id # get one user
POST /users # create user
PUT /users/:id # update user
DELETE /users/:id # delete user
GET /users/:id/orders # user's orders
BAD:
GET /getUsers # verb in URL
POST /createUser # verb in URL
PUT /updateUser/42 # why is update in the URL?
POST /deleteUser/42 # DELETE exists for a reason
GET /userInfo # what's "info"? inconsistent
Nested resources for relationships , but don't go deeper than 2-3 levels:
/users/:userId/orders # fine
/users/:userId/orders/:orderId # fine
/users/:userId/orders/:orderId/items # OK but getting deep
/users/:userId/orders/:orderId/items/:itemId/shipping # too deep
status codes¶
Use them correctly. Don't return 200 for everything with errors in the body
// 200 OK - successful GET , PUT , PATCH
res.status(200).json({ data: users })
// 201 Created - successful POST (resource created)
res.status(201).json({ data: newUser })
// 204 No Content - successful DELETE
res.status(204).send()
// 400 Bad Request - invalid input
res.status(400).json({ error: 'Email is required' })
// 401 Unauthorized - not authenticated
res.status(401).json({ error: 'Invalid credentials' })
// 403 Forbidden - authenticated but not authorized
res.status(403).json({ error: 'Insufficient permissions' })
// 404 Not Found - resource doesn't exist
res.status(404).json({ error: 'User not found' })
// 409 Conflict - duplicate resource , state conflict
res.status(409).json({ error: 'Email already exists' })
// 422 Unprocessable Entity - validation failed
res.status(422).json({ error: 'Validation failed' , details: errors })
// 429 Too Many Requests - rate limited
res.status(429).json({ error: 'Rate limit exceeded' })
// 500 Internal Server Error - unexpected server error
res.status(500).json({ error: 'Internal server error' })
JSON response formatting¶
Consistent envelope format:
// SUCCESS ENVELOPE
app.get('/users/:id' , async (req , res) => {
const user = await db.findUser(req.params.id)
if (!user) {
return res.status(404).json({
success: false,
error: 'User not found'
})
}
res.json({
success: true,
data: user,
meta: {
requested_at: new Date().toISOString()
}
})
})
// ERROR ENVELOPE
app.use((err , req , res , next) => {
res.status(err.statusCode || 500).json({
success: false,
error: err.isOperational ? err.message : 'Internal server error',
...(err.errors && { details: err.errors }),
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
})
})
pagination and filtering middleware¶
// middleware for pagination
const paginate = (req , res , next) => {
const page = Math.max(parseInt(req.query.page) || 1 , 1)
const limit = Math.min(parseInt(req.query.limit) || 20 , 100)
const offset = (page - 1) * limit
req.pagination = { page , limit , offset }
next()
}
// middleware for filtering
const filter = (req , res , next) => {
const allowedFilters = ['name' , 'email' , 'role' , 'status']
const filters = {}
for (const key of allowedFilters) {
if (req.query[key] !== undefined) {
filters[key] = req.query[key]
}
}
req.filters = filters
next()
}
// usage
app.get('/users' , paginate , filter , async (req , res) => {
const { offset , limit } = req.pagination
const filters = req.filters
let query = 'SELECT * FROM users WHERE 1=1'
const params = []
if (filters.role) {
params.push(filters.role)
query += ` AND role = $${params.length}`
}
query += ` ORDER BY id LIMIT $${params.length + 1} OFFSET $${params.length + 2}`
params.push(limit , offset)
const result = await db.query(query , params)
res.json({
success: true,
data: result.rows,
pagination: {
page: req.pagination.page,
limit: req.pagination.limit,
total: result.totalCount // you need a COUNT query too
}
})
})
API versioning¶
// VERSION 1 - in URL path
app.use('/api/v1/users' , userRoutesV1)
app.use('/api/v2/users' , userRoutesV2)
// VERSION 2 - in header
// client sends: Accept: application/vnd.yourapp.v2+json
app.use('/api/users' , (req , res , next) => {
const version = req.headers['accept']?.match(/vnd\.yourapp\.v(\d+)/)?.[1]
req.apiVersion = parseInt(version) || 1
next()
} , userRoutes)
URL versioning is simpler and more common. Header versioning is cleaner for the URL but harder to test with curl
OpenAPI / Swagger¶
Document your API so consumers don't have to read your code
npm install swagger-jsdoc swagger-ui-express
const swaggerJsdoc = require('swagger-jsdoc')
const swaggerUi = require('swagger-ui-express')
const options = {
definition: {
openapi: '3.0.0',
info: {
title: 'My Express API',
version: '1.0.0',
description: 'Express API documentation'
},
servers: [
{ url: 'http://localhost:3000' , description: 'Development' }
]
},
apis: ['./src/routes/*.js']
}
const swaggerSpec = swaggerJsdoc(options)
app.use('/api-docs' , swaggerUi.serve , swaggerUi.setup(swaggerSpec))
// routes/users.js - JSDoc annotations generate the spec
/**
* @openapi
* /users:
* get:
* summary: List all users
* responses:
* 200:
* description: Returns user list
*/
router.get('/' , handler)
prerequisites¶
express_14_database.md - PostgreSQL , MySQL , MongoDB , connection pooling , transactions
next → express_16_testing.md