Skip to content

Building RESTful APIs - Resources Over RPC

Table of Contents


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