API Design

EE 547 - Unit 6

Dr. Brandon Franzke

Fall 2026

Communicating Between Programs

Separate Programs Cooperate by Exchanging Messages

Why separate

  • Different organizations: no access to each other’s code or machines
  • Products, such as the database: installed and configured, not modified
  • One organization’s own parts: split to scale, fail or be replaced independently

One order, as exchanges

  1. The client sends the order to the order server
  2. The server has the card charged by the payment processor
  3. The server has the stock reserved by the partner’s system
  4. The server writes the order to the database
  5. The server has the receipt sent by the notification service

What each exchange is

  • Bytes sent to an address, bytes received back
  • The sender has no view of the receiver’s code, data or state; only the reply
  • Nothing shared: no memory, no language, no deployment

Programs Agree in Advance on What Messages Mean

{"customer": 8812,
 "items": [{"sku": "A-204", "qty": 2}],
 "amount": 1999}

Held the same on both sides

  • The field names, and which are required
  • Each field’s type: qty an integer, sku a string
  • Each field’s unit: amount in cents, or in dollars
  • What comes back, and what the server does: one order, charged once

Bytes carry none of it

  • 1999 is cents to one program and dollars to another; both parse it without error
  • The agreement is outside both programs: written down, in documentation and schemas

In advance

  • The client is written against the agreement, tested against it, then shipped
  • The agreement is complete before either program runs

Checked on arrival

  • The server checks each message’s format when it arrives: names present, types right
  • Built from one shared schema, both programs catch a format mismatch at build time

Meaning

  • No check sees that 1999 was read as dollars
  • A mismatch in meaning surfaces at run time, in a wrong total, a wrong bill, a wrong count

Separate Programs Fail and Change Independently

Partial failure

  • The payment processor down while the order server runs
  • The network drops a request, or drops the reply after the work was done
  • To the sender, both are the same event: no reply within the time it is willing to wait
  • The wait is the sender’s own limit; the receiver never learns the sender gave up

Separate release schedules

  • The server rebuilt every week or two; a client version installed stays in use for months; a partner’s system released a few times a year
  • Every version still installed keeps sending the messages it was built to send
  • A change to what a message means reaches old clients never, and new clients at their next release

A Contract Replaces What a Function Call Guaranteed

What the bytes mean

  • Call: the language’s typed values, checked before the call runs
  • Message: an agreed format, and rules for valid content
  • Checked by the receiver, on arrival

What happened

  • Call: a value or an exception, exactly once
  • Message: an outcome from an agreed set, if a reply arrives
  • No reply: the sender resends, so what a second copy does is stated in advance

Who is asking

  • Call: part of the same program
  • Message: from any program that reaches the address
  • Protected requests: proof of the sender; the server decides what it may do

APIs Are Contracts Between Servers and Their Clients

What it states

  • The operations, and how each is addressed
  • Accepted: fields, types, required, and optional parts
  • Returned: on success, and on each kind of failure
  • Effect: what changes in the server’s data
  • Promises: what a repeat does, how long a result stays valid, how many requests a client may send

One operation, two levels of contract

create order
  accepts   customer, items, payment token
  returns   the order and its number
create order
  accepts   customer, items, payment token,
            a client-chosen request key
  returns   the order and its number
  fails     payment declined     (no order created)
            an item unavailable  (no order created)
  repeat    same request key: the first order returned,
            no second charge
  • First: what to send, nothing more; after a timeout, a resend may charge the card twice
  • Second: every outcome, and a resend that is safe

Anything the Contract Does Not State Is Private to the Server

Encapsulation: client developers see the contract, never the server’s code

Private to the server

  • The language, the framework, the libraries
  • How the data is stored, and in how many copies
  • How many machines answer, and which one answered a given request

Changeable without telling any client

  • Moving a read from the database to a cache
  • Rewriting the server in another language
  • Adding machines

What the contract states: changed only by telling every client, because some client depends on it

Synchronous Requests Make the Caller Wait for the Reply

Request and reply: synchronous

  • For an answer the sender needs before it continues: the price before checkout, the card accepted or declined
  • Requires the receiver running at that moment; the sender’s time is spent waiting
  • HTTP is built for this exchange: one request, one reply

Queued message: asynchronous

  • For work that can happen later: the receipt, the invoice, the nightly report
  • Requires a queue that stores the message; the receiver takes it when it is running, after downtime included
  • The sender learns the outcome only if something sends it back

Published event: asynchronous

  • For a fact that many programs act on: an order was placed
  • Requires a broker that delivers a copy to each subscriber, each in its own time
  • The sender does not know who receives, or how many

Clients Depend on What a Reply Means as Well as Its Format

Format: names, types, and structure

before   {"order_id": 17, "total": 1999}
after    {"order_id": 17, "amount": 1999}
  • A renamed field is missing when the client reads it
  • Immediate failure, at the line that reads it
  • Checkable: a schema lists every name and type

Meaning: units, effects, and promises

before   {"order_id": 17, "total": 1999}    cents
after    {"order_id": 17, "total": 1999}    dollars
  • Same format; the client parses it without error
  • Every order displayed and billed one hundred times too high
  • Found after the fact, by a person, in the numbers

In a schema: the type is checked, the words are not

"total": {"type": "integer", "description": "the order's total, in cents, tax included"}
  • A validator rejects "total": "19.99" and accepts a server that starts sending dollars
  • The contract carries the words anyway: in a schema’s descriptions, in documentation, in the tests the server runs against its own replies

HTTP Gives Every API the Same Request and Reply Structure

TCP: bytes in order between two ports, with losses resent

  • No message boundaries

HTTP: the application protocol on top of TCP

  • Where each request and reply begins and ends
  • A method and a target on every request; a status on every reply
  • Headers: facts about the message, such as its format and length

The API: the meaning HTTP leaves out

  • Which targets exist, what the data means, what each operation does and promises

Caches, Proxies, and Libraries Work the Same for Every API

HTTP defines what each method and status means; an API that uses them as defined gets the software’s behavior

On the client

  • HTTP library: marks each reply succeeded or failed by its status; follows a reply that points elsewhere; resends a read that got no reply
  • Client code: one failure path for every API, written once
  • Browser: reuses a stored read while the reply said it stays valid

Between client and server

  • Cache: answers a repeated read itself; the method says the request changes nothing, the reply says how long it stays valid
  • Load balancer: resends a failed request to another machine when the method says a repeat is harmless
  • Gateway: limits each client’s requests; its refusal means the same to every client

When every request is a POST to one address and every reply is 200

  • Outcome only in the body: every reply a success to all of this software
  • No reuse of reads; no telling a harmless repeat from a second charge

API Styles Differ in Where the Operation Appears in a Request

Two operations: read an order’s status; cancel the order

Actions (RPC)

POST /getOrderStatus
{"order_id": 17}

POST /cancelOrder
{"order_id": 17}
  • One name per operation, chosen by the API’s designer
  • Every request a POST: to a cache, a proxy or a library, a read and a cancellation look alike
  • A client holds the list of names and each one’s parameters

Resources (REST)

GET /orders/17

DELETE /orders/17
  • Each thing has an address; HTTP’s methods are the operations
  • GET: cacheable, safe to repeat; DELETE: a change, marked by the method alone
  • A client holds the addresses; the methods are HTTP’s own

Queries (GraphQL)

POST /graphql
{ order(id: 17) { status } }

POST /graphql
mutation { cancelOrder(id: 17) }
  • One address; the client writes the shape of the answer it wants
  • Read or change: visible only inside the body
  • A client holds the schema: the types, their fields, and the operations on them

Sending an HTTP Request

URLs Combine the API’s Address With the Request Target

The API’s address: chosen once, for every operation

  • Scheme: https, HTTP inside an encrypted connection; http, the same text unencrypted
  • Host: a name; DNS turns it into a machine’s address
  • Port: the program on that machine; omitted when it is the scheme’s default, 443 for https and 80 for http

The request target: chosen per operation, by the API’s design

  • Path: the thing the request is about; /flights for all flights, /flights/7 for one
  • Query: key=value pairs after ?, joined by &; which flights, how many, in what order
  • Encoding: characters outside a safe set written as % and two hex digits; a space is %20

Servers Route Requests by Method and Target

GET /get?origin=LAX&date=2026-10-20 HTTP/1.1
Host: httpbin.org
User-Agent: curl/8.16.0
Accept: application/json

Request line: method, target, version, separated by spaces

  • Method: the kind of operation; GET reads, POST sends data
  • Target: the path and query from the URL
  • Version: HTTP/1.1 is text as shown; HTTP/2 carries the same parts in binary frames

Routing: the method and the target select the handler; nothing else in the message does

Headers: one Name: value per line, facts about the request

  • Host: the name from the URL; one machine serves many names
  • User-Agent: the client program, as it names itself
  • Accept: the formats the client reads
  • Names are case-insensitive, in any order

End of headers: an empty line

Body: none; a read’s parameters travel in the target

httpbin.org: a public server that echoes each request back as JSON

  • curl -v prints the request as sent; --http1.1 keeps it as text

Status Codes State What Happened to the Request

GET /get?origin=LAX&date=2026-10-20 HTTP/1.1
Host: httpbin.org
...

HTTP/1.1 200 OK
Date: Tue, 13 Oct 2026 11:11:15 GMT
Content-Type: application/json
Content-Length: 345
Server: gunicorn/19.9.0

{
  "args": {"date": "2026-10-20", "origin": "LAX"},
  "headers": {"Accept": "application/json", ...},
  "url": "https://httpbin.org/get?origin=LAX&date=2026-10-20"
}

Status line: version, status code, reason phrase

  • 200: the request succeeded; three digits a program acts on before it reads anything else
  • OK: free text for a person; NOT FOUND and Not Found are the same reply

Headers: facts about the reply

  • Content-Type: the body format, JSON here
  • Content-Length: the body’s size in bytes, which marks where this reply ends on the connection
  • Date, Server: when it was produced, and by what software

Body: the data; here, the server’s echo of the query it parsed

  • The same connection carries the next request after the body’s last byte

Content-Type Names the Body Format

POST /post HTTP/1.1
Host: httpbin.org
User-Agent: curl/8.16.0
Accept: */*
Content-Type: application/json
Content-Length: 51

{"passenger_id": 42, "flight_id": 7, "seat": "12A"}

POST: the method for sending data to the server

  • The data goes in the body, after the empty line
  • The reply echoes it back, parsed, under "json"

Content-Type: the body format

  • application/json here; a browser form sends application/x-www-form-urlencoded; a file upload, multipart/form-data
  • A format the server does not accept: 415 Unsupported Media Type

Content-Length: 51, the body’s size in bytes

  • The server reads exactly that many bytes after the empty line

Accept: the formats the client reads; */* is any of them

  • A server that can produce none of them: 406 Not Acceptable
  • The reply’s Content-Type states the format it chose

