GraphQL
A GraphQL schema is served as a GraphQL endpoint, /graphql under --base-path
(graphql.path changes it). Every query, mutation and subscription the schema allows is
answered, with no resolvers to write.
- GraphQL over HTTP.
POSTwithapplication/json({query, variables, operationName, extensions}) orapplication/graphql,GETwith the same as query parameters (queries only: a mutation overGETgets 405), and batches (a JSON array of requests,graphql.max_batch). Clients that acceptapplication/graphql-response+jsonget 400 for requests that fail before execution;application/jsonclients get 200 for every well-formed request, as the specification asks. - Validation. Requests are checked against the schema with the rules of graphql-js and its
messages (unknown fields and arguments, wrong values and variable types, fragments that can’t
apply, fields that can’t be merged…). Errors have
locationsandextensions.code(GRAPHQL_PARSE_FAILED,GRAPHQL_VALIDATION_FAILED,BAD_USER_INPUT), and the log shows each as a violation. - Execution. Fragments, inline fragments, aliases, variables with defaults,
@skip,@includeand__typenamebehave as in a real server. Interfaces and unions return one of their object types. A field that fails is null and its error has the field’spath; a non-null field’s null goes up to the nearest nullable one. - Introspection.
__schemaand__typeare complete, so GraphiQL, Apollo Sandbox and code generators work against the mock (graphql.introspection: falseturns it off). The SDL is at/graphql/schema.graphqlandGET /__apigenie/spec. - GraphiQL. A browser opening the endpoint gets GraphiQL, loaded from esm.sh
(
graphql.playground: falseturns it off).
Generated data
Values follow the field’s type: enum values, list lengths between graphql.lists.min and
max (or a first, last, limit, count… argument, the field’s own or its
connection’s), nulls with graphql.null_probability, and realistic values for fields named like
email, city, phone, url or firstName, for name, title and genre by the type
they belong to (Author.name is a person’s, Book.title a book’s), and for custom scalars by
their name (DateTime, Date, URL, UUID, JSON, Long, Decimal, EmailAddress…).
--seed repeats a run.
The schema can say more, with graphql-faker’s directives or the mock’s own. They need no definitions in the schema and don’t show in introspection:
type User {
name: String @fake(type: fullName)
email: String @fake(type: email, options: {emailProvider: "example.com"})
joined: String @fake(type: pastDate, options: {dateFormat: "YYYY-MM-DD"})
role: Role @examples(values: ["ADMIN", "EDITOR"])
tags: [String!] @listLength(min: 1, max: 3)
score: Int @mock(min: 0, max: 100)
code: String @mock(faker: "string.numeric", args: [6])
nickname: String @mock(nullProbability: 0.5)
}
scalar Money @mock(min: 1, max: 500)
@fake(type:, options:)takes the 64 types of graphql-faker and the options most used (minMoney,maxMoney,decimalPlaces,minNumber,maxNumber,precisionNumber,dateFormat,dateFrom,dateTo,loremSize,imageSize,imageKeywords,passwordLength,emailProvider,useFullAddress). Data is English whatever thelocale.@examples(values:)picks one of the values;@listLength(min:, max:)sizes a list.@mocktakesfaker(any faker provider, asx-faker) withargs(a list by position or an object by name),valueorvalues,minandmaxfor numbers,lengthorminLengthandmaxLengthfor lists, andnullProbability. On a root field,delay(milliseconds) delays the response anderrorRatewitherrormakes it fail; on a subscription field,intervalandcountpace its events.
graphql.fields does the same from the configuration, for a field (Type.field) or a scalar,
over the schema’s directives. A schema that defines a directive of the same name keeps it: the
mock leaves it alone.
The store
Objects whose type has an id field are entities: once made, their fields keep their values
for the next requests, so { user(id: "7") { name } } answers the same name every time. The
usual root fields work on them, by their names and types:
| Field | What it does |
|---|---|
user(id: ID!): User, node(id: ID!): Node |
The entity with that id; a new one when there is none (graphql.store.unknown_ids: null answers null). |
users: [User!]! |
The users, made up to the list’s length once, then the same. |
usersConnection(first:, after:, last:, before:) |
The users as a Relay connection: edges { cursor node }, nodes, pageInfo, totalCount. |
createUser(input: ...), addUser(...), newUser, registerUser… |
A new user with the input’s fields; payload types get it in their field and clientMutationId back. |
updateUser(id:, input:), editUser, setUser… |
The user, with the input’s fields. |
deleteUser(id:), removeUser… |
Removes the user; returns it, true, its id or a payload. |
--dynamic and graphql.store.enabled: false turn the store off; POST /__apigenie/seed
empties it, and so does a reload. graphql.store.max_entities (10000) bounds it.
Examples
An example answers a request with data of its own. Operation examples match an operation’s name, its document (layout and comments don’t count) and some of its variables; field examples are the value of a field for some arguments, in any operation:
# schema_examples/users.yaml, next to schema.graphql: an example, or a list of them
- name: ada
operation: GetUser
variables: {id: "1"}
response:
data: {user: {id: "1", name: Ada Lovelace}}
- name: grace # a field example
field: Query.user
arguments: {id: "2"}
data: {name: Grace Hopper}
Example data is cut to the request’s selection set, and fields it lacks are generated, so one
example serves every query of its operation. Examples come from graphql.examples in the
configuration, from graphql.examples_files (files and directories of .json, .yaml and
.yml, each an example or a list of them) and from the directory named after the schema
(schema.graphql takes schema_examples/). Prefer: example=ada picks one by name;
--dynamic and Prefer: dynamic=true leave them out. An example’s errors and extensions
are added to the response, and status sets the HTTP status.
Scenarios and errors
Rules of scenarios and operations match GraphQL operations by the operation’s name
(operationId: GetUser) or by a root field (operationId: Query.user, or tag: Query.user).
Besides status, headers, body (the whole response), example, dynamic and the
simulation, they take errors:
scenarios:
users-down:
rules:
- match: {operationId: Query.user}
errors:
- message: The users service is down.
path: [user]
extensions: {code: UNAVAILABLE}
- Partial results # no path: added to the response's errors
An error with a path fails that field: it is null (or null up to the nearest nullable field)
and the error has its locations. Prefer: code=, example=, dynamic=, delay=, drop= and
scenario= apply as for OpenAPI.
Subscriptions
WebSocket clients of the endpoint speak graphql-transport-ws (the graphql-ws library: Apollo
Client, urql, GraphiQL) or the legacy graphql-ws of subscriptions-transport-ws, by the
subprotocol they offer. A subscription gets an event every graphql.subscriptions.interval
(1000 ms), each a fresh execution of its operation, until count events (0: until the client
completes it); a field’s @mock(interval:, count:) wins. Queries and mutations over WebSocket get
one result. A request that accepts text/event-stream gets the same as server-sent events
(next events, then complete: graphql-sse’s distinct connections mode). The log has ws-open,
ws-in, ws-out and ws-close events; the server’s stop closes the clients with 1001 and a
reload with 1012.
Contract testing of a GraphQL API
With --proxy-to, the GraphQL endpoint goes to a real GraphQL server, and the mock checks every
request against the schema and every answer against the schema and the request’s selection set.
Point your frontend, your tests or a load test at the mock, and the traffic becomes a contract
test of the server:
apigenie-server schema.graphql --proxy-to http://localhost:4000/graphql --report contract.json --fail-on errors
Listening on http://127.0.0.1:4010/graphql (spec: schema.graphql, 12 operations)
Proxy: http://localhost:4000/graphql (requests: log, responses: log, unknown routes: forward, mode: always)
The URL’s path is the server’s endpoint (HOST:PORT alone keeps the mock’s, /graphql). The
upstream gets the request as it came (a batch stays a batch), without the mock’s __ query
parameters and X-Apigenie-Mock-* headers, with Via and X-Forwarded-*, and
Accept-Encoding: identity. GraphiQL, /graphql/schema.graphql, the admin API and OPTIONS
stay local. Requests for other paths are forwarded unchecked (request.path.unknown), or answered
404 with --proxy-unknown reject. A proxy makes up no subscription events: WebSocket and
server-sent events subscriptions aren’t served.
What is checked. Requests as the mock checks them (graphql-js’s rules and messages), and each answer as graphql-js would have produced it, field by field:
| Check | Severity | When |
|---|---|---|
request.body.malformed |
error | Not a GraphQL request: not JSON, no query, an unsupported content type. |
request.query.malformed, request.operation.unknown |
error | The query can’t be parsed; operationName names no operation, or is missing. |
request.query.invalid, request.variables.incompatible |
error | The operation isn’t valid against the schema; a variable doesn’t match its type. |
request.method.incompatible |
error | A mutation over GET. |
request.field.deprecated |
info | A deprecated field, argument, input field or enum value is used. |
request.path.unknown |
warn | A request for another path than the endpoint (forwarded unchecked). |
response.status.incompatible |
error | A 1xx or 3xx status, or a 4xx with data: an executed operation that says it failed. |
response.status.server-error |
info | The server answered with 5xx. |
response.content-type.incompatible |
error | Neither application/json nor application/graphql-response+json. |
response.body.malformed |
error | Not JSON, or a batch not answered with a list of as many results. |
response.shape.incompatible |
error | Not a map of data, errors and extensions: other keys, neither data nor errors, data: null without errors, empty errors. |
response.errors.incompatible |
error | An error without a string message, with locations that aren’t {line, column}, a path of other than names and indexes, extensions that aren’t an object. |
response.errors.unexpected, response.errors.path-unknown |
warn | An error’s entries outside extensions; a path that names no field the operation selects. |
response.data.incompatible |
error | A selected field (or alias) missing; null in a non-null position that no error’s path explains; a list, an object or a scalar where the type says otherwise; Int beyond 32 bits, a non-finite Float, a String, Boolean or ID of another kind, an unknown enum value; a __typename that isn’t a possible type. |
response.data.unexpected |
warn | A key the operation didn’t select. |
response.data.typename-missing |
info | An object of an interface or union came without __typename: only the selections outside type conditions are checked. |
response.data.unvalidatable |
info | Nesting deeper than 64, or more than 100 violations in one answer. |
response.field-error |
warn | The server ran the operation, but fields failed: errors next to data (null or partial), one per error, e.g. the field book failed: not implemented. Valid GraphQL, but a contract test shouldn’t pass it silently; --fail-on warnings (or contract.severity) fails on it. |
response.request-error |
warn | The server didn’t execute the operation (authentication, persisted queries…). |
response.schema-drift |
error | The server rejected an operation the schema allows (GRAPHQL_VALIDATION_FAILED, “Cannot query field…”): its schema differs. |
schema.drift, schema.addition |
error, info | The server’s schema (introspected) lacks or changed something of the schema; has more. |
schema.unchecked |
info | The server’s schema can’t be introspected. |
Fragments, inline fragments, @skip and @include (with the request’s variables), aliases and
merged fields are followed as the server must; custom scalars take any JSON value.
contract.severity changes a check’s severity or turns it off, as for OpenAPI.
The server’s schema. With the first request it forwards, the proxy also sends the server the
usual introspection query (with that request’s Authorization, Cookie and X-Api-Key), turns
the answer into a schema and compares it with the served one, as apigenie diff does: what the
server lacks or changed incompatibly is schema.drift (located by Type.field), what it adds
schema.addition. The log has a server event with the result (schema of http://localhost:4000/graphql: 1 breaking difference, 2 additions), and the report a schema
section. A server that isn’t up yet is asked again with the next request; POST /__apigenie/contract/schema-check asks now. proxy.schema_check: false turns it off.
Report or enforce. As for OpenAPI, the proxy reports by default: answers carry
X-Apigenie-Mock-Violations and X-Apigenie-Mock-Violations-Detail (locations such as
["response", "body", "/data/book/title"], /2/data/... for the third result of a batch). With
--strict, a request with errors isn’t forwarded: it gets the GraphQL errors the mock answers with
(GRAPHQL_VALIDATION_FAILED…). An answer with errors is replaced by the invalid-upstream-response
502 problem. Warnings and notes never block.
Fallback mode. --proxy-mode fallback (proxy.mode) lets scenario rules, examples and the
scripts’ onGraphQL answer first, as for a mock; what nothing answers goes to the server. A batch
goes to the server whole as soon as one of its operations does.
Recording. --record-dir DIR writes the answer to each valid operation as an example file
(GetBook-1.yaml: name, operation, query, variables, response, and status when not
200), which the mock serves as it is: point graphql.examples_files at the directory, or name it
after the schema (schema_examples/). Bodies are recorded as they are.
Coverage and reports. The mock counts the schema’s fields that requests selected (every field
of its object and interface types, as GraphQL Inspector’s coverage does) and its root fields
(Query.book): covered, partial (some fields under it never selected) or not-tested. Mocked
traffic counts too. GET /__apigenie/contract is the report; when the server stops it prints a
summary, writes --report and --report-junit (a test case per root field, one for the server’s
schema, one for the coverage threshold) and exits with 5 when --fail-on or --min-coverage
(the share of fields) isn’t met. The JSON report has summary, schema, rootFields, fields,
uncoveredFields, operations (by name), otherRoutes and violations.