CMPUT-404 · Lesson 9 of 12 · 36 min
Designing APIs
An application programming interface (API) lets other programs use your application: your own front end, a mobile app, a partner's system, or a script written by a student. Because APIs are used by code you do not control, they are much harder to change than a web page; a poorly chosen URL or field name may have to be supported for years. This lesson covers the dominant style, REST over HTTP with JSON, in depth with a working server and client, then describes it formally with OpenAPI, and compares GraphQL, gRPC, WebSockets and Server-Sent Events, explaining when each fits. Lesson 1 introduced HTTP methods and status codes; here they become design tools.
Resources and REST
REST (representational state transfer), described by Roy Fielding in his 2000 dissertation, models an API as resources identified by URLs and manipulated through a uniform interface: the standard HTTP methods. Collections are plural nouns (/v1/rooms), members have identifiers (/v1/rooms/R002), and relationships nest one level when it is natural (/v1/rooms/R002/bookings). Verbs belong in methods, not paths: POST /v1/bookings creates a booking, rather than /createBooking. Responses use the status codes clients already understand, so generic tools, caches and libraries work without special knowledge.
Safe methods do not change state; idempotent methods have the same effect whether applied once or several times, which matters because networks fail and clients retry. Error responses deserve as much care as successes. RFC 9457 defines problem details, a standard JSON shape with type, title, status and detail fields and the media type application/problem+json, so clients can handle errors uniformly. Use 400 for malformed requests, 401 when authentication is missing, 403 when it is insufficient, 404 for unknown resources, 409 for conflicts with current state, 412 for failed preconditions, 422 for well-formed but invalid content, and 429 when rate-limited (lesson 10).
A working REST API
The example below implements a small booking API with Python's standard library and exercises it with urllib, showing the features that make an API robust in practice: cursor pagination, conditional requests with ETags, optimistic concurrency with If-Match, idempotency keys for safe retries, 201 Created with a Location header, and problem details for errors.
# A small REST API with a real HTTP server and a urllib client:
# JSON resources, 201 + Location, problem details, cursor pagination, ETag/If-Match and idempotency keys.
import json, hashlib, threading, urllib.request, urllib.error
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
ROOMS = {f"R{i:03d}": {"id": f"R{i:03d}", "building": ["Library", "Science", "Arts"][i % 3], "seats": 4 + i % 9}
for i in range(1, 8)}
BOOKINGS, IDEMPOTENCY = {}, {}
def etag(obj): return '"' + hashlib.sha256(json.dumps(obj, sort_keys=True).encode()).hexdigest()[:12] + '"'
class API(BaseHTTPRequestHandler):
def send(self, code, body=None, headers=(), ctype="application/json"):
data = json.dumps(body).encode() if body is not None else b""
self.send_response(code)
if data: self.send_header("Content-Type", ctype)
for k, v in headers: self.send_header(k, v)
self.send_header("Content-Length", str(len(data))); self.end_headers(); self.wfile.write(data)
def problem(self, code, title, detail): # RFC 9457 problem details
self.send(code, {"type": "about:blank", "title": title, "status": code, "detail": detail},
ctype="application/problem+json")
def body(self):
return json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
def do_GET(self):
u = urlparse(self.path); q = parse_qs(u.query)
if u.path == "/v1/rooms":
after, limit = q.get("cursor", [""])[0], min(int(q.get("limit", ["3"])[0]), 100)
items = [r for k, r in sorted(ROOMS.items()) if k > after][:limit]
nxt = items[-1]["id"] if len(items) == limit else None
return self.send(200, {"items": items, "next_cursor": nxt})
if u.path.startswith("/v1/rooms/"):
room = ROOMS.get(u.path.rsplit("/", 1)[1])
if not room: return self.problem(404, "Not Found", "no such room")
if self.headers.get("If-None-Match") == etag(room): return self.send(304, headers=[("ETag", etag(room))])
return self.send(200, room, [("ETag", etag(room)), ("Cache-Control", "max-age=60")])
self.problem(404, "Not Found", "unknown resource")
def do_POST(self):
if self.path != "/v1/bookings": return self.problem(404, "Not Found", "unknown resource")
key = self.headers.get("Idempotency-Key")
if key in IDEMPOTENCY: return self.send(*IDEMPOTENCY[key]) # replay the stored response
b = self.body()
if b.get("room_id") not in ROOMS or not isinstance(b.get("slot"), int):
return self.problem(422, "Unprocessable Content", "room_id must exist and slot must be an integer")
bid = f"B{len(BOOKINGS) + 1}"
BOOKINGS[bid] = {"id": bid, **b}
result = (201, BOOKINGS[bid], [("Location", f"/v1/bookings/{bid}")])
if key: IDEMPOTENCY[key] = result
self.send(*result)
def do_PATCH(self):
room = ROOMS.get(self.path.rsplit("/", 1)[1])
if not room: return self.problem(404, "Not Found", "no such room")
if self.headers.get("If-Match") != etag(room):
return self.problem(412, "Precondition Failed", "room changed since you read it")
room.update({k: v for k, v in self.body().items() if k in ("seats", "building")})
self.send(200, room, [("ETag", etag(room))])
def log_message(self, *a): pass
srv = ThreadingHTTPServer(("127.0.0.1", 0), API)
threading.Thread(target=srv.serve_forever, daemon=True).start()
BASE = f"http://127.0.0.1:{srv.server_port}"
def call(method, path, body=None, **headers):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data, method=method,
headers={"Content-Type": "application/json", **headers})
try:
with urllib.request.urlopen(req) as r: return r.status, dict(r.headers), json.loads(r.read() or b"null")
except urllib.error.HTTPError as e:
raw = e.read(); return e.code, dict(e.headers), json.loads(raw) if raw else None
cursor, page = "", 1
while True: # follow cursors until the last page
s, h, b = call("GET", f"/v1/rooms?limit=3&cursor={cursor}")
print(f"page {page}: {[r['id'] for r in b['items']]} next={b['next_cursor']}")
if not b["next_cursor"]: break
cursor, page = b["next_cursor"], page + 1
s, h, room = call("GET", "/v1/rooms/R002"); tag = h["ETag"]
print("GET R002:", s, room, "ETag", tag)
print("conditional GET:", call("GET", "/v1/rooms/R002", **{"If-None-Match": tag})[0])
print("PATCH with fresh ETag:", call("PATCH", "/v1/rooms/R002", {"seats": 9}, **{"If-Match": tag})[0])
print("PATCH with stale ETag:", call("PATCH", "/v1/rooms/R002", {"seats": 2}, **{"If-Match": tag})[2]["title"])
for attempt in (1, 2): # client retries after a timeout: same key
s, h, b = call("POST", "/v1/bookings", {"room_id": "R002", "slot": 9}, **{"Idempotency-Key": "c0ffee-42"})
print(f"POST attempt {attempt}: {s} Location={h['Location']} id={b['id']}")
print("bookings stored:", len(BOOKINGS))
s, h, b = call("POST", "/v1/bookings", {"room_id": "R999", "slot": "nine"})
print("bad POST:", s, h["Content-Type"], "-", b["detail"])
srv.shutdown()page 1: ['R001', 'R002', 'R003'] next=R003
page 2: ['R004', 'R005', 'R006'] next=R006
page 3: ['R007'] next=None
GET R002: 200 {'id': 'R002', 'building': 'Arts', 'seats': 6} ETag "96954d194f4d"
conditional GET: 304
PATCH with fresh ETag: 200
PATCH with stale ETag: Precondition Failed
POST attempt 1: 201 Location=/v1/bookings/B1 id=B1
POST attempt 2: 201 Location=/v1/bookings/B1 id=B1
bookings stored: 1
bad POST: 422 application/problem+json - room_id must exist and slot must be an integerEach feature solves a practical problem. Cursor pagination, the API form of lesson 8's keyset pagination, returns a next_cursor the client passes back unchanged (production APIs usually encode it, for example in base64, so clients treat it as opaque and do not build their own), so pages stay consistent even as rooms are added. The ETag lets a client revalidate cheaply and receive 304 with no body. If-Match makes updates conditional: the second PATCH carried the ETag from before the first update, so the server refused it with 412 rather than silently overwriting someone else's change. The Idempotency-Key header, a pattern popularized by payment APIs such as Stripe and being standardized by the IETF, lets a client that timed out retry a POST safely: the server recognizes the key and replays the original response instead of creating a second booking. Finally, the invalid request received a machine-readable 422 problem response.
Versioning and evolution
APIs must evolve without breaking existing clients. Additive changes, such as new optional fields, new endpoints or new optional parameters, are safe if clients ignore fields they do not recognize, a rule worth stating in documentation. Removing or renaming fields, changing types or making optional fields required are breaking changes that need a new version. Common approaches put the major version in the path (/v1/), in a header, or in a dated version string, as Stripe does. When retiring a version, announce it early and use the Deprecation and Sunset response headers to warn clients programmatically.
Describing APIs with OpenAPI
The OpenAPI Specification (formerly Swagger), now at version 3.1 and aligned with JSON Schema, describes an HTTP API's paths, parameters, request bodies, responses and data schemas in a YAML or JSON document. From one description, tools generate interactive documentation, client libraries in many languages, server stubs, request validators and mock servers. Teams either write the description first and build to it (design-first) or generate it from code, as FastAPI does from Python type hints. The description below covers part of the booking API; a short check confirms every schema reference resolves.
# An OpenAPI 3.1 description, written as data and validated for basic consistency.
import json
spec = {
"openapi": "3.1.0",
"info": {"title": "Room Booking API", "version": "1.0.0"},
"paths": {
"/v1/rooms/{roomId}": {"get": {
"summary": "Get one room",
"parameters": [{"name": "roomId", "in": "path", "required": True, "schema": {"type": "string"}}],
"responses": {"200": {"description": "The room",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Room"}}}},
"404": {"description": "No such room"}}}},
"/v1/bookings": {"post": {
"summary": "Create a booking",
"parameters": [{"name": "Idempotency-Key", "in": "header", "schema": {"type": "string"}}],
"requestBody": {"required": True, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/NewBooking"}}}},
"responses": {"201": {"description": "Created"}, "422": {"description": "Invalid booking"}}}}},
"components": {"schemas": {
"Room": {"type": "object", "required": ["id", "building", "seats"],
"properties": {"id": {"type": "string"}, "building": {"type": "string"}, "seats": {"type": "integer", "minimum": 1}}},
"NewBooking": {"type": "object", "required": ["room_id", "slot"],
"properties": {"room_id": {"type": "string"}, "slot": {"type": "integer", "minimum": 0, "maximum": 23}}}}}}
refs = [v for v in json.dumps(spec).split('"') if v.startswith("#/components/")]
missing = [r for r in refs if r.split("/")[-1] not in spec["components"]["schemas"]]
for path, ops in spec["paths"].items():
for method, op in ops.items():
print(f"{method.upper():5s} {path:20s} -> {', '.join(op['responses'])}")
print("references:", len(refs), "unresolved:", missing or "none")GET /v1/rooms/{roomId} -> 200, 404
POST /v1/bookings -> 201, 422
references: 2 unresolved: noneGraphQL
GraphQL, released by Facebook in 2015, exposes a single endpoint and a typed schema; clients send queries that name exactly the fields and related objects they need, and receive a JSON response of the same shape. This avoids over-fetching (receiving fields you do not need) and under-fetching (needing several round trips to assemble a screen), which is valuable for varied clients such as mobile apps. Each field is computed by a resolver function, as the miniature below shows.
# What GraphQL does, in miniature: the client names the fields; resolvers fetch only those.
ROOMS = {"L201": {"id": "L201", "building": "Library", "seats": 6},
"S105": {"id": "S105", "building": "Science", "seats": 12}}
BOOKINGS = [{"room": "L201", "user": "amelie", "slot": 9}, {"room": "L201", "user": "jordan", "slot": 10},
{"room": "S105", "user": "amelie", "slot": 11}]
calls = []
def resolve(obj_type, obj, selection):
out = {}
for field, sub in selection.items():
if obj_type == "Room" and field == "bookings":
calls.append(f"bookings(room={obj['id']})")
out[field] = [resolve("Booking", b, sub) for b in BOOKINGS if b["room"] == obj["id"]]
else:
out[field] = obj[field]
return out
# Equivalent to: { room(id: "L201") { building bookings { user slot } } }
query = {"room": ({"id": "L201"}, {"building": None, "bookings": {"user": None, "slot": None}})}
args, sel = query["room"]
calls.append(f"room(id={args['id']})")
print({"data": {"room": resolve("Room", ROOMS[args["id"]], sel)}})
print("resolver calls:", calls){'data': {'room': {'building': 'Library', 'bookings': [{'user': 'amelie', 'slot': 9}, {'user': 'jordan', 'slot': 10}]}}}
resolver calls: ['room(id=L201)', 'bookings(room=L201)']GraphQL's flexibility has costs. Caching is harder because every request is a POST to one URL with a different body; resolvers can produce N+1 queries unless batched with a DataLoader; and clients can write deeply nested, expensive queries, so servers need depth and cost limits. Authorization must be checked in each resolver, not just at the endpoint. GraphQL suits products with many client types and rapidly changing screens; REST remains simpler for public APIs and resource-oriented services.
gRPC
gRPC, open-sourced by Google in 2015, defines services and messages in Protocol Buffers, a compact binary format, and runs over HTTP/2 with support for streaming in either or both directions. Generated code gives each language strongly typed clients and servers, and binary encoding is smaller and faster to parse than JSON. gRPC is popular for communication between internal services, where both sides are controlled by the same organization; browsers cannot speak it directly and need a gateway such as gRPC-Web or Connect.
// bookings.proto: a gRPC service definition (listing). Code generators produce typed clients and servers.
syntax = "proto3";
package booking.v1;
service BookingService {
rpc GetRoom (GetRoomRequest) returns (Room);
rpc CreateBooking (CreateBookingRequest) returns (Booking);
rpc WatchRoom (GetRoomRequest) returns (stream SeatUpdate); // server streaming
}
message GetRoomRequest { string room_id = 1; }
message Room { string id = 1; string building = 2; int32 seats = 3; }
message CreateBookingRequest { string room_id = 1; int32 slot = 2; string idempotency_key = 3; }
message Booking { string id = 1; string room_id = 2; int32 slot = 3; }
message SeatUpdate { string room_id = 1; int32 free = 2; }Real-time updates: WebSockets and Server-Sent Events
Sometimes the server must tell the client something without being asked: seats running out, a chat message, a build finishing. Polling, asking every few seconds, is simple but wasteful and slow to notice changes. Server-Sent Events (SSE) keep one HTTP response open and stream text events through it; the browser's EventSource API reconnects automatically and resumes from the last event identifier. WebSockets upgrade an HTTP connection into a full-duplex channel for messages in both directions, suited to chat, collaborative editing and games. Long-lived connections need different scaling: each one occupies server resources, load balancers must support them, and when a server restarts, clients must reconnect with random delays to avoid all reconnecting at once.
# Server-Sent Events: one long HTTP response carrying a stream of text events.
import threading, time, urllib.request
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
class Stream(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
for i, seats in enumerate([3, 2, 0], start=1):
msg = f"id: {i}\nevent: seats\ndata: {{\"room\": \"G010\", \"free\": {seats}}}\n\n"
self.wfile.write(msg.encode()); self.wfile.flush()
time.sleep(0.05)
def log_message(self, *a): pass
srv = ThreadingHTTPServer(("127.0.0.1", 0), Stream)
threading.Thread(target=srv.serve_forever, daemon=True).start()
with urllib.request.urlopen(f"http://127.0.0.1:{srv.server_port}/events") as r:
print("Content-Type:", r.headers["Content-Type"])
event = {}
for raw in r: # read line by line as events arrive
line = raw.decode().rstrip("\n")
if line:
k, _, v = line.partition(": "); event[k] = v
else:
print(f"event #{event['id']} ({event['event']}): {event['data']}"); event = {}
srv.shutdown()Content-Type: text/event-stream
event #1 (seats): {"room": "G010", "free": 3}
event #2 (seats): {"room": "G010", "free": 2}
event #3 (seats): {"room": "G010", "free": 0}// A WebSocket client in the browser (listing): full-duplex messages over one upgraded connection.
const ws = new WebSocket('wss://booking.example.ca/live');
ws.addEventListener('open', () => ws.send(JSON.stringify({ type: 'subscribe', room: 'G010' })));
ws.addEventListener('message', e => {
const msg = JSON.parse(e.data);
if (msg.type === 'seats') document.querySelector('#free').textContent = `${msg.free} seats free`;
});
ws.addEventListener('close', () => setTimeout(connect, 1000 + Math.random() * 4000)); // reconnect with jitterWorked examples
Worked problem
Designing endpoints
Design REST endpoints to list a user's bookings, cancel one booking, and change a booking's slot.
Show solution
- List: GET /v1/users/{userId}/bookings?cursor=...&limit=20, or GET /v1/bookings?user=me for the signed-in user.
- Cancel: DELETE /v1/bookings/{bookingId}, returning 204; repeating it is harmless (idempotent), returning 404 or 204.
- Change slot: PATCH /v1/bookings/{bookingId} with {"slot": 11} and If-Match to avoid overwriting concurrent changes.
- Errors: 409 if the new slot is taken; 422 if slot is out of range; 403 if the booking belongs to someone else.
Answer: Nouns for resources, methods for actions, and specific status codes for each failure.
Worked problem
Making a retry safe
A mobile app POSTs a booking, the connection drops, and it does not know whether the server received the request. What should it do?
Show solution
- Before the first attempt, generate a random Idempotency-Key, such as a UUID, and keep it with the pending request.
- Retry with the same key, after a delay that grows exponentially with random jitter.
- If the first request succeeded, the server replays the stored 201 response; otherwise it creates the booking now.
- The server keeps keys for a limited time, such as 24 hours.
Answer: Retry with the same idempotency key; the server guarantees at most one booking.
Worked problem
Choosing a style
Pick an API style for: (a) an open data feed of campus building hours; (b) calls between a booking service and a payment service inside one company; (c) a live seat counter on a page.
Show solution
- (a) REST with JSON: simple, cacheable, easy for anyone to use, documented with OpenAPI.
- (b) gRPC: typed contracts and efficient calls between internal services, or REST if the team prefers uniformity.
- (c) Server-Sent Events: one-way server push that browsers support natively.
Answer: REST for the public feed, gRPC for internal calls, SSE for the live counter.
Common mistakes
MistakePut actions in URLs, like /getRooms or /deleteBooking?id=4.
InsteadUse resource URLs with HTTP methods: GET /rooms, DELETE /bookings/4.
MistakeReturn 200 with {"error": ...} for failures.
InsteadUse the correct status code and an application/problem+json body.
MistakeRetrying a POST is always safe.
InsteadPOST is not idempotent; use idempotency keys so retries do not duplicate actions.
MistakeRenaming a field is a minor change.
InsteadRemoving or renaming fields breaks clients; add new fields and deprecate old ones.
MistakeGraphQL removes the need for authorization checks per field.
InsteadEach resolver must enforce access, and query depth and cost must be limited.
MistakeWebSockets are needed for any live update.
InsteadSSE is simpler for one-way server push and works over plain HTTP.
Summary and key terms
- REST models resources as URLs operated on by standard methods and status codes.
- Use problem details for errors and specific codes such as 404, 409, 412, 422 and 429.
- Cursor pagination, ETags, If-Match and idempotency keys make APIs robust.
- Evolve additively; version and deprecate when breaking changes are unavoidable.
- OpenAPI describes HTTP APIs for documentation, code generation and validation.
- GraphQL lets clients select fields; gRPC offers fast typed internal calls.
- SSE and WebSockets push updates; choose by direction and complexity.
- REST
- An architectural style using resources, URLs and a uniform HTTP interface.
- Idempotent
- Having the same effect whether a request is applied once or many times.
- Problem details
- The RFC 9457 standard JSON format for HTTP API errors.
- Cursor pagination
- Paging with an opaque token marking the position after the last item.
- If-Match
- A request header making an update conditional on the current ETag.
- Idempotency key
- A client-chosen token that lets a server recognize and deduplicate retries.
- OpenAPI
- A specification for describing HTTP APIs in YAML or JSON.
- GraphQL
- A query language where clients request exactly the fields they need.
- gRPC
- An RPC framework using Protocol Buffers over HTTP/2.
- Server-Sent Events
- A one-way stream of text events over a long HTTP response.
Practice problems
Work each problem on paper first, then reveal the answer to check.
Which methods are idempotent?
Show answer
GET, PUT and DELETE (and HEAD and OPTIONS); POST and generally PATCH are not.
What should a successful POST that creates a resource return?
Show answer
201 Created with a Location header pointing to the new resource.
In the REST run, why did the second PATCH fail with 412?
Show answer
Its If-Match ETag was stale because the first PATCH had changed the room.
In the REST run, why did two POSTs create only one booking?
Show answer
They shared an Idempotency-Key, so the server replayed the stored response.
Which media type do problem details use?
Show answer
application/problem+json.
Name two safe API changes.
Show answer
Adding an optional field; adding a new endpoint or optional parameter.
What problem does GraphQL solve, and what new problem does it introduce?
Show answer
It avoids over- and under-fetching, but makes caching and query cost control harder.
Why do browsers need a gateway to use gRPC?
Show answer
Browsers cannot control HTTP/2 framing directly as gRPC requires; gRPC-Web or Connect bridges this.
When is SSE preferable to WebSockets?
Show answer
When updates flow only from server to client, such as notifications or progress.
In the GraphQL miniature, which resolvers ran?
Show answer
room(id=L201) and bookings(room=L201).
Lesson quiz · 5 questions
Score 60% or more to complete this lesson.
Create a free account to save your progress, take quizzes and earn a certificate.