Defining Date, Money, and Identifier Formats in the Contract

JSON: objects, arrays, strings, numbers, true, false, null; no date, decimal or byte type, and other text formats lack the same

Dates and times: a string, in the form the contract names

"2026-10-20T08:15:00-07:00"     ISO 8601, with its offset
"10/20/2026 8:15"               zone and order unstated

Money: 19.99 parses as a binary floating-point number in Python and in JavaScript

>>> 0.1 + 0.2
0.30000000000000004
  • Integer cents, "total": 1999, or a decimal string, "total": "19.99"; the currency in its own field

Identifiers: JavaScript holds every JSON number as a 64-bit float, exact up to \(2^{53}\)

> JSON.parse('{"booking_id": 9007199254740993}')
{ booking_id: 9007199254740992 }
  • A browser client reads a different booking than the server sent, with no error
  • Identifiers that may pass \(2^{53}\) are sent as strings

Binary data: no byte type

  • A file as its own request with its own Content-Type
  • Or as base64 text inside the JSON, a third larger than the bytes

Status Code Classes: The First Digit Is Enough to Act On

First digit Meaning Client’s next step
2xx Succeeded Use the reply
3xx The answer is at another address Follow Location
4xx The request is wrong Fix it; sent again unchanged, it fails again
5xx The server failed Try later; the request may be correct
  • 1xx: interim replies, handled inside the HTTP library

Successes differ in what comes back

  • 200 OK: the body is the answer
  • 201 Created: a new thing exists; Location gives its address
  • 204 No Content: done, and no body

The class is HTTP’s: which codes an API uses, and what each means for it, is the contract’s

  • A code the client does not know is handled as the first code of its class: 422 as 400
GET /status/404 HTTP/1.1
Host: httpbin.org
...

HTTP/1.1 404 NOT FOUND
Content-Length: 0
DELETE /get HTTP/1.1
Host: httpbin.org
...

HTTP/1.1 405 METHOD NOT ALLOWED
Allow: OPTIONS, GET, HEAD
  • 404: nothing at this target
  • 405: the target exists, not for this method; Allow lists the methods it takes
  • Both 4xx: the request changes, or the same reply comes back

Different Failures Get Different Status Codes

The request: POST /bookings, passenger 42, flight 7, seat 12A

Succeeded: 201 Created, Location: /bookings/1001

Fix the request: 400 Bad Request

  • Malformed JSON, a missing field, "flight_id": "seven"
  • Sent again unchanged, it fails again

Prove who is asking: 401 Unauthorized

  • No credentials, or expired ones; sign in, then resend

Stop: 403 Forbidden, 404 Not Found

  • 403: the sender is known and may not book for passenger 42; signing in again changes nothing
  • 404: nothing at the target; /booking for /bookings

Read the current state, then choose again: 409 Conflict

  • Seat 12A was taken between the seat map and this request
  • Well formed, permitted, and refused: the flight changed under it

Wait, then resend unchanged: 429 Too Many Requests, 503 Service Unavailable

  • 429: this client is over its limit; Retry-After: 30
  • 503: the server cannot answer now; Retry-After: 120
  • Nothing in the request to change, only when to send it

No reply: the connection times out

  • Whether a booking was made is unknown; a resend may book the seat twice

Client Libraries and Gateways Treat Every 200 as a Success

The same refused booking, reported two ways

HTTP/1.1 409 Conflict
Content-Type: application/json

{"error": "seat_taken", "seat": "12A"}
  • Marked failed by every client library, the same way for every API
  • Counted as an error by the gateway and on every dashboard
HTTP/1.1 200 OK
Content-Type: application/json

{"ok": false, "error": "seat_taken"}
  • Marked succeeded by the client library; the ok field is each client’s own check
  • Zero errors at the gateway and on the dashboard while bookings fail

Calling Remote Procedures

RPC Requests Name a Procedure and Include Its Arguments

POST /getFlight
{"flight_id": 7}

POST /createBooking
{"passenger_id": 42, "flight_id": 7, "seat": "12A"}

POST /cancelBooking
{"booking_id": 1001}

Remote procedure call (RPC): the API as a list of procedures

  • One target per procedure, named by the designer
  • The body: the arguments, as one object
  • The reply body: the return value, or the error
client = AirlineClient('https://api.airline.example')
flight = client.getFlight(flight_id=7)
booking = client.createBooking(passenger_id=42,
                               flight_id=7, seat='12A')

The client stub: one method per procedure, generated or written from the list

  • Reads as a function call; builds the request, sends it, parses the reply
  • Every operation the server offers appears as a method the client program holds

What the client must hold: the procedure names and each one’s arguments, exactly as the server spells them

RPC Sends Every Operation as a POST With a 200 Reply

POST /getFlight         HTTP/1.1 200 OK
POST /createBooking     HTTP/1.1 200 OK
POST /cancelBooking     HTTP/1.1 200 OK
POST /createBooking     HTTP/1.1 200 OK
                        {"error": "seat_taken"}

One method, one status: the operation and the outcome are inside the bodies

  • A read and a cancellation: the same POST, told apart only by the path the designer chose
  • A refusal: 200 with an error object, by this API’s own convention

What no cache, proxy or library does

  • Store a reply: nothing marks getFlight as a read
  • Resend after no reply: nothing marks cancelBooking as harmless to repeat
  • Count the refused booking as an error
getFlight(flight_id)
getFlightByNumber(flight_number, date)
listFlightsByOrigin(origin, date)
listFlightsByRoute(origin, destination, date)
searchFlights(origin, destination, date, max_price)

The list grows with every question asked

  • Each question about flights is a new procedure with its own argument shape
  • The stub is regenerated and every client rebuilt to hold it
  • Five procedures, five conventions for the same table of flights

Each API, its own rules

  • Error shapes, naming, argument passing: decided per API, learned per API

RPC Frameworks Define Their Own Status Codes

POST /createBooking
{"passenger_id": 42, "flight_id": 7, "seat": "12A"}

HTTP/1.1 200 OK
{"error": {"code": "ALREADY_EXISTS",
           "message": "seat 12A is taken"}}

Why a second set: HTTP’s status is 200 for every reply, so the outcome needs a code of its own

  • gRPC: a fixed set of named codes, NOT_FOUND, ALREADY_EXISTS, UNAVAILABLE among them, sent after the body
  • JSON-RPC: an error object with a numeric code and a message; -32601 for an unknown procedure
try:
    booking = client.createBooking(passenger_id=42,
                                   flight_id=7, seat='12A')
except AlreadyExists as e:
    seat = choose_another(e.message)

In the stub: the code becomes an exception; the client’s code handles it as it would a local one

Outside the stub: a gateway, a proxy, a dashboard counts 200 and nothing else

  • Which code a procedure returns, and when, is in the procedure list, where no cache, proxy or gateway reads

gRPC Generates Both Ends From One Procedure File

service Airline {
  rpc GetFlight (FlightRequest) returns (Flight);
  rpc CreateBooking (BookingRequest) returns (Booking);
}

message BookingRequest {
  int32  passenger_id = 1;
  int32  flight_id    = 2;
  string seat         = 3;
}

The file is the contract: procedures, argument types, return types

  • Compiled into a client stub and a server skeleton in each language
  • A field changed by hand on one side fails to compile on the other

On the wire

  • Binary bodies: an integer in one to a few bytes rather than its digits; field numbers in place of names
  • Carried over HTTP/2, with the status code sent after the body

Field numbers

  • A new field with a new number: old clients skip it, new servers read it
  • The rule for changing the contract is the numbering, held in the file

Where it is used

  • Between services one organization runs, where both ends are rebuilt from the same file
  • Not reachable from a browser without a proxy in between

RPC Conventions

POST /rpc/v1/createBooking
Content-Type: application/json

{"passenger_id": 42, "flight_id": 7, "seat": "12A"}
POST /rpc
Content-Type: application/json

{"jsonrpc": "2.0", "id": 1,
 "method": "createBooking",
 "params": {"passenger_id": 42, "flight_id": 7, "seat": "12A"}}

Two shapes: a target per procedure under one prefix, or one target with the procedure named in the body (JSON-RPC)

Names: verb then noun, one spelling throughout: getFlight, listFlights, createBooking, cancelBooking

Method: POST for every call; some APIs allow GET for reads so that caches apply to them

Arguments: one JSON object, named fields, never positional

Replies: the result as one object; an error as one object with a code and a message, never both in one reply

Versioning: a prefix in the path, /rpc/v1/, or a new procedure name; old names kept running

The list: published as a document or a schema file; the client stub generated from it where the tooling exists

Choosing the HTTP Method

GET Requests Do Not Change Server State

GET /flights/7
GET /flights?origin=LAX&date=2026-10-20
HEAD /flights/7

Safe: the request does not change server state, however many times it is sent

  • GET reads the thing at the target; HEAD reads its headers alone
  • The reply differs between two reads because the thing changed, never because it was read
  • Logging the read, counting it: allowed; the client asked for no change

The promise is the server’s: made by accepting GET for the operation

  • Nothing in HTTP checks it

What follows, for caches, proxies, and libraries

  • Sent by anyone, at any time, in any order, with no permission beyond reading
  • Fetched before it is needed: a browser loading the next page’s links
  • Answered from a stored copy, by a browser or by a cache between
  • Repeated after no reply, by the HTTP library, without asking the client’s code

A read with many parameters: still a GET, with the parameters in the query, so that all of the above applies to it

PUT and DELETE Requests Are Idempotent

PUT /bookings/1001
{"passenger_id": 42, "flight_id": 7, "seat": "14C"}

DELETE /bookings/1001

POST /bookings
{"passenger_id": 42, "flight_id": 7, "seat": "12A"}

Idempotent: a second identical request leaves the server as the first did

  • PUT twice: the same body stored again; DELETE twice: already gone, 404, state unchanged
  • The reply may differ; the state does not
  • Every safe method is idempotent

Neither: each copy is one more action

  • POST /bookings twice: two bookings
Method What it does Safe Idempotent
GET Reads the thing at the target yes yes
HEAD Reads its headers only yes yes
PUT Replaces the thing at the target with the body no yes
PATCH Changes part of it no no
DELETE Removes it no yes
POST Creates something, or any other action no no
  • PATCH promises nothing because its body may be an instruction, such as add 500 miles, rather than a value
  • The server promises by the method it accepts for each operation; caches, proxies, and libraries act as if the promise holds

