Back/Module P-10 REST API Design and OpenAPI Documentation
Module P-10·23 min read

REST constraints and resource modeling, versioning strategies, cursor vs offset pagination, OpenAPI 3.0 spec, Swagger UI — designing APIs that don't need a changelog.

Module P-10 — REST API Design and OpenAPI Documentation

What this module covers: A well-designed API is a product. Developers who consume it should be able to predict its behaviour, discover its capabilities, and upgrade to new versions without their code breaking. This module covers the REST constraints that produce predictable APIs, resource modelling, versioning strategies and their trade-offs, offset vs cursor pagination, the HTTP status codes that actually matter, and generating interactive OpenAPI 3.0 documentation from your Express routes with Swagger UI.


REST Constraints: What Makes an API RESTful

REST (Representational State Transfer) is not a standard — it is a set of architectural constraints defined by Roy Fielding. An API that follows them is predictable and easier to consume.

The six constraints, and what they mean in practice:

1. Uniform interface — a consistent way to interact with all resources. In practice: use HTTP methods semantically, use nouns not verbs in URLs, use standard status codes.

2. Stateless — each request contains all information needed to process it. No session state stored on the server between requests. Authentication via token (not session cookie) follows this constraint.

3. Client-server separation — the client and server evolve independently. Your mobile app and your API can be deployed separately.

4. Cacheable — responses must declare whether they can be cached. Use Cache-Control, ETag, and Last-Modified headers.

5. Layered system — the client doesn't know if it's talking to the actual server or a proxy/load balancer.

6. Code on demand (optional) — servers can send executable code to clients (e.g., JavaScript). Rarely used in APIs.

Most "REST APIs" only follow constraints 1–3. That's fine. What matters practically is the uniform interface.


Resource Modelling: URLs Are Nouns

URLs identify resources. HTTP methods express what to do with them.

# Wrong — verbs in URLs
GET  /getUsers
POST /createUser
POST /deleteUser?id=42
POST /updateUserStatus

# Right — nouns + HTTP methods
GET    /users          → list users
POST   /users          → create a user
GET    /users/42       → get user 42
PATCH  /users/42       → update user 42 (partial update)
PUT    /users/42       → replace user 42 (full update)
DELETE /users/42       → delete user 42

Nested resources — when a resource only makes sense in the context of another:

GET    /users/42/posts          → posts by user 42
POST   /users/42/posts          → create a post for user 42
GET    /users/42/posts/7        → post 7 by user 42
DELETE /users/42/posts/7        → delete post 7 by user 42

Limit nesting depth — more than two levels becomes unwieldy. /users/42/posts/7/comments/3/likes is hard to read and hard to maintain. At that depth, consider a top-level resource: GET /comments/3/likes.

Actions that don't fit CRUD — some operations are not naturally resource-oriented. Use sub-resources with a verb:

POST /orders/42/cancel      → cancel order 42
POST /users/42/verify-email → verify user's email
POST /posts/7/publish       → publish a draft post
POST /accounts/merge        → merge two accounts

These are acceptable when the alternative (overloading PATCH) would be unclear.


HTTP Methods and Idempotency

MethodIdempotentSafeUse for
GETRead — never modify state
HEADLike GET but no body — check existence/headers
POSTCreate, or actions with side effects
PUTFull replacement — same result if called multiple times
PATCHPartial update
DELETEDelete — second call returns 404, not an error

Idempotent: calling the operation N times produces the same result as calling it once. Idempotency enables safe retries — crucial for unreliable networks. Design your API to support this: DELETE returning 404 on a second call is correct, not an error.

PATCH vs PUT:

typescript
// PATCH — update only the provided fields PATCH /users/42 { "name": "New Name" } // → only name changes, email/role unchanged // PUT — full replacement (omitted fields may be reset to defaults) PUT /users/42 { "name": "New Name", "email": "existing@email.com", "role": "user" } // → entire resource replaced

For most APIs, PATCH is the right choice for updates — PUT requires the client to know the full current state.


HTTP Status Codes That Matter

Use the smallest set of status codes necessary. Consistency beats completeness.

2xx — Success
  200 OK              — standard success (GET, PATCH, PUT, DELETE with body)
  201 Created         — resource created (POST that creates something)
  204 No Content      — success, no body (DELETE, logout)

3xx — Redirection
  301 Moved Permanently — resource has a new permanent URL
  304 Not Modified      — ETag match, use cached response

4xx — Client errors (the caller did something wrong)
  400 Bad Request       — malformed syntax, invalid parameters
  401 Unauthorized      — not authenticated (misleadingly named)
  403 Forbidden         — authenticated but not permitted
  404 Not Found         — resource doesn't exist
  409 Conflict          — state conflict (duplicate, insufficient stock)
  410 Gone              — resource existed but was permanently deleted
  422 Unprocessable     — validation failure (semantically invalid)
  429 Too Many Requests — rate limited

