Advanced Examples
Examples 57-80 cover GraphQL (schema definition, field selection, the resolver pattern, the N+1
problem and DataLoader-style batching, and mutations), gRPC (.proto service definitions, unary
calls, server-streaming, and bidirectional streaming), an explicit REST/GraphQL/gRPC comparison
(caching behavior, field-evolution rules, and a scenario-driven selection matrix), the
contract-first workflow (writing the OpenAPI spec first, implementing a handler from it, and a
conformance test that catches a type regression), and a closing cluster that combines earlier
concerns: a versioned migration window, idempotency plus rate limiting on one endpoint, GraphQL's
partial-error model versus REST's problem+json, Relay-style cursor connections, a HATEOAS-driven
client, a REST-vs-gRPC payload-size proxy, generated human documentation, and a final example that
assembles five prior concerns into one versioned, paginated, idempotent, rate-limited REST API with
a GraphQL/gRPC facade note. Every example is a complete, self-contained, originally-authored Python
3 script using only the standard library -- gRPC and GraphQL examples SIMULATE the protocol's own
semantics in-process rather than depending on the grpcio or graphql-core packages, which keeps
every example runnable with nothing beyond python3 example.py.
Example 57: Defining a GraphQL Schema and Types
ex-57 · exercises co-24
A GraphQL schema declares types and their fields ONCE, in a small domain-specific language (SDL) -- distinct from OpenAPI's per-operation paths (Example 12), a GraphQL schema describes DATA SHAPES, and every operation later queries a subset of them.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC
graph LR
Q[Query] -->|article id: ID!| A[Article]
A -->|id: ID!| S1[Scalar]
A -->|title: String!| S2[Scalar]
A -->|author: Author!| B[Author]
B -->|id: ID!| S3[Scalar]
B -->|name: String!| S4[Scalar]
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#000000,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000000,color:#000000,stroke-width:2px
class Q blue
class A orange
class B teal
class S1,S2,S3,S4 purple
learning/code/ex-57-graphql-schema-def/example.py
# pyright: strict
"""Example 57: Defining a GraphQL Schema and Types. (co-24)
A GraphQL schema declares types and their fields ONCE, in a small
domain-specific language (SDL) -- distinct from OpenAPI's per-operation
paths (Example 12), a GraphQL schema describes DATA SHAPES, and every
operation later queries a subset of them.
"""
SDL_LINES = [ # => co-24: the SDL, built line by line so every line can be explained
"type Article {", # => co-24: opens the Article object type
" id: ID!", # => co-24: a required (non-null, "!") scalar field
" title: String!", # => co-24: another required scalar field
" author: Author!", # => co-24: a required field whose type is ANOTHER declared object type
"}", # => closes Article
"", # => a blank line, purely for the generated SDL's own readability
"type Author {", # => co-24: opens the Author object type, referenced by Article above
" id: ID!", # => co-24: Author's own required id field
" name: String!", # => co-24: Author's own required name field
"}", # => closes Author
"", # => another blank line
"type Query {", # => co-24: the ROOT type -- every readable operation is a field here
" article(id: ID!): Article", # => co-24: one query field, taking an id, returning an Article
"}", # => closes Query
] # => end of SDL_LINES
SCHEMA_SDL = "\n".join(SDL_LINES) # => co-24: assembles the full SDL text from its own annotated lines
def extract_type_names(sdl: str) -> list[str]: # => co-24: a tiny structural parser, no library needed
type_names: list[str] = [] # => collects every "type Name {" declaration found
for line in sdl.splitlines(): # => co-24: scans the SDL line by line
stripped = line.strip() # => trims leading/trailing whitespace
if stripped.startswith("type ") and stripped.endswith("{"): # => co-24: matches "type X {"
name = stripped.removeprefix("type ").removesuffix("{").strip() # => extracts just "X"
type_names.append(name) # => co-24: records this type's own name
return type_names # => every type name the SDL declares, in declaration order
def sdl_parses(sdl: str) -> bool: # => co-24: a minimal "does this look like valid SDL" check
open_braces = sdl.count("{") # => counts every opening brace
close_braces = sdl.count("}") # => counts every closing brace
return open_braces == close_braces and open_braces > 0 # => co-24: balanced braces, non-empty
types = extract_type_names(SCHEMA_SDL) # => co-24: parses the schema's own declared types
print(f"declared types: {types}") # => Output: ['Article', 'Author', 'Query']
is_valid = sdl_parses(SCHEMA_SDL) # => co-24: verifies the SDL is at least structurally well-formed
# => is_valid is True (type: bool) -- every "{" in SCHEMA_SDL has a matching "}"
print(f"SDL parses: {is_valid}") # => Output: TrueRun: python3 example.py
Output:
declared types: ['Article', 'Author', 'Query']
SDL parses: TrueKey takeaway: extract_type_names finds all three declared types (Article, Author,
Query) purely by scanning for type Name { lines -- the SDL is plain text, but its structure is
regular enough to parse without a full GraphQL parser.
Why it matters: A schema is the CONTRACT every GraphQL query is checked against -- Example 58's executor and every later GraphQL example in this tier assume this schema (or one like it) already exists, the same way Example 12's OpenAPI document exists before REST's handlers run. A query that asks for a field the schema does not define fails validation before it ever reaches a resolver, catching the mistake at the cheapest possible point in the request lifecycle.
Example 58: A Client Selects Exactly the Fields It Needs
ex-58 · exercises co-24
The caller's own query names the specific fields it wants, and a minimal executor returns ONLY those fields -- the opposite of REST's fixed response shape (Example 1), where the server alone decides what comes back.
learning/code/ex-58-graphql-query-fields/example.py
# pyright: strict
"""Example 58: A Client Selects Exactly the Fields It Needs. (co-24)
The caller's own query names the specific fields it wants, and a minimal
executor returns ONLY those fields -- the opposite of REST's fixed response
shape (Example 1), where the server alone decides what comes back.
"""
ARTICLE_DATA = { # => co-24: the FULL underlying record, before any field selection
"id": "1", # => always present regardless of what the client asked for
"title": "Hello, API Design", # => a field a caller might select
"body": "A very long body...", # => a field a caller might choose NOT to select
"views": 42, # => another field a caller might choose NOT to select
} # => end of ARTICLE_DATA
def execute_query(requested_fields: list[str]) -> dict[str, object]: # => co-24: a minimal executor
return {field: ARTICLE_DATA[field] for field in requested_fields if field in ARTICLE_DATA}
# => co-24: returns ONLY the fields the client's own query named
small_query = execute_query(["id", "title"]) # => a client that only needs id and title
print(f"small query: {small_query}") # => Output: {'id': '1', 'title': 'Hello, API Design'}
large_query = execute_query(["id", "title", "body", "views"]) # => a client that needs everything
# => large_query has all 4 keys -- the SAME executor, driven purely by the requested_fields argument
print(f"large query: {large_query}") # => Output: all four fields, because all four were requestedRun: python3 example.py
Output:
small query: {'id': '1', 'title': 'Hello, API Design'}
large query: {'id': '1', 'title': 'Hello, API Design', 'body': 'A very long body...', 'views': 42}Key takeaway: execute_query(["id", "title"]) and execute_query([..., "body", "views"])
return different-sized dictionaries from the SAME underlying ARTICLE_DATA -- the response shape
is determined entirely by what the caller asked for, not by anything fixed on the server side.
Why it matters: This is GraphQL's central design idea, and the direct cause of Example 59's contrast -- a REST endpoint decides its own response shape once, for every caller; a GraphQL endpoint's response shape is decided FRESH, per request, by whoever is calling it. This is precisely what eliminates the over-fetching and under-fetching trade-off REST forces on API designers: one endpoint now correctly serves a mobile client's minimal needs and a dashboard's data-hungry needs alike.
Example 59: The Same Data via REST vs GraphQL
ex-59 · exercises co-24
A REST endpoint (Example 1) returns its ONE fixed shape regardless of what the caller actually needs; the SAME underlying data via GraphQL (Example 58) returns only the requested fields -- this example puts both side by side against the identical record.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph LR
subgraph REST
R1[GET /articles/1] --> R2["ALL 4 fields<br/>(fixed shape)"]
end
subgraph GraphQL
G1["query { id title }"] --> G2["ONLY 2 fields<br/>(client-selected)"]
end
R2 -.->|2 fields wasted| W[Over-fetched: body, views]
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef brown fill:#CA9161,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#000000,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000000,color:#000000,stroke-width:2px
class R1 blue
class R2 brown
class G1 teal
class G2 orange
class W purple
learning/code/ex-59-graphql-overfetch-contrast/example.py
# pyright: strict
"""Example 59: The Same Data via REST vs GraphQL. (co-24)
A REST endpoint (Example 1) returns its ONE fixed shape regardless of what
the caller actually needs; the SAME underlying data via GraphQL (Example
58) returns only the requested fields -- this example puts both side by
side against the identical record.
"""
RECORD = {"id": "1", "title": "Hello, API Design", "body": "A very long body...", "views": 42}
# => co-24: the single underlying record both styles serve
def rest_get_article() -> dict[str, object]: # => co-24: REST's own fixed response shape
return dict(RECORD) # => co-24: ALWAYS all four fields -- the caller has no say in the shape
def graphql_query(requested_fields: list[str]) -> dict[str, object]: # => co-24: GraphQL's selective shape
return {field: RECORD[field] for field in requested_fields if field in RECORD} # => only what was asked
client_needs = ["id", "title"] # => this specific client only ever reads two fields
rest_response = rest_get_article() # => co-24: REST call -- ignores what the client actually needs
print(f"REST returns {len(rest_response)} fields: {sorted(rest_response.keys())}") # => Output: all 4
graphql_response = graphql_query(client_needs) # => co-24: GraphQL call -- matches the client's own need
print(f"GraphQL returns {len(graphql_response)} fields: {sorted(graphql_response.keys())}") # => Output: 2
wasted_fields = set(rest_response.keys()) - set(graphql_response.keys()) # => co-24: over-fetched fields
# => wasted_fields is {'body', 'views'} -- bytes this specific client paid for but never reads
print(f"REST over-fetched: {sorted(wasted_fields)}") # => Output: ['body', 'views'] -- unused by this clientRun: python3 example.py
Output:
REST returns 4 fields: ['body', 'id', 'title', 'views']
GraphQL returns 2 fields: ['id', 'title']
REST over-fetched: ['body', 'views']Key takeaway: wasted_fields computes the set difference between what REST returned and what
this specific client actually needed -- body and views traveled over the wire for nothing, a
concrete, measurable cost this client never asked to pay.
Why it matters: "Over-fetching" is not an abstract complaint -- this example makes it a
concrete set of named fields a client received but never used, which is exactly the class of waste
Example 50's ?fields= and Example 51's JSON:API sparse fieldsets try to claw back within REST's
own model, and GraphQL avoids by design.
Example 60: A Resolver Resolving a Field
ex-60 · exercises co-25
A resolver is a small function bound to ONE field -- when the executor (Example 58) needs that field's value, it calls the field's own resolver rather than reading a fixed dictionary directly, letting a field's value be computed instead of merely stored.
learning/code/ex-60-graphql-resolver/example.py
# pyright: strict
"""Example 60: A Resolver Resolving a Field. (co-25)
A resolver is a small function bound to ONE field -- when the executor
(Example 58) needs that field's value, it calls the field's own resolver
rather than reading a fixed dictionary directly, letting a field's value be
computed instead of merely stored.
"""
from typing import Callable # => a resolver is just a function, typed for clarity
ARTICLES: dict[str, dict[str, object]] = {"1": {"id": "1", "title": "Hello", "view_count": 42}}
# => co-25: the underlying store a resolver reads from
def resolve_title(article_id: str) -> str: # => co-25: a resolver for the "title" field specifically
return str(ARTICLES[article_id]["title"]) # => co-25: reads the stored value directly
def resolve_view_count_doubled(article_id: str) -> int: # => co-25: a resolver that COMPUTES its value
raw_value = ARTICLES[article_id]["view_count"] # => co-25: the raw stored value, typed as object
assert isinstance(raw_value, int) # => co-25: narrows object -> int before arithmetic, pyright-clean
return raw_value * 2 # => co-25: the field's VALUE is derived, not merely stored verbatim
FIELD_RESOLVERS: dict[str, Callable[[str], object]] = { # => co-25: field name -> its own resolver function
"title": resolve_title, # => a simple pass-through resolver
"view_count_doubled": resolve_view_count_doubled, # => a resolver that computes its own value
} # => end of FIELD_RESOLVERS
def resolve_field(field_name: str, article_id: str) -> object: # => co-25: the executor's own dispatch step
resolver = FIELD_RESOLVERS[field_name] # => co-25: looks up the specific field's resolver
return resolver(article_id) # => co-25: calls it -- the field's value is whatever the function returns
title_value = resolve_field("title", "1") # => co-25: resolves a plain, stored field
print(f"title: {title_value}") # => Output: title: Hello
doubled_value = resolve_field("view_count_doubled", "1") # => co-25: resolves a COMPUTED field
# => doubled_value is 84 (type: object, runtime type int) -- never stored anywhere, only computed
print(f"view_count_doubled: {doubled_value}") # => Output: view_count_doubled: 84 -- 42 doubledRun: python3 example.py
Output:
title: Hello
view_count_doubled: 84Key takeaway: resolve_field dispatches to a DIFFERENT function depending on which field was
requested -- "title" reads a stored value verbatim, while "view_count_doubled" computes its
value fresh (42 * 2 = 84), and the executor's own code never needed to know the difference.
Why it matters: Binding each field to its own resolver function is what lets a single GraphQL
schema serve fields backed by wildly different sources -- a stored value, a computed value, or (as
Example 61 shows next) a value that requires its own network or database call -- all through the
identical resolve_field dispatch mechanism.
Example 61: An N+1 Resolver, Then a DataLoader Batch
ex-61 · exercises co-25
Resolving each article's author with its OWN separate lookup means N articles cost N+1 queries
overall (1 for articles, N for authors) -- batching all N author lookups into ONE call (the
DataLoader pattern) collapses that back down to 2 queries total, regardless of N. The diagram below
counts that root articles query; the runnable example's QUERY_COUNT below does not, so it
measures the author lookups alone and reads N -> 1 rather than (N+1) -> 2.
%% Color Palette: Teal #029E73, Brown #CA9161
graph LR
subgraph "Naive (N+1)"
A1[3 articles] --> Q1[query articles: 1]
A1 --> Q2[fetch author a1: 1]
A1 --> Q3[fetch author a2: 1]
A1 --> Q4[fetch author a1 AGAIN: 1]
Q2 & Q3 & Q4 --> T1["4 queries total"]
end
subgraph "DataLoader (batched)"
A2[3 articles] --> Q5[query articles: 1]
A2 --> Q6["batch-fetch authors: 1"]
Q5 & Q6 --> T2["2 queries total"]
end
classDef brown fill:#CA9161,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
class T1 brown
class T2 teal
learning/code/ex-61-graphql-n1-dataloader/example.py
# pyright: strict
"""Example 61: An N+1 Resolver, Then a DataLoader Batch. (co-25)
Resolving each article's author with its OWN separate lookup means N
articles cost N+1 queries overall (1 for articles, N for authors) -- batching
all N author lookups into ONE call (the DataLoader pattern) collapses that
back down to 2 queries total, regardless of N. QUERY_COUNT below tracks ONLY
the author lookups (the "+N" and the "+1"), not the initial articles query,
so the counter this example prints reads N -> 1, not (N+1) -> 2.
"""
ARTICLES = [ # => co-25: 3 articles, each referencing an author by id
{"id": "1", "author_id": "a1"}, # => article 1, written by author a1
{"id": "2", "author_id": "a2"}, # => article 2, written by author a2
{"id": "3", "author_id": "a1"}, # => article 3, ALSO written by author a1
] # => end of ARTICLES
AUTHORS = {"a1": "Ada", "a2": "Grace"} # => co-25: the underlying author store
QUERY_COUNT = [0] # => a mutable counter cell -- counts author lookups only, NOT the root articles query
# => that's why this counter reads 3 -> 1 below, while the diagram's totals (which also count the
# => root articles query) read 4 -> 2 -- both numbers are correct, they're just counting different things
def fetch_author_naive(author_id: str) -> str: # => co-25: the N+1 resolver -- one call PER article
QUERY_COUNT[0] += 1 # => co-25: every single call increments the counter
return AUTHORS[author_id] # => the author's name, fetched one at a time
QUERY_COUNT[0] = 0 # => reset before measuring the naive path
naive_results = [fetch_author_naive(article["author_id"]) for article in ARTICLES] # => co-25: 3 separate calls
# => naive_results is ['Ada', 'Grace', 'Ada'] -- author "a1" was queried TWICE, redundantly
print(f"naive results: {naive_results}, query count: {QUERY_COUNT[0]}") # => Output: 3 names, 3 queries
def fetch_authors_batched(author_ids: list[str]) -> dict[str, str]: # => co-25: the DataLoader-style batch
QUERY_COUNT[0] += 1 # => co-25: exactly ONE call, regardless of how many ids are in the batch
unique_ids = set(author_ids) # => co-25: deduplicates repeated ids (a1 appears twice above)
return {author_id: AUTHORS[author_id] for author_id in unique_ids} # => one lookup covering every id
QUERY_COUNT[0] = 0 # => reset before measuring the batched path
all_author_ids = [article["author_id"] for article in ARTICLES] # => co-25: collects every id UP FRONT
author_map = fetch_authors_batched(all_author_ids) # => co-25: ONE call resolves all three articles' authors
batched_results = [author_map[a_id] for a_id in all_author_ids] # => maps each article back to its author
# => batched_results == naive_results, but QUERY_COUNT[0] is 1, not 3 -- same answer, 1/3 the queries
print(f"batched results: {batched_results}, query count: {QUERY_COUNT[0]}") # => Output: same 3 names, 1 queryRun: python3 example.py
Output:
naive results: ['Ada', 'Grace', 'Ada'], query count: 3
batched results: ['Ada', 'Grace', 'Ada'], query count: 1Key takeaway: Both paths produce the IDENTICAL ['Ada', 'Grace', 'Ada'] result -- the only
difference is query_count, which drops from 3 (one per article) to 1 (one call covering all
three, deduplicated), simply by collecting every id UP FRONT instead of resolving one at a time.
Why it matters: N+1 is GraphQL's most notorious performance trap -- Example 60's per-field resolver pattern, applied naively to a list of articles, calls the author resolver once PER article, so a 100-article page silently makes 101 database calls; batching (the DataLoader pattern) is the standard fix, and this example proves the count drops without changing the result.
Example 62: A Mutation Writes Data
ex-62 · exercises co-24
GraphQL separates read operations (Query, Examples 58-61) from write operations (Mutation) at
the SCHEMA level -- a mutation both performs a side effect and returns a value, the same "write,
then return the result" shape REST's POST (Example 5) already uses.
learning/code/ex-62-graphql-mutation/example.py
# pyright: strict
"""Example 62: A Mutation Writes Data. (co-24)
GraphQL separates read operations (`Query`, Examples 58-61) from write
operations (`Mutation`) at the SCHEMA level -- a mutation both performs a
side effect and returns a value, the same "write, then return the result"
shape REST's POST (Example 5) already uses.
"""
ARTICLES: dict[str, dict[str, object]] = {} # => co-24: the store a mutation writes into
NEXT_ID = [1] # => a mutable counter cell -- mints a fresh id per created article
def create_article_mutation(title: str) -> dict[str, object]: # => co-24: mutation createArticle(title)
new_id = str(NEXT_ID[0]) # => a fresh id for this specific article
article: dict[str, object] = {"id": new_id, "title": title} # => co-24: explicit dict[str, object]
ARTICLES[new_id] = article # => co-24: the SIDE EFFECT -- writes into the store
NEXT_ID[0] += 1 # => advances the counter for the NEXT mutation
return article # => co-24: mutations still RETURN a value, just like a query does
result = create_article_mutation("Hello, GraphQL") # => co-24: runs the mutation once
print(f"mutation result: {result}") # => Output: {'id': '1', 'title': 'Hello, GraphQL'}
print(f"stored articles: {len(ARTICLES)}") # => Output: 1 -- co-24: the side effect actually happened
# => a Query field would never be allowed to mutate ARTICLES -- the schema separates the two by nameRun: python3 example.py
Output:
mutation result: {'id': '1', 'title': 'Hello, GraphQL'}
stored articles: 1Key takeaway: create_article_mutation both writes into ARTICLES (a side effect) AND returns
the created article (a value) -- the len(ARTICLES) == 1 check confirms the write actually
happened, not just that a value was returned.
Why it matters: GraphQL's schema-level split between Query and Mutation makes the
read/write distinction explicit and enforceable at the TYPE level -- a client (or a linter) can see
from the schema alone which operations are safe to retry freely and which perform a side effect,
the same distinction Example 3's method semantics table makes for REST.
Example 63: Writing a .proto Service + Messages
ex-63 · exercises co-26
A .proto file declares message types and a service's RPC methods in Protocol Buffers' own IDL --
structurally similar to Example 57's GraphQL SDL, but describing RPC METHODS rather than a
queryable graph of types.
learning/code/ex-63-grpc-proto-define/example.py
# pyright: strict
"""Example 63: Writing a .proto Service + Messages. (co-26)
A `.proto` file declares message types and a service's RPC methods in
Protocol Buffers' own IDL -- structurally similar to Example 57's GraphQL
SDL, but describing RPC METHODS rather than a queryable graph of types.
"""
PROTO_LINES = [ # => co-26: the .proto source, built line by line so every line can be explained
'syntax = "proto3";', # => co-26: proto3 REQUIRES this exact declaration, first line
"", # => a blank line, purely for the generated source's own readability
"message ArticleRequest {", # => co-26: opens the request message
" string id = 1;", # => co-26: field "id", assigned WIRE NUMBER 1 (never reused across versions)
"}", # => closes ArticleRequest
"", # => another blank line
"message ArticleResponse {", # => co-26: opens the response message
" string id = 1;", # => co-26: field "id", wire number 1
" string title = 2;", # => co-26: field "title", wire number 2
"}", # => closes ArticleResponse
"", # => another blank line
"service ArticleService {", # => co-26: opens the service -- a NAMED group of RPC methods
" rpc GetArticle (ArticleRequest) returns (ArticleResponse);", # => co-26: ONE unary RPC method
"}", # => closes ArticleService
] # => end of PROTO_LINES
PROTO_SOURCE = "\n".join(PROTO_LINES) # => co-26: assembles the full .proto text from its own annotated lines
def extract_service_methods(proto_source: str) -> list[str]: # => co-26: a tiny structural parser
methods: list[str] = [] # => collects every "rpc MethodName (" declaration found
for line in proto_source.splitlines(): # => co-26: scans the .proto source line by line
stripped = line.strip() # => trims leading/trailing whitespace
if stripped.startswith("rpc "): # => co-26: matches an RPC method declaration
method_name = stripped.removeprefix("rpc ").split(" ")[0] # => extracts just the method name
methods.append(method_name) # => co-26: records this RPC's own name
return methods # => every RPC method name the service declares
def proto_compiles(proto_source: str) -> bool: # => co-26: a minimal "does this look valid" check
has_syntax_decl = "syntax = " in proto_source # => co-26: proto3 requires an explicit syntax line
balanced_braces = proto_source.count("{") == proto_source.count("}") # => structural sanity check
return has_syntax_decl and balanced_braces # => both conditions must hold
methods = extract_service_methods(PROTO_SOURCE) # => co-26: parses the service's own declared methods
print(f"declared RPC methods: {methods}") # => Output: ['GetArticle']
compiles = proto_compiles(PROTO_SOURCE) # => co-26: verifies the .proto source is at least well-formed
# => compiles is True (type: bool) -- syntax line present, and every brace closes
print(f".proto compiles: {compiles}") # => Output: TrueRun: python3 example.py
Output:
declared RPC methods: ['GetArticle']
.proto compiles: TrueKey takeaway: Every field in PROTO_SOURCE carries an explicit WIRE NUMBER (= 1, = 2) --
Example 68 later shows why that number, not the field's name or position, is what makes gRPC's own
evolution rule work.
Why it matters: A .proto file is Protocol Buffers' equivalent of Example 57's GraphQL SDL and
Example 12's OpenAPI schema -- ONE source of truth that generates both server-side handler stubs
and client-side call stubs in whatever language each side uses, keeping client and server
mechanically in sync with the same contract. This mechanical generation is what makes gRPC's cross-language promise real in practice: a Go server and a Python client compiled from the same .proto file cannot silently drift out of sync the way hand-written client code can.
Example 64: A Unary RPC
ex-64 · exercises co-26
A unary RPC is the gRPC shape closest to a normal function call -- ONE request message in, ONE
response message out, matching Example 63's GetArticle service definition, simulated here as a
plain in-process call rather than an actual network round trip.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph LR
subgraph "Example 64: Unary"
A1[1 request] --> A2[1 response]
end
subgraph "Example 65: Server-streaming"
B1[1 request] --> B2[N responses]
end
subgraph "Example 66: Bidirectional"
C1[N requests] --> C2[N responses, interleaved]
end
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#000000,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
class A1,A2 blue
class B1,B2 orange
class C1,C2 teal
learning/code/ex-64-grpc-unary/example.py
# pyright: strict
"""Example 64: A Unary RPC. (co-26)
A unary RPC is the gRPC shape closest to a normal function call -- ONE
request message in, ONE response message out, matching Example 63's
`GetArticle` service definition, simulated here as a plain in-process
call rather than an actual network round trip.
"""
from dataclasses import dataclass # => typed request/response messages, matching the .proto shapes
ARTICLES = {"1": "Hello, gRPC"} # => co-26: the underlying store the RPC handler reads from
@dataclass # => co-26: mirrors PROTO_SOURCE's ArticleRequest message
class ArticleRequest: # => co-26: exactly one field, matching the .proto message
id: str # => the single field the request message carries
@dataclass # => co-26: mirrors PROTO_SOURCE's ArticleResponse message
class ArticleResponse: # => co-26: exactly two fields, matching the .proto message
id: str # => echoes the requested id back
title: str # => the resolved title
def get_article(request: ArticleRequest) -> ArticleResponse: # => co-26: the unary RPC handler itself
title = ARTICLES[request.id] # => co-26: ONE request in -> a lookup against the store
return ArticleResponse(id=request.id, title=title) # => co-26: ONE response out -- unary, start to finish
request = ArticleRequest(id="1") # => the single request message a client sends
response = get_article(request) # => co-26: the unary call -- one request, one response, nothing streamed
# => response is ArticleResponse(id='1', title='Hello, gRPC') -- exactly the shape .proto declared
print(f"response: {response}") # => Output: ArticleResponse(id='1', title='Hello, gRPC')Run: python3 example.py
Output:
response: ArticleResponse(id='1', title='Hello, gRPC')Key takeaway: ArticleRequest and ArticleResponse mirror Example 63's .proto message
definitions field for field -- a unary RPC is the simplest of gRPC's four call shapes, structurally
no different from calling any ordinary typed function.
Why it matters: Unary is the BASELINE gRPC calls the other three examples in this cluster build on -- Example 65 keeps the single request but streams MANY responses back, and Example 66 lets both sides stream at once, so understanding this simplest shape first makes the streaming variants read as natural extensions rather than new concepts.
Example 65: A Server-Streaming RPC
ex-65 · exercises co-26
Unlike Example 64's one-request-one-response unary call, a server-streaming RPC takes ONE request but returns MANY response messages over time -- a Python generator models this naturally: the client makes one call, then iterates over a stream of results.
sequenceDiagram
participant Client
participant Server
Client->>Server: ListArticlesRequest(author_id="a1")
Server-->>Client: yield ArticleTitle("Hello")
Server-->>Client: yield ArticleTitle("World")
Server-->>Client: yield ArticleTitle("gRPC Streaming")
Note over Client,Server: ONE request, THREE streamed responses
learning/code/ex-65-grpc-server-streaming/example.py
# pyright: strict
"""Example 65: A Server-Streaming RPC. (co-26)
Unlike Example 64's one-request-one-response unary call, a server-streaming
RPC takes ONE request but returns MANY response messages over time -- a
Python generator models this naturally: the client makes one call, then
iterates over a stream of results.
"""
from collections.abc import Iterator # => co-26: the streaming response is an Iterator, not a single value
from dataclasses import dataclass # => typed request/response messages
ARTICLES_BY_AUTHOR = {"a1": ["Hello", "World", "gRPC Streaming"]} # => co-26: multiple titles per author
@dataclass # => co-26: ONE request message, naming which author's articles to stream
class ListArticlesRequest: # => co-26: exactly one field, matching the .proto request shape
author_id: str # => the single field the request carries
@dataclass # => co-26: each individually-streamed response message
class ArticleTitle: # => co-26: exactly one field per streamed message
title: str # => one title per streamed message
def list_articles_by_author(request: ListArticlesRequest) -> Iterator[ArticleTitle]: # => co-26: server-streaming
titles = ARTICLES_BY_AUTHOR[request.author_id] # => co-26: ONE request resolves the WHOLE list up front
for title in titles: # => co-26: but the RESPONSE is yielded one message at a time
yield ArticleTitle(title=title) # => co-26: each yield is a separate streamed response message
request = ListArticlesRequest(author_id="a1") # => the single request that starts the stream
streamed_titles = list(list_articles_by_author(request)) # => co-26: consumes the FULL stream into a list
# => streamed_titles has 3 ArticleTitle entries -- one request produced three response messages
print(f"streamed {len(streamed_titles)} messages: {streamed_titles}") # => Output: 3 ArticleTitle messages
for i, article_title in enumerate(list_articles_by_author(request), start=1): # => co-26: iterate lazily too
print(f"message {i}: {article_title.title}") # => Output: one line per streamed title, in orderRun: python3 example.py
Output:
streamed 3 messages: [ArticleTitle(title='Hello'), ArticleTitle(title='World'), ArticleTitle(title='gRPC Streaming')]
message 1: Hello
message 2: World
message 3: gRPC StreamingKey takeaway: list_articles_by_author is called just ONCE with ONE request, yet its result is
iterated three times -- the yield inside the loop is what turns a single call into a STREAM of
individually-delivered response messages, rather than one big list sent all at once.
Why it matters: Server-streaming lets a client start processing the FIRST result before the LAST one has even been produced -- valuable for large result sets (a live feed, a long export) where waiting for the entire response to be ready before sending anything would add unnecessary latency to the first useful piece of data.
Example 66: A Bidirectional-Streaming RPC
ex-66 · exercises co-26
Bidirectional streaming lets BOTH sides send a sequence of messages over the SAME call, interleaved -- unlike Example 65's server-only stream, here the client streams requests in while simultaneously reading responses out, modeled here as an interleaved read/write loop over a shared connection.
sequenceDiagram
participant Client
participant Server
Client->>Server: ChatMessage("hello")
Server-->>Client: ChatMessage("HELLO")
Client->>Server: ChatMessage("from")
Server-->>Client: ChatMessage("FROM")
Client->>Server: ChatMessage("grpc")
Server-->>Client: ChatMessage("GRPC")
Note over Client,Server: interleaved -- neither side waits for the OTHER's full stream
learning/code/ex-66-grpc-bidi-streaming/example.py
# pyright: strict
"""Example 66: A Bidirectional-Streaming RPC. (co-26)
Bidirectional streaming lets BOTH sides send a sequence of messages over
the SAME call, interleaved -- unlike Example 65's server-only stream, here
the client streams requests in while simultaneously reading responses out,
modeled here as an interleaved read/write loop over a shared connection.
"""
from collections.abc import Iterator # => co-26: both the input and output are streams
from dataclasses import dataclass # => typed request/response messages
@dataclass # => co-26: ONE message the client streams IN, per chat line
class ChatMessage:
text: str # => the message text this participant sent
def echo_uppercase_bidi(client_stream: Iterator[ChatMessage]) -> Iterator[ChatMessage]: # => co-26: bidi RPC
for incoming in client_stream: # => co-26: reads ONE client message at a time, as it arrives
reply = ChatMessage(text=incoming.text.upper()) # => co-26: the server's own per-message reply
yield reply # => co-26: streams the reply back IMMEDIATELY -- interleaved, not batched at the end
def client_messages() -> Iterator[ChatMessage]: # => co-26: simulates the client's OWN outbound stream
yield ChatMessage(text="hello") # => client message 1
yield ChatMessage(text="from") # => client message 2
yield ChatMessage(text="grpc") # => client message 3
interleaved_pairs: list[tuple[str, str]] = [] # => co-26: records (sent, received) pairs, in arrival order
server_replies = echo_uppercase_bidi(client_messages()) # => co-26: wires the client stream INTO the server
for sent, received in zip(client_messages(), server_replies, strict=True): # => co-26: walks both streams
interleaved_pairs.append((sent.text, received.text)) # => co-26: pairs each send with its own reply
for sent_text, received_text in interleaved_pairs: # => print each interleaved exchange
print(f"sent={sent_text!r} -> received={received_text!r}") # => Output: 3 lines, each upper-cased
# => neither side waited for the OTHER's entire stream to finish before replyingRun: python3 example.py
Output:
sent='hello' -> received='HELLO'
sent='from' -> received='FROM'
sent='grpc' -> received='GRPC'Key takeaway: echo_uppercase_bidi is itself a generator that CONSUMES a stream while
PRODUCING one -- each incoming message gets its own reply yielded immediately, rather than the
server waiting to collect every client message before replying to any of them.
Why it matters: Bidirectional streaming is the ONLY one of gRPC's four call shapes with no direct REST or GraphQL equivalent -- a real-time chat, a live collaborative edit session, or a telemetry pipeline where both sides need to keep talking over one long-lived connection are the kinds of problems this shape solves that request/response protocols cannot express directly.
Example 67: Contrasting REST, GraphQL, and gRPC on Caching
ex-67 · exercises co-27
REST's GET (Example 44) is cacheable by any HTTP-aware intermediary via Cache-Control; GraphQL
(Examples 57-62) rides over POST, which HTTP caches never store by default; gRPC (Examples 63-66)
uses a binary framing HTTP caches cannot even parse -- this example states each fact explicitly.
learning/code/ex-67-style-caching-contrast/example.py
# pyright: strict
"""Example 67: Contrasting REST, GraphQL, and gRPC on Caching. (co-27)
REST's GET (Example 44) is cacheable by any HTTP-aware intermediary via
`Cache-Control`; GraphQL (Examples 57-62) rides over POST, which HTTP
caches never store by default; gRPC (Examples 63-66) uses a binary framing
HTTP caches cannot even parse -- this example states each fact explicitly.
"""
from dataclasses import dataclass # => a small typed record describing each style's own caching story
@dataclass # => co-27: one row per API style, describing its caching characteristics
class CachingProfile: # => co-27: four fields, one row per style compared below
style: str # => the API style's own name
http_method: str # => the transport-level HTTP method it typically rides over
cacheable_by_default: bool # => co-27: can a generic HTTP cache store this response, unmodified?
reason: str # => WHY that is true or false for this style
REST_PROFILE = CachingProfile( # => co-27: REST's own profile
style="REST", # => this row describes REST
http_method="GET", # => REST reads ride over GET (Example 3)
cacheable_by_default=True, # => co-27: GET + Cache-Control is exactly what caches understand
reason="GET is safe/cacheable; Cache-Control (Example 44) is a first-class HTTP mechanism", # => co-27
) # => end of REST_PROFILE
GRAPHQL_PROFILE = CachingProfile( # => co-27: GraphQL's own profile
style="GraphQL", # => this row describes GraphQL
http_method="POST", # => co-27: queries are typically sent as POST bodies, not GET query strings
cacheable_by_default=False, # => co-27: HTTP caches never store POST responses by default
reason="the query lives in the POST body, invisible to a cache keyed on the URL", # => co-27
) # => end of GRAPHQL_PROFILE
GRPC_PROFILE = CachingProfile( # => co-27: gRPC's own profile
style="gRPC", # => this row describes gRPC
http_method="POST (HTTP/2, binary framing)", # => co-27: gRPC always rides over HTTP/2 POST
cacheable_by_default=False, # => co-27: binary Protobuf frames are opaque to a generic HTTP cache
reason="binary framing plus POST means no generic HTTP cache can parse or store it", # => co-27
) # => end of GRPC_PROFILE
PROFILES = [REST_PROFILE, GRAPHQL_PROFILE, GRPC_PROFILE] # => co-27: all three, ready to compare
# => PROFILES has exactly 1 True and 2 False for cacheable_by_default -- only REST is cache-friendly
for profile in PROFILES: # => print each style's own caching verdict
print(f"{profile.style}: cacheable_by_default={profile.cacheable_by_default} ({profile.reason})")
# => Output: REST=True, GraphQL=False, gRPC=False, each with its own stated reasonRun: python3 example.py
Output:
REST: cacheable_by_default=True (GET is safe/cacheable; Cache-Control (Example 44) is a first-class HTTP mechanism)
GraphQL: cacheable_by_default=False (the query lives in the POST body, invisible to a cache keyed on the URL)
gRPC: cacheable_by_default=False (binary framing plus POST means no generic HTTP cache can parse or store it)Key takeaway: Only REST's cacheable_by_default is True -- GraphQL and gRPC are both False,
but for TWO DIFFERENT reasons stated explicitly in reason: GraphQL because of its transport
(POST), gRPC because of both its transport AND its binary framing.
Why it matters: Caching is not a minor implementation detail -- an API style that rides over
GET with Cache-Control gets browser caching, CDN caching, and reverse-proxy caching essentially
for free; a style that does not (GraphQL, gRPC) must reimplement that caching behavior
application-side (a persisted-query cache, an application-level result cache) if it needs it at all.
Example 68: How Each Style Evolves a Field
ex-68 · exercises co-27
Adding a field is safe in all three styles (REST's Example 32, GraphQL's schema, gRPC's numbered proto fields), but each has its OWN specific rule for what makes that addition safe -- this example states each rule and demonstrates it holding.
learning/code/ex-68-style-evolution-contrast/example.py
# pyright: strict
"""Example 68: How Each Style Evolves a Field. (co-27)
Adding a field is safe in all three styles (REST's Example 32, GraphQL's
schema, gRPC's numbered proto fields), but each has its OWN specific rule
for what makes that addition safe -- this example states each rule and
demonstrates it holding.
"""
REST_V1: dict[str, object] = {"id": 1, "title": "Hello"} # => co-27: REST -- adding a field is safe if OPTIONAL
REST_V2: dict[str, object] = {"id": 1, "title": "Hello", "author": "Ada"} # => co-27: Example 32's own rule
def rest_old_client_reads(response: dict[str, object]) -> dict[str, object]: # => co-27: reads known fields only
return {"id": response["id"], "title": response["title"]} # => ignores "author" entirely, safely
rest_before = rest_old_client_reads(REST_V1) # => an old client against the old shape
rest_after = rest_old_client_reads(REST_V2) # => the SAME old client against the evolved shape
print(f"REST: old client unaffected = {rest_before == rest_after}") # => Output: True -- co-27's own claim
GRAPHQL_SCHEMA_V1_FIELDS = {"id", "title"} # => co-27: GraphQL -- adding a NULLABLE field is safe
GRAPHQL_SCHEMA_V2_FIELDS = {"id", "title", "author"} # => co-27: a new, optional field added to the schema
def graphql_query_still_valid(query_fields: set[str], schema_fields: set[str]) -> bool: # => co-27: validity check
return query_fields.issubset(schema_fields) # => co-27: a query is valid if every field it asks for exists
old_query = {"id", "title"} # => an existing client's own query, unaware "author" now exists
still_valid = graphql_query_still_valid(old_query, GRAPHQL_SCHEMA_V2_FIELDS) # => co-27: checked against V2
print(f"GraphQL: old query still valid = {still_valid}") # => Output: True -- co-27: adding "author" broke nothing
GRPC_FIELDS_V1 = {1: "id", 2: "title"} # => co-27: gRPC -- fields are identified by NUMBER, not position
GRPC_FIELDS_V2 = {1: "id", 2: "title", 3: "author"} # => co-27: a NEW field number, never reused
def grpc_decode_known_fields(wire_fields: dict[int, object], known: dict[int, str]) -> dict[str, object]:
# => co-27: decodes only field numbers the OLD client's own generated code recognizes
return {known[num]: value for num, value in wire_fields.items() if num in known} # => unknown numbers skipped
wire_message: dict[int, object] = {1: "1", 2: "Hello", 3: "Ada"} # => co-27: a client message, V2's schema
decoded_by_old_client = grpc_decode_known_fields(wire_message, GRPC_FIELDS_V1) # => co-27: decoded with OLD field map
# => decoded_by_old_client has 2 keys, not 3 -- field number 3 was never even in the OLD client's map
print(f"gRPC: old client decodes = {decoded_by_old_client}") # => Output: field 3 silently ignored, no errorRun: python3 example.py
Output:
REST: old client unaffected = True
GraphQL: old query still valid = True
gRPC: old client decodes = {'id': '1', 'title': 'Hello'}Key takeaway: All three checks confirm the SAME underlying pattern in three different mechanisms -- REST's old client ignores an unread key, GraphQL's old query never mentions the new field so it stays valid, and gRPC's old client simply has no entry for wire number 3, so it decodes only what it recognizes.
Why it matters: Naming the SPECIFIC mechanism behind "adding a field is safe" for each style
matters because the mechanisms diverge in their FAILURE modes -- REST breaks if a field is
REMOVED, GraphQL breaks if a REQUIRED (non-null) field changes type, and gRPC breaks specifically
if a WIRE NUMBER is reused for a different field, which is why Example 63's .proto comments flag
those numbers as "never reused."
Example 69: Picking a Style per Scenario, With a Rationale
ex-69 · exercises co-27
Choosing REST, GraphQL, or gRPC is a design decision, not a default -- this example encodes a small decision matrix, one scenario per row, and asserts every recommended style is backed by an explicit, checkable rationale.
learning/code/ex-69-style-selection-matrix/example.py
# pyright: strict
"""Example 69: Picking a Style per Scenario, with a Rationale. (co-27)
Choosing REST, GraphQL, or gRPC is a design decision, not a default -- this
example encodes a small decision matrix, one scenario per row, and asserts
every recommended style is backed by an explicit, checkable rationale.
"""
from dataclasses import dataclass # => a small typed record for one scenario's own recommendation
@dataclass # => co-27: one row -- a scenario, its recommended style, and WHY
class StyleRecommendation: # => co-27: three fields, one row per scenario in the matrix below
scenario: str # => a short description of the situation being decided
recommended_style: str # => co-27: REST, GraphQL, or gRPC
rationale: str # => co-27: the SPECIFIC property that drove this choice
DECISION_MATRIX = [ # => co-27: a small, explicit table -- not a vague "it depends"
StyleRecommendation( # => scenario 1: a public, cacheable, browsable API
scenario="public API consumed by many unknown third-party clients", # => scenario 1's own description
recommended_style="REST", # => co-27: broad tooling support, HTTP caching (Example 67)
rationale="cacheable over plain HTTP, universally understood, easy to document (Example 79)", # => co-27
), # => end of scenario 1
StyleRecommendation( # => scenario 2: a mobile client with variable network conditions
scenario="mobile client on a slow network, many different screens with different data needs", # => scenario 2
recommended_style="GraphQL", # => co-27: avoids Example 59's over-fetching
rationale="each screen selects exactly its own fields (Example 58), minimizing payload size", # => co-27
), # => end of scenario 2
StyleRecommendation( # => scenario 3: two internal microservices talking to each other
scenario="two internal microservices exchanging high-volume, low-latency calls", # => scenario 3's own description
recommended_style="gRPC", # => co-27: binary framing, streaming (Examples 64-66)
rationale="binary Protobuf encoding plus HTTP/2 streaming outperforms JSON-over-HTTP/1.1", # => co-27
), # => end of scenario 3
] # => end of DECISION_MATRIX
# => DECISION_MATRIX has exactly 3 rows, one per style this course covers -- no scenario is left vague
VALID_STYLES = {"REST", "GraphQL", "gRPC"} # => co-27: the only three styles this course covers
for recommendation in DECISION_MATRIX: # => co-27: verify every row before trusting it
assert recommendation.recommended_style in VALID_STYLES, "unrecognized style" # => a real style, not a typo
assert len(recommendation.rationale) > 0, "every recommendation needs a stated reason" # => never unjustified
print(f"{recommendation.scenario!r} -> {recommendation.recommended_style} ({recommendation.rationale})")
# => Output: three lines, each scenario paired with its style AND its specific justificationRun: python3 example.py
Output:
'public API consumed by many unknown third-party clients' -> REST (cacheable over plain HTTP, universally understood, easy to document (Example 79))
'mobile client on a slow network, many different screens with different data needs' -> GraphQL (each screen selects exactly its own fields (Example 58), minimizing payload size)
'two internal microservices exchanging high-volume, low-latency calls' -> gRPC (binary Protobuf encoding plus HTTP/2 streaming outperforms JSON-over-HTTP/1.1)Key takeaway: Every row's rationale cites a SPECIFIC property demonstrated by an earlier
example (Example 67's caching, Example 58's field selection, Examples 64-66's streaming) -- the
two assert statements enforce that no recommendation in the matrix is ever left unjustified.
Why it matters: "It depends" is true but useless as engineering guidance -- encoding the decision as a matrix with an explicit, checkable rationale per row forces the SPECIFIC property driving each choice into the open, which is what makes the choice defensible later, when someone asks why a particular service picked gRPC over REST.
Example 70: Writing the OpenAPI Spec Before Any Code
ex-70 · exercises co-09, co-14
Contract-first means the OpenAPI document (Example 12) is authored and agreed upon BEFORE a single handler is written -- the spec becomes the design decision itself, and Example 71's handler is later WRITTEN TO MATCH it, not the other way around.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph LR
A["Example 70:<br/>Write spec FIRST"] --> B["Example 71:<br/>Write handler TO MATCH"]
B --> C["Example 72:<br/>Assert live response conforms"]
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#000000,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
class A blue
class B orange
class C teal
learning/code/ex-70-contract-first-openapi/example.py
# pyright: strict
"""Example 70: Writing the OpenAPI Spec Before Any Code. (co-09, co-14)
Contract-first means the OpenAPI document (Example 12) is authored and
agreed upon BEFORE a single handler is written -- the spec becomes the
design decision itself, and Example 71's handler is later WRITTEN TO
MATCH it, not the other way around.
"""
from typing import Any # => the spec is arbitrary nested JSON
SPEC_WRITTEN_FIRST: dict[str, Any] = { # => co-09: this dict exists BEFORE any handler code does
"paths": { # => co-14: every field a future handler must return is decided HERE
"/articles/{id}": { # => co-09: the ONE resource path this spec covers
"get": { # => co-09: the operation's own declared response shape
"responses": { # => co-09: every status code this operation can return
"200": { # => a successful GET
"content": { # => co-09: media types the 200 response can be returned as
"application/json": { # => co-09: the ONE media type this spec declares
"schema": { # => co-14: the FIELDS a conforming response must have
"type": "object", # => co-14: the response body is a JSON object
"required": ["id", "title"], # => co-14: these two fields are MANDATORY
"properties": {"id": {"type": "integer"}, "title": {"type": "string"}}, # => co-14
} # => end of schema
} # => end of application/json
} # => end of content
} # => end of the 200 response
} # => end of responses
} # => end of the get operation
} # => end of /articles/{id}
} # => end of paths
} # => end of SPEC_WRITTEN_FIRST
def required_fields(spec: dict[str, Any], path: str, method: str) -> list[str]: # => co-09: reads the contract
operation = spec["paths"][path][method] # => the specific operation's own definition
schema = operation["responses"]["200"]["content"]["application/json"]["schema"] # => its response schema
return list(schema["required"]) # => co-14: exactly the fields a handler MUST produce
fields_the_handler_must_return = required_fields(SPEC_WRITTEN_FIRST, "/articles/{id}", "get")
# => co-09: this list is now the DESIGN -- it exists before Example 71's handler is written
print(f"handler must return: {fields_the_handler_must_return}") # => Output: ['id', 'title']Run: python3 example.py
Output:
handler must return: ['id', 'title']Key takeaway: required_fields reads SPEC_WRITTEN_FIRST and returns ['id', 'title'] --
notice that this list existed as data BEFORE any handler function was written; the design decision
is captured in the spec, not discovered later by reading whatever a handler happens to return.
Why it matters: Contract-first inverts the usual order: instead of writing a handler and then documenting what it does (documentation that can silently drift, Example 79's whole point), the spec is the design ARTIFACT itself, agreed on first -- Example 71 writes a handler specifically to satisfy this already-existing list, not the reverse.
Example 71: Implementing Handlers From the Spec
ex-71 · exercises co-12
Following Example 70's contract-first spec, the handler is now WRITTEN TO MATCH the schema that already existed -- the spec drove the implementation, and this example verifies the resulting response actually satisfies every field the spec required.
learning/code/ex-71-spec-driven-server/example.py
# pyright: strict
"""Example 71: Implementing Handlers From the Spec. (co-12)
Following Example 70's contract-first spec, the handler is now WRITTEN TO
MATCH the schema that already existed -- the spec drove the implementation,
and this example verifies the resulting response actually satisfies every
field the spec required.
"""
REQUIRED_FIELDS = ["id", "title"] # => co-12: taken directly from Example 70's already-agreed contract
# => REQUIRED_FIELDS is ['id', 'title'] (type: list[str]) -- lifted verbatim from the spec
def get_article_handler(article_id: int) -> dict[str, object]: # => co-12: WRITTEN AFTER the spec existed
return {"id": article_id, "title": "Hello, API Design"} # => co-12: deliberately matches REQUIRED_FIELDS
def response_matches_schema(response: dict[str, object], required: list[str]) -> bool: # => co-12: validates
return all(field in response for field in required) # => co-12: every required field must be present
response = get_article_handler(1) # => co-12: calls the handler that was written to satisfy the spec
print(f"response: {response}") # => Output: {'id': 1, 'title': 'Hello, API Design'}
conforms = response_matches_schema(response, REQUIRED_FIELDS) # => co-12: checks it against the CONTRACT
# => conforms is True (type: bool) -- the implementation was designed AFTER the contract, not before
print(f"conforms to spec: {conforms}") # => Output: True -- co-12: the handler satisfies what was designed firstRun: python3 example.py
Output:
response: {'id': 1, 'title': 'Hello, API Design'}
conforms to spec: TrueKey takeaway: REQUIRED_FIELDS is copied straight from Example 70's spec-derived list --
get_article_handler was written to satisfy it, and response_matches_schema confirms it does,
closing the loop from "spec exists" to "implementation matches spec."
Why it matters: The direction of dependency matters -- here, the SPEC constrains the implementation, not the other way around. Example 72 goes one step further: it checks not just that the required fields are PRESENT, but that they carry the exact TYPES the spec promised. Reversing that direction -- writing the handler first and documenting it afterward -- almost always produces a spec that quietly drifts from what the code actually does, which is the exact failure Example 72's conformance check exists to catch.
Example 72: Asserting Live Responses Conform to the Spec
ex-72 · exercises co-12, co-01
An OpenAPI spec (co-01: "the API IS the contract with its consumers") is only as trustworthy as the tests that enforce it -- a conformance test calls the LIVE handler and asserts the response matches the declared schema's types, not merely that the required keys exist (Example 71).
learning/code/ex-72-spec-conformance-test/example.py
# pyright: strict
"""Example 72: Asserting Live Responses Conform to the Spec. (co-12, co-01)
An OpenAPI spec (co-01: "the API IS the contract with its consumers") is
only as trustworthy as the tests that enforce it -- a conformance test
calls the LIVE handler and asserts the response matches the declared
schema's types, not merely that the required keys exist (Example 71).
"""
from typing import Any # => the spec is arbitrary nested JSON
RESPONSE_SCHEMA: dict[str, Any] = { # => co-12: field name -> the Python type it must actually be
"id": int, # => co-12: the spec says "integer" -- Python's int
"title": str, # => co-12: the spec says "string" -- Python's str
} # => end of RESPONSE_SCHEMA
def get_article_handler(article_id: int) -> dict[str, object]: # => the LIVE handler under test
return {"id": article_id, "title": "Hello, API Design"} # => a conforming response
def get_article_handler_broken(article_id: int) -> dict[str, object]: # => co-12: a hypothetical regression
return {"id": str(article_id), "title": "Hello, API Design"} # => co-12: "id" is now a STRING, not int
def assert_conforms(response: dict[str, object], schema: dict[str, Any]) -> None: # => co-12: the actual test
for field_name, expected_type in schema.items(): # => co-01: checks EVERY promised field, one by one
assert field_name in response, f"missing field: {field_name!r}" # => co-12: presence check
actual_value = response[field_name] # => the live value returned
assert isinstance(actual_value, expected_type), ( # => co-12: TYPE check, not just presence
f"{field_name!r} should be {expected_type.__name__}, got {type(actual_value).__name__}"
) # => end of the type-mismatch message
assert_conforms(get_article_handler(1), RESPONSE_SCHEMA) # => co-01: the contract holds -- passes silently
print("conforming handler: contract test passed") # => Output: contract test passed
try: # => co-12: run the SAME test against the type-regressed handler
assert_conforms(get_article_handler_broken(1), RESPONSE_SCHEMA) # => expected to fail on "id"'s type
print("broken handler: contract test passed (UNEXPECTED)") # => would only print if the bug went uncaught
except AssertionError as exc: # => co-12: the type regression is caught HERE, loudly
# => exc's message names the exact field and the exact type mismatch found
print(f"broken handler: contract test FAILED as expected: {exc}") # => Output: caught, with a clear reasonRun: python3 example.py
Output:
conforming handler: contract test passed
broken handler: contract test FAILED as expected: 'id' should be int, got strKey takeaway: assert_conforms catches the type regression ("id" returned as str instead
of int) that Example 71's simpler response_matches_schema (a mere presence check) would have
missed entirely -- both handlers return an "id" field, but only one returns it as the TYPE the
spec promised.
Why it matters: co-01's claim that "the API IS the contract" is only true if something actually enforces it against LIVE behavior -- a spec that is never checked against real responses can drift silently, the exact failure Example 34's consumer contract test guards against for REST fields in general; this example applies the same discipline specifically to the spec's declared TYPES.
Example 73: Evolving v1 -> v2 With a Deprecation Window
ex-73 · exercises co-13, co-15
A real migration serves BOTH versions simultaneously for a window: v1 carries Examples 35-36's
Deprecation/Sunset headers while still working, and v2 (Example 29's URI-path strategy) serves
the new shape -- callers migrate on their own schedule, within the stated window.
learning/code/ex-73-versioned-migration/example.py
# pyright: strict
"""Example 73: Evolving v1 -> v2 With a Deprecation Window. (co-13, co-15)
A real migration serves BOTH versions simultaneously for a window: v1
carries Examples 35-36's `Deprecation`/`Sunset` headers while still
working, and v2 (Example 29's URI-path strategy) serves the new shape --
callers migrate on their own schedule, within the stated window.
"""
from dataclasses import dataclass # => a small typed response record for this example
@dataclass # => co-15: status, headers (carrying the migration notice on v1 only), and the body
class Response: # => co-13/co-15: the SAME shape serves both v1 and v2 responses
status: int # => the HTTP status code -- identical for v1 and v2 while both are served
headers: dict[str, str] # => empty for v2, carries Deprecation+Sunset for v1
body: dict[str, object] # => v1's older shape vs v2's newer shape
def get_article_v1(article_id: int) -> Response: # => co-13: the OLD path, still served during the window
return Response( # => co-15: still works, but flagged for retirement
status=200, # => v1 still succeeds -- this IS the deprecation window, not an outage
headers={ # => co-15: BOTH migration-notice headers, together
"Deprecation": "true", # => Example 35: signals deprecation
"Sunset": "Wed, 01 Jul 2026 00:00:00 GMT", # => Example 36: names the exact retirement date
}, # => end of the headers dict
body={"id": article_id, "title": "Hello"}, # => co-13: the OLDER response shape
) # => end of the v1 Response construction
def get_article_v2(article_id: int) -> Response: # => co-13: the NEW path, the migration TARGET
return Response( # => co-13: no deprecation headers -- this is the CURRENT, supported version
status=200, # => v2 also succeeds
headers={}, # => co-13: nothing to warn about -- v2 has no retirement scheduled
body={"id": article_id, "title": "Hello", "author": "Ada"}, # => co-13: the NEWER, additive shape
) # => end of the v2 Response construction
v1_response = get_article_v1(1) # => a caller still on the OLD path, during the window
print(f"v1: status={v1_response.status}, deprecated={'Deprecation' in v1_response.headers}") # => Output: True
v2_response = get_article_v2(1) # => a caller ALREADY migrated to the NEW path
print(f"v2: status={v2_response.status}, deprecated={'Deprecation' in v2_response.headers}") # => Output: False
both_serve_now = v1_response.status == 200 and v2_response.status == 200 # => co-13: the window's own promise
# => both_serve_now is True -- neither caller is forced to migrate on someone else's schedule
print(f"both versions serve during the window: {both_serve_now}") # => Output: TrueRun: python3 example.py
Output:
v1: status=200, deprecated=True
v2: status=200, deprecated=False
both versions serve during the window: TrueKey takeaway: both_serve_now is True -- v1 keeps working (status 200) even while flagged
Deprecation: true, and v2 works too, with neither header -- a real migration window means both
versions genuinely function, not that v1 is already broken while v2 is announced.
Why it matters: This is where three earlier concerns become one operational reality: Example 29's versioning strategy decides HOW two versions coexist, and Examples 35-36's headers decide HOW callers are warned during that coexistence -- a migration is not a single cutover moment but a WINDOW, and this example is what that window looks like from both an old and a new caller's perspective, simultaneously.
Example 74: Combining Idempotency + Rate Limiting on One Endpoint
ex-74 · exercises co-18, co-19
Idempotency (Examples 37-39) and rate limiting (Examples 40-42) are INDEPENDENT concerns that both apply to the SAME write endpoint at once -- this example checks the rate limit FIRST (a rejected call should not even reach idempotency bookkeeping), then applies idempotency second.
learning/code/ex-74-idempotent-rate-limited-write/example.py
# pyright: strict
"""Example 74: Combining Idempotency + Rate Limiting on One Endpoint. (co-18, co-19)
Idempotency (Examples 37-39) and rate limiting (Examples 40-42) are
INDEPENDENT concerns that both apply to the SAME write endpoint at once --
this example checks the rate limit FIRST (a rejected call should not even
reach idempotency bookkeeping), then applies idempotency second.
"""
from dataclasses import dataclass, field # => field: default_factory for the headers dict
REQUEST_BUDGET = [2] # => co-19: a small budget -- 2 calls allowed before 429 trips
IDEMPOTENCY_STORE: dict[str, dict[str, object]] = {} # => co-18: key -> the response it produced
@dataclass # => co-18/co-19: status, headers (Retry-After when limited), and the body
class Response:
status: int # => the HTTP status code
headers: dict[str, str] = field(default_factory=dict[str, str]) # => carries Retry-After when limited
body: dict[str, object] = field(default_factory=dict[str, object]) # => the charge, when one is returned
def create_charge(idempotency_key: str, amount: int) -> Response: # => POST /charges, both concerns combined
if REQUEST_BUDGET[0] <= 0: # => co-19: rate limit is checked FIRST -- before any idempotency bookkeeping
return Response(status=429, headers={"Retry-After": "60"}) # => co-19: rejected, key never recorded
if idempotency_key in IDEMPOTENCY_STORE: # => co-18: a REPLAY, checked only AFTER the budget allows it
return Response(status=200, body=IDEMPOTENCY_STORE[idempotency_key]) # => co-18: the stored response
REQUEST_BUDGET[0] -= 1 # => co-19: consumes budget only for a genuinely NEW request
body: dict[str, object] = {"id": 1, "amount": amount, "status": "succeeded"} # => explicit dict[str, object]
IDEMPOTENCY_STORE[idempotency_key] = body # => co-18: recorded for any future replay
return Response(status=201, body=body) # => 201 -- a freshly created charge
first = create_charge("key-1", 5000) # => call 1: new key, budget=2 -> 1
print(f"call 1 (new key): status={first.status}, body={first.body}") # => Output: 201
replay = create_charge("key-1", 5000) # => call 2: SAME key -- co-18's replay path, budget STILL 1
print(f"call 2 (replay): status={replay.status}, body={replay.body}") # => Output: 200, IDENTICAL body
replay_again = create_charge("key-1", 5000) # => call 3: ANOTHER replay of the SAME key
print(f"call 3 (replay): status={replay_again.status}") # => Output: 200 -- budget untouched by replays
new_key = create_charge("key-2", 3000) # => call 4: a genuinely NEW key -- consumes the LAST budget unit
print(f"call 4 (new key): status={new_key.status}") # => Output: 201, budget=2 -> 1 -> 0
exhausted = create_charge("key-3", 1000) # => call 5: yet ANOTHER new key -- budget is now exhausted
# => exhausted.status is 429 -- the two independent concerns compose: budget gates BEFORE idempotency
print(f"call 5 (new key, no budget): status={exhausted.status}") # => Output: 429 -- co-19's own rejectionRun: python3 example.py
Output:
call 1 (new key): status=201, body={'id': 1, 'amount': 5000, 'status': 'succeeded'}
call 2 (replay): status=200, body={'id': 1, 'amount': 5000, 'status': 'succeeded'}
call 3 (replay): status=200
call 4 (new key): status=201
call 5 (new key, no budget): status=429Key takeaway: Calls 2 and 3 (replays of "key-1") never consume budget -- only calls 1 and 4
(genuinely new keys) do, which is exactly why call 5's brand-new key hits the exhausted budget and
gets 429, while a replay of an ALREADY-charged key never would, no matter how many times it is
resent.
Why it matters: The ORDER of these two checks matters -- rate limiting first means a caller that is simply retrying an already-successful idempotent write is never penalized for that retry, while a caller making genuinely new requests still respects the budget; checking idempotency first would let an attacker exhaust the rate limit's bookkeeping with replay traffic that should be essentially free.
Example 75: GraphQL Partial Errors vs REST problem+json
ex-75 · exercises co-30, co-08
REST signals failure via the STATUS CODE (Example 12's problem+json, a non-2xx status); GraphQL
signals failure via an errors ARRAY alongside a data object that may be PARTIALLY populated --
the HTTP status stays 200 even when part of the query failed.
learning/code/ex-75-error-envelope-graphql/example.py
# pyright: strict
"""Example 75: GraphQL Partial Errors vs REST problem+json. (co-30, co-08)
REST signals failure via the STATUS CODE (Example 12's `problem+json`, a
non-2xx status); GraphQL signals failure via an `errors` ARRAY alongside a
`data` object that may be PARTIALLY populated -- the HTTP status stays 200
even when part of the query failed.
"""
from typing import Any # => both error shapes are arbitrary nested JSON
def rest_error_response(article_id: int) -> tuple[int, dict[str, Any]]: # => co-08: REST's own error shape
status = 404 # => co-08: the FAILURE is communicated via the status code itself
body = { # => RFC 9457's application/problem+json envelope (Example 12)
"type": "https://example.com/probs/not-found", # => a URI identifying this problem TYPE
"title": "Article Not Found", # => a short, human-readable summary
"status": status, # => co-08: the SAME status, echoed inside the body too
"detail": f"No article with id {article_id}", # => specific to THIS occurrence
} # => end of the problem+json body
return status, body # => co-08: status and body travel together, as one failure signal
def graphql_response_with_partial_error() -> dict[str, Any]: # => co-30: GraphQL's own error shape
return { # => co-30: status stays 200 -- the FAILURE lives entirely inside this body
"data": {"article": {"id": "1", "title": "Hello"}, "comments": None}, # => co-30: PARTIAL success
"errors": [ # => co-30: a list -- MULTIPLE fields can fail independently in one response
{"message": "Comments service unavailable", "path": ["comments"]} # => co-30: WHICH field failed
], # => end of errors
} # => end of the GraphQL response
rest_status, rest_body = rest_error_response(999) # => co-08: a REST call for a missing article
print(f"REST: status={rest_status}, body={rest_body}") # => Output: 404, full problem+json body
graphql_body = graphql_response_with_partial_error() # => co-30: a GraphQL call where ONE field failed
# => the transport-level status for this call is 200 -- the failure lives entirely in "errors"
print(f"GraphQL: status=200 (always), body={graphql_body}") # => Output: 200, article present, comments failed
article_succeeded = graphql_body["data"]["article"] is not None # => co-30: this field's data IS present
comments_failed = graphql_body["data"]["comments"] is None # => co-30: this field's data is NOT present
print(f"GraphQL partial success: article ok={article_succeeded}, comments failed={comments_failed}")
# => Output: True, True -- co-30: one field's failure did not fail the WHOLE responseRun: python3 example.py
Output:
REST: status=404, body={'type': 'https://example.com/probs/not-found', 'title': 'Article Not Found', 'status': 404, 'detail': 'No article with id 999'}
GraphQL: status=200 (always), body={'data': {'article': {'id': '1', 'title': 'Hello'}, 'comments': None}, 'errors': [{'message': 'Comments service unavailable', 'path': ['comments']}]}
GraphQL partial success: article ok=True, comments failed=TrueKey takeaway: The GraphQL response's HTTP status would be 200 -- the SAME status a fully
successful call would return -- with the failure visible only INSIDE the body's errors array,
paired with data.comments being None while data.article is fully populated.
Why it matters: This has real client-code implications -- a REST client can check response.ok
(status-based) to know whether anything failed; a GraphQL client must ALWAYS inspect the errors
array explicitly, because a 200 status alone says nothing about whether every requested field
actually resolved successfully. A team migrating a REST client to GraphQL that forgets this distinction ships a client that silently treats a partially-failed response as a full success, a class of bug REST's status-code-first design does not even allow.
Example 76: Relay-Style Cursor Connections in GraphQL
ex-76 · exercises co-17, co-24
The Relay connection spec standardizes GraphQL's OWN cursor pagination shape (co-17's general
cursor idea, Example 8) as edges (each wrapping a node plus its own cursor) and a pageInfo
object naming whether more pages exist -- a fixed, reusable shape every paginated field can share.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph TD
A[Connection] --> B["edges: [Edge]"]
B --> C["node: Article"]
B --> D["cursor: String"]
A --> E["pageInfo: PageInfo"]
E --> F["hasNextPage: Boolean"]
E --> G["endCursor: String"]
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#000000,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
class A blue
class B orange
class E teal
learning/code/ex-76-pagination-graphql-connections/example.py
# pyright: strict
"""Example 76: Relay-Style Cursor Connections in GraphQL. (co-17, co-24)
The Relay connection spec standardizes GraphQL's OWN cursor pagination
shape (co-17's general cursor idea, Example 8) as `edges` (each wrapping a
`node` plus its own `cursor`) and a `pageInfo` object naming whether more
pages exist -- a fixed, reusable shape every paginated field can share.
"""
from typing import Any # => a connection is arbitrary nested JSON
ALL_ARTICLES = ["Hello", "World", "GraphQL", "Connections", "Relay"] # => co-17: 5 items, paged 2 at a time
def build_connection(items: list[str], after_cursor: str | None, page_size: int) -> dict[str, Any]:
# => co-17/co-24: builds ONE page in the Relay connection shape
start_index = 0 if after_cursor is None else int(after_cursor) + 1 # => co-17: resumes AFTER the cursor
page_items = items[start_index : start_index + page_size] # => co-17: exactly page_size items, or fewer
edges = [ # => co-24: one edge per item, each carrying its OWN cursor
{"node": item, "cursor": str(start_index + offset)} for offset, item in enumerate(page_items)
] # => end of edges
has_next_page = (start_index + page_size) < len(items) # => co-17: are there MORE items after this page?
end_cursor = edges[-1]["cursor"] if edges else None # => co-17: the cursor the NEXT request should resume from
return { # => co-24: the standardized Relay connection envelope
"edges": edges, # => co-24: REQUIRED -- every item plus its own cursor
"pageInfo": {"hasNextPage": has_next_page, "endCursor": end_cursor}, # => co-17: pagination metadata
} # => end of the connection
page_1 = build_connection(ALL_ARTICLES, after_cursor=None, page_size=2) # => co-17: the FIRST page, no cursor yet
print(f"page 1: {page_1}") # => Output: edges for items 0-1, hasNextPage=True
page_2 = build_connection(ALL_ARTICLES, after_cursor=page_1["pageInfo"]["endCursor"], page_size=2)
# => co-17: resumes from where page 1's endCursor left off
print(f"page 2: {page_2}") # => Output: edges for items 2-3, hasNextPage=True
page_3 = build_connection(ALL_ARTICLES, after_cursor=page_2["pageInfo"]["endCursor"], page_size=2)
# => co-17: the LAST page -- fewer items remain than a full page
# => page_3["pageInfo"]["hasNextPage"] is False and page_3["pageInfo"]["endCursor"] is still set
print(f"page 3: {page_3}") # => Output: edge for item 4 only, hasNextPage=False -- co-17: end of the listRun: python3 example.py
Output:
page 1: {'edges': [{'node': 'Hello', 'cursor': '0'}, {'node': 'World', 'cursor': '1'}], 'pageInfo': {'hasNextPage': True, 'endCursor': '1'}}
page 2: {'edges': [{'node': 'GraphQL', 'cursor': '2'}, {'node': 'Connections', 'cursor': '3'}], 'pageInfo': {'hasNextPage': True, 'endCursor': '3'}}
page 3: {'edges': [{'node': 'Relay', 'cursor': '4'}], 'pageInfo': {'hasNextPage': False, 'endCursor': '4'}}Key takeaway: Page 3 has only ONE edge (not two) and hasNextPage=False -- the connection
shape naturally handles the "last, partial page" case the same way it handles a full page, and
endCursor still resolves correctly even on that final, shorter page.
Why it matters: co-17's cursor pagination idea (Example 8's REST version) and GraphQL's Relay
connection spec solve the IDENTICAL underlying problem -- stable pagination that survives inserts
and deletes between pages -- but Relay fixes the exact SHAPE (edges/node/cursor/pageInfo)
so any GraphQL client library can paginate ANY connection field without custom per-field logic.
Example 77: A Client That Follows Links Instead of Hardcoded URLs
ex-77 · exercises co-28
A HATEOAS-driven client never hardcodes /authors/{id} -- it starts at ONE known root URL, then
NAVIGATES purely by reading _links out of each HAL response (Example 56), the way a human clicks
links in a browser instead of typing every URL from memory.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph LR
A["/ (hardcoded root)"] -->|follow _links.articles| B["/articles/1"]
B -->|follow _links.author| C["/authors/7"]
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#000000,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
class A blue
class B orange
class C teal
learning/code/ex-77-hateoas-driven-client/example.py
# pyright: strict
"""Example 77: A Client That Follows Links Instead of Hardcoded URLs. (co-28)
A HATEOAS-driven client never hardcodes `/authors/{id}` -- it starts at ONE
known root URL, then NAVIGATES purely by reading `_links` out of each HAL
response (Example 56), the way a human clicks links in a browser instead of
typing every URL from memory.
"""
from typing import Any # => the HAL responses are arbitrary nested JSON
SERVER: dict[str, dict[str, Any]] = { # => co-28: a tiny in-memory "server" -- URL -> its own HAL response
"/": {"_links": {"articles": {"href": "/articles/1"}}}, # => co-28: the ONLY hardcoded URL a client needs
"/articles/1": { # => an article resource, reached ONLY by following a link
"id": 1, # => the article's own plain attribute
"title": "Hello, API Design", # => another plain attribute
"_links": {"self": {"href": "/articles/1"}, "author": {"href": "/authors/7"}}, # => co-28: more links
}, # => end of the /articles/1 entry
"/authors/7": {"id": 7, "name": "Ada", "_links": {"self": {"href": "/authors/7"}}}, # => the final resource
} # => end of SERVER
ROOT_URL = "/" # => co-28: the ONE URL this client is allowed to hardcode
def follow_link(current_response: dict[str, Any], relation: str) -> dict[str, Any]: # => co-28: navigation step
target_href = current_response["_links"][relation]["href"] # => co-28: reads the URL FROM the response
return SERVER[target_href] # => co-28: "fetches" it -- the client never constructed this URL itself
visited_urls: list[str] = [] # => records every URL actually visited, to prove no hardcoding beyond ROOT_URL
root_response = SERVER[ROOT_URL] # => co-28: step 1 -- the ONLY hardcoded fetch
visited_urls.append(ROOT_URL) # => records the root visit
article_response = follow_link(root_response, "articles") # => co-28: step 2 -- follows the "articles" link
visited_urls.append(article_response["_links"]["self"]["href"]) # => records the article's own self-link
author_response = follow_link(article_response, "author") # => co-28: step 3 -- follows the "author" link
visited_urls.append(author_response["_links"]["self"]["href"]) # => records the author's own self-link
print(f"visited: {visited_urls}") # => Output: ['/', '/articles/1', '/authors/7'] -- discovered, not hardcoded
print(f"final resource: {author_response}") # => Output: the author record, reached via two link hops
# => the client's ONLY string literal was ROOT_URL -- every other URL came from a prior responseRun: python3 example.py
Output:
visited: ['/', '/articles/1', '/authors/7']
final resource: {'id': 7, 'name': 'Ada', '_links': {'self': {'href': '/authors/7'}}}Key takeaway: Only ROOT_URL = "/" is ever hardcoded in the client -- both /articles/1 and
/authors/7 are DISCOVERED by reading _links out of the responses that preceded them, exactly
the way visited_urls records the path this client actually took.
Why it matters: This is HATEOAS (co-28's constraint) made concrete: a server could change
/authors/7 to /people/7 tomorrow, and THIS client would still work unmodified, because it never
hardcoded that URL in the first place -- it only ever followed the href the server itself
provided, the way Example 56's HAL format was designed to be consumed.
Example 78: Measuring gRPC vs REST Round-Trip
ex-78 · exercises co-27
No real network call runs in a self-contained script -- so this example measures a HONEST proxy instead: encoded PAYLOAD SIZE, the dominant factor in round-trip time on a real network. JSON (REST) stands in for text-based encoding; a compact fixed-width encoding stands in for Protobuf's binary framing.
learning/code/ex-78-grpc-vs-rest-latency/example.py
# pyright: strict
"""Example 78: Measuring gRPC vs REST Round-Trip. (co-27)
No real network call runs in a self-contained script -- so this example
measures a HONEST proxy instead: encoded PAYLOAD SIZE, the dominant factor
in round-trip time on a real network. JSON (REST) stands in for text-based
encoding; a compact fixed-width encoding stands in for Protobuf's binary framing.
"""
import json # => stdlib: measures REST's actual JSON encoding size
import struct # => stdlib: builds a compact binary encoding, standing in for Protobuf
ARTICLE: dict[str, object] = {"id": 1, "title": "Hello", "views": 42} # => co-27: same data, encoded two ways
def rest_json_size(article: dict[str, object]) -> int: # => co-27: REST's own encoding -- JSON text
encoded = json.dumps(article).encode("utf-8") # => co-27: a normal JSON payload, as bytes over the wire
return len(encoded) # => co-27: the number of bytes REST actually sends
def grpc_binary_size(article_id: int, title: str, views: int) -> int: # => co-27: a Protobuf-like binary stand-in
title_bytes = title.encode("utf-8") # => the variable-length string field
fixed_part = struct.pack(">ii", article_id, views) # => co-27: two fixed-width 4-byte integers, no field names
return len(fixed_part) + len(title_bytes) # => co-27: no repeated key names, unlike JSON's "id":/"title":
json_bytes = rest_json_size(ARTICLE) # => co-27: measures REST's own encoded size
print(f"REST (JSON) payload size: {json_bytes} bytes") # => Output: includes repeated field-name text
binary_bytes = grpc_binary_size(1, "Hello", 42) # => co-27: measures the binary stand-in's encoded size
print(f"gRPC (binary) payload size: {binary_bytes} bytes") # => Output: smaller -- no repeated field names
savings_percent = round((1 - binary_bytes / json_bytes) * 100) # => co-27: the proxy for latency advantage
# => savings_percent is a positive int -- smaller payloads mean fewer bytes to serialize/transmit/parse
print(f"binary encoding is ~{savings_percent}% smaller") # => Output: a concrete, honest, reproducible numberRun: python3 example.py
Output:
REST (JSON) payload size: 40 bytes
gRPC (binary) payload size: 13 bytes
binary encoding is ~68% smallerKey takeaway: The SAME logical data (id, title, views) encodes to 40 bytes as JSON but
only 13 bytes in the compact binary stand-in -- roughly a third of the size, because JSON repeats
every field's NAME as text on every message, while the binary form encodes only the values.
Why it matters: This example is explicit about what it does NOT measure -- real network latency depends on connection setup, serialization speed, and many other factors this self-contained script cannot honestly simulate. What it CAN measure honestly is payload size, which is a real, reproducible, and directly relevant factor in gRPC's typical performance advantage over JSON-over-HTTP for high-volume internal traffic (Example 69's scenario 3).
Example 79: Generating Human Docs (Swagger UI / Redoc) From the Spec
ex-79 · exercises co-11
Human-readable API documentation (Swagger UI, Redoc) is GENERATED from the same OpenAPI spec that already drives validation (Example 12) and mocking -- ONE source of truth, never a hand-written doc page that silently drifts from what the API actually does.
learning/code/ex-79-openapi-full-docs/example.py
# pyright: strict
"""Example 79: Generating Human Docs (Swagger UI / Redoc) From the Spec. (co-11)
Human-readable API documentation (Swagger UI, Redoc) is GENERATED from the
same OpenAPI spec that already drives validation (Example 12) and mocking
-- ONE source of truth, never a hand-written doc page that silently drifts
from what the API actually does.
"""
from typing import Any # => the spec is arbitrary nested JSON
SPEC: dict[str, Any] = { # => co-11: the SAME kind of spec Examples 12 and 70-72 already use
"info": {"title": "Articles API", "version": "1.0.0"}, # => co-11: metadata every doc page needs
"paths": { # => co-11: every operation the generated docs will list
"/articles/{id}": { # => co-11: one path, with two documented operations
"get": {"summary": "Fetch a single article by id"}, # => co-11: a human-readable summary
"put": {"summary": "Replace an article's title (idempotent)"}, # => Example 4's own semantics
}, # => end of /articles/{id}
"/articles": { # => co-11: a second path, with one documented operation
"post": {"summary": "Create a new article"}, # => Example 5's own semantics
}, # => end of /articles
}, # => end of paths
} # => end of SPEC
def generate_docs_page(spec: dict[str, Any]) -> str: # => co-11: the "Swagger UI / Redoc" generation step
lines = [f"# {spec['info']['title']} (v{spec['info']['version']})", ""] # => co-11: a title line, from the spec
for path, operations in spec["paths"].items(): # => co-11: one section per PATH
for method, operation in operations.items(): # => co-11: one line per OPERATION on that path
lines.append(f"- {method.upper()} {path} -- {operation['summary']}") # => co-11: generated, not hand-typed
return "\n".join(lines) # => co-11: the FULL generated documentation page
docs_page = generate_docs_page(SPEC) # => co-11: runs the generator against the live spec
print(docs_page) # => Output: a title line plus one bullet per operation, all pulled from SPEC
operation_count = sum(len(ops) for ops in SPEC["paths"].values()) # => co-11: counts every documented operation
# => operation_count is 3 (type: int) -- if SPEC gains a 4th operation, the docs regenerate automatically
print(f"\ndocumented {operation_count} operations") # => Output: 3 -- GET, PUT, and POST, all renderedRun: python3 example.py
Output:
# Articles API (v1.0.0)
- GET /articles/{id} -- Fetch a single article by id
- PUT /articles/{id} -- Replace an article's title (idempotent)
- POST /articles -- Create a new article
documented 3 operationsKey takeaway: generate_docs_page never mentions "GET", "PUT", or "POST" as literal
strings written by a human documenting the API -- every line of the generated page comes directly
from SPEC's own structure, so a fourth operation added to SPEC would appear in the docs
automatically, with no separate documentation step required.
Why it matters: This closes the loop the contract-first cluster (Examples 70-72) opened -- the spec is not just a validation artifact and an implementation target, it is ALSO the source for human-facing documentation, which is exactly what tools like Swagger UI and Redoc do in practice: render an OpenAPI document as a browsable reference page, kept in sync by construction rather than by discipline.
Example 80: A Versioned REST API From an OpenAPI Spec, End to End
ex-80 · exercises co-09, co-17, co-18, co-30, co-27
The closing example of the Advanced tier assembles five prior concerns onto ONE small API: a
versioned path (co-13), cursor pagination (co-17), an idempotent write (co-18), a consistent
problem+json error envelope (co-30), and a GraphQL/gRPC facade NOTE (co-27) -- then verifies the
whole thing conforms to its own spec (co-09), end to end.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["/v1/articles (co-13)"] --> B["list: cursor page (co-17)"]
A --> C["create: idempotent (co-18)"]
A --> D["get: problem+json 404 (co-30)"]
B & C & D --> E["rate-limit gate first (co-27)"]
E --> F["GraphQL/gRPC facade (co-27)"]
F --> G["e2e conformance (co-09)"]
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#000000,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000000,color:#000000,stroke-width:2px
classDef brown fill:#CA9161,stroke:#000000,color:#FFFFFF,stroke-width:2px
class A blue
class B,F orange
class C,G teal
class D purple
class E brown
learning/code/ex-80-contract-first-api/example.py
# pyright: strict
"""Example 80: A Versioned REST API From an OpenAPI Spec, End to End. (co-09, co-17, co-18, co-30, co-27)
The closing example of the Advanced tier assembles five prior concerns onto
ONE small API: a versioned path (co-13), cursor pagination (co-17), an
idempotent write (co-18), a consistent `problem+json` error envelope
(co-30), and a GraphQL/gRPC facade NOTE (co-27) -- then verifies the whole
thing conforms to its own spec (co-09), end to end.
"""
from dataclasses import dataclass, field # => field: default_factory for mutable dataclass defaults
STORE: dict[int, dict[str, object]] = {1: {"id": 1, "title": "Hello"}, 2: {"id": 2, "title": "World"}}
# => co-09: the spec's own resource collection, at /v1/articles
IDEMPOTENCY_STORE: dict[str, dict[str, object]] = {} # => co-18: key -> the response it produced
REQUEST_BUDGET = [5] # => co-27's own rate-limit budget, shared across every call below
@dataclass # => co-09: status, headers, and body -- the shape every response in this API shares
class Response: # => co-09: the ONE shape every operation below returns
status: int # => the HTTP status code
headers: dict[str, str] = field(default_factory=dict[str, str]) # => carries Retry-After when limited
body: dict[str, object] = field(default_factory=dict[str, object]) # => the resource or error payload
def list_articles_v1(after_cursor: str | None, limit: int) -> Response: # => co-13/co-17: GET /v1/articles
if REQUEST_BUDGET[0] <= 0: # => co-27: rate limit applies to reads too, checked first
return Response(429, {"Retry-After": "60"}) # => co-27: rejected before touching the store
REQUEST_BUDGET[0] -= 1 # => co-27: consumes budget for this call
all_ids = sorted(STORE.keys()) # => co-17: a stable, deterministic order to paginate over
start = 0 if after_cursor is None else all_ids.index(int(after_cursor)) + 1 # => co-17: resumes after the cursor
page_ids = all_ids[start : start + limit] # => co-17: exactly `limit` ids, or fewer at the end
edges = [{"node": STORE[i], "cursor": str(i)} for i in page_ids] # => co-17: Example 76's own connection shape
has_next = (start + limit) < len(all_ids) # => co-17: are there more items after this page?
body: dict[str, object] = {"edges": edges, "pageInfo": {"hasNextPage": has_next}} # => co-17: paginated envelope
return Response(200, {}, body) # => a normal, successful, paginated response
def create_article_v1(idempotency_key: str, title: str) -> Response: # => co-13/co-18: POST /v1/articles
if REQUEST_BUDGET[0] <= 0: # => co-27: rate limit checked BEFORE idempotency bookkeeping
return Response(429, {"Retry-After": "60"}) # => co-27: rejected outright
if idempotency_key in IDEMPOTENCY_STORE: # => co-18: a REPLAY of a previous write
return Response(200, {}, IDEMPOTENCY_STORE[idempotency_key]) # => co-18: the stored response, verbatim
REQUEST_BUDGET[0] -= 1 # => co-27: consumes budget only for a genuinely new write
new_id = max(STORE.keys()) + 1 # => a fresh id, one past the current maximum
article: dict[str, object] = {"id": new_id, "title": title} # => the newly created resource
STORE[new_id] = article # => co-09: writes into the SAME store list_articles_v1 reads from
IDEMPOTENCY_STORE[idempotency_key] = article # => co-18: recorded for any future replay
return Response(201, {}, article) # => 201 -- freshly created
def get_article_v1(article_id: int) -> Response: # => co-13/co-30: GET /v1/articles/{id}
if article_id not in STORE: # => the requested resource does not exist
problem: dict[str, object] = { # => co-30: RFC 9457's application/problem+json envelope
"type": "https://example.com/probs/not-found", # => a URI identifying this problem TYPE
"title": "Article Not Found", # => a short, human-readable summary
"status": 404, # => co-30: the SAME status, echoed inside the body too
"detail": f"No article with id {article_id}", # => specific to THIS occurrence
} # => end of the problem+json body
return Response(404, {}, problem) # => co-30: a consistent error shape, regardless of WHICH operation
return Response(200, {}, STORE[article_id]) # => 200, the found resource
GRAPHQL_FACADE_NOTE = { # => co-27: the SAME operations, named as they would appear via a GraphQL facade
"list_articles_v1": "query { articles(first: N, after: CURSOR) { edges { node cursor } pageInfo } }", # => co-27
"create_article_v1": "mutation { createArticle(title: TITLE) { id title } }", # => co-27
} # => end of GRAPHQL_FACADE_NOTE
GRPC_FACADE_NOTE = { # => co-27: the SAME operations, named as they would appear via a gRPC facade
"list_articles_v1": "rpc ListArticles (ListArticlesRequest) returns (stream Article)", # => co-27
"create_article_v1": "rpc CreateArticle (CreateArticleRequest) returns (Article)", # => co-27
} # => end of GRPC_FACADE_NOTE
page = list_articles_v1(after_cursor=None, limit=1) # => end-to-end call 1: paginated read
print(f"page 1: {page.status}, {page.body}") # => Output: 200, one edge, hasNextPage=True
created = create_article_v1("key-e2e-1", "End to End") # => end-to-end call 2: idempotent write
print(f"created: {created.status}, {created.body}") # => Output: 201, the new article
replayed = create_article_v1("key-e2e-1", "End to End") # => end-to-end call 3: replay -- idempotency holds
print(f"replayed: {replayed.status}, same id={replayed.body['id'] == created.body['id']}") # => Output: 200, True
missing = get_article_v1(999) # => end-to-end call 4: a consistent problem+json error
print(f"missing: {missing.status}, {missing.body['title']}") # => Output: 404, Article Not Found
conforms = page.status == 200 and created.status == 201 and missing.status == 404 # => co-09: overall conformance
# => conforms is True -- five previously-separate concepts (co-13/17/18/30/27) compose without conflict
print(f"end-to-end conformance: {conforms}") # => Output: True -- co-09: every operation matched its own contract
print(f"GraphQL facade for create: {GRAPHQL_FACADE_NOTE['create_article_v1']}") # => co-27: the facade equivalent
print(f"gRPC facade for create: {GRPC_FACADE_NOTE['create_article_v1']}") # => co-27: the OTHER facade equivalentRun: python3 example.py
Output:
page 1: 200, {'edges': [{'node': {'id': 1, 'title': 'Hello'}, 'cursor': '1'}], 'pageInfo': {'hasNextPage': True}}
created: 201, {'id': 3, 'title': 'End to End'}
replayed: 200, same id=True
missing: 404, Article Not Found
end-to-end conformance: True
GraphQL facade for create: mutation { createArticle(title: TITLE) { id title } }
gRPC facade for create: rpc CreateArticle (CreateArticleRequest) returns (Article)Key takeaway: conforms is True only because ALL THREE operations behaved correctly at
once -- pagination returned a proper connection shape, the idempotent write's replay returned the
SAME id as the original create, and the missing-resource lookup returned a consistent
problem+json error -- five concerns from earlier examples, holding together on one small API.
Why it matters: This is the Advanced tier's closing statement: none of REST's, GraphQL's, or
gRPC's individual mechanisms exist in isolation in a real system -- versioning, pagination,
idempotency, and error handling all apply to the SAME endpoints simultaneously, and the
GRAPHQL_FACADE_NOTE/GRPC_FACADE_NOTE dictionaries are a reminder that the same underlying
operations (list_articles_v1, create_article_v1) can be exposed through more than one API style
without changing what they actually DO underneath.
← Previous: Intermediate Examples · Next: Capstone →
Last updated July 28, 2026