Resending After No Reply Is Harmless for Idempotent Methods

No reply: the request was applied, or never arrived; the sender cannot tell which

Idempotent: resend without looking

  • GET: the same read; PUT: the same seat written twice; DELETE: gone either way
  • HTTP libraries’ retry settings resend these methods by default, and POST never

Not idempotent: the resend is a second request

  • POST /bookings twice: two bookings, two charges, and the sender holds only the second
  • What a second copy does is for the contract to state; HTTP promises nothing

PUT Requests Replace Everything at the Target

GET /bookings/1001

{"passenger_id": 42, "flight_id": 7, "seat": "12A",
 "meal": "vegetarian", "status": "confirmed"}
PUT /bookings/1001

{"passenger_id": 42, "flight_id": 7, "seat": "14C"}

PUT: the body is the whole thing, stored at the address the client names

  • A PUT with missing fields erases them: after this one, seat 14C and no meal
  • A strict server refuses instead: 400, with the missing fields named
  • Nothing at that address yet: created there, 201
PATCH /bookings/1001

{"seat": "14C"}

PATCH: the body names the fields that change; the rest stay

  • Meal and status untouched
  • A plain field set is idempotent; an instruction such as add 500 miles is not, so PATCH promises nothing

Selection

  • The whole thing, as a profile saved from a form: PUT
  • One field of a large thing: PATCH

Creating Items With POST

POST /bookings
Content-Type: application/json

{"passenger_id": 42, "flight_id": 7, "seat": "12A"}
HTTP/1.1 201 Created
Location: /bookings/1001
Content-Type: application/json

{"booking_id": 1001, "seat": "12A", "status": "confirmed"}

The address: assigned by the server; the client learns it from Location

  • PUT needs an address the client already holds; a new booking has none

Also POST: anything that is not a read, a replacement or a removal

  • Check in for a booking; send the confirmation again; a search with more parameters than a URL holds

No promise: neither safe nor idempotent

  • No cache stores a POST reply; no proxy or library resends a POST on its own

Idempotency Keys Make a Repeated POST Harmless

POST /bookings
Idempotency-Key: 3f9c0a7e-5d2b-4c81-9e07-1a6b2f3c4d5e
Content-Type: application/json

{"passenger_id": 42, "flight_id": 7, "seat": "12A"}

The key: made up by the client once per intended booking, sent on every attempt

  • A random value long enough never to repeat
  • A resend after no reply carries the same key

The server: stores the key with the first attempt’s result

  • Same key again: the stored reply, 201 with /bookings/1001; no second booking
  • Same key, different body: refused, a reused key
  • Keys kept for a bounded time, hours to a day, then forgotten

Where: payments, bookings, anything whose repeat costs money

  • The contract names the header and its rules; HTTP defines none of it

Cache-Control States How Long a GET Reply Stays Fresh

GET /cache/60 HTTP/1.1
Host: httpbin.org
...

HTTP/1.1 200 OK
Date: Tue, 13 Oct 2026 12:45:55 GMT
Content-Type: application/json
Content-Length: 260
Cache-Control: public, max-age=60

Fresh: for max-age seconds from the reply, a stored copy is the answer

  • The browser, a cache between, the HTTP library’s own cache: each answers from the copy without contacting the server
  • GET replies only; a POST reply is not stored

Per reply, by the server

  • The departures board: max-age=60
  • A passenger’s own itinerary: private, stored by that browser only
  • A seat map at booking time: no-store

Conditional GETs Return 304 When Nothing Changed

GET /etag/f7a1 HTTP/1.1
Host: httpbin.org
...

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 261
ETag: f7a1
GET /etag/f7a1 HTTP/1.1
Host: httpbin.org
If-None-Match: "f7a1"
...

HTTP/1.1 304 NOT MODIFIED
ETag: f7a1

ETag: a version mark for the thing at this address, changed whenever the thing changes

  • A hash of the body, or a version number from the database row

If-None-Match: the mark the client holds, sent with a stale copy’s next read

  • Unchanged: 304, no body; the copy is fresh again for another max-age
  • Changed: 200, the new body, and a new mark

What it costs: one round trip with no body, in place of the body again

  • Last-Modified and If-Modified-Since do the same with a timestamp, to the second

Browsers, Crawlers, and Proxies Repeat Any GET

GET /bookings/1001/cancel

A cancel link as a GET: one request, the booking cancelled

Software that trusts the promise

  • A browser fetches links on a page before they are clicked: cancelled on arrival
  • A crawler follows every link it finds: cancelled by an indexer
  • A proxy answers a repeated GET from its copy: the second cancel never reaches the server, and looks successful
  • A library resends after no reply: run twice

The same action under its own method

DELETE /bookings/1001

POST /bookings/1001/cancel
  • Not fetched ahead, not followed, not answered from a copy
  • DELETE: resent on its own after no reply, harmless
  • POST: resent only by the client’s own code, with an idempotency key

Designing a REST API

REST Gives Each Thing an Address

procedures                                   resources
POST /getFlight       {"flight_id": 7}       GET    /flights/7
POST /createBooking   {...}                  POST   /bookings
POST /cancelBooking   {"booking_id": 1001}   DELETE /bookings/1001

Resource: a thing with an address: a flight, a booking, the list of flights from LAX

  • The address names the thing; nothing in it names an operation
  • The operations are HTTP’s methods, so the promises hold and caches, proxies, and libraries act on each request as its method says

Representation: the reply is one rendering of the thing, in the format Accept asked for

  • GET /flights/7 as JSON for a program, as CSV for a spreadsheet: the same thing, two formats
  • The reply’s fields need not be the stored columns

REST: Representational State Transfer, the name of this style

  • A set of constraints on how an API uses HTTP: addressed things, HTTP’s methods, stateless requests, cacheable replies
  • Not a protocol or a standard; nothing checks that an API follows them

RESTful: an API that follows the constraints; used loosely for any API that names things and uses HTTP’s methods

RESTful Paths Name Collections and Their Items

/flights               the collection
/flights/7             one item
/bookings
/bookings/1001
/airports
/airports/LAX          an item named by its code

Naming

  • Plural nouns for collections
  • The item’s identifier is assigned by the server, or is a code the thing already has
  • An empty collection is 200 with an empty list; 404 is for an address with nothing at it
Method On the collection, /flights On one item, /flights/7
GET The list The one flight
POST Create a flight; the server assigns its address Rarely offered
PUT Rarely offered Replace the flight
PATCH Rarely offered Change part of it
DELETE Rarely offered Remove it
  • One path, two things: the list and its members, told apart by the identifier
  • Operations on the whole collection at once are rarely offered because they are rarely intended; DELETE /flights removes every flight

REST Conventions

/v1/flights
/v1/flights/7
/v1/flights/7/bookings
/v1/bookings/1001
/v1/airports/LAX
/v1/frequent-flyers/42

Paths: lowercase, plural nouns, hyphens between words, no trailing slash

  • No verbs: the method is the verb
  • Nesting for ownership, two levels; everything else a query parameter
  • A version prefix on the path, /v1/, or a header; the old version kept running

Identifiers: the server’s number, or a code the thing already has (LAX); never a name that can change

Methods and replies

  • POST to a collection: 201 with Location
  • PUT and PATCH on an item: 200 with the item, or 204 with nothing
  • DELETE: 204; a second DELETE: 404

Bodies: JSON, one object; a collection as an object with a list inside, so that total and next have somewhere to go

  • Field names in one case throughout, snake_case or camelCase
  • Dates ISO 8601, money in integer cents, identifiers that may grow as strings

Related things: a nested path for what one thing owns; a link in the body for what it refers to; a sub-collection for many-to-many, /flights/7/passengers

Query Parameters Narrow a Collection

GET /flights?origin=LAX&date=2026-10-20
GET /flights?origin=LAX&date=2026-10-20&sort=-departure_time
GET /flights?origin=LAX&fields=flight_number,departure_time

One collection, every question: the procedures for flights become one address and a query

  • Filters combine with AND
  • A sort key, with a sign for descending
  • The fields wanted, when the whole thing is too much

Still a GET: safe, stored by caches with the query as part of the key, resent after no reply

GET /flights?origin=LAX&date=Oct-20

HTTP/1.1 400 Bad Request
{"error": "invalid_parameter", "parameter": "date",
 "expected": "YYYY-MM-DD"}

Malformed: 400, naming the parameter and the form expected

Unknown name: ignored, or 400, as the contract states

  • Ignored means a misspelled filter returns everything, without error

No match: 200 and an empty list

  • The collection exists; nothing in it matched

Reading a Large Collection in Pages

GET /flights?origin=LAX&limit=50&offset=100

{"flights": [ ... 50 items ... ],
 "total": 2500,
 "next": "/flights?origin=LAX&limit=50&offset=150"}

Page size: limit, capped by the server

  • 2,500 flights at 400 bytes each is a megabyte in one reply; 50 is 20 kilobytes

Position: offset, the rows to skip

  • Page \(n\) starts at \(n \times \text{limit}\)

The next page: an address in the reply

  • The client follows it; the server chooses how the address is formed
  • No next: the last page

Cost on the server: the skipped rows are read and discarded

  • OFFSET 100: 100 rows read for none returned; page 50 reads 2,500 rows for 50
  • Deep pages are the slow ones

With a filter: the filter applies first, the pages are of the result

Cursors Page Without Skipping or Repeating

GET /flights?origin=LAX&limit=50&after=2026-10-20T08:15:00Z,7

{"flights": [ ... ],
 "next": "/flights?origin=LAX&limit=50&after=2026-10-20T11:40:00Z,23"}

Cursor: the sort key of the last item returned, carried in the next address

  • The offset counts rows; the cursor names one
  • Sent opaque, as base64, so clients carry it and do not build it

Cost: one index seek to the key, the same on page 1 and page 50

Limits

  • No jump to page 7; pages are reached in order
  • The sort must be by the cursor’s key, with a tiebreaker such as the identifier
  • A row inserted behind the cursor is not seen until the next read from the start

Stateless Requests Let Any Server Answer

In every request: who is asking, what is asked, where in the list, which format

  • Proof of the sender on each request, not a login remembered by the server
  • The cursor and the filters repeated each time

On the server: no memory of a client between its requests

  • The data, in the database; nothing else per client

What follows

  • Any server answers any request; the load balancer routes freely
  • A restarted server loses nothing; a new server adds capacity at once

Cost: the proof verified on every request; the request larger by what it repeats

Actions Can Be Resources When Their Result Is a Thing