5xx — Server errors (your code is wrong)
  500 Internal Server Error — unexpected error
  502 Bad Gateway           — upstream service returned an error
  503 Service Unavailable   — intentional downtime / overloaded
  504 Gateway Timeout       — upstream service timed out

A note on 400 vs 422: both are used for validation errors. 400 is more common, 422 is technically more correct for semantically invalid input that is syntactically valid. Pick one and be consistent — mixing them confuses clients.


API Versioning Strategies

APIs change. Versioning lets you make breaking changes without breaking existing clients.

Strategy 1: URL versioning — most common, most visible:

/v1/users
/v2/users
typescript
// src/app.ts import v1Router from './routes/v1/index.js'; import v2Router from './routes/v2/index.js'; app.use('/v1', v1Router); app.use('/v2', v2Router);

Pros: obvious, easy to test in browser, easy to route at the load balancer. Cons: URLs are supposed to identify resources, not API versions.

Strategy 2: Header versioning — cleaner URLs:

GET /users
Accept-Version: 2
typescript
// src/middleware/version.ts export function versionRouter(v1Handler, v2Handler) { return (req, res, next) => { const version = parseInt(req.headers['accept-version'] as string ?? '1'); if (version === 2) return v2Handler(req, res, next); return v1Handler(req, res, next); }; }

Pros: clean URLs. Cons: harder to test, often stripped by proxies.

Strategy 3: Content negotiation:

Accept: application/vnd.myapi.v2+json

Used by GitHub and Stripe. Most expressive, most complex.

The pragmatic choice: URL versioning (/v1/, /v2/) for public APIs. Header versioning for internal APIs. Start with /v1/ from day one — retrofitting versioning is painful.

What counts as a breaking change:

  • Removing or renaming a field in a response
  • Changing a field's type
  • Removing an endpoint
  • Changing authentication requirements
  • Making an optional field required

What is NOT a breaking change:

  • Adding new optional fields to responses
  • Adding new optional query parameters
  • Adding new endpoints

Pagination: Offset vs Cursor

Offset pagination — simple but has problems at scale:

GET /posts?page=3&limit=20
GET /posts?offset=40&limit=20
typescript
// Response { "data": [...], "total": 847, "page": 3, "limit": 20, "totalPages": 43, "hasNext": true, "hasPrev": true }

Problems:

  • Page drift: if an item is inserted while paginating, page 3 starts one record later — items get skipped or duplicated.
  • Performance: SELECT ... OFFSET 10000 forces the database to scan and discard 10,000 rows.

Cursor pagination — solves both problems, harder to implement:

GET /posts?limit=20
GET /posts?cursor=eyJpZCI6NDJ9&limit=20  // cursor is the last seen ID, base64 encoded
typescript
// src/repositories/posts.repository.ts export async function findPublishedCursor(limit: number, cursor?: string) { const decodedCursor = cursor ? JSON.parse(Buffer.from(cursor, 'base64url').toString()) : null; const posts = await prisma.post.findMany({ where: { published: true, ...(decodedCursor && { id: { lt: decodedCursor.id } }), // posts before cursor }, orderBy: { id: 'desc' }, take: limit + 1, // fetch one extra to determine hasMore }); const hasMore = posts.length > limit; const data = posts.slice(0, limit); const nextCursor = hasMore ? Buffer.from(JSON.stringify({ id: data[data.length - 1].id })).toString('base64url') : null; return { data, nextCursor, hasMore }; } // Response { "data": [...], "nextCursor": "eyJpZCI6MjJ9", "hasMore": true }

Cursor pagination is O(1) at any depth and is immune to page drift. Use it for feeds, activity streams, and any large dataset. Offset pagination is fine for admin UIs with small datasets where users need to jump to a specific page.


Filtering, Sorting, and Field Selection

Standard query parameter conventions:

typescript
// Filtering GET /posts?status=published&authorId=42 // Multiple values for same field GET /posts?status=published&status=draft // ?status[]=published&status[]=draft // Sorting (prefix - for descending) GET /posts?sort=-createdAt // newest first GET /posts?sort=title,-createdAt // title asc, then newest first // Field selection (sparse fieldsets) GET /posts?fields=id,title,createdAt // only return these fields // Full example GET /posts?status=published&authorId=42&sort=-createdAt&limit=20&cursor=xxx
typescript
// src/validators/posts.schema.ts export const listPostsSchema = z.object({ status: z.enum(['published', 'draft', 'archived']).optional(), authorId: z.coerce.number().optional(), sort: z.string().default('-createdAt'), limit: z.coerce.number().int().min(1).max(100).default(20), cursor: z.string().optional(), fields: z.string().optional(), // comma-separated field names });

