Skip to main content

REST Principles - Designing APIs That Don't Break Clients

Reading time: ~30 minutes | Level: Intermediate → Engineering

Before reading further, look at these two API designs for the exact same operations:

# Design A - the way many teams naturally start
POST /api/getUserData {"user_id": 123}
POST /api/deleteUser {"user_id": 123}
POST /api/updateUserEmail {"user_id": 123, "email": "[email protected]"}
POST /api/getUserOrders {"user_id": 123, "status": "pending"}
POST /api/cancelOrder {"user_id": 123, "order_id": 456}

# Design B - RESTful
GET /api/users/123
DELETE /api/users/123
PATCH /api/users/123 {"email": "[email protected]"}
GET /api/users/123/orders?status=pending
DELETE /api/orders/456

Both work. Both are valid HTTP. Design B is RESTful. What concrete, production problems does Design A cause?

Show Answer

Design A has six specific production problems:

1. Nothing is cacheable. Every route uses POST. HTTP proxies, CDNs, and browsers cache GET responses automatically. POST responses are never cached. Design A cannot benefit from edge caching even for read-heavy endpoints like user profile data. Every request hits the origin server.

2. No safe retries. POST is not idempotent. If POST /api/deleteUser times out mid-flight, the client cannot know whether the delete succeeded. Retrying risks nothing with a DELETE (idempotent), but with a POST there is no safe assumption. Retry logic requires application-layer deduplication instead of leveraging HTTP semantics.

3. No standard tooling. Load balancers, API gateways, and monitoring tools understand HTTP methods. A gateway can route GET to a read replica and POST/PUT/DELETE to the primary. It can separately track error rates for reads vs writes. Design A collapses all operations into POST - the gateway sees only POST traffic and cannot apply method-aware routing or rate limiting.

4. Documentation drift. There is no standard format for "here are all the operations this endpoint supports" when every operation is POST /api/someAction. OpenAPI generation becomes manual. Clients cannot autodiscover available operations. Design B is self-documenting through HTTP conventions.

5. Inconsistent client code. Every client (Python, JavaScript, mobile) that calls Design A must know the exact action string for every operation. Design B clients can be generated from an OpenAPI spec and updated automatically when the spec changes.

6. No standard error semantics. Design A returns 200 for everything and puts error information in the body - clients must parse the body on every call to determine success. Design B uses HTTP status codes that every HTTP client, library, and middleware understands natively.

REST is not about following rules for their own sake. Each REST constraint solves a concrete engineering problem. This lesson explains what those problems are and how the constraints solve them.

What You Will Learn

  • Roy Fielding's six REST constraints and the engineering problems each constraint solves
  • The uniform interface: URLs as nouns, HTTP methods as verbs, self-descriptive messages
  • URL design: plural nouns, nested resources, query parameters, consistent naming
  • HTTP method semantics for REST: which method for which operation, every time
  • Status codes for REST APIs: the full set a production API must handle
  • Pagination: limit/offset vs cursor-based vs page-based - tradeoffs for each
  • Versioning: URL path, header, and query param strategies - tradeoffs
  • Error response format: RFC 7807 Problem Details - the industry standard
  • Richardson Maturity Model: where most production APIs actually sit
  • OpenAPI / Swagger: machine-readable API contracts

Prerequisites

  • HTTP method semantics from Lesson 01 (safe, idempotent, wire format)
  • HTTP status codes from Lesson 01
  • Basic familiarity with web API concepts

Part 1 - Roy Fielding's Six REST Constraints

REST (Representational State Transfer) is an architectural style, not a protocol. Roy Fielding defined it in his 2000 PhD dissertation as a set of constraints that, when applied together, produce a system with specific desirable properties.

Constraint 1 - Client-Server Separation

The UI (client) and data storage (server) are separated by a uniform interface. Each can evolve independently.

Engineering consequence: Your Python API server does not need to know whether the client is a browser, a mobile app, a CLI tool, or another service. The server is responsible for data and business logic; the client is responsible for user interface and user interaction. This separation enables independent deployment cycles.

Constraint 2 - Stateless

Every request contains all information needed to process it. The server stores no session state between requests.