POST   /bookings/1001/checkin      check in; a check-in now exists
GET    /bookings/1001/checkin      the boarding pass
DELETE /bookings/1001/checkin      undo the check-in

An action with a result: check-in produces a seat confirmed, a boarding pass, a time

  • Modeled as the thing it produces: created by POST, read by GET, undone by DELETE
  • The promises and the software apply to it: the boarding pass is stored and resent like any read
POST   /bookings/1001/send-confirmation

An action with no result: send the confirmation email again

  • A POST to a target named for the action, as in RPC
  • No GET, nothing to store, nothing to resend on its own

Selection

  • Something to read afterwards: a thing with an address
  • Nothing to read afterwards: an action target

Handling Requests in Flask

Frameworks Turn HTTP Requests Into Function Calls

The framework’s part: the same for every service

  • Reads the text from the socket and parses it into a method, a target, headers, and a body
  • Matches the method and the path against the routes the developer registered
  • Turns whatever the handler returns into a reply, and writes it back

The developer’s part: one function per route, taking the request, returning the reply

Flask: a small Python framework, used in the examples

  • A route is a decorator on a function
  • The request is a global object inside the handler
  • The return value is the body, with an optional status and headers

Others: Express for JavaScript, Spring for Java, FastAPI and Django for Python; the same parts under different names

Routes Bind a Method and a Path to a Function

from flask import Flask, jsonify, request, abort

app = Flask(__name__)

FLIGHTS = {7: {"flight_id": 7, "flight_number": "AA 100",
               "origin": "LAX", "destination": "JFK",
               "seats_remaining": 12}}


@app.get('/flights/<int:flight_id>')
def get_flight(flight_id):
    flight = FLIGHTS.get(flight_id)
    if flight is None:
        abort(404)
    return jsonify(flight)

@app.get('/flights/<int:flight_id>'): GET on that path shape runs this function

  • <int:flight_id>: the path segment, converted to an integer and passed as the argument
  • /flights/abc: no match, 404 before the function runs

jsonify(flight): the body as JSON, Content-Type set, status 200

abort(404): stops the function and sends the status

Query parameters: request.args.get('origin'), a string or None

GET /flights/7

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 97

{"destination":"JFK","flight_id":7,"flight_number":"AA 100",
 "origin":"LAX","seats_remaining":12}

Handlers Return a Body, a Status, and Headers

BOOKINGS = {}


@app.post('/bookings')
def create_booking():
    data = request.get_json()
    if data is None or 'seat' not in data:
        return jsonify(error='missing seat'), 400
    booking_id = 1001 + len(BOOKINGS)
    BOOKINGS[booking_id] = {"booking_id": booking_id,
                            **data, "status": "confirmed"}
    return (jsonify(BOOKINGS[booking_id]), 201,
            {'Location': f'/bookings/{booking_id}'})

request.get_json(): the body parsed, when Content-Type says JSON; None otherwise

The return: a body alone, or a tuple of body, status, and headers

POST /bookings
Content-Type: application/json

{"passenger_id": 42, "flight_id": 7, "seat": "12A"}

HTTP/1.1 201 CREATED
Content-Type: application/json
Content-Length: 86
Location: /bookings/1001

{"booking_id":1001,"flight_id":7,"passenger_id":42,
 "seat":"12A","status":"confirmed"}
POST /bookings
Content-Type: application/json

{"passenger_id": 42}

HTTP/1.1 400 BAD REQUEST

{"error":"missing seat"}
  • Both replies as the server sent them; the reason phrases are Flask’s

flask run Serves the Handlers on One Port

$ flask --app airline run --port 8000
 * Serving Flask app 'airline'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. ...
 * Running on http://127.0.0.1:8000
127.0.0.1 - - [13/Oct/2026 06:35:12] "GET /flights/7 HTTP/1.1" 200 -
127.0.0.1 - - [13/Oct/2026 06:35:12] "GET /flights/99 HTTP/1.1" 404 -
127.0.0.1 - - [13/Oct/2026 06:35:12] "POST /bookings HTTP/1.1" 201 -
127.0.0.1 - - [13/Oct/2026 06:35:12] "POST /bookings HTTP/1.1" 400 -

One process, one port: airline.py imported, app found, a socket opened on 8000

  • Reachable from this machine only, at 127.0.0.1; --host 0.0.0.0 to accept connections from others

The log: one line per request, with the method, the target, and the status

Development server: one request at a time; restarts on file changes with --debug

  • A production server, such as gunicorn, runs the same app in a set of worker processes behind one port

State in the process: BOOKINGS is a dictionary in this process; a restart empties it, a second process has its own

Specifying the API

Documenting the Contract in the Handler’s Docstring

@app.post('/bookings')
def create_booking():
    """Book a seat on a flight.

    Accepts  passenger_id, flight_id, seat such as 12A;
             meal optional: standard, vegetarian, kosher
    Returns  201 and the booking; Location is its address
    Fails    400  the body is malformed; the error names the field
             403  the sender may not book for this passenger
             409  the seat is taken
    Repeat   the same Idempotency-Key returns the first booking
    """
    data = request.get_json()
    ...

The contract, where the code is: what the operation accepts, what it returns, each way it fails, what a repeat does

  • Written by the developer who writes the handler, read by the next one who changes it
  • The same five things an API states for every operation, in words

Still by hand

  • Nothing checks a body against them
  • Nothing generates a client from them
  • The handler and its docstring drift apart, and a person finds it

Generating the API Document From Structured Docstrings

from flasgger import Swagger
Swagger(app)

@app.post('/bookings')
def create_booking():
    """Book a seat on a flight.
    ---
    requestBody:
      content:
        application/json:
          schema: {$ref: '#/components/schemas/BookingRequest'}
    responses:
      201: {description: The booking; Location is its address}
      400: {description: Malformed body; the error names the field}
      409: {description: The seat is taken}
    """

After the ---: the same facts in a fixed form, YAML, that a tool reads

  • The words before the line stay as the operation’s description
  • Each status the handler returns, with what its body is

At startup: every handler’s block collected into one document

  • Served by the app itself: the document as JSON at its own address, and a page to read and try each operation at /apidocs/
  • Routes and methods come from the decorators, so the document cannot name an operation that does not exist

Other frameworks: FastAPI assembles the same document from the handler’s type annotations, so one declaration gives the document and the body check

One OpenAPI Document Describes the Whole API

openapi: 3.1.0
info: {title: Airline API, version: "1.4.0"}
paths:
  /flights/{flight_id}:
    get: ...
  /bookings:
    post: ...
  /bookings/{booking_id}:
    get: ...
    delete: ...
components:
  schemas:
    BookingRequest: ...
    Booking: ...
    Problem: ...

OpenAPI: the document’s format, the same for every REST API

  • Every address, every method on it, each one’s request body, and each reply by status
  • The body shapes once, under components, referred to by name

Made from it, without writing code

  • Documentation pages
  • A client library in each language, one method per operation, as gRPC’s stubs come from the procedure file
  • A mock server that answers with the documented shapes before the real one exists
  • The server’s own body check, from the same schemas

Held by the server’s developers, published to clients, versioned with the code

  • A change to the API is a change to this document, visible in review as a diff

Schemas Define Which Bodies Are Valid

{
  "type": "object",
  "required": ["passenger_id", "flight_id", "seat"],
  "properties": {
    "passenger_id": {"type": "integer", "minimum": 1},
    "flight_id":    {"type": "integer", "minimum": 1},
    "seat":         {"type": "string",
                     "pattern": "^[0-9]{1,2}[A-K]$"},
    "meal":         {"type": "string",
                     "enum": ["standard", "vegetarian", "kosher"]}
  },
  "additionalProperties": false
}

JSON Schema: the body’s shape, as JSON itself

  • Which fields, which required, each one’s type
  • Ranges, patterns, the allowed values of a field
  • Whether unknown fields are refused
{"passenger_id": 42, "flight_id": 7, "seat": "12A"}
  valid

{"passenger_id": 42, "flight_id": 7, "seat": "twelve A"}
  'twelve A' does not match '^[0-9]{1,2}[A-K]$'

{"passenger_id": 42, "seat": "12A"}
  'flight_id' is a required property

{"passenger_id": 42, "flight_id": "seven", "seat": "12A"}
  'seven' is not of type 'integer'

A validator: the same answer on every machine, in every language, with no code written for it

Outside the schema: that flight 7 exists, that seat 12A is free, that passenger_id is the sender

  • Format in the schema; meaning in the words beside it and in the handler

Validating the Body Before Using It

from jsonschema import validate, ValidationError

@app.post('/bookings')
def create_booking():
    data = request.get_json()
    try:
        validate(data, BOOKING_SCHEMA)
    except ValidationError as e:
        return jsonify(error='invalid_request',
                       field='/'.join(map(str, e.path)),
                       detail=e.message), 400
    if seat_taken(data['flight_id'], data['seat']):
        return jsonify(error='seat_taken',
                       seat=data['seat']), 409
    booking = create(data)
    return jsonify(booking), 201, {'Location': booking['url']}

At the boundary: the schema check is the first thing the handler does

  • Everything after it works with a body known to have the right shape
  • 400 with the field and the validator’s message; the client fixes the request

Then the handler’s own checks: what the schema cannot know

  • The seat already taken: 409
  • The sender not allowed to book for this passenger: 403

The same schema on both sides: the client validates before sending and gets the same message without a round trip

From the document: a framework that validates from the OpenAPI schemas keeps the check and the document from drifting apart

Using One Error Shape Across the API

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{"type": "https://api.airline.example/errors/seat-taken",
 "title": "Seat already taken",
 "status": 409,
 "detail": "Seat 12A on flight 7 was booked at 08:14",
 "instance": "/bookings",
 "seat": "12A"}

Problem details: one shape for every error the API returns

  • type: a stable identifier for this kind of error, a URL that may document it
  • title: the same short text for every error of this kind
  • status: the code, repeated in the body
  • detail: this occurrence, free to change
  • Extra fields as the error needs

Why one shape

  • Client code reads every error the same way, then branches on type
  • Text in title and detail can be reworded without breaking a client; type cannot

In the document: the shape once, as the Problem schema; for each operation, which type values it returns

Kept out of an error body

  • A stack trace, a query, a file path: the server’s private parts
  • A different shape from a different layer: the gateway’s 429 and the handler’s 409 read alike

Changes That Add Keep Old Clients Working

before   {"booking_id": 1001, "seat": "12A", "total": 1999}
after    {"booking_id": 1001, "seat": "12A", "total": 1999,
          "meal": "standard"}

