@travelpass/graphql-client
v0.0.3
Published
A minimal, typed GraphQL client. A focused port of graphql-request: typed documents in, typed data out, with middleware hooks for everything else.
Readme
@travelpass/graphql-client
A small, typed GraphQL client for the main case: POST a typed document, get typed data back.
It started as a trimmed-down fork of graphql-request. It keeps what most apps actually use and drops the rest.
- Typed results and variables from your codegen documents
- Required variables are enforced at compile time
- Per-request headers, merged over client headers
- Errors carry partial data, status and headers
- Three extension points:
requestMiddleware,responseMiddleware, and a customfetch
Install
npm install @travelpass/graphql-client graphqlgraphql is a peer dependency.
Quick start
import { createGraphQLClient } from '@travelpass/graphql-client'
import { gql } from 'src/__generated__/gql'
const client = createGraphQLClient('https://api.example.com/graphql', {
headers: {
authorization: `Bearer ${token}`,
},
})
const getHotelQuery = gql(`
query GetHotel($hotelId: ID!) {
hotel(id: $hotelId) {
id
name
}
}
`)
const data = await client.request({
document: getHotelQuery,
variables: {
hotelId,
},
})
// typed
data.hotel.nameVariables
Variables are typed from the document, and the compiler decides whether they are required.
// no variables: `variables` optional
await client.request({
document: currentUserQuery,
})
// has variables: required
await client.request({
document: getHotelQuery,
variables: {
hotelId,
},
})
// compile error: missing variables
await client.request({
document: getHotelQuery,
})Request headers
Headers set on the client are sent with every request. Pass headers to add or override headers for one call:
await client.request({
document: EMIT_COLOSSUS_EVENT,
headers: {
'x-colossus-metadata': colossusMetadata,
},
variables: {
input,
},
})Headers are plain Record<string, string> objects. Header names are lowercased before merging, so a per-request header replaces the client header of the same name regardless of casing.
Reading response headers
request returns data. When a call needs something from the response headers, pass onResponseHeaders:
let searchRequestId: string | null = null
const data = await client.request({
document: SEARCH_HOTELS,
onResponseHeaders: headers => {
searchRequestId = headers.get('x-request-id')
},
variables,
})It runs only on successful responses. On failure, headers are on the error (see below).
Errors
A failed request throws a ClientError when:
- the response contains GraphQL
errors(even with HTTP 200), or - the HTTP status is not 2xx.
import { ClientError } from '@travelpass/graphql-client'
try {
await client.request({
document: VALIDATE_RATE,
variables: {
rateToken,
},
})
} catch (error) {
if (error instanceof ClientError) {
error.response.data // partial data, if the server returned any
error.response.errors // GraphQL errors
error.response.status // HTTP status
error.response.headers // response headers
error.response.body // raw body text, useful for non-GraphQL 4xx/5xx
}
}Network failures and aborted requests (including timeouts) are not ClientErrors. They throw whatever fetch throws, such as a TimeoutError DOMException.
Configuration
createGraphQLClient(url, {
headers, // Record<string, string>, or a function returning one (called per request)
fetch, // custom fetch; defaults to globalThis.fetch
requestMiddleware, // change the request before it is sent
responseMiddleware, // observe every completed response
...requestInit, // any other RequestInit option (credentials, cache, ...)
})requestMiddleware
Runs before every request. Receives the request (including operationName and variables) and returns the request to send. Use it to change headers, the URL, or the signal.
Per-operation timeouts:
const timeouts: Record<string, number> = {
ResultsSearchHotels: 15_000,
}
const client = createGraphQLClient(url, {
headers,
requestMiddleware: req => ({
...req,
signal: AbortSignal.timeout(timeouts[req.operationName ?? ''] ?? 5_000),
}),
})responseMiddleware
Runs after every completed response, success or error. It observes and cannot change the result. Use it for logging and metrics.
const client = createGraphQLClient(url, {
headers,
responseMiddleware: (response, req) => {
const status = response instanceof Error ? 'error' : 'success'
sendStatsdMetric('graphql_request', {
tags: [`operation:${req.operationName}`, `status:${status}`],
})
},
})It does not run when the request never completes (network failure, abort, timeout), because fetch throws before there is a response. Catch those at the call site, or wrap request.
Custom fetch
Swap the transport for retries, tracing, caching, or anything else that works at the fetch level:
const client = createGraphQLClient(url, {
fetch: myInstrumentedFetch,
})Types
import type { GraphQLClient } from '@travelpass/graphql-client'
const withTracking = (client: GraphQLClient): GraphQLClient => ({
request: async requestArgs => {
// ...track, then:
return client.request(requestArgs)
},
})Because the client is a plain object of functions, request can be destructured or passed around without losing this.
Scope
This client covers the main case on purpose. It does not support:
- GET requests
- Batching
- File uploads
- Subscriptions
errorPolicy(partial data is available onClientError.response.data)- Custom JSON serializers
Most of these can be built on the extension points above if you need them.
Migrating from graphql-request
| graphql-request | This client |
| ------------------------------------------------------------------ | ------------------------------------------------------------ |
| new GraphQLClient(url, config) | createGraphQLClient(url, config) |
| client.request(doc, variables) | client.request({ document, variables }) |
| client.rawRequest(print(doc), variables) then response.headers | client.request({ document, variables, onResponseHeaders }) |
| client.request(doc, variables, requestHeaders) | client.request({ document, variables, headers }) |
| import { ClientError } from '@travelpass/graphql-request' | import { ClientError } from '@travelpass/graphql-client' |
Update every ClientError import. instanceof only matches errors from the same package.
License
Contributing
Contributions are welcome. See CONTRIBUTING.md for setup, scripts, and how to open a pull request.