Engineering consequence: Any server instance can handle any request. Horizontal scaling (adding more server nodes behind a load balancer) is trivially correct - no sticky sessions, no session replication, no session migration on node failure. This is the constraint that makes REST services scale.

# Stateful (wrong for REST) - server tracks session
@app.route("/cart/add")
def add_to_cart():
session["cart"].append(request.json["item"]) # server holds state

# Stateless (correct for REST) - client sends full context
@app.route("/orders")
def create_order():
user_id = verify_jwt(request.headers["Authorization"]) # client sends identity
items = request.json["items"] # client sends full cart
return create_order_for_user(user_id, items)

Constraint 3 - Cacheable

Responses must label themselves as cacheable or non-cacheable. Cacheable responses may be reused by clients, proxies, and CDNs.

Engineering consequence: GET /users/123 can be cached by a CDN for 60 seconds, serving millions of reads without hitting your database. Cache-Control, ETag, and Last-Modified headers implement this. The constraint requires that endpoints which should be cached use GET (not POST), because only GET responses are cached by default.

Constraint 4 - Uniform Interface

Four sub-constraints define the uniform interface:

  1. Resource identification in requests: URLs identify resources. /users/123 identifies the user with ID 123. The resource (the concept of "user 123") is separate from its representation (JSON, XML, HTML).

  2. Manipulation of resources through representations: the client holds a representation of a resource and uses it to modify or delete the resource via standard HTTP methods.

  3. Self-descriptive messages: each message contains enough information to describe how to process it. Content-Type: application/json tells the receiver how to parse the body.

  4. HATEOAS (Hypermedia As The Engine Of Application State): responses include links to related actions. A GET /users/123 response might include "links": {"orders": "/users/123/orders", "delete": "/users/123"}.

Engineering consequence: Clients and servers can evolve independently because the interface contract is the uniform API, not knowledge baked into each side about the other's internals.

Constraint 5 - Layered System

The client cannot tell whether it is connected directly to the origin server or to an intermediate (load balancer, CDN, API gateway, caching proxy).

Engineering consequence: You can add a CDN in front of your API with zero client changes. You can route traffic through an API gateway for auth, rate limiting, and logging without changing any server code. The client talks to one URL and the infrastructure handles the rest.

Constraint 6 - Code on Demand (Optional)

Servers can send executable code to clients (e.g., JavaScript in browser responses). This is the only optional constraint.

Engineering consequence: Not relevant for most Python API work. REST is fully satisfied without this constraint.

Part 2 - URL Design

URLs Are Nouns, Methods Are Verbs

The URL identifies the resource. The HTTP method describes the operation. Never put verbs in URLs:

# Wrong - verb in URL
GET /api/getUser/123
POST /api/createUser
POST /api/deleteUser/123
POST /api/updateUser/123

# Right - nouns in URL, verbs are HTTP methods
GET /api/users/123
POST /api/users
DELETE /api/users/123
PATCH /api/users/123

Plural Nouns

Use plural nouns for collections, always:

/users # collection of users
/users/123 # specific user
/orders # collection of orders
/orders/456 # specific order
/products/789/reviews # reviews for product 789

Nested Resources

Use URL nesting for resources that belong to a parent resource:

GET /users/123/orders # all orders for user 123
GET /users/123/orders/456 # order 456 for user 123
POST /users/123/orders # create an order for user 123

:::warning Do Not Nest More Than Two Levels Deep Deep nesting creates long URLs that are brittle - every level couples the client to the parent chain. /users/123/orders/456/items/789/reviews/1 is unwieldy. If an order item has reviews, consider /order-items/789/reviews as a top-level resource. As a rule: nest one level for ownership (/users/123/orders); flatten at two levels or beyond. :::

Query Parameters for Filtering, Sorting, and Pagination

GET /users?status=active&role=admin # filter
GET /users?sort=created_at&order=desc # sort
GET /users?limit=25&offset=50 # pagination (limit/offset)
GET /users?page=3&per_page=25 # pagination (page-based)
GET /users?cursor=eyJpZCI6MTAwfQ== # pagination (cursor-based)
GET /orders?user_id=123&status=pending # filter by foreign key

Consistent Naming Conventions

Pick one and enforce it everywhere:

