Building RESTful APIs - Resources Over RPC¶
Table of Contents¶
- REST Principles : Resources , Methods , Status Codes
- CRUD Mapping to HTTP Methods
- Request/Response Formats : JSON , HAL , JSON:API
- Error Response Format Standards (RFC 7807)
- Versioning APIs : URL vs Header vs Negotiation
- Pagination , Filtering , Sorting Patterns
- Security : Rate Limiting , Input Validation , IDOR
REST Principles : Resources , Methods , Status Codes¶
REST (Representational State Transfer) treats everything as a resource identified by a URL. You manipulate resources through HTTP methods and represent them in standard formats
URI vs URL - the URI identifies the resource , the URL tells you where to find it
Resources are nouns , not verbs: * ✅ GET /users - list users * ✅ POST /users - create user * ❌ GET /getUsers - verb in URL * ❌ POST /createUser - verb in URL
Standard HTTP methods for CRUD:
| Method | Action | Collection (e.g. /users) | Specific (e.g. /users/1) |
|---|---|---|---|
| GET | Read | List users | Get user 1 |
| POST | Create | Create user | 405 (or sub-resource) |
| PUT | Replace | 405 | Replace user 1 |
| PATCH | Partial update | 405 | Update user 1 |
| DELETE | Delete | 405 | Delete user 1 |
HTTP status codes you must know:
| Code | Meaning | When to Use |
|---|---|---|
| 200 | OK | Successful GET , PUT , PATCH |
| 201 | Created | Successful POST (include Location header) |
| 204 | No Content | Successful DELETE , PUT returning no body |
| 301 | Moved Permanently | Resource URL changed |
| 400 | Bad Request | Malformed input , validation failure |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but not permitted |
| 404 | Not Found | Resource doesn't exist |
| 405 | Method Not Allowed | Wrong method for the resource |
| 409 | Conflict | State conflict (duplicate resource) |
| 422 | Unprocessable Entity | Semantic validation failure |
| 429 | Too Many Requests | Rate limit hit |
| 500 | Internal Server Error | Server-side failure |
| 503 | Service Unavailable | Overloaded , maintenance |
CRUD Mapping to HTTP Methods¶
const http = require('node:http')
const users = new Map()
let nextId = 1
function sendJSON(res, status, data) {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(data))
}
async function parseBody(req) {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
return JSON.parse(Buffer.concat(chunks).toString())
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`)
const pathParts = url.pathname.split('/').filter(Boolean)
try {
// /api/users or /api/users/:id
if (pathParts[0] !== 'api' || pathParts[1] !== 'users') {
return sendJSON(res, 404, { error: 'Not found' })
}
const userId = pathParts[2] ? parseInt(pathParts[2], 10) : null
// LIST - GET /api/users
if (!userId && req.method === 'GET') {
const page = parseInt(url.searchParams.get('page')) || 1
const limit = parseInt(url.searchParams.get('limit')) || 10
const userList = Array.from(users.values())
const paged = userList.slice((page - 1) * limit, page * limit)
return sendJSON(res, 200, {
data: paged,
page,
limit,
total: userList.length
})
}
// CREATE - POST /api/users
if (!userId && req.method === 'POST') {
const body = await parseBody(req)
if (!body.name || !body.email) {
return sendJSON(res, 400, { error: 'name and email are required' })
}
const user = {
id: nextId++,
name: body.name,
email: body.email,
createdAt: new Date().toISOString()
}
users.set(user.id, user)
res.writeHead(201, {
'Content-Type': 'application/json',
'Location': `/api/users/${user.id}`
})
return res.end(JSON.stringify({ data: user }))
}
// GET - GET /api/users/:id
if (userId && req.method === 'GET') {
const user = users.get(userId)
if (!user) return sendJSON(res, 404, { error: 'User not found' })
return sendJSON(res, 200, { data: user })
}
// UPDATE - PUT /api/users/:id (full replace)
if (userId && req.method === 'PUT') {
if (!users.has(userId)) return sendJSON(res, 404, { error: 'Not found' })
const body = await parseBody(req)
users.set(userId, { id: userId, ...body, updatedAt: new Date().toISOString() })
return sendJSON(res, 200, { data: users.get(userId) })
}
// DELETE - DELETE /api/users/:id
if (userId && req.method === 'DELETE') {
if (!users.has(userId)) return sendJSON(res, 404, { error: 'Not found' })
users.delete(userId)
return sendJSON(res, 204)
}
return sendJSON(res, 405, { error: 'Method not allowed' })
} catch (err) {
if (err instanceof SyntaxError) {
return sendJSON(res, 400, { error: 'Invalid JSON body' })
}
console.error(err)
return sendJSON(res, 500, { error: 'Internal server error' })
}
})
server.listen(3000)
Request/Response Formats : JSON , HAL , JSON:API¶
Plain JSON - simple but no structure for links or metadata:
{
"id": 1,
"name": "ali",
"email": "ali@example.com"
}
HAL (Hypertext Application Language) - embeds links for discoverability:
{
"_links": {
"self": { "href": "/api/users/1" },
"orders": { "href": "/api/users/1/orders" },
"collection": { "href": "/api/users" }
},
"id": 1,
"name": "ali",
"email": "ali@example.com"
}
JSON:API - standardized format with included resources and error objects:
{
"data": {
"type": "users",
"id": "1",
"attributes": {
"name": "ali",
"email": "ali@example.com"
},
"relationships": {
"orders": {
"links": {
"related": "/api/users/1/orders"
}
}
}
}
}
Error Response Format Standards (RFC 7807)¶
RFC 7807 (Problem Details for HTTP APIs) standardizes error responses:
function problemDetails(res, status, detail, options = {}) {
const body = {
type: options.type || 'about:blank',
title: options.title || http.STATUS_CODES[status] || 'Unknown',
status,
detail,
instance: options.instance || req?.url || '/'
}
// extension members
if (options.errors) body.errors = options.errors
if (options.traceId) body.traceId = options.traceId
res.writeHead(status, {
'Content-Type': 'application/problem+json',
'Content-Language': 'en'
})
res.end(JSON.stringify(body))
}
// usage
problemDetails(res, 400, 'Email already registered', {
type: 'https://api.example.com/errors/duplicate-email',
errors: [{ field: 'email', reason: 'must be unique' }]
})
// response:
// {
// "type": "https://api.example.com/errors/duplicate-email",
// "title": "Bad Request",
// "status": 400,
// "detail": "Email already registered",
// "errors": [{"field": "email", "reason": "must be unique"}]
// }
Versioning APIs : URL vs Header vs Negotiation¶
URL-based versioning - most common:
// /api/v1/users vs /api/v2/users
app.use('/api/v1', v1Router)
app.use('/api/v2', v2Router)
Pros: simple , cacheable , bookmarks work Cons: URL pollution , clients hardcode versions
Header-based versioning:
// Accept: application/vnd.api+json; version=1
// or custom: X-API-Version: 2
const version = req.headers['accept']?.match(/version=(\d+)/)?.[1] || 1
Pros: clean URLs Cons: harder to test , cache key requires header , not visible in browser
Content negotiation versioning:
// Accept: application/vnd.myapp.v1+json
// Accept: application/vnd.myapp.v2+json
const mediaType = req.headers['accept']
if (mediaType.includes('vnd.myapp.v1+json')) { /* v1 handler */ }
if (mediaType.includes('vnd.myapp.v2+json')) { /* v2 handler */ }
Recommendation: Start without versioning. Add /v1/ when you need to break backward compatibility. Most APIs never need v2
Pagination , Filtering , Sorting Patterns¶
Cursor-based pagination (recommended for lists):
// GET /api/users?cursor=eyJpZCI6MTB9&limit=20
async function paginate(req, res, queryDb) {
const limit = Math.min(parseInt(req.query.limit) || 20, 100)
const cursor = req.query.cursor
? JSON.parse(Buffer.from(req.query.cursor, 'base64').toString())
: null
const results = await queryDb({ after: cursor, limit: limit + 1 })
const hasMore = results.length > limit
if (hasMore) results.pop()
const nextCursor = hasMore
? Buffer.from(JSON.stringify({ id: results[results.length - 1].id })).toString('base64')
: null
res.json({
data: results,
pagination: {
nextCursor,
hasMore,
limit
}
})
}
Page/offset pagination (simpler but offset drift):
// GET /api/users?page=2&limit=20
function offsetPagination(req, res, total, results) {
const page = parseInt(req.query.page) || 1
const limit = parseInt(req.query.limit) || 20
const totalPages = Math.ceil(total / limit)
res.json({
data: results,
pagination: {
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1
}
})
}
Filtering - consistent query parameter convention:
// GET /api/users?filter[name]=ali&filter[role]=admin
function parseFilters(url) {
const filters = {}
for (const [key, value] of url.searchParams) {
const match = key.match(/^filter\[(\w+)\]$/)
if (match) {
filters[match[1]] = value
}
}
return filters
}
// GET /api/users?sort=-createdAt,name
function parseSort(url) {
const sortParam = url.searchParams.get('sort')
if (!sortParam) return []
return sortParam.split(',').map(field => {
if (field.startsWith('-')) {
return { field: field.slice(1), order: 'desc' }
}
return { field, order: 'asc' }
})
}
Security : Rate Limiting , Input Validation , IDOR¶
Insecure Direct Object Reference (IDOR) - user A accesses user B's resource by changing an ID:
// VULNERABLE
app.get('/api/users/:id', (req, res) => {
const user = db.getUser(req.params.id)
res.json(user)
})
// attacker changes /api/users/1 to /api/users/2 and sees someone else's data
Mitigation - always verify authorization:
app.get('/api/users/:id', authenticate, async (req, res) => {
const user = await db.getUser(req.params.id)
// admin can access anyone , users can only access themselves
if (req.user.role !== 'admin' && req.user.id !== parseInt(req.params.id)) {
return sendJSON(res, 403, { error: 'Forbidden' })
}
res.json(user)
})
Input validation checklist: * Validate all params against expected type (string vs number) * Reject unexpected fields in POST/PUT bodies * Validate email , URL formats with regex * Sanitize strings for length limits * Never trust Content-Type or Content-Length
Rate limiting per endpoint:
const rateLimits = new Map()
function rateLimitEndpoint(maxRequests, windowMs) {
return (req, res, next) => {
const key = req.ip + ':' + req.url
const now = Date.now()
const windowStart = now - windowMs
if (!rateLimits.has(key)) {
rateLimits.set(key, [])
}
const timestamps = rateLimits.get(key).filter(t => t > windowStart)
timestamps.push(now)
rateLimits.set(key, timestamps)
if (timestamps.length > maxRequests) {
return sendJSON(res, 429, { error: 'Rate limit exceeded', retryAfter: windowMs / 1000 })
}
next()
}
}
// apply to sensitive endpoints
app.use('/api/auth', rateLimitEndpoint(5, 60000)) // 5 per minute
app.use('/api/users', rateLimitEndpoint(100, 60000)) // 100 per minute
prerequisites¶
web_07_static.md - Content serving patterns , caching headers , path resolution
next => web_09_websocket.md