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.
| Route | Purpose |
|---|---|
POST /graphql | application/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/schema | The schema in SDL |
GET /graphql/ws | Subscriptions 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) andorderUpdates(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 noAcceptheader: the legacy behaviour,200for every well-formed request, errors in theerrorsarray.application/graphql-response+json, and also*/*(what curl and Hurl send by default): the GraphQL-over-HTTP status codes,400when 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"