ConventionExample
snake_case (recommended for JSON)/user_profiles, field: first_name
kebab-case (common for URLs)/user-profiles, field: n/a
camelCase/userProfiles, field: firstName

The most common production pattern: kebab-case for URL paths, snake_case for JSON field names.

Part 3 - HTTP Method Semantics for REST

OperationMethodURLRequest BodyResponse
List resourcesGET/usersNone200 + array
Get one resourceGET/users/123None200 + object or 404
Create resourcePOST/usersNew resource data201 + object + Location header
Full replacePUT/users/123Complete resource200 + object
Partial updatePATCH/users/123Changed fields only200 + updated object
Delete resourceDELETE/users/123None204 no body
Check existenceHEAD/users/123None200 or 404, no body
List methodsOPTIONS/usersNoneAllow: GET, POST, HEAD

:::danger Never Use GET for State-Changing Operations A monitoring system, CDN, browser prefetch, or automated health checker may issue GET requests to any URL it finds. If your GET endpoint deletes a record, creates a resource, or charges a card, these non-human actors will trigger it. The rule has no exceptions in production. :::

Part 4 - Status Codes for REST APIs

The Complete Production Set

# Flask example demonstrating correct status code usage

from flask import Flask, jsonify, request

app = Flask(__name__)

@app.route("/users", methods=["POST"])
def create_user():
data = request.get_json()
if not data:
return jsonify({"error": "Request body required"}), 400 # Bad Request

if User.query.filter_by(email=data.get("email")).first():
return jsonify({"error": "Email already registered"}), 409 # Conflict

user = User.create(data)
return jsonify(user.to_dict()), 201, { # Created
"Location": f"/users/{user.id}"
}

@app.route("/users/<int:user_id>")
def get_user(user_id):
user = User.query.get(user_id)
if not user:
return jsonify({"error": "User not found"}), 404 # Not Found
return jsonify(user.to_dict()), 200 # OK

@app.route("/users/<int:user_id>", methods=["PATCH"])
def update_user(user_id):
if not is_authenticated(request):
return jsonify({"error": "Authentication required"}), 401 # Unauthenticated

user = User.query.get(user_id)
if not user:
return jsonify({"error": "User not found"}), 404

if not can_modify(current_user, user):
return jsonify({"error": "Insufficient permissions"}), 403 # Forbidden

errors = validate_user_update(request.get_json())
if errors:
return jsonify({"errors": errors}), 422 # Validation Error

user.update(request.get_json())
return jsonify(user.to_dict()), 200

@app.route("/users/<int:user_id>", methods=["DELETE"])
def delete_user(user_id):
user = User.query.get(user_id)
if not user:
return jsonify({}), 204 # Idempotent: already gone = success
user.delete()
return "", 204 # No Content - success with no body

Status Code Quick Reference

CodeNameREST usage
200 OKSuccess with bodyGET, PATCH, PUT responses
201 CreatedNew resourcePOST that creates; include Location header
204 No ContentSuccess, no bodyDELETE; PUT/PATCH when no response body needed
400 Bad RequestMalformed requestMissing required field, wrong type
401 UnauthorizedUnauthenticatedNo token, expired token, invalid token
403 ForbiddenAuthorized but lacks permissionToken valid but role insufficient
404 Not FoundResource absentID does not exist
405 Method Not AllowedWrong methodPOST to a GET-only endpoint
409 ConflictDuplicate or conflictEmail already registered, optimistic lock conflict
410 GonePermanently deletedResource existed but was permanently removed
422 Unprocessable EntityValidation failedValid JSON but invalid business rules
429 Too Many RequestsRate limitedInclude Retry-After header
500 Internal Server ErrorServer bugUnhandled exception
503 Service UnavailableServer overloaded or downInclude Retry-After if temporary

:::note 401 vs 403: The Persistent Confusion HTTP chose misleading names. 401 Unauthorized means "you have not proven who you are" (authentication missing or failed). 403 Forbidden means "I know who you are but you are not allowed" (authorization check failed). The names are wrong but the codes are correct. Return 401 when the request lacks valid credentials. Return 403 when it has credentials but the role or permission check fails. :::

Part 5 - Pagination Patterns

Pagination is required for any collection endpoint where the number of items can grow unboundedly. Three patterns exist:

Limit/Offset Pagination

# Request
GET /users?limit=25&offset=50

# Response
{
"data": [...], # 25 items
"pagination": {
"total": 1247,
"limit": 25,
"offset": 50,
"next": "/users?limit=25&offset=75",
"prev": "/users?limit=25&offset=25"
}
}

Tradeoffs:

  • Simple to implement and understand
  • Works with SQL LIMIT/OFFSET directly
  • Problem: as offset grows, the database scans and discards offset rows before returning results - performance degrades linearly with offset value
  • Problem: if a record is inserted between page 1 and page 2 being fetched, page 2 contains a duplicate
  • Use when: total item count is needed, data is mostly static, offsets stay small

Cursor-Based (Keyset) Pagination

# Request - first page
GET /users?limit=25

# Response
{
"data": [...],
"pagination": {
"next_cursor": "eyJpZCI6MTI1fQ==", # base64("{"id":125}")
"has_more": true
}
}

# Next page - send cursor
GET /users?limit=25&cursor=eyJpZCI6MTI1fQ==
# Server implementation
from base64 import b64decode, b64encode
import json

def get_users_cursor(cursor: str = None, limit: int = 25):
query = User.query.order_by(User.id)

if cursor:
cursor_data = json.loads(b64decode(cursor))
query = query.filter(User.id > cursor_data["id"])

users = query.limit(limit + 1).all()

has_more = len(users) > limit
if has_more:
users = users[:limit]

next_cursor = None
if has_more:
next_cursor = b64encode(
json.dumps({"id": users[-1].id}).encode()
).decode()

return {"data": [u.to_dict() for u in users], "next_cursor": next_cursor}

Tradeoffs:

  • O(1) database cost regardless of page depth - uses an index seek, not a scan
  • No duplicate/missing records on concurrent inserts
  • Cannot jump to page N arbitrarily (no random access)
  • Cannot show total count (count query required separately)
  • Use when: large datasets, real-time data, feeds, infinite scroll

Page-Based Pagination

GET /users?page=3&per_page=25
# Equivalent to limit/offset with offset = (page-1)*per_page

Simple UI affordance (page numbers), but has the same database performance and consistency issues as limit/offset. Appropriate for admin interfaces and small datasets.

:::tip Use Cursor-Based Pagination for Production APIs Limit/offset pagination is simple to implement and understand but degrades in performance and correctness as datasets grow. For any API that will handle large or growing datasets - user tables, order histories, event logs - implement cursor-based pagination from the start. Migrating pagination strategy later breaks every existing client. :::

Part 6 - API Versioning Strategies

URL Path Versioning (Most Common)

/v1/users/123
/v2/users/123 # new version, can coexist with v1

Pros: Explicit, visible in logs and monitoring, easy to route at the load balancer level, bookmark-able.

Cons: "Pollutes" the URL structure; REST purists argue the URL should identify the resource, not the API version.

Industry use: Stripe, GitHub, Twilio, and most major public APIs use URL versioning.

Header Versioning

GET /users/123
Accept: application/vnd.myapi.v2+json

Pros: URL stays clean; version is part of content negotiation (semantically correct per REST).

Cons: Not visible in browser address bar, not bookmarkable, harder to debug with curl, requires header-aware routing.

Industry use: GitHub's API supports this pattern; rarely the primary mechanism.

Query Parameter Versioning

GET /users/123?version=2

Pros: Works in browsers without configuration, cacheable.

Cons: Cache behavior is version-specific; easy to forget the parameter and silently get v1.

Industry use: Google APIs use ?v= parameters for some services.

:::warning Versioning Strategy Cannot Be Changed Easily Whatever versioning strategy you choose is baked into every client that calls your API. Changing from URL versioning to header versioning requires every client to change their code. Choose URL path versioning for public APIs (explicit, visible, debuggable, supported by all tools). Use header versioning internally if your team has strong opinions about URL purity. :::

Part 7 - RFC 7807 Error Responses

RFC 7807 defines the "Problem Details" standard for HTTP API error responses. It gives error bodies a standard shape that clients can handle generically:

