Lesson 34 / 34
GraphQL over HTTP
Transport over a single endpoint, the caching difference between POST and GET, persisted documents, and measuring the information a single address leaves for intermediaries and logs.
Contents
Everything written up to this point ran inside a single process: the query was a string, the result an object. A real service receives the query over the network and returns the response over the network.
This is not just a wrapping job. A transport that uses a single endpoint and, mostly, a single method requires rethinking everything the previous topic built: address-based caching, status code mapping, the information intermediaries and logs see. This lesson puts the engine on top of HTTP and measures what this decision takes away and what it brings.
The Server
The server supports three transport formats. POST carries the query, the variables,
and the operation name in its body. GET carries the same three in query parameters.
The third carries, instead of the query text, the identifier of a persisted
document.
The parser, validator, schema, cost meter, and engine written in the previous lessons are used as they are; the server is a thin layer that calls them in sequence.
// server.mjs — puts GraphQL on top of HTTP // POST /graphql { query, variables, operationName } in the body // GET /graphql?query=... the query in the address; cacheable // GET /graphql?documentId=... a persisted document; short, and bounded by an allowlist import { createServer } from "node:http"; import { createHash } from "node:crypto"; import { parse } from "./parser.mjs"; import { validate } from "./validate.mjs"; import { execute } from "./executor.mjs"; import { measure, checkLimits } from "./cost.mjs"; import { SCHEMA_L as SCHEMA } from "./schema-list.mjs"; import { RESOLVERS } from "./data.mjs"; const LIMITS = { maxDepth: 6, maxCost: 1000 }; export const hashDocument = (text) => createHash("sha256").update(text).digest("hex").slice(0, 12); // Persisted documents: the client sends only its id. export const PERSISTED = new Map([ `query LoanList { loans(first: 6) { id status } }`, `query LoanDetail($id: ID!) { loan(id: $id) { id status member { name } } }`, ].map((m) => [hashDocument(m), m])); const readBody = (request) => new Promise((resolve) => { let v = ""; request.on("data", (p) => (v += p)); request.on("end", () => resolve(v)); }); const respond = (response, code, body, extraHeaders = {}) => { response.writeHead(code, { "content-type": "application/graphql-response+json; charset=utf-8", ...extraHeaders }); response.end(JSON.stringify(body)); }; createServer(async (request, response) => { response.sendDate = false; const url = new URL(request.url, "http://local"); if (url.pathname !== "/graphql") return respond(response, 404, { errors: [{ message: "path not found", code: "path_not_found" }] }); let text, variables = {}, operationName; if (request.method === "POST") { const g = JSON.parse((await readBody(request)) || "{}"); ({ query: text, variables: variables = {}, operationName } = g); } else { const documentId = url.searchParams.get("documentId"); text = documentId ? PERSISTED.get(documentId) : url.searchParams.get("query"); variables = JSON.parse(url.searchParams.get("variables") ?? "{}"); operationName = url.searchParams.get("operationName") ?? undefined; if (documentId && !text) return respond(response, 404, { errors: [{ message: "no persisted document", code: "document_not_found" }] }); } let document; try { document = parse(text ?? ""); } catch (h) { return respond(response, 400, { errors: [{ message: h.message, code: "parsing" }] }); } const validationErrors = validate(SCHEMA, document); if (validationErrors.length) return respond(response, 400, { errors: validationErrors.map((i) => ({ message: i, code: "validation" })) }); const op = operationName ? document.operations.find((i) => i.name === operationName) : document.operations[0]; const measurement = measure(SCHEMA, document, op, { defaultMultiplier: 6, variables }); const violations = checkLimits(measurement, LIMITS); if (violations.length) return respond(response, 400, { errors: violations.map((i) => ({ message: i, code: "limit" })) }); const result = await execute(SCHEMA, RESOLVERS, document, { operationName, variables }); // Field-level errors are partial success: the request was fulfilled, 200 comes back. const headers = request.method === "GET" ? { "cache-control": "public, max-age=30" } : { "cache-control": "no-store" }; respond(response, 200, result, { ...headers, "graphql-operation": op.name ?? "unnamed" }); }).listen(8441, "127.0.0.1", () => { console.log("graphql server 127.0.0.1:8441"); for (const [k, m] of PERSISTED) console.log(`persisted ${k} ${/query (\w+)/.exec(m)[1]}`); });
#!/usr/bin/env bash # Three transport formats and two error cases node server.mjs > /tmp/startup & s=$! sleep 0.6 cat /tmp/startup DOCUMENT_ID=$(grep 'LoanDetail' /tmp/startup | cut -d' ' -f2) show() { printf -- '--- %s ---\n' "$1"; shift; curl -sS -D - -o /tmp/g "$@" | grep -iE '^HTTP|^cache-control|^graphql-operation' | tr -d '\r'; cat /tmp/g; echo; } show "POST (query in body)" -X POST -H 'content-type: application/json' \ -d '{"query":"query LoanDetail($id: ID!) { loan(id: $id) { id status member { name } } }","variables":{"id":"O-2"}}' \ http://127.0.0.1:8441/graphql show "GET (query in address)" --get --data-urlencode 'query=query LoanList { loans(first: 2) { id status } }' \ http://127.0.0.1:8441/graphql show "GET (persisted document)" "http://127.0.0.1:8441/graphql?documentId=$DOCUMENT_ID&variables=%7B%22id%22%3A%22O-1%22%7D" show "field not in schema" -X POST -H 'content-type: application/json' \ -d '{"query":"{ loan(id: \"O-1\") { overdueFine } }"}' http://127.0.0.1:8441/graphql show "limit exceeded" -X POST -H 'content-type: application/json' \ -d '{"query":"{ loans(first: 500) { id member { name loans(first: 500) { id } } } }"}' http://127.0.0.1:8441/graphql kill "$s"; wait "$s" 2>/dev/null
graphql server 127.0.0.1:8441
persisted 632f08670d3d LoanList
persisted 3b33f0f1112e LoanDetail
--- POST (query in body) ---
HTTP/1.1 200 OK
cache-control: no-store
graphql-operation: LoanDetail
{"data":{"loan":{"id":"O-2","status":"OPEN","member":{"name":"Member 1"}}}}
--- GET (query in address) ---
HTTP/1.1 200 OK
cache-control: public, max-age=30
graphql-operation: LoanList
{"data":{"loans":[{"id":"O-1","status":"OPEN"},{"id":"O-2","status":"OPEN"}]}}
--- GET (persisted document) ---
HTTP/1.1 200 OK
cache-control: public, max-age=30
graphql-operation: LoanDetail
{"data":{"loan":{"id":"O-1","status":"OPEN","member":{"name":"Member 0"}}}}
--- field not in schema ---
HTTP/1.1 400 Bad Request
{"errors":[{"message":"Query.loan: type Loan has no field \"overdueFine\"","code":"validation"}]}
--- limit exceeded ---
HTTP/1.1 400 Bad Request
{"errors":[{"message":"cost 502000, limit 1000","code":"limit"}]}
Because document identifiers are derived from a hash function, they do not change from machine to machine, but the identifier changes when the document text changes.
What the Status Code Means
The responses’ status codes fall into two groups, and the split is not arbitrary.
The cases that return 400 are the cases where the query never ran at all: the document could not be parsed, does not match the schema, or exceeds the limit. The request was not fulfilled; what the client sent is at fault, and retrying does not help.
The cases that return 200 are the cases where the query ran — even if a field-level
error is present. This is the partial success built in the previous lesson: a field
crashing does not mean the request was not fulfilled, because the rest of the response
is valid. A response can carry both data and errors, and the status code cannot
report this.
The split is the same as the 400-versus-422 split from the previous topic: a request that could not be understood and a request that was understood and partially fulfilled are different things. The difference here is that the second group is not counted as a failure.
What Intermediaries and Logs See
The real cost of the single-endpoint decision is that the request’s distinguishing information moves from the address into the body. A cache or access log along the path recognizes a request only by method and address.
// intermediary.mjs — an intermediary on the path recognizes a request only by method+address. // If the same workload is sent through four transport formats, how much does what the // intermediary sees change? import { createHash } from "node:crypto"; const hashDocument = (m) => createHash("sha256").update(m).digest("hex").slice(0, 12); const OPERATIONS = [ { name: "LoanList", path: "/loans", query: `query LoanList { loans(first: 6) { id status } }`, variables: {} }, { name: "LoanDetail", path: "/loans/O-1", query: `query LoanDetail($id: ID!) { loan(id: $id) { id status member { name } } }`, variables: { id: "O-1" } }, { name: "LoanDetail", path: "/loans/O-2", query: `query LoanDetail($id: ID!) { loan(id: $id) { id status member { name } } }`, variables: { id: "O-2" } }, { name: "MemberSummary", path: "/members/U-1001", query: `query MemberSummary($k: ID!) { member(code: $k) { name fine } }`, variables: { k: "U-1001" } }, ]; const WORKLOAD = Array.from({ length: 12 }, (_, i) => OPERATIONS[i % OPERATIONS.length]); const enc = encodeURIComponent; const FORMATS = { "resource-based (GET)": (o) => ["GET", o.path], "GraphQL POST": () => ["POST", "/graphql"], "GraphQL GET (query)": (o) => ["GET", `/graphql?query=${enc(o.query)}&variables=${enc(JSON.stringify(o.variables))}`], "GraphQL GET (persisted)": (o) => ["GET", `/graphql?documentId=${hashDocument(o.query)}&variables=${enc(JSON.stringify(o.variables))}`], }; console.log("transport requests cache keys from cache distinct operations avg. address"); for (const [name, build] of Object.entries(FORMATS)) { const keys = WORKLOAD.map((o) => build(o).join(" ")); const unique = new Set(keys); // An intermediary can only cache GET requests. const cacheable = keys[0].startsWith("GET") ? WORKLOAD.length - unique.size : 0; const operationGroups = new Set(keys.map((a) => a.split("?")[0] + (a.includes("query=") || a.includes("documentId=") ? a.split("&")[0].split("?")[1] : ""))); const avgLength = Math.round(keys.reduce((t, a) => t + a.length, 0) / keys.length); console.log(`${name.padEnd(25)} ${String(WORKLOAD.length).padStart(5)} ${String(unique.size).padStart(17)} ${String(cacheable).padStart(11)} ${String(operationGroups.size).padStart(18)} ${String(avgLength).padStart(10)}`); }
transport requests cache keys from cache distinct operations avg. address resource-based (GET) 12 4 8 4 14 GraphQL POST 12 1 0 1 13 GraphQL GET (query) 12 4 8 3 156 GraphQL GET (persisted) 12 4 8 3 69
The POST line shows the cost of the single endpoint: all twelve requests fall onto a single cache key, none of them can be served from cache, and the access log sees the twelve requests as a single row type. To an intermediary, every request looks the same.
The GET lines win this back. When the query moves to the address, the cache-key count matches resource-based transport, and eight of the twelve requests become servable from cache. The cost is address length: 156 characters on average, and real queries are far longer than this. Persisted documents bring this cost down to 69 characters.
The 4-versus-3 difference in the distinct-operations column is not a shortfall, it is a
different grouping. A resource-based log separates requests by resource; a
GraphQL log, by operation. When LoanDetail is called with two different ids, it is
a single row in the second grouping. Which one is useful depends on the question being
asked: “which resource gets read the most” wants the first, “which screen is slow” wants
the second.
Persisted documents have a second consequence too. Because the server only runs the documents it has registered, an arbitrary query the client sends is never even parsed. Part of what the cost limit protects is thereby protected on its own: an unknown document is rejected without running.
Summary
- A GraphQL request can be carried in three ways: POST with the query in the body, GET with the query in the address, and GET with a persisted document identifier.
- Requests that never run (parsing, validation, limit) return 400; requests that run return 200 even if they contain a field-level error, because partial success is not failure.
- POST requests to a single endpoint fall onto a single cache key; none of the twelve requests can be served from cache, and all of them show up as one row type in the log.
- Moving the query to the address matches the cache-key count with resource-based transport; the cost is an address length that climbs into the hundreds of characters.
- Persisted documents cut the address down to a fraction of that and ensure the server only runs allowed documents.
- A resource-based log groups by resource, a GraphQL log by operation; the two answer different questions.
Course Wrap-Up
This course took up the design of an interface in four stages, and used the same criterion at every stage: can a decision’s payoff be measured?
API Styles compared the resource-based, remote-procedure-call-based, and query-based approaches. The difference between them is not a ranking of superiority, it is which side decides how much: in resource-based design the server decides the response’s shape, in query-based design the client does.
Resource and Contract Design carried the resource-based approach all the way through. Resource modeling, address layout, method selection, status code mapping, body naming, pagination, filtering, partial response, idempotency keys, and connection-based responses — all of it served a single question: the client should not have to guess what the server will do.
Errors, Versions and Documentation built the contract’s shape over time. The error body was fixed to a single format, validation errors were reported at the field level, the real difference between versioning styles was measured to lie in intermediaries, breaking-ness was defined with opposite rules in the request and response directions, and the version number was derived from the schema difference. Then the contract became machine-readable: a validator, documentation, contract tests, and a mock server were all generated from the same definition. This topic’s lasting takeaway fits in one sentence — a contract is a contract only if it can be validated where it is written.
GraphQL in Detail told the query-based approach by measuring it, and did so without reaching for a library, by writing its own parser and engine. The type system validated the query before it ran; the three operation types’ execution rule was measured on a single-copy book; resolver calls were separated from data-source round trips, and the N+1 problem was brought down from 101 round trips to 3; fragments were shown to disappear at run time, and variables were shown to fix the distinct-document count; cursor pagination’s advantage over offset was measured while the list changed; field-level authorization and partial errors were built; the required mark was shown able to spread one field’s crash across the entire response; and why the cost limit is more fundamental than the depth limit was put down with a number.
The two approaches’ shared lesson is visible here too. In resource-based design, the server guesses what the consumer wants and sends too much or too little data; in query-based design, the consumer gets exactly what it asked for, but the server cannot know in advance how much work it will do. The first is a cost paid at design time, the second at run time. Contract tests make the first measurable, the cost limit makes the second measurable.
One question kept being deferred throughout this course. We wrote that only the branch
where a loan record was opened should see its note, but we never asked how we would know
the requester really is that branch’s clerk; the authorization rules read the user and
role fields on the context, and how those fields got there was left open. The consumer
identifier arriving as a header in the deprecation telemetry was another view of the same
gap — a client declaring its own identity is not authentication.
The Authentication and Authorization course fills this gap. It separates how an identity is proven from what that identity is permitted to do; it builds the secure implementation of password and token storage; and it compares the measurable consequences of defining an authorization model on roles, attributes, or relationships. When the authorization rules we wrote in this course move there, how the context gets filled will no longer be an assumption.
To keep your progress and take notes, Log in
My notes
Log in to take notes.