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).

Four panels comparing API styles. REST: client sends separate GET requests for a room and its bookings. GraphQL: client sends one query naming exactly the fields needed and gets one response. gRPC: client and server exchange binary protocol buffer messages over HTTP/2, including a stream. WebSocket and SSE: a long-lived connection pushes updates from server to client.
Request shapes of REST, GraphQL, gRPC and push channels

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 REST API with cursor pagination, ETags, If-Match, idempotency keys and problem details, exercised by a urllib client
# 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()
Output
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 integer

Each 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.

Part of an OpenAPI 3.1 description, with a consistency check of its schema references
# 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")
Output
GET   /v1/rooms/{roomId}   -> 200, 404
POST  /v1/bookings         -> 201, 422
references: 2 unresolved: none

GraphQL

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.

GraphQL in miniature: the query shape drives which resolvers run
# 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)
Output
{'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.

A Protocol Buffers service definition for gRPC (listing)
// 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.

A Server-Sent Events stream read incrementally by a client
# 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()
Output
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 browser WebSocket client with jittered reconnection (listing)
// 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 jitter

Worked 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
  1. List: GET /v1/users/{userId}/bookings?cursor=...&limit=20, or GET /v1/bookings?user=me for the signed-in user.
  2. Cancel: DELETE /v1/bookings/{bookingId}, returning 204; repeating it is harmless (idempotent), returning 404 or 204.
  3. Change slot: PATCH /v1/bookings/{bookingId} with {"slot": 11} and If-Match to avoid overwriting concurrent changes.
  4. 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
  1. Before the first attempt, generate a random Idempotency-Key, such as a UUID, and keep it with the pending request.
  2. Retry with the same key, after a delay that grows exponentially with random jitter.
  3. If the first request succeeded, the server replays the stored 201 response; otherwise it creates the booking now.
  4. 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
  1. (a) REST with JSON: simple, cacheable, easy for anyone to use, documented with OpenAPI.
  2. (b) gRPC: typed contracts and efficient calls between internal services, or REST if the team prefers uniformity.
  3. (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.

  1. Which methods are idempotent?

    Show answer

    GET, PUT and DELETE (and HEAD and OPTIONS); POST and generally PATCH are not.

  2. What should a successful POST that creates a resource return?

    Show answer

    201 Created with a Location header pointing to the new resource.

  3. 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.

  4. 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.

  5. Which media type do problem details use?

    Show answer

    application/problem+json.

  6. Name two safe API changes.

    Show answer

    Adding an optional field; adding a new endpoint or optional parameter.

  7. 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.

  8. 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.

  9. When is SSE preferable to WebSockets?

    Show answer

    When updates flow only from server to client, such as notifications or progress.

  10. 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.

Take the quiz