Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

GraphQL

A GraphQL API with users, products, orders and reviews (the same fake customers and orders the MCP CRM tools use), served over the GraphQL-over-HTTP conventions.

RoutePurpose
POST /graphqlapplication/json (query, variables, operationName, extensions) or application/graphql (the query as the body)
GET /graphql?query=...Queries only (mutations over GET are 405); browsers asking for text/html without a query get the GraphiQL IDE
GET /graphql/schemaThe schema in SDL
GET /graphql/wsSubscriptions over WebSocket

Schema summary (see /graphql/schema for the full SDL):

  • Queries: user(id), users(limit, offset), product(id), products(category, limit), order(id), orders(userId, status, limit).
  • Mutations: createUser(input), updateUser(id, input), createOrder(input) (nothing is stored; the result is computed).
  • Subscriptions: ticker(count, intervalMs) (1 to 100 ticks, default 5, every 50 to 10000 ms, default 1000) and orderUpdates(orderId, intervalMs) (PENDING to DELIVERED, default every 500 ms).
POST {{base_url}}/graphql
Content-Type: application/json
{"query": "{ users(limit: 2) { id name email } }"}
HTTP 200
[Asserts]
jsonpath "$.data.users" count == 2
jsonpath "$.data.users[0].id" == "u1"
jsonpath "$.data.users[0].name" == "Alice Chen"
GET {{base_url}}/graphql
[Query]
query: { products(limit: 2) { id name price } }
HTTP 200
[Asserts]
jsonpath "$.data.products" count == 2
POST {{base_url}}/graphql
Content-Type: application/graphql

{ product(id: "p1") { name price inStock } }

HTTP 200
[Asserts]
jsonpath "$.data.product.name" == "Mechanical Keyboard"
POST {{base_url}}/graphql
Content-Type: application/json
{
  "query": "query A { users(limit: 1) { name } } query B { products(limit: 1) { name } }",
  "operationName": "B",
  "variables": {}
}
HTTP 200
[Asserts]
jsonpath "$.data.products" count == 1
jsonpath "$.data.users" not exists
POST {{base_url}}/graphql
Content-Type: application/json
{"query": "mutation($in: CreateUserInput!) { createUser(input: $in) { id name role } }", "variables": {"in": {"name": "Ada", "email": "ada@example.com"}}}
HTTP 200
[Asserts]
jsonpath "$.data.createUser.name" == "Ada"
jsonpath "$.data.createUser.role" == "USER"
GET {{base_url}}/graphql
[Query]
query: mutation { createUser(input: {name: "x", email: "y"}) { id } }
HTTP 405

Status codes and media types

The response media type follows Accept:

  • application/json, or no Accept header: the legacy behaviour, 200 for every well-formed request, errors in the errors array.
  • application/graphql-response+json, and also */* (what curl and Hurl send by default): the GraphQL-over-HTTP status codes, 400 when the document fails to parse or validate or exceeds a limit.
# Legacy media type: 200 even for a validation error.
POST {{base_url}}/graphql
Accept: application/json
Content-Type: application/json
{"query": "{ nope }"}
HTTP 200
[Asserts]
header "Content-Type" == "application/json"
jsonpath "$.errors" count >= 1

# GraphQL-over-HTTP media type (also chosen for Accept: */*): 400.
POST {{base_url}}/graphql
Accept: application/graphql-response+json
Content-Type: application/json
{"query": "{ nope }"}
HTTP 400
[Asserts]
header "Content-Type" contains "application/graphql-response+json"

Limits

Query depth 10, complexity 500 and 30 aliases (fragments expanded), so you can show a gateway's own GraphQL protection in front of a server that also defends itself.

POST {{base_url}}/graphql
Accept: application/graphql-response+json
Content-Type: application/json
{"query": "{ users { orders { user { orders { user { orders { user { orders { user { orders { user { name } } } } } } } } } } } }"}
HTTP 400
[Asserts]
jsonpath "$.errors[0].message" contains "nested too deep"

Automatic persisted queries

Send extensions.persistedQuery (version: 1, sha256Hash of the query text). An unknown hash answers PersistedQueryNotFound; sending the query with its hash registers it (the hash is checked); afterwards the hash alone is enough. The cache is bounded. This is the flow APQ-aware gateways and CDNs rely on.

# Automatic persisted queries: the hash alone is unknown at first...
POST {{base_url}}/graphql
Content-Type: application/json
{"extensions": {"persistedQuery": {"version": 1, "sha256Hash": "b55b2debe0cc080e753c0833207d47312584ae9adc4b1a4f0b9f138af321e371"}}}
HTTP 200
[Asserts]
jsonpath "$.errors[0].message" == "PersistedQueryNotFound"
jsonpath "$.errors[0].extensions.code" == "PERSISTED_QUERY_NOT_FOUND"

# ...register it by sending the query with its SHA-256 hash...
POST {{base_url}}/graphql
Content-Type: application/json
{"query": "query DocsApq { users(limit: 1) { name } }", "extensions": {"persistedQuery": {"version": 1, "sha256Hash": "b55b2debe0cc080e753c0833207d47312584ae9adc4b1a4f0b9f138af321e371"}}}
HTTP 200

# ...then the hash is enough.
POST {{base_url}}/graphql
Content-Type: application/json
{"extensions": {"persistedQuery": {"version": 1, "sha256Hash": "b55b2debe0cc080e753c0833207d47312584ae9adc4b1a4f0b9f138af321e371"}}}
HTTP 200
[Asserts]
jsonpath "$.data.users[0].name" == "Alice Chen"

Subscriptions

/graphql/ws speaks both WebSocket subprotocols: graphql-transport-ws (the graphql-ws library) and the legacy graphql-ws (subscriptions-transport-ws). With websocat ($WS is the base URL with ws://, for example ws://localhost:8080):

printf '%s\n' \
  '{"type":"connection_init"}' \
  '{"id":"1","type":"subscribe","payload":{"query":"subscription { ticker(count: 3, intervalMs: 200) { sequence timestamp } }"}}' \
  | websocat -n --max-messages-rev 5 --protocol graphql-transport-ws "$WS/graphql/ws"
GET {{base_url}}/graphql/schema
HTTP 200
[Asserts]
body contains "type QueryRoot"
body contains "type SubscriptionRoot"