# RFC 7807 Problem Details format
{
"type": "https://api.example.com/errors/insufficient-funds", # URI identifying the error type
"title": "Insufficient Funds", # human-readable short description
"status": 422, # HTTP status code (repeated for convenience)
"detail": "Your balance of $10.00 is less than the charge of $50.00", # specific instance detail
"instance": "/transactions/failed/f47ac10b" # URI of the specific occurrence
}
# Flask implementation of RFC 7807
from flask import jsonify

def problem(
type_: str,
title: str,
status: int,
detail: str = None,
instance: str = None,
**extensions
) -> tuple:
body = {
"type": type_,
"title": title,
"status": status,
}
if detail:
body["detail"] = detail
if instance:
body["instance"] = instance
body.update(extensions) # allow API-specific extensions

return jsonify(body), status, {
"Content-Type": "application/problem+json"
}


# Usage in views
@app.route("/users/<int:user_id>", methods=["DELETE"])
def delete_user(user_id):
user = User.query.get(user_id)
if not user:
return problem(
type_="https://api.example.com/errors/user-not-found",
title="User Not Found",
status=404,
detail=f"No user exists with ID {user_id}",
instance=f"/users/{user_id}",
)
user.delete()
return "", 204

:::tip Use RFC 7807 for All Error Responses A consistent error format means clients can write a single error handler for all API errors. Without a standard, every endpoint has its own error structure - some return {"error": "..."}, some return {"message": "..."}, some return {"errors": [...]}. Clients must handle each case separately. RFC 7807 with Content-Type: application/problem+json signals to clients that the body follows the standard schema. :::

Part 8 - Richardson Maturity Model

The Richardson Maturity Model (RMM) describes how RESTful an API is on a scale from 0 to 3. Most production APIs sit at Level 2.

Level 0: One endpoint (/api/rpc), every operation is POST, operation name is in the body. This is how SOAP and XML-RPC work. Caching is impossible; no semantics are conveyed by the URL or method.

Level 1: Each resource has its own URL (/users, /orders). Still uses POST for all operations. Better than Level 0 (routing is cleaner) but all the HTTP method semantics are lost.

Level 2: Resources + correct HTTP methods + meaningful status codes. This is what "REST" means in practice at most companies. Caching works for GET. Proxies understand method semantics. OpenAPI can describe the API. This is the industry standard target.

Level 3 (HATEOAS): Responses include hypermedia links to next possible actions. A GET /orders/456 response includes "links": {"cancel": "/orders/456/cancel", "items": "/orders/456/items"}. Clients navigate by following links rather than by hardcoding URLs. This is theoretically ideal (clients and servers can evolve independently without URL coupling) but is rarely implemented in practice because most API clients are generated from specs, not link-following agents.

:::note HATEOAS Is Theoretically Ideal, Practically Rare The REST dissertation assumes clients navigate APIs like browsers navigate websites - by following links. Most API clients are instead generated from OpenAPI specs and know the URL structure at code-generation time. HATEOAS adds response payload overhead and requires sophisticated client-side link-following logic that few teams implement. Level 2 with a clean OpenAPI spec achieves most of HATEOAS's practical benefits. Build to Level 2; consider HATEOAS only if your client ecosystem genuinely benefits from runtime navigability. :::

Part 9 - OpenAPI / Swagger

OpenAPI (formerly Swagger) is a machine-readable specification for HTTP APIs. It describes every endpoint, every request parameter, every request body schema, every response code, and every response body schema:

# openapi.yaml - abbreviated example
openapi: "3.1.0"
info:
title: User API
version: "1.0.0"
paths:
/users/{user_id}:
get:
summary: Get a user
parameters:
- name: user_id
in: path
required: true
schema:
type: integer
responses:
"200":
description: User found
content:
application/json:
schema:
$ref: "#/components/schemas/User"
"404":
description: User not found
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"

Why OpenAPI matters in production:

  • Client generation: tools like openapi-generator produce Python, JavaScript, Go, and Java clients directly from the spec - no manual client writing
  • Documentation: Swagger UI and Redoc render interactive documentation automatically
  • Contract testing: API changes that break the spec are caught in CI before deployment
  • Validation: middleware can validate incoming requests against the spec before they reach your view code
  • Mocking: API consumers can generate mock servers from the spec before the real implementation exists