OpenAPI 3.0 Documentation

OpenAPI (formerly Swagger) is the standard for describing REST APIs. A valid OpenAPI spec generates interactive documentation, client SDKs, and test mocks.

bash
npm install swagger-ui-express @types/swagger-ui-express npm install swagger-jsdoc @types/swagger-jsdoc

Defining the spec

typescript
// src/docs/swagger.ts import swaggerJsdoc from 'swagger-jsdoc'; export const swaggerSpec = swaggerJsdoc({ definition: { openapi: '3.0.0', info: { title: 'My API', version: '1.0.0', description: 'REST API documentation', }, servers: [ { url: 'http://localhost:3000', description: 'Development' }, { url: 'https://api.myapp.com', description: 'Production' }, ], components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', }, }, schemas: { User: { type: 'object', properties: { id: { type: 'integer', example: 42 }, name: { type: 'string', example: 'Jatin Saraf' }, email: { type: 'string', format: 'email' }, role: { type: 'string', enum: ['user', 'admin'] }, createdAt: { type: 'string', format: 'date-time' }, }, }, ValidationError: { type: 'object', properties: { error: { type: 'string', example: 'Validation failed' }, issues: { type: 'array', items: { type: 'object', properties: { field: { type: 'string' }, message: { type: 'string' }, }, }, }, }, }, }, }, }, apis: ['./src/routes/**/*.ts'], // JSDoc comments in route files });

Annotating routes with JSDoc

typescript
// src/routes/users.routes.ts /** * @openapi * /users: * post: * summary: Create a new user * tags: [Users] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [name, email, password] * properties: * name: * type: string * example: Jatin Saraf * email: * type: string * format: email * password: * type: string * minLength: 8 * responses: * 201: * description: User created * content: * application/json: * schema: * $ref: '#/components/schemas/User' * 400: * description: Validation failed * content: * application/json: * schema: * $ref: '#/components/schemas/ValidationError' * 409: * description: Email already registered */ router.post('/', validate(createUserSchema), usersController.createUser); /** * @openapi * /users/{id}: * get: * summary: Get user by ID * tags: [Users] * security: * - bearerAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: The user * content: * application/json: * schema: * $ref: '#/components/schemas/User' * 401: * description: Authentication required * 404: * description: User not found */ router.get('/:id', authenticate, validate(idParamSchema, 'params'), usersController.getUserById);

Serving Swagger UI

typescript
// src/app.ts import swaggerUi from 'swagger-ui-express'; import { swaggerSpec } from './docs/swagger.js'; // Only expose docs in non-production, or gate behind auth if (env.NODE_ENV !== 'production') { app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); app.get('/docs.json', (req, res) => res.json(swaggerSpec)); }

Visit http://localhost:3000/docs — interactive documentation where you can send real requests.


ETag Caching

ETags let clients avoid downloading unchanged resources:

typescript
import crypto from 'crypto'; export const getUserById = asyncHandler(async (req, res) => { const user = await usersService.findById(parseInt(req.params.id)); // Generate ETag from content hash const etag = `"${crypto.createHash('md5').update(JSON.stringify(user)).digest('hex')}"`; res.setHeader('ETag', etag); res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate'); // Client sends ETag back in If-None-Match header if (req.headers['if-none-match'] === etag) { return res.status(304).send(); // Not Modified — client uses cached version } res.json(user); });

Summary

  • URLs are nouns, methods are verbs. /users/42 is the resource; DELETE /users/42 is the operation. Verbs in URLs are a design smell.
  • Limit nesting to two levels. Deeper nesting is hard to maintain. Prefer top-level resources with filter params over deep nesting.
  • PATCH for partial updates, PUT for full replacement. PATCH is almost always what you want.
  • Idempotency matters for reliability. GET, DELETE, and PUT should be safely retryable. Design PATCH with idempotency in mind.
  • URL versioning (/v1/, /v2/) is the pragmatic choice. Start with /v1/ on day one.
  • Cursor pagination for large datasets and feeds — no page drift, O(1) query at any depth. Offset pagination for small admin datasets where jumping to page N matters.
  • OpenAPI 3.0 spec + Swagger UI = interactive docs your API consumers can use immediately. Generate from JSDoc comments in route files — documentation stays co-located with the code.

Next: GraphQL with Apollo Server — schema-first API design, queries, mutations, subscriptions, the N+1 problem and how DataLoader solves it, and when GraphQL is the better choice over REST.

Discussion