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. POST with application/json ({query, variables, operationName, extensions}) or application/graphql, GET with the same as query parameters (queries only: a mutation over GET gets 405), and batches (a JSON array of requests, graphql.max_batch). Clients that accept application/graphql-response+json get 400 for requests that fail before execution; application/json clients 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 locations and extensions.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, @include and __typename behave 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’s path; a non-null field’s null goes up to the nearest nullable one.
  • Introspection. __schema and __type are complete, so GraphiQL, Apollo Sandbox and code generators work against the mock (graphql.introspection: false turns it off). The SDL is at /graphql/schema.graphql and GET /__apigenie/spec.
  • GraphiQL. A browser opening the endpoint gets GraphiQL, loaded from esm.sh (graphql.playground: false turns 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 the locale.
  • @examples(values:) picks one of the values; @listLength(min:, max:) sizes a list.
  • @mock takes faker (any faker provider, as x-faker) with args (a list by position or an object by name), value or values, min and max for numbers, length or minLength and maxLength for lists, and nullProbability. On a root field, delay (milliseconds) delays the response and errorRate with error makes it fail; on a subscription field, interval and count pace 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.

Was this helpful?