FastAPI generates OpenAPI specifications automatically from Python type annotations. Flask requires manual spec writing or extensions like flask-smorest.

Graded Practice Challenges

Level 1 - Predict and Identify

Question 1: A developer builds this endpoint. What REST principle does it violate, and what concrete problem will it cause?

@app.route("/get-user-and-deactivate/<int:user_id>")
def get_and_deactivate(user_id):
user = User.query.get(user_id)
user.active = False
db.session.commit()
return jsonify(user.to_dict()), 200
Show Answer

Two violations:

  1. Verb in the URL: get-and-deactivate is a verb phrase. URLs should be nouns identifying resources.

  2. GET performs a state change: the GET method is supposed to be safe (read-only). Any proxy, CDN, browser prefetch, monitoring tool, or load balancer health check that hits this URL will deactivate users. The operation should be:

# Correct design
PATCH /users/123 {"active": false}
# or
DELETE /users/123 # if deactivation means deletion

The practical consequence: a monitoring system that probes GET /get-user-and-deactivate/1 every 30 seconds will deactivate user 1 every 30 seconds. This is a production incident, not a design disagreement.

Question 2: At what Richardson Maturity Model level is this API?

POST /api/rpc
Body: {"method": "getUser", "params": {"id": 123}}

POST /api/rpc
Body: {"method": "createOrder", "params": {"user_id": 123, "items": [...]}}
Show Answer

Level 0 - The Swamp of POX.

One endpoint (/api/rpc), all operations via POST, operation name in the request body. No HTTP method semantics. No URL-based resource identification. No cacheable responses.

This is the SOAP/XML-RPC pattern. It works but loses all benefits of HTTP infrastructure: no caching, no standard error handling, no automatic tooling support, no method-aware routing at the load balancer.

Question 3: Which pagination pattern should you choose for a real-time event feed with millions of entries, and why?

Show Answer

Cursor-based (keyset) pagination.

Reasons:

  1. Performance: limit/offset pagination requires the database to scan and discard offset rows. For page 1000 with 25 items per page, that is 24,975 rows discarded. Cursor-based pagination uses an index seek directly to the cursor position - O(1) cost regardless of depth.

  2. Correctness: in a real-time feed, new events are continuously inserted. With limit/offset, if 5 events are inserted between page 1 and page 2 being fetched, page 2 contains 5 duplicates of page 1. Cursor-based pagination anchors to a specific row ID - new inserts do not affect the cursor position.

  3. Scalability: a million-entry event feed with limit/offset will become unusable at high page numbers. Cursor-based remains constant-time.

Question 4: What is wrong with this error response, and what should it look like?

@app.route("/users/<int:user_id>", methods=["DELETE"])
def delete_user(user_id):
user = User.query.get(user_id)
if not user:
return jsonify({"success": False, "msg": "not found"}), 200
user.delete()
return jsonify({"success": True}), 200
Show Answer

Three problems:

  1. Wrong status code for error: returning 200 for "not found" forces clients to parse the body to detect errors. HTTP status codes exist to convey success/failure - 404 is the correct code.

  2. Wrong status code for success: DELETE should return 204 No Content with no body, not 200 with a body.

  3. Non-standard error format: {"success": False, "msg": "not found"} is ad-hoc. Every client must know this specific structure.

Corrected version (RFC 7807):

@app.route("/users/<int:user_id>", methods=["DELETE"])
def delete_user(user_id):
user = User.query.get(user_id)
if not user:
return jsonify({
"type": "https://api.example.com/errors/not-found",
"title": "User Not Found",
"status": 404,
"detail": f"No user with ID {user_id} exists",
}), 404, {"Content-Type": "application/problem+json"}
user.delete()
return "", 204

Level 2 - Debug and Fix

Find and fix all REST design violations in this Flask API:

from flask import Flask, request, jsonify

app = Flask(__name__)

# Route 1
@app.route("/api/getAllActiveUsers")
def get_all_active_users():
users = User.query.filter_by(active=True).all()
return jsonify([u.to_dict() for u in users])

# Route 2
@app.route("/api/createNewUser", methods=["POST"])
def create_new_user():
data = request.get_json()
user = User(**data)
db.session.add(user)
db.session.commit()
return jsonify({"status": "created", "id": user.id}), 200