before   {"booking_id": 1001, "seat": "12A", "total": 1999}
after    {"booking_id": 1001, "seat": "12A", "amount": 1999}

Compatible: an installed client, unchanged, keeps working

  • A new optional field in a reply, which old clients do not read
  • A new optional parameter or request field, which old clients do not send
  • A new operation
  • A loosened check: a longer seat pattern accepted

Breaking: some installed client fails or misbehaves

  • A field renamed, removed, or given another type
  • A request field made required
  • A check tightened: bodies that passed yesterday refused today
  • A meaning changed with the format unchanged: total in cents one day, dollars the next

The rule: a change is compatible when it lands only in what no existing client reads or sends

  • The document’s diff shows the first three kinds; the fourth is found by a person

Running Old and New Versions Side by Side

A new version: the breaking changes, collected and released together under a new prefix

  • /v1/bookings and /v2/bookings on the same server, the same handlers behind a translation where the shapes differ
  • Or in a header: Accept: application/vnd.airline.v2+json, the same address for both

Retiring the old one

  • Sunset: Wed, 01 Apr 2027 00:00:00 GMT on every old-version reply, months ahead
  • After the date: 410 Gone
  • Installed clients that nobody updates stay on the old version until it stops; the cost of a breaking change is carrying both until then

Contract Tests Validate Replies Against the Schema

def test_create_booking_matches_contract(client):
    reply = client.post('/bookings', json={
        'passenger_id': 42, 'flight_id': 7, 'seat': '12A'})
    assert reply.status_code == 201
    assert reply.headers['Location'].startswith('/bookings/')
    validate(reply.get_json(), BOOKING_SCHEMA)   # the document's schema


def test_seat_taken_is_a_problem(client):
    client.post('/bookings', json=BOOKING)
    reply = client.post('/bookings', json=BOOKING)
    assert reply.status_code == 409
    assert reply.get_json()['type'].endswith('/seat-taken')

What the tests hold the server to: the status, the headers, and the body shape the document promises, for success, and for each failure

  • A renamed field fails the test run, before any client sees it
  • The document’s diff flags a removed or retyped field on its own

Both ends against one document: the client library generated from it, the server tested against it

What passes every test: total switched from cents to dollars, with the schema unchanged

  • Format is checked by machines; meaning is held by people, in the words beside the schema and in the test that asserts 1999

Authenticating API Requests

Requests Can Name Their Sender but Cannot Prove It

POST /bookings
Content-Type: application/json

{"passenger_id": 42, "flight_id": 7, "seat": "12A"}

What the server has: the bytes, and the address they came from

  • The body names passenger 42; anyone can type that, so naming is not proof
  • The address names a machine, shared by everyone behind the same router, and not a person
  • The same text can come from the passenger’s app, a partner’s system, or any script

Two questions, answered separately

  • Authentication: who sent this request
  • Authorization: what that sender may do, here, to this thing

Authentication needs: something in the request that only this client could have put there

  • A secret only that client holds, or a token issued to it
  • Checked by the server on every request, since the server keeps nothing between requests

Authorization needs: a rule in the handler, applied to that identity and this data

Clients Send Proof of Identity With Every Request

GET /bookings/1001
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
GET /flights?origin=LAX
X-Api-Key: ak_live_7f3c6b2a5d9e4f8ba1c39e8d7c6b5a4f

Where it goes: the Authorization header, or a header the API names

  • Bearer: the server treats whoever presents the token as its owner
  • Never in the URL: addresses are logged, bookmarked, and sent in Referer

What the proof must be

  • Known to one client only, so that presenting it identifies that client
  • Checkable by the server on its own
  • Limited in time: it expires, or the server can revoke it
  • Present on every request: the previous request proves nothing about this one

Over TLS only

  • The proof is a secret; on plain http every router on the way can read it
  • Every scheme assumes https

Missing or invalid: 401, with WWW-Authenticate naming the scheme the server accepts

API Keys Identify a Client Program

The simplest proof: a secret the server issued to one program

X-Api-Key: ak_live_7f3c6b2a5d9e4f8ba1c39e8d7c6b5a4f

Issued once to a program: the partner’s inventory system, a reporting script, a mobile app’s backend

  • A long random string; it cannot be guessed or derived
  • Shown once at creation; the server stores only its hash, as it does a password

On every request: the server hashes the key and looks up the record: which client, which plan, which limits

What a key is for

  • Telling clients apart: whose requests, whose limit, whose bill
  • Programs that run unattended, with the key in their configuration

A key’s limits

  • A person: the key says which program is calling, not who is using it
  • Short-lived: it is valid until rotated or revoked
  • Scoped on its own: what the key may do is stored in its record

Rotation: issue a second key, deploy it, then delete the first

Login Sends the Password Once and Returns a Token

POST /sessions
Content-Type: application/json

{"email": "p42@example.com", "password": "correct horse battery"}

HTTP/1.1 201 Created
Content-Type: application/json

{"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
 "expires_at": "2026-10-18T11:35:00Z"}
GET /bookings/1001
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

The password: sent once over TLS and compared with a stored hash; never sent again

  • Not stored by the app, not sent with each request, not given to any other program

The token: issued by the server, sent by the app on every request after

  • Represents the person for a limited time
  • Logging out, or a changed password, ends it

Two forms of token, by where the server keeps its meaning

  • A random string that indexes a record on the server
  • A signed statement that contains its meaning

Opaque Tokens Keep Their Claims on the Server

token      9f1c3e7a2b8d4f6e0a5c7b9d1e3f5a7c

sessions table
token_hash   user_id   expires_at             scope
sha256(...)  42        2026-10-18 11:35:00    bookings:read ...

Opaque: the string means nothing by itself

  • Long, random, and stored as a hash, like a key
  • The record holds the meaning: the user, the expiry, what it may do

On every request: hash, look up, read the record

  • One store, shared by every server behind the load balancer, so that any server can answer
  • A round trip to that store per request

Revoked at once: delete the record and the next request with that token fails

  • Logout, a changed password, a stolen phone: each is one delete

Cost: the lookup, and the store that every server depends on

Signed Tokens Contain Their Own Claims

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiI0MiIsImlhdCI6MTc5MjMyMjQwMCwiZXhwIjoxNzkyMzIzMzAwLCJzY29wZSI6ImJvb2tpbmdzOnJlYWQgYm9va2luZ3M6d3JpdGUifQ
.
UrseWNyWy7zGM4sXtsPCPOfR09vJ0dbfellwwpnzIz8
header     {"alg":"HS256","typ":"JWT"}
claims     {"sub":"42","iat":1792322400,"exp":1792323300,
            "scope":"bookings:read bookings:write"}
