Skip to content
intermediate Phase 11 · API Design

REST API Design

Design clean REST APIs — resource naming, HTTP methods, status codes, pagination, and filtering.

1h 15m
0 problems
Topic Progress 0%

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:

  1. Client-Server Separation: The client handles the UI while the server manages data storage. They evolve independently as long as the interface stays stable.
  2. 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.
  3. Cacheable: Responses must declare whether they can be cached. Proper caching reduces server load and improves client performance.
  4. Uniform Interface: Resources are identified by URIs, and clients interact with resources through standard HTTP methods. This is what makes REST "RESTful."
  5. Layered System: Clients cannot tell whether they are connected directly to the server or through intermediaries like load balancers or CDNs.
  6. 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-profiles not /api/userProfiles
  • Never expose table names directly — use domain language: /api/orders not /api/order_table
  • Use query parameters for filtering, not path changes: /api/posts?author=alice
  • Avoid file extensions in URIs: use Accept header 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?

Question 1 options

2. Why is cursor-based pagination preferred over offset pagination for large datasets?

Question 2 options

3. What HTTP status code should you return when a request fails validation on a well-formed JSON body?

Question 3 options

Flashcards

Question

What makes a REST API endpoint idempotent?

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?

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?

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-Key header for POST requests that must not duplicate
  • Server stores response for 24h and returns cached result on retry