# Route 3
@app.route("/api/user/<id>/delete", methods=["GET"])
def delete_user(id):
user = User.query.get(id)
if user:
db.session.delete(user)
db.session.commit()
return jsonify({"deleted": True}), 200
return jsonify({"deleted": False, "error": "not found"}), 200

# Route 4
@app.route("/api/user/<id>", methods=["POST"])
def update_user(id):
data = request.get_json()
user = User.query.get(id)
for k, v in data.items():
setattr(user, k, v)
db.session.commit()
return jsonify(user.to_dict()), 200
Show Solution

Route 1 - Verb in URL, missing filter as query param:

# Wrong: /api/getAllActiveUsers
# Right: /users?active=true
@app.route("/users")
def list_users():
active = request.args.get("active", type=bool)
query = User.query
if active is not None:
query = query.filter_by(active=active)
return jsonify([u.to_dict() for u in query.all()]), 200

Route 2 - Verb in URL, wrong success status code:

# Wrong: /api/createNewUser, returns 200
# Right: /users, returns 201 + Location header
@app.route("/users", methods=["POST"])
def create_user():
data = request.get_json()
user = User(**data)
db.session.add(user)
db.session.commit()
return jsonify(user.to_dict()), 201, {"Location": f"/users/{user.id}"}

Route 3 - Verb in URL, GET performs state change, wrong status codes:

# Wrong: GET /api/user/<id>/delete, returns 200 for both success and failure
# Right: DELETE /users/<id>, returns 204 for success, 404 for not found
@app.route("/users/<int:user_id>", methods=["DELETE"])
def delete_user(user_id):
user = User.query.get(user_id)
if not user:
return jsonify({
"type": "https://api.example.com/errors/not-found",
"title": "Not Found",
"status": 404,
}), 404
db.session.delete(user)
db.session.commit()
return "", 204

Route 4 - Wrong method (POST instead of PATCH), no 404 handling:

# Wrong: POST for update, crashes if user not found
# Right: PATCH /users/<id>, handles 404
@app.route("/users/<int:user_id>", methods=["PATCH"])
def update_user(user_id):
user = User.query.get(user_id)
if not user:
return jsonify({
"type": "https://api.example.com/errors/not-found",
"title": "Not Found",
"status": 404,
}), 404
data = request.get_json()
for k, v in data.items():
setattr(user, k, v)
db.session.commit()
return jsonify(user.to_dict()), 200

Level 3 - Design Challenge

Design the REST API for an e-commerce platform with:

  • Users, products, orders, and order items
  • Product reviews (a user can review a product they ordered)
  • Order status workflow: pending → confirmed → shipped → delivered → returned

Specify: URL structure, HTTP methods, status codes for all success and error cases, pagination strategy, versioning strategy, and RFC 7807 error types.

Show Reference Solution
Base URL: https://api.shop.example.com/v1

# USERS
GET /users # list users (admin only)
POST /users # register
GET /users/{id} # get profile
PATCH /users/{id} # update profile
DELETE /users/{id} # deactivate account

# PRODUCTS
GET /products # list with ?category=&min_price=&max_price=&sort=&cursor=
POST /products # create (admin)
GET /products/{id} # get product
PATCH /products/{id} # update (admin)
DELETE /products/{id} # archive (admin), returns 204

# PRODUCT REVIEWS (owned by user, related to product)
GET /products/{id}/reviews # list reviews with ?cursor=&limit=
POST /products/{id}/reviews # create review (must have ordered product)
PATCH /reviews/{id} # update own review
DELETE /reviews/{id} # delete own review, returns 204

# ORDERS
GET /orders # list caller's orders with ?status=&cursor=
POST /orders # create order
GET /orders/{id} # get order (owner or admin)
GET /orders/{id}/items # list items in order

# ORDER STATUS TRANSITIONS (sub-resources for actions)
POST /orders/{id}/confirm # 200 + updated order or 409 (wrong status)
POST /orders/{id}/ship # 200 + tracking info or 409
POST /orders/{id}/deliver # 200 + updated order or 409
POST /orders/{id}/return # 200 + return details or 422 (past return window)

# Status codes:
# 200 - read success (GET, PATCH, PUT, status transitions)
# 201 - create success (POST /users, POST /products, POST /orders, POST /reviews)
# 204 - delete success (DELETE *, no body)
# 400 - malformed request body
# 401 - missing/invalid auth token
# 403 - authenticated but insufficient permission
# 404 - resource not found
# 409 - conflict (duplicate review, invalid state transition)
# 422 - validation error (invalid email, price < 0, past return window)
# 429 - rate limited (include Retry-After)

# Pagination: cursor-based for all collections
# (order histories and review feeds can grow unboundedly)

# Versioning: URL path /v1/ (explicit, debuggable, CDN-routable)

# Error format: RFC 7807
# type URIs define categories:
# https://api.shop.example.com/errors/not-found
# https://api.shop.example.com/errors/insufficient-funds
# https://api.shop.example.com/errors/invalid-state-transition
# https://api.shop.example.com/errors/review-already-exists
# https://api.shop.example.com/errors/return-window-expired

# Example: trying to ship an unconfirmed order
{
"type": "https://api.shop.example.com/errors/invalid-state-transition",
"title": "Invalid State Transition",
"status": 409,
"detail": "Order abc123 is in state 'pending' and cannot be shipped. Confirm the order first.",
"instance": "/orders/abc123",
"current_status": "pending",
"required_status": "confirmed",
"allowed_transitions": ["confirm", "cancel"]
}

Design decisions:

  • Order status transitions use POST to sub-resource URLs (/orders/{id}/confirm) rather than PATCH with {"status": "confirmed"} - this makes the action explicit, allows validation of the transition, and returns transition-specific response bodies (tracking info on ship, return label URL on return)
  • Reviews are accessible both via /products/{id}/reviews (product context) and directly via /reviews/{id} for mutation - this is the two-level nesting rule in practice
  • Cursor-based pagination for all collections - product catalogs can have millions of items; order histories grow indefinitely
  • RFC 7807 error bodies include current_status, required_status, and allowed_transitions as extensions - API-specific fields that help clients display useful error messages without parsing detail strings

Key Takeaways

  • REST is an architectural style with six constraints; each constraint solves a concrete engineering problem - statelessness enables horizontal scaling, cacheability enables CDN offload, uniform interface enables independent evolution
  • URLs are nouns identifying resources; HTTP methods are verbs describing operations. Never put verbs in URLs. Never use GET for operations with side effects
  • GET, HEAD, OPTIONS are safe and idempotent; PUT and DELETE are idempotent; POST and PATCH are neither - these properties determine what infrastructure can cache and retry
  • Use plural nouns for collections (/users), nest one level for owned resources (/users/123/orders), flatten beyond that
  • Status codes are part of the API contract: 201 + Location for create, 204 for delete, 422 for validation errors, 409 for conflicts, 429 for rate limiting with Retry-After
  • 401 means unauthenticated (credentials missing or invalid); 403 means authorized identity lacks permission - the HTTP names are misleading but the semantics are standard
  • Cursor-based pagination is correct for large and real-time datasets; limit/offset degrades with depth and has correctness issues under concurrent writes
  • URL path versioning (/v1/) is the industry standard for public APIs: explicit, visible in logs, routable at the load balancer
  • RFC 7807 Problem Details gives errors a standard shape (type, title, status, detail, instance) with Content-Type: application/problem+json - use it for all error responses
  • The Richardson Maturity Model: Level 0 (one POST endpoint), Level 1 (URL per resource), Level 2 (HTTP verbs + status codes - industry standard), Level 3 (HATEOAS - theoretically ideal, rarely implemented)
  • OpenAPI generates documentation, client SDKs, validation middleware, and mock servers from a single machine-readable spec - FastAPI generates it automatically; Flask requires extensions

What's Next

Lesson 03 covers Flask - the micro-framework that puts these REST principles into code. You will build a production-quality Flask API with the application factory pattern, Blueprints for modular routing, consistent error handlers, and a test suite using app.test_client(). The lesson opens with a Flask endpoint that silently returns 500 for three distinct failure modes - and shows exactly how to fix all three.

© 2026 EngineersOfAI. All rights reserved.