signature  HMAC-SHA256(header . claims, the server's key)

JSON Web Token (JWT): three parts, base64url, joined by dots

  • sub: whom the token is about; iat and exp: issued and expires, as Unix seconds; scope: what it may do

Verifying: recompute the signature over the first two parts with the key and compare

  • Any server holding the key verifies without a lookup and without a shared store
  • One changed character in the claims and the signature no longer matches

Keys

  • A shared secret: the issuer and the verifier are the same service
  • A key pair: the issuer signs with the private key, any service verifies with the public one

What the server keeps: the key, and nothing per token

JWT Claims Are Readable by Anyone and Writable Only With the Key

$ echo 'eyJzdWIiOiI0MiIsImlhdCI6MTc5MjMyMjQwMCwiZXhwIjoxNzkyMzIzMzAw
LCJzY29wZSI6ImJvb2tpbmdzOnJlYWQgYm9va2luZ3M6d3JpdGUifQ==' | base64 -d
{"sub":"42","iat":1792322400,"exp":1792323300,
 "scope":"bookings:read bookings:write"}

Signed, not encrypted: base64 is an encoding; anyone with the token can decode it

  • The signature stops a change; it keeps nothing secret
  • Only what the holder may see goes in the claims: no password hash, no other users’ data

Irrevocable until exp: the token verifies until then, whatever has happened to the account

  • A stolen token is accepted until it expires
  • A closed account keeps working until its tokens expire

So lifetimes are short: minutes

  • exp fifteen minutes after iat in the token above
  • The client needs a new token every few minutes, and cannot send the password each time

Revoking the Refresh Token Ends Access Within One Lifetime

Access token: signed, short-lived, sent on every request, verified without a lookup

Refresh token: opaque, long-lived, sent only to the login service to get the next access token

  • Looked up on each renewal, so it can be revoked there

Together

  • Revocation, at the next renewal rather than the next request
  • A stolen access token is valid for at most one lifetime

Where each goes: the access token to every service; the refresh token to one, stored where the app keeps secrets

OAuth Grants a Program Limited Access on a User’s Behalf

Delegation: the booking app writes a trip to the passenger’s calendar, held by another company

  • The app never sees the password; the provider never sees the app
  • The user grants a scope, calendar.read or calendar.write, and can withdraw it at the provider

OAuth 2.0: the sequence above, standardized, with the token as the result

  • The same bearer token, the same Authorization header, the same short lifetime and refresh
  • “Sign in with …” is this sequence with an identity scope added (OpenID Connect): the token also says who the user is

Checking Permission Where the Data Is

@app.before_request
def identify_sender():
    token = request.headers.get('Authorization', '').removeprefix('Bearer ')
    try:
        g.claims = jwt.decode(token, SIGNING_KEY, algorithms=['HS256'])
    except jwt.InvalidTokenError:
        abort(401)


@app.get('/bookings/<int:booking_id>')
def get_booking(booking_id):
    booking = db.get_booking(booking_id)
    if booking is None:
        abort(404)
    if booking['passenger_id'] != int(g.claims['sub']) \
            and 'agent' not in g.claims['scope']:
        abort(403)
    return jsonify(booking)

Authentication once per request: a hook that runs before every handler verifies the token and stores the claims for it

  • No valid token: 401 before any handler runs

Authorization in the handler: the rule needs the row

  • Passenger 42 may read booking 1001 because it is theirs; that fact is in the booking, not in the token
  • An agent may read any booking; that fact is in the token’s scope

Where each check can live

  • Token validity and scope: a gateway can check them for every service
  • Ownership: only the code that has the data

Stolen Tokens Can Impersonate the User Until They Expire

Every scheme is a secret in two places: in transit, and at rest on the client

  • In transit: TLS, or every router reads it
  • At rest: a key in a configuration file, a token in a phone’s keychain, a cookie in a browser; a copy of any one is a copy of the user’s access

Where tokens leak

  • Logged with the URL when sent as a query parameter
  • Pasted into an issue, committed to a repository
  • Read from a laptop’s memory by other software on it

What bounds the damage

  • Lifetime: fifteen minutes of access per stolen access token
  • Scope: a token for reading bookings cannot cancel one
  • Revocation: the refresh token and the session record, deleted

Selection

  • A program calling on its own: an API key
  • A person using an app: login, access token, and refresh token
  • A program acting for a person at another service: OAuth

Calling Another Service

Handlers Become Clients When They Call Other Services

@app.post('/bookings')
def create_booking():
    data = request.get_json()
    booking = db.create_booking(data)
    charge = requests.post(f'{PAYMENTS}/charges',
                           json={'amount': booking['total_cents'],
                                 'token': data['payment_token']},
                           headers={'Idempotency-Key': booking['id']},
                           timeout=(1, 3))
    if charge.status_code != 201:
        db.cancel_booking(booking['id'])
        return problem(422, 'payment-declined'), 422
    return jsonify(booking), 201, {'Location': booking['url']}

One request inside another: the booking handler waits for the payment processor while the passenger’s app waits for the handler

  • The passenger’s wait is the sum: the handler’s work, the charge, and the network between
  • The processor is another organization’s program, reached by the same HTTP, with the same three outcomes: a reply, an error reply, no reply

What the handler now holds

  • A timeout; without one, the passenger waits as long as the processor takes
  • A decision for each outcome, including no reply, when the charge may or may not have gone through
  • The idempotency key, so that a resend does not charge twice

Calls Without a Timeout Wait as Long as the Dependency Takes

requests.post(url, json=body, timeout=(1, 3))
#                               connect^  ^read

Two waits

  • Connect: the time to open the connection; a machine that is down, or an address with nothing listening
  • Read: the time between bytes once connected; a server that accepted the request and stalled

The default is no timeout: a call without one waits as long as the other side takes, possibly forever

  • One stalled dependency, and every worker process ends up waiting on it

A budget: the caller’s own client is waiting too

  • The passenger’s app gives up at 5 s; the handler’s own work takes about 1 s; the charge gets 3 s
  • The dependency’s timeout is what remains of the budget, whatever the dependency usually takes

At the timeout: an exception in the handler, and no knowledge of the outcome

  • The charge may have gone through after the handler stopped listening
  • The reply to the passenger has to say so, or the resend has to be safe

Retries Are for Transient Failures

from requests.adapters import HTTPAdapter
from urllib3.util import Retry

retry = Retry(total=3, backoff_factor=0.5,
              status_forcelist=[502, 503, 504],
              allowed_methods=['GET', 'PUT', 'DELETE'])
session = requests.Session()
session.mount('https://', HTTPAdapter(max_retries=retry))
  • Three more attempts at most, after a connection failure or one of the listed statuses
  • Waits of 0, 1, and 2 seconds between them, doubling from backoff_factor
  • A Retry-After on 429 or 503 is waited out instead
  • POST is not in the list; it is retried only by code that holds an idempotency key

Transient: the next attempt can succeed with the request unchanged

  • No reply; 502, 503, 504; 429 once its wait has passed; a connection reset

Not transient: the request itself is the problem

  • 400, 401, 403, 404, 409: sent again unchanged, the same reply comes back

Backoff with jitter: each wait longer than the last, with a random part

  • A thousand clients failing at once would otherwise retry at the same instant, and fail at the same instant again

Bounded: a count and a total time, inside the budget

Retries Compound Across Layers

One failure, many requests: each layer that retries multiplies what the layer below sends

  • The processor is down; sixteen requests arrive for one booking, then sixty-four with a gateway in between
  • The retries keep it down after it comes back

One layer retries: the one nearest the failure, with a bounded count

  • The layers above it return the failure to their own callers

Stop early: a dependency that has been failing for a minute is not retried on each request

Circuit Breakers Stop Calling Services That Keep Failing

Closed: calls go through; failures are counted

Open: after a run of failures, calls are not made for a cooldown

  • The handler fails at once: 503 to its own client, with Retry-After
  • No worker waits out a timeout on a dependency known to be down

Half-open: after the cooldown, one call is let through

  • A reply closes the breaker; a failure reopens it

Per dependency, in the caller: the payment processor’s breaker is open while the seat map’s is closed

Long-Running Work Becomes a Job the Client Polls

POST /flights/7/boarding-passes

HTTP/1.1 202 Accepted
Location: /jobs/318

{"job_id": 318, "status": "queued"}
GET /jobs/318

HTTP/1.1 200 OK
{"job_id": 318, "status": "running", "done": 140, "of": 212}

GET /jobs/318

HTTP/1.1 200 OK
{"job_id": 318, "status": "done",
 "result": "/flights/7/boarding-passes.zip"}

Longer than a request should wait: 212 boarding passes to render, a report over a month of bookings, a charge that the processor settles in minutes

202 Accepted: the request is valid and the work has been queued, not done

  • Location: the job, a thing with an address like any other

The job: queued, running, done or failed, with progress and, at the end, where the result is

  • Read by the client at any interval; each read a plain GET
  • Or the client gives an address of its own and the server calls it when the job ends

The work: in a separate process that takes jobs from a queue, so that the handler returns in milliseconds and a restart loses no job

Dependency Failures Become the Server’s Own Status Codes

HTTP/1.1 503 Service Unavailable
Retry-After: 30
Content-Type: application/problem+json

{"type": "https://api.airline.example/errors/payments-unavailable",
 "title": "Payments are unavailable",
 "status": 503,
 "detail": "The payment processor did not answer within 3 s"}

The client sees one API: the airline’s, in its own error shape, with its own Retry-After

  • Which service behind it failed is in detail, for a person

Which code

  • No reply or a timeout from the dependency: 504 Gateway Timeout
  • A broken reply from it: 502 Bad Gateway
  • Its breaker open, or its 503: 503, with a Retry-After the client can use

Whose fault

  • The dependency refused the passenger’s request, a declined card: 422 or 409, for the passenger to act on
  • The dependency refused a request the server itself built, a 400 from the processor: a server bug, 500

Unchanged: the error shape, the status classes, the promise that a resend with the same key charges once

Structuring a Flask Application

Decorated Functions Define the API’s Operations

from flask import Flask, jsonify, request, abort

app = Flask(__name__)


@app.get('/flights')
def list_flights():
    flights = db.list_flights(origin=request.args.get('origin'))
    return jsonify(flights=flights)


@app.get('/flights/<int:flight_id>')
def get_flight(flight_id):
    flight = db.get_flight(flight_id)
    if flight is None:
        abort(404, description=f'No flight {flight_id}')
    return jsonify(flight)


@app.post('/bookings')
def create_booking():
    ...

One function per operation: the API’s operations, each written as a function with its method and path on the decorator

  • GET /flights lists; GET /flights/<id> reads one; POST /bookings creates
  • The decorator registers the pair with the app; the function body is the operation

The app object: created once when the process starts, called by the server with each request

  • The server parses the text; the app finds the function whose method and path match and calls it
  • Whatever the function returns, the app turns into the reply text

Inside a function: the path’s values arrive as arguments; the rest of the request is read from request; the reply is whatever is returned

Flask Passes Path Values as Function Arguments

@app.get('/flights')
def list_flights():                      # no values in the path
    flights = db.list_flights()
    return jsonify(flights=flights)


@app.get('/flights/<int:flight_id>')
def get_flight(flight_id):               # flight_id is the int 7
    flight = db.get_flight(flight_id)
    if flight is None:
        abort(404)
    return jsonify(flight)


@app.get('/airports/<code>/flights')
def airport_flights(code):               # code is the str 'LAX'
    flights = db.list_flights(origin=code)
    return jsonify(flights=flights)


url_for('get_flight', flight_id=7)        # '/flights/7'
url_for('airport_flights', code='LAX')    # '/airports/LAX/flights'
GET /flights                  list_flights()
GET /flights/7                get_flight(flight_id=7)
GET /airports/LAX/flights     airport_flights(code='LAX')

A fixed path: the function takes no arguments; the rule matches that path and nothing else

A variable segment: <name> in the path matches one segment and passes it as the argument of that name

  • <int:flight_id>: digits only, converted to int before the call
  • No converter: a string, any characters but /
  • path matches across slashes; uuid matches one

Building addresses: url_for takes the function’s name and the values, and returns the path

  • The Location of a new booking is url_for('get_booking', booking_id=...), so no handler spells out a path
  • A changed rule changes every address built from it

request Holds the Query, Headers, and Body

from flask import request, g

request.method            # 'POST'
request.path              # '/bookings'
request.args.get('origin')            # query string
request.headers.get('X-Request-Id')
request.get_json(silent=True)         # None if not JSON
request.form                          # a submitted form
request.accept_mimetypes.best         # from Accept

g.request_id = '25af563f'   # set in a hook, read anywhere
g.db                        # the connection, opened on first use

request: the parsed request, as a global name

  • The same name in every handler and hook; it refers to the request being handled by this thread, and to nothing outside a request
  • get_json parses only when Content-Type says JSON; silent=True returns None instead of raising 400

g: an empty object per request, for what the hooks and the handler share

  • The request id, the identity from the token, the database connection
  • Gone when the request ends; the next request starts with an empty g

Outside a request: both raise; a test uses app.test_request_context() to make one

The Return Value Becomes the Reply

@bp.get('/<int:flight_id>')
def get_flight(flight_id):
    flight = db.get_flight(flight_id)
    if flight is None:
        abort(404, description=f'No flight {flight_id}')
    reply = jsonify(flight)
    reply.headers['Cache-Control'] = 'public, max-age=60'
    return reply
GET /flights/7

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 133
Cache-Control: public, max-age=60
X-Request-Id: 2b81f02e

{"departure_time":"2026-10-20T08:15","destination":"JFK",
 "flight_id":7,"flight_number":"AA 100","origin":"LAX",
 "seats_remaining":12}

What a handler may return

  • A Response: status, headers and body already set
  • A string or a dict: Flask wraps it, HTML or JSON, status 200
  • A tuple: the body, then a status, then a headers dict

jsonify: a Response with the body serialized and Content-Type: application/json

  • Headers added to it afterward, as the cache header above; make_response does the same for any body

abort(404): raises; the error handlers turn it into the reply

  • Nothing after it runs

Routing Rejects Unmatched Paths and Methods

GET /flights/7          get_flight(flight_id=7)
GET /flights/abc        no rule matches: 404
GET /flight/7           no rule matches: 404
PUT /flights/7          the path matches, the method does not: 405
PUT /flights/7

HTTP/1.1 405 METHOD NOT ALLOWED
Allow: GET, HEAD, OPTIONS
Content-Type: application/problem+json

The match comes first: the app compares the method and path against every rule before any function runs

  • /flights/abc fails the int converter, so the rule does not match
  • A rule matching the path but not the method: 405, with Allow naming the methods that do

No function ran: the reply is the router’s, built by the same error handlers as a handler’s own abort

  • HEAD and OPTIONS are added to every GET rule by Flask, which is why Allow lists three

Settled before the handler runs: whether the path exists and whether the method is allowed

Error Handlers Build the Failure Replies

def problem(status, kind, detail):
    body = {'type': f'https://api.airline.example/errors/{kind}',
            'title': kind.replace('-', ' '), 'status': status,
            'detail': detail, 'instance': request.path}
    reply = jsonify(body)
    reply.status_code = status
    reply.mimetype = 'application/problem+json'
    return reply


def register(app):
    @app.errorhandler(HTTPException)
    def http_error(e):
        reply = problem(e.code, e.name.lower().replace(' ', '-'), e.description)
        if e.code == 405:
            reply.headers['Allow'] = ', '.join(sorted(e.valid_methods))
        return reply

    @app.errorhandler(SeatTaken)
    def seat_taken(e):
        return problem(409, 'seat-taken', str(e))

    @app.errorhandler(Exception)
    def unexpected(e):
        app.logger.exception('unhandled error')
        return problem(500, 'internal-error', 'The request could not be completed')
GET /flights/99

HTTP/1.1 404 NOT FOUND
Content-Type: application/problem+json

{"type": ".../errors/not-found", "title": "not found",
 "status": 404, "detail": "No flight 99",
 "instance": "/flights/99"}

Three sources, one shape

  • abort(404) and the router’s own 404 and 405: all HTTPException
  • The application’s own exceptions, raised in the data module: SeatTaken
  • Anything else: logged with its traceback, answered as 500 with nothing of the traceback in it

Handlers raise; they do not build error replies: the shape is decided once, here

Hooks Run Code Before and After the Handler

before_request: runs first, for every rule

  • Returning a reply here ends the request: the 401 for a bad token comes from here, before any handler

after_request: takes the reply, returns it

  • A header on every reply, a log line, a count; runs for error replies too

teardown_appcontext: after the reply is sent, with the exception if there was one

  • Closing what the request opened, whether it failed or not

Middleware Wraps the App Outside Its Hooks

from werkzeug.middleware.proxy_fix import ProxyFix

app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)

WSGI middleware: a callable that takes the app and returns a wrapped app; the server calls the wrapper

  • Runs on every request before the app sees it and on every reply after
  • ProxyFix: behind a load balancer every request arrives from the balancer’s address; the middleware reads X-Forwarded-For and X-Forwarded-Proto and sets the client’s real address and scheme before the app looks

What it has: the raw request and the raw reply, as the server passes them

What it does not have: request, g, the hooks, the error handlers; it runs outside the app

  • A GraphQL executor mounted as middleware beside the app cannot reach the app’s database connection in g, which is why the endpoint is a route inside the app instead

Hook or middleware

  • Needs the app’s objects, or applies to some routes: a hook
  • Must see every request to any app the server runs, or must change the request before routing: middleware

Templates Render Data Into HTML

<h1>Departures from {{ origin }}</h1>
<table>
  <tr><th>Flight</th><th>To</th><th>Departs</th><th>Seats</th></tr>
  {% for f in flights %}
  <tr>
    <td>{{ f.flight_number }}</td>
    <td>{{ f.destination }}</td>
    <td>{{ f.departure_time }}</td>
    <td>{% if f.seats_remaining > 0 %}{{ f.seats_remaining }}
        {% else %}sold out{% endif %}</td>
  </tr>
  {% endfor %}
</table>
return render_template('board.html', flights=flights, origin='LAX')

Jinja: a page with holes, filled from the handler’s values

  • { } inserts a value; {% %} loops and branches
  • The handler passes names; the template reaches into them with dots

Escaping, on by default: every inserted value has <, >, & and quotes replaced

flight_number   AA <script>alert(1)</script>
rendered        AA &lt;script&gt;alert(1)&lt;/script&gt;
  • A value from the database, or from a passenger’s input, cannot become markup on someone else’s page
  • | safe turns it off for one value, and is the thing to look for in a review

Layout once: a base template with the page’s frame; each page extends it and fills its blocks

Accept Selects the Reply Format

@bp.get('')
def list_flights():
    flights = db.list_flights(origin=request.args.get('origin'))
    if request.accept_mimetypes.best == 'text/html':
        return render_template('board.html', flights=flights,
                               origin=request.args.get('origin', 'all airports'))
    return jsonify(flights=flights)
GET /flights?origin=LAX
Accept: application/json

{"flights":[{"flight_id":7,"flight_number":"AA 100", ...},
            {"flight_id":12, ...}, {"flight_id":23, ...}]}
GET /flights?origin=LAX
Accept: text/html

<h1>Departures from LAX</h1>
<table> ... <td>AA 100</td><td>JFK</td> ... </table>

The same data, two representations: the handler queries once and chooses the rendering by the request’s Accept

  • A browser asks for text/html and gets the page
  • A program asks for application/json and gets the list

What stays the same: the address, the query parameter, the data module’s query, the error handlers, the hooks

What differs: the last line

  • A separate page route is the other choice, /board, when the page and the API drift apart

Blueprints Split the Routes Into Modules

airline/
  __init__.py      create_app: the factory
  flights.py       blueprint: /flights
  bookings.py      blueprint: /bookings
  db.py            the queries; one connection per request
  errors.py        the error shape and its handlers
  templates/
    board.html     the departures page
wsgi.py            app = create_app()

Blueprint: a group of routes with a shared prefix, defined in its own file without an app

  • flights.py and bookings.py each define one; the app registers both
  • The function’s name is prefixed by the blueprint’s: url_for('bookings.get_booking', ...)
  • A blueprint’s own hooks and error handlers apply to its routes alone

One module per concern: routes by resource, queries, errors, and pages each in one place

def create_app(config=None):
    app = Flask(__name__)
    app.config['DATABASE'] = os.environ.get('AIRLINE_DB', 'airline.sqlite')
    app.config.update(config or {})

    app.register_blueprint(flights_bp)
    app.register_blueprint(bookings_bp)
    errors.register(app)

    @app.before_request
    def start_request():
        g.request_id = request.headers.get('X-Request-Id') or uuid.uuid4().hex[:8]

    @app.after_request
    def mark_reply(reply):
        reply.headers['X-Request-Id'] = g.request_id
        return reply

    @app.teardown_appcontext
    def close_db(exc):
        db.close()
    return app

The factory: a function that builds and returns the app, instead of a module-level object

  • Configuration from the environment first, then whatever the caller passes: the tests pass a temporary database file
  • Two apps from one factory, with different settings, in one process; wsgi.py makes the one the server runs

Queries Belong to the Data Module

# db.py
def connect():
    if 'db' not in g:
        g.db = sqlite3.connect(current_app.config['DATABASE'])
        g.db.row_factory = sqlite3.Row
    return g.db


def create_booking(passenger_id, flight_id, seat):
    conn = connect()
    cur = conn.execute(
        'INSERT INTO bookings (passenger_id, flight_id, seat, status)'
        ' VALUES (?,?,?,?)', (passenger_id, flight_id, seat, 'confirmed'))
    conn.execute('UPDATE flights SET seats_remaining = seats_remaining - 1'
                 ' WHERE flight_id = ?', (flight_id,))
    conn.commit()
    return get_booking(cur.lastrowid)
# bookings.py
try:
    booking = db.create_booking(data['passenger_id'], data['flight_id'], data['seat'])
except sqlite3.IntegrityError:
    raise SeatTaken(f"Seat {data['seat']} on flight {data['flight_id']} is taken")

One connection per request: opened on first use, kept in g, closed by the teardown hook

  • The configuration names the database; the tests point it at a temporary file

The handler’s view: functions that take and return plain values

  • No SQL in a handler; no HTTP in the data module
  • The UNIQUE (flight_id, seat) constraint is what refuses the second booking; the handler turns the database’s error into the API’s

Replaceable in one place: the file, a PostgreSQL connection, a managed service; the handlers unchanged

Testable in one place: app.test_client() against the temporary database, every handler and hook included

gunicorn Runs the App in Worker Processes

$ gunicorn --workers 3 --bind 127.0.0.1:8001 wsgi:app
[94697] [INFO] Starting gunicorn 26.2.0
[94697] [INFO] Listening at: http://127.0.0.1:8001 (94697)
[94697] [INFO] Using worker: sync
[94701] [INFO] Booting worker with pid: 94701
[94702] [INFO] Booting worker with pid: 94702
[94703] [INFO] Booting worker with pid: 94703

WSGI: the app object is a callable the server invokes with each parsed request; Flask and gunicorn meet at that interface

The master: opens the socket, forks the workers, restarts one that dies, reloads on a signal

Each worker: a process with its own copy of the app, its own memory, its own connection

  • A dictionary in the app is three dictionaries, one per worker, each seeing its own writes
  • Anything shared between requests goes to the database, or to a store every worker reaches

Sizing: a few workers per core; the development server is one process with one thread

Sync Workers Serve One Request at a Time

One request per worker: a worker that is waiting on the payment processor serves no one else

  • Three workers, 3 s each on a stalled processor: one request a second for the whole service, every other address included
  • The timeout from the budget is also a limit on how long a worker is held

More capacity

  • More workers, each a full process with its own memory
  • Threaded or asynchronous workers, which hold many waiting requests per process, for handlers that mostly wait

Where this application’s time goes: in the queries and in the calls to other services, which is what the worker count has to cover

Designing a GraphQL API

Queries Read and Mutations Write

Query: a read, written as the outline of the answer

{
  flight(id: 7) {
    flightNumber
    destination
    seatsRemaining
  }
}
{
  "data": {
    "flight": {
      "flightNumber": "AA 100",
      "destination": "JFK",
      "seatsRemaining": 12
    }
  }
}

Mutation: a write, named like a procedure, answered like a query

mutation {
  createBooking(input: {passengerId: 42, flightId: 7, seat: "12A"}) {
    id
    status
    flight { seatsRemaining }
  }
}
{
  "data": {
    "createBooking": {
      "id": 1,
      "status": "confirmed",
      "flight": { "seatsRemaining": 11 }
    }
  }
}

Both sent as POST /graphql with the text in the body; the reply follows the request’s outline, under data

The Schema Is a Graph of Types Joined by Fields

Types: each with its fields and their types; ! non-null, [ ] a list

  • Flight.bookings and Booking.flight are fields whose type is another type: the relationships, as fields

