liaisev5.3.1
/GraphQL

Guide

GraphQL

This page covers createGraphQL, the client for a GraphQL backend. Read it when your API speaks GraphQL. Most of the guide applies unchanged, and the last section lists what differs.

createGraphQL calls return the same Result, run the same middleware and report errors the same way as REST calls. Every operation is sent as a POST with { query, variables }:

import { createGraphQL, Operation, gql } from 'liaise'

type Category = { id: string; name: string; status: string }

const GET_CATEGORY = gql`
  query GetCategory($id: String!) {
    category(id: $id) {
      id
      name
      status
    }
  }
`

// The second type is the response's data, keyed by the field the query selects.
const getCategory = new Operation<{ id: string }, { category: Category }>({
  operation: GET_CATEGORY,
})

const graphql = createGraphQL({
  endpoint: 'https://api.example.com/graphql',
  operations: { getCategory },
  onError: (error) => console.error(error.status, error.body),
})

const { data, error, response, retry } = await graphql.getCategory({ id: '123' })

The call sends a POST to the endpoint with a JSON body: the text of GET_CATEGORY as query, and { "id": "123" } as variables.

Operation<TVariables, TData> takes the variables type first and the type of the response’s data second. data is keyed by the fields the query selects, so here it is { category: Category }, and the name is data.category.name. gql marks the string as GraphQL for your editor.

liaise doesn’t read the document, so these are types you write, and nothing checks them against your schema. A code generator can write them instead: GraphQL Code Generator’s typescript-operations plugin produces a data type and a variables type for each named operation, such as GetCategoryQuery and GetCategoryQueryVariables, and those go straight into Operation<…>. liaise takes the document as a string, so a TypedDocumentNode doesn’t plug in.

An operation with no variables is called without arguments. Write Record<string, never> as its variables type:

type Viewer = { id: string; name: string }

const getViewer = new Operation<Record<string, never>, { viewer: Viewer }>({
  operation: gql`query { viewer { id name } }`,
})

const api = createGraphQL({ endpoint: 'https://api.example.com/graphql', operations: { getViewer } })

const { data } = await api.getViewer() // no arguments

Queries and mutations

To keep queries and mutations apart, use the queries and mutations keys instead of operations. Each client uses one shape or the other, and TypeScript refuses both together.

const graphql = createGraphQL({
  endpoint: 'https://api.example.com/graphql',
  queries: {
    getCategory: new Operation<{ id: string }, { category: Category }>({ operation: GET_CATEGORY }),
  },
  mutations: {
    updateCategory: new Operation<{ id: string; name: string }, { updateCategory: Category }>({
      operation: gql`
        mutation UpdateCategory($id: String!, $name: String!) {
          updateCategory(id: $id, name: $name) { id name status }
        }
      `,
    }),
  },
})

graphql.query.getCategory({ id: '123' })
graphql.mutation.updateCategory({ id: '123', name: 'New Name' })

A query and a mutation may have the same name. Each runs its own operation, and dedupe and share treat them as separate endpoints, so a query never cancels or joins the mutation of the same name.

Caching queries

GraphQL sends every operation as a POST, and cacheMiddleware caches only GET and HEAD unless you tell it otherwise. To cache a query, give its operation a cache that includes POST:

import { cacheMiddleware } from 'liaise/middleware'

const getCategory = new Operation<{ id: string }, { category: Category }>({
  operation: GET_CATEGORY,
  middleware: [cacheMiddleware({ methods: ['POST'] })],
})

Put it on the query operations you want cached, never on the client. A client-level cache would also wrap your mutations, and a cached mutation answers the second one with the first one’s response.

GraphQL errors

A 2xx response with { errors: [...] } is an error. It has kind: 'http', the response’s own status, and the GraphQLError[] in error.body. The same if (error) check covers GraphQL errors, HTTP errors and network errors.

GraphQL allows partial success, where one field fails and the rest of the query resolves. That data is kept in error.partialData. result.data stays null whenever error is set, so Result keeps its two clean branches.

const { error } = await graphql.getCategory({ id: '123' })
if (error) {
  console.log(error.body)        // GraphQLError[]
  console.log(error.partialData) // the data the server sent with the errors, or undefined
}
  • A 2xx with neither data nor errors is a 'parse' error, the same rule as an empty body on REST. That covers an empty body, {}, {"data": null} and a JSON root that isn’t an object. error.body holds the raw response text, such as '' or '{}'.
  • {"data": null, "errors": [...]} is still kind: 'http', because the errors are checked first. Any partial result is in error.partialData.

Same as REST, and different

Most of what the guide says about createApi holds for createGraphQL.

The same as REST

  • Every call returns a Result, { data, error, response, retry }, and error.kind names the failure (Handling errors).
  • middleware runs on the client, the Operation and the call, in the same order. The context has the same shape, so the built-in middleware and yours work unchanged.
  • headers go on the client, the Operation and the call, and merge by the three levels.
  • onError goes on createGraphQL and works as in Reporting errors with onError.
  • fetchOptions go on createGraphQL, an Operation or a call, and merge as on REST (Cookies and other fetch options).
  • retry() is on every Result.
  • dedupe goes on the Operation, as in Drop stale calls with dedupe.
  • share goes on the Operation, as in Sharing identical requests. Variables must match exactly, key order included. On a mutation, the note there about writes applies.
  • timeout goes on createGraphQL, the Operation or the call, and the most specific one wins. It is one deadline for the whole call, retries included (Set a deadline with timeout).
  • log goes on createGraphQL, as in Log every call. Each line names the operation, as in [liaise] → POST getCategory https://api.example.com/graphql.
  • getHeaders() is on every operation (getHeaders()).
  • schema goes on the Operation and validates the response’s whole data. The response type stays explicit (Validating responses).
  • signal and skipMiddleware go on the call.

Different from REST

  • endpoint is the full URL of the GraphQL endpoint. It takes the place of baseUrl.
  • Variables always go in the JSON body. There are no path params, no query strings and no bodyAs.
  • Every operation is a POST, with Content-Type: application/json unless you set your own.
  • There is no responseType. The response is always read as JSON.

Every option is listed under createGraphQL options and Operation options.

Next: Testing your code shows how to test code that calls liaise, against a stub fetch.

esc