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:
-
Resource identification in requests: URLs identify resources.
/users/123identifies the user with ID 123. The resource (the concept of "user 123") is separate from its representation (JSON, XML, HTML). -
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.
-
Self-descriptive messages: each message contains enough information to describe how to process it.
Content-Type: application/jsontells the receiver how to parse the body. -
HATEOAS (Hypermedia As The Engine Of Application State): responses include links to related actions. A
GET /users/123response 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:
| Convention | Example |
|---|---|
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
| Operation | Method | URL | Request Body | Response |
|---|---|---|---|---|
| List resources | GET | /users | None | 200 + array |
| Get one resource | GET | /users/123 | None | 200 + object or 404 |
| Create resource | POST | /users | New resource data | 201 + object + Location header |
| Full replace | PUT | /users/123 | Complete resource | 200 + object |
| Partial update | PATCH | /users/123 | Changed fields only | 200 + updated object |
| Delete resource | DELETE | /users/123 | None | 204 no body |
| Check existence | HEAD | /users/123 | None | 200 or 404, no body |
| List methods | OPTIONS | /users | None | Allow: 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
| Code | Name | REST usage |
|---|---|---|
200 OK | Success with body | GET, PATCH, PUT responses |
201 Created | New resource | POST that creates; include Location header |
204 No Content | Success, no body | DELETE; PUT/PATCH when no response body needed |
400 Bad Request | Malformed request | Missing required field, wrong type |
401 Unauthorized | Unauthenticated | No token, expired token, invalid token |
403 Forbidden | Authorized but lacks permission | Token valid but role insufficient |
404 Not Found | Resource absent | ID does not exist |
405 Method Not Allowed | Wrong method | POST to a GET-only endpoint |
409 Conflict | Duplicate or conflict | Email already registered, optimistic lock conflict |
410 Gone | Permanently deleted | Resource existed but was permanently removed |
422 Unprocessable Entity | Validation failed | Valid JSON but invalid business rules |
429 Too Many Requests | Rate limited | Include Retry-After header |
500 Internal Server Error | Server bug | Unhandled exception |
503 Service Unavailable | Server overloaded or down | Include 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/OFFSETdirectly - Problem: as offset grows, the database scans and discards
offsetrows 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-generatorproduce 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:
-
Verb in the URL:
get-and-deactivateis a verb phrase. URLs should be nouns identifying resources. -
GET performs a state change: the
GETmethod 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:
-
Performance: limit/offset pagination requires the database to scan and discard
offsetrows. 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. -
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.
-
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:
-
Wrong status code for error: returning
200for "not found" forces clients to parse the body to detect errors. HTTP status codes exist to convey success/failure -404is the correct code. -
Wrong status code for success: DELETE should return
204 No Contentwith no body, not200with a body. -
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, andallowed_transitionsas extensions - API-specific fields that help clients display useful error messages without parsingdetailstrings
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,OPTIONSare safe and idempotent;PUTandDELETEare idempotent;POSTandPATCHare 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+Locationfor create,204for delete,422for validation errors,409for conflicts,429for rate limiting withRetry-After 401means unauthenticated (credentials missing or invalid);403means 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) withContent-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.