Entry points: the fields of Query and Mutation, with their arguments

  • Every query starts at one of them and follows fields from there; there are no other addresses
  • The server publishes the schema on request, so an editor completes fields and refuses wrong ones before sending

Queries Walk the Graph From an Entry Point

{
  flights(origin: "LAX") {
    flightNumber
    seatsRemaining
    bookings {
      seat
      passengerId
    }
  }
}

Query: from Query.flights to a list of Flight; on each, two scalars and bookings; on each booking, two scalars

  • A field of another type opens another set of braces; a scalar ends the walk
  • A field the type lacks is refused before anything runs: Cannot query field 'gate' on type 'Flight'
{
  "data": {
    "flights": [
      {
        "flightNumber": "AA 100",
        "seatsRemaining": 11,
        "bookings": [
          { "seat": "12A", "passengerId": 42 }
        ]
      },
      { "flightNumber": "UA 512", "seatsRemaining": 3, "bookings": [] },
      { "flightNumber": "DL 47",  "seatsRemaining": 0, "bookings": [] }
    ]
  }
}

Reply: the same outline, filled in; a list where the field was a list, an object where it was a type, the value where it was a scalar

Overfetching: Resource Replies Include Fields the Screen Does Not Use

By resource: each flight as the server defines it, every field, every time

GET /flights?origin=LAX
  • The board shows four of the seventeen fields; the rest are sent, parsed, and dropped on every refresh

By query: the fields the board shows, so the reply is sized by the board

{
  flights(origin: "LAX") {
    flightNumber
    destination
    departureTime
    status
  }
}
  • The gate agent’s screen names the seat map and nothing else; the same server answers both

Resolvers Batch Per-Item Reads Into One Query per Level

Resolver: the server function for one field; Flight.bookings is called once per flight in the list

  • Written plainly, it runs one database read each time: the N+1, moved from the network into the server

Batching: a loader collects the keys asked for at one level and runs one read for all of them, then hands each resolver its part

  • The DataLoader pattern: one read per level of the query, whatever the list’s length
  • The same loader caches within a request: a flight asked for twice is read once

One Schema Drives Validation, Replies, and Errors

mutation {
  createBooking(input: {passengerId: 57, flightId: 7, seat: "12A"}) {
    id
  }
}
{
  "data": null,
  "errors": [
    {
      "message": "Seat 12A on flight 7 is taken",
      "path": ["createBooking"],
      "locations": [{ "line": 2, "column": 3 }],
      "extensions": { "code": "SEAT_TAKEN" }
    }
  ]
}
  • Sent with 200 OK; the outcome is in the body, in a shape the specification fixes

From the schema, by the server library, with no handler code

  • The query checked against the types before anything runs; a wrong one refused with 400 and the position of the mistake
  • Arguments converted and checked against their input type
  • The reply built in the query’s outline; a resolver’s failure placed in errors with a path, null at that field, the other fields filled
  • The schema itself served on request, for editors and clients

From the resolver: the message, and the API’s own code under extensions

Outside the client: a gateway, a proxy, a dashboard counts a 200; by resource the same seat is a 409 that every program between counts

GraphQL Fits Many Clients Asking Different Questions

Where a query API fits

  • Clients that differ: a phone screen, a desktop page, a partner’s system, each wanting a different cut of the same data
  • One API in front of many services: the server resolves each field from the service that has it; the client sees one schema
  • Data that is a graph of relationships, walked differently by each use

Where a resource API fits

  • Public APIs with simple shapes, read far more than written
  • Reads that caches, browsers, and CDNs should store, by address
  • Clients with few, stable needs

Costs on each side: work a resource API did not ask for

Server

  • Every query is a different program the client wrote; the cost of a query is not known from its address
  • Depth and cost limits, so that the recursive walk is refused
  • Authorization per field, since one query reaches many types
  • Loaders on every list field

Client

  • The queries themselves, and a library to send them, cache replies by field, and merge them
  • No HTTP caching of reads unless queries go as GET

Both at once: a resource API for the public and a query layer for the company’s own screens, over the same data

ariadne Is a Schema-First GraphQL Library for Python

$ pip install ariadne
from ariadne import QueryType, make_executable_schema, graphql_sync

type_defs = """
type Query { flight(id: Int!): Flight }
type Flight { id: Int!  flightNumber: String!  seatsRemaining: Int! }
"""

query = QueryType()

@query.field("flight")
def resolve_flight(_, info, id):
    return db.get_flight(id)

schema = make_executable_schema(type_defs, query)

@app.post("/graphql")
def graphql_endpoint():
    ok, result = graphql_sync(schema, request.get_json())
    return jsonify(result), 200 if "data" in result else 400

Server library: the schema text and the resolvers in; parsing, checking, calling, and assembling done. Independent of the web framework: mounted on Flask as the one route above, or as WSGI middleware in front of any app

The names

  • GraphQL: the specification and the query language, begun at Facebook, held since 2018 by a foundation under the Linux Foundation; graphql-js is its reference implementation, and graphql-core the Python port the servers below build on
  • Python servers: ariadne (schema-first, the text above), strawberry and graphene (code-first, the schema generated from Python classes)
  • Apollo: a company’s product line, Apollo Server and Apollo Client in JavaScript and the Federation gateway; Relay is Meta’s client
  • Clients: Apollo Client, Relay, urql for the browser; gql for Python
  • Loaders: aiodataloader and the loader each server ships

Client library: sends queries, keeps replies in a cache keyed by type and id, generates reply types from the schema

Tooling from the schema alone: editors that complete queries, documentation rendered from the types, a registry that diffs each change

The division: the schema is the contract; the server enforces it, the client consumes it, the tooling reads it

Serving Browser Clients

Browsers Send Cookies Without Asking the Page

POST /sessions                          login, from the airline's own page

HTTP/1.1 201 Created
Set-Cookie: session=9f1c3e7a2b8d; HttpOnly; Secure; SameSite=Lax; Path=/
GET /bookings/1001
Cookie: session=9f1c3e7a2b8d         added by the browser, not by the page

Cookie: a value the server sets once and the browser returns on every later request to that site, until it expires

  • The session cookie is the opaque token, carried by the browser instead of by the app’s code
  • HttpOnly: the page’s scripts cannot read it; Secure: sent over https only

What differs from a program as client: the proof is attached by the browser’s rule, not by the page’s code

  • Every request to the airline’s host includes it: a link clicked, a form posted, an image loaded, a script’s fetch
  • The page that caused the request need not be the airline’s

What the server sees: a request with a valid session, whoever composed it

  • The booking handler, the hooks, the permission check: all as for any logged-in request

Same-Origin Policy: A Script Reads Replies From Its Own Origin

Origin: scheme, host, and port together; a different host, or http against https, is a different origin

  • app.airline.example and api.airline.example are two origins, the same company or not

The rule: a script may send a request anywhere; it may read the reply only from its own origin

  • Another site’s page cannot read the user’s bookings through the user’s cookie
  • The server still receives and answers the request; the browser withholds the reply from the script

CORS Headers Let a Server Admit Other Origins

OPTIONS /post HTTP/1.1
Host: httpbin.org
Origin: https://app.airline.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.airline.example
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS
Access-Control-Allow-Headers: content-type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 3600

Preflight: before a request that is not a plain form or GET, the browser asks the server with OPTIONS whether the page’s origin may send it

  • JSON bodies, custom headers, PUT and DELETE all need one
  • Max-Age: how long the answer is kept, so it is asked once an hour and not once a request

Cross-origin resource sharing (CORS): headers on the server’s replies naming the origins whose scripts may read them

  • Allow-Origin: one origin, echoed, or * for any
  • Allow-Credentials: true: cookies may be sent along, and then * is not allowed
  • On every reply, not only the preflight: the browser checks the header on the real reply too

The page at app.airline.example calling api.airline.example: the API’s replies include Allow-Origin: https://app.airline.example, and nothing else changes

  • Flask: an extension adds the headers in one line; the handlers are untouched

What CORS is not: a protection for the server; a program outside a browser reads any reply regardless

Any Script on the Page Can Read What the Page Stores

localStorage.setItem('token', accessToken);   // readable by every script on the page

fetch('/bookings/1001', {
  headers: { Authorization: 'Bearer ' + localStorage.getItem('token') }
});

Page storage: localStorage, sessionStorage, variables in scripts

  • Readable by any script that runs on the page, including one that was injected

Injected scripts: a review left unescaped, an advertisement, a library update

  • Reads the token and sends it elsewhere; the token then impersonates the user from anywhere, until it expires

HttpOnly cookie: sent by the browser, unreadable by any script

  • The injected script can still make requests from the page, as the page can, but cannot take the proof away

Selection

  • A site of pages: the session in an HttpOnly, Secure, SameSite cookie
  • An application in the browser calling the JSON API: a short-lived access token held in memory, renewed from a refresh token in an HttpOnly cookie
  • Neither in localStorage

Escaping: the template that escapes every value is what keeps the injected script out in the first place

Page Code and Its Keys Are Public

const MAPS_KEY = 'ak_live_7f3c6b2a5d9e4f8ba1c39e8d7c6b5a4f';
fetch(`https://maps.example/tiles?key=${MAPS_KEY}&...`);

Everything the browser receives, the user has: the page’s scripts, every request they send, every reply, in the developer tools

  • A key in page code is a public value by the time the page loads
  • The same for a key in a mobile app’s binary, with more effort

What a key in a page can be: an identifier, not a secret

  • Tells the map provider which site is calling, for its bill and limits
  • Bound to the page’s origin on the provider’s side, so copying it elsewhere gains nothing

What stays on the server: any key that may spend money or read data

  • The page calls the airline; the airline calls the payment processor with its own key
  • The browser does not see the processor

Browsers Enforce Limits the Page Cannot Change

Connections: six at a time to one host over HTTP/1.1; the seventh request waits

  • One HTTP/2 connection multiplexes them all, which is why API hosts serve HTTP/2

Mixed content: a page on https may not fetch from http; the request is blocked before it is sent

Redirects: followed by the browser; a script sees the final reply and not the hop

Referer: the page’s address sent with each request it causes, unless the page says otherwise

Caching: by the reply’s headers, in the browser’s store, shared by every page on the origin

What the server can set, per reply, to tighten the page’s own rules

  • Content-Security-Policy: which origins scripts may be loaded from and sent to
  • Strict-Transport-Security: https only for this host from now on
  • Cookie attributes, CORS headers: all server-side decisions the browser enforces on the page

None of it binds a program: a script outside a browser does what its author wrote; these rules exist because the page’s author and the user are not the same person