@quilted/graphql
v3.3.8
Published
Tiny, type-safe helpers for using GraphQL
Downloads
521
Readme
@quilted/graphql
Tiny, type-safe helpers for using GraphQL. This includes helpers for fetching GraphQL queries and mutations, functions to help you create GraphQL resolvers for your server, and utilities for testing projects that depend on GraphQL results.
To provide better integration for GraphQL in your build tools, combine this with @quilted/graphql-tools
, or use Quilt as a framework.
Installation
# npm
npm install @quilted/graphql --save
# pnpm
pnpm install @quilted/graphql --save
# yarn
yarn add @quilted/graphql
Usage
Fetching GraphQL queries and mutations
GraphQL is only useful if you can fetch results. This library provides a few helpers for fetching GraphQL results over the most common transport: HTTP. These utilities are focused on being as small as possible — basic GraphQL fetches require only about 1Kb of compressed code, and streaming fetches require only about 2Kb.
If you’re getting started with GraphQL, you probably have a GraphQL server being served over HTTP. The Quilt GraphQL application template, for example, serves its GraphQL endpoint on /graphql
of the app’s domain. To create a function that lets you fetch data from an HTTP endpoint like this, use the createGraphQLFetch()
function:
import {createGraphQLFetch} from '@quilted/graphql';
const fetchGraphQL = createGraphQLFetch({url: '/graphql'});
The createGraphQLFetch()
function accepts options to customize the GraphQL request before it is performed. The only required option is url
, which specifies the URL to send the GraphQL request to.
The resulting function can be called with a GraphQL query or mutation, and returns a promise that resolves to the data (or errors) returned by the GraphQL server:
import {graphql} from '@quilted/graphql';
// `graphql` is optional, it just provides nice syntax highlighting
// in some editors.
const query = graphql`
query MyQuery {
# ...
}
`;
const {data, errors} = await fetchGraphQL(query);
You can also provide variables
to the fetch function, as well as a signal
to abort the request:
try {
const controller = new AbortController();
const timeout = window.setTimeout(() => {
controller.abort();
}, 1_000);
const {data, errors} = await fetchGraphQL(query, {
variables: {name: 'Winston'},
signal: controller.signal,
});
window.clearTimeout(timeout);
} catch (error) {
// handle abort
}
This library also provides GraphQLQuery
and GraphQLMutation
classes that can be used to create observable GraphQL operations. These classes are useful when you want to fetch a GraphQL result multiple times, and when you want to be able to observe each GraphQL call as it completes.
import {createGraphQLFetch, GraphQLQuery} from '@quilted/graphql';
const fetchGraphQL = createGraphQLFetch({url: '/graphql'});
const query = new GraphQLQuery('query MyQuery { ... }', {fetch: fetchGraphQL});
const firstResult = await query.fetch({name: 'Winston'}); // {data: ...}
const secondResult = await query.fetch({name: 'Molly'}); // {data: ...}
query.value; // The most recent GraphQL result
Customizing GraphQL HTTP requests
By default, operations are sent to the specified url
using a POST
request, according to the GraphQL over HTTP specification. You can force operations to be made using GET
requests instead by setting the method
option:
import {createGraphQLFetch} from '@quilted/graphql';
const fetch = createGraphQLFetch({
url: 'https://my-app.com/query',
method: 'GET',
});
You can add additional headers by specifying a headers
option:
import {createGraphQLFetch} from '@quilted/graphql';
const fetch = createGraphQLFetch({
url: 'https://my-app.com/query',
// You can pass anything accepted by `new Headers()` here
headers: {
'X-Client': 'web',
},
});
In addition to being able to the method
, headers
, and url
as global, statically-defined options, each can also be set as a function that is called with the operation being fetched, and returns the value to use:
import {createGraphQLFetch} from '@quilted/graphql';
const fetch = createGraphQLFetch({
url: 'https://my-app.com/query',
// POST for mutations, GET for queries
method: (operation) =>
/^mutation\s/.test(operation.source) ? 'POST' : 'GET',
});
Alternatively, each option can be set per-fetch, as part of the second argument to the fetch function:
import {createGraphQLFetch} from '@quilted/graphql';
const fetch = createGraphQLFetch({
url: 'https://my-app.com/query',
});
const {data} = await fetch(`query { me { name } }`, {
method: 'GET',
});
The operation source is sent in all HTTP requests: as the query
parameter for GET
requests, and as the query
body field for POST
requests. To accomplish techniques like "persisted" GraphQL queries, you may want to exclude the operation source, and send only only a hashed identifier of each GraphQL operation. You can disable sending the source for all GraphQL fetches by setting source: false
when creating your fetch()
function:
import {createGraphQLFetch} from '@quilted/graphql';
// Importing `.graphql` files automatically generates hashed
// identifiers for your operations. If you don’t use this feature,
// you must pass the identifier yourself.
import myQuery from './MyQuery.graphql';
const fetch = createGraphQLFetch({
source: false,
url: 'https://my-app.com/query',
});
const {data} = await fetch(myQuery);
This isn’t typically useful unless you also communicate the operation’s hash identifier. Here’s an example showing how you could pass the identifier as an additional URL parameter:
import {createGraphQLFetch} from '@quilted/graphql';
import myQuery from './MyQuery.graphql';
const fetch = createGraphQLFetch({
source: false,
url(operation) {
const url = new URL('https://my-app.com/query');
url.searchParams.set('id', operation.id);
return url;
},
});
const {data} = await fetch(myQuery);
Here’s an alternative approach, which sends the operation using a GraphQL extensions
field, according to Apollo’s automatic persisted queries protocol:
import {createGraphQLFetch} from '@quilted/graphql';
import myQuery from './MyQuery.graphql';
const fetch = createGraphQLFetch({
source: false,
url: 'https://my-app.com/query',
extensions(operation) {
return {
persistedQuery: {version: 1, sha256Hash: operation.id},
};
},
});
const {data} = await fetch(myQuery);
These source
and extension
options can be set globally, as shown above, or per-fetch:
import {createGraphQLFetch} from '@quilted/graphql';
import myQuery from './MyQuery.graphql';
const fetch = createGraphQLFetch({
url: 'https://my-app.com/query',
});
const {data} = await fetch(myQuery, {
source: false,
method: 'GET',
extensions: {
persistedQuery: {version: 1, sha256Hash: myQuery.id},
},
});
If you want to take more control over the HTTP request, this library also provides a helpful subclass of the built-in Request
class that will automatically serialize GraphQL operations into the body of the request. You can use instances of this object with the global fetch()
API, but remember that you will need to parse the response yourself.
import {GraphQLFetchRequest} from '@quilted/graphql';
const request = new GraphQLFetchRequest(
'/graphql',
'query MyQuery($name: String!) { ... }',
{
variables: {name: 'Winston'},
},
);
const response = await fetch(request);
const result = await response.json();
Streaming GraphQL results with @stream
and @defer
Some GraphQL servers support streaming results for the @defer
and @stream
directives. When an operation contains these directives, partial results are streamed to the client as they are available, and must be combined together to form a final result. To create a function that lets you fetch data from an HTTP endpoint like this, use the createGraphQLStreamingFetch()
function:
import {createGraphQLStreamingFetch} from '@quilted/graphql';
const fetchGraphQL = createGraphQLStreamingFetch({url: '/graphql'});
This function accepts the same options as createGraphQLFetch()
. Instead of returning just a promise for the final result, this function returns an object that is both a promise (resolves when the final result has been received and combined) and an async iterable (yields partial results as they are received):
import {graphql} from '@quilted/graphql';
// `graphql` is optional, it just provides nice syntax highlighting
// in some editors.
const query = graphql`
query MyQuery {
# ...
}
`;
for await (const {data, errors, incremental} of fetchGraphQL(query)) {
// ...
}
Building type-safe GraphQL resolvers
GraphQL resolvers are functions that return the data for a particular type in a GraphQL schema. Writing these resolvers with the benefits of type-safe can be tricky, so this library provides a set of “resolver builders” to make this process easier.
To start, you need a type that describes your GraphQL schema. Quilt expects a schema to be represented as a single layer of nested objects, matching the types as named in your GraphQL schema. Fields on these types are expected to be functions that take the variable type for that field, and return the data for that field. For example, given the following GraphQL schema:
type Query {
me: Person!
}
type Mutation {
greet(name: String!): String!
}
type Person {
name: String!
}
schema {
query: Query
mutation: Mutation
}
Quilt expects a type like this:
interface Schema {
Query: {
me(variables: Record<string, never>): Person;
};
Mutation: {
greet(variables: {readonly name: string}): string;
};
Person: {
name(variables: Record<string, never>): string;
};
}
If you use @quilted/graphql-tools
to generate type definitions, you can have Quilt create this type for you automatically by importing from a .graphql
file:
import type {Schema} from './schema.graphql';
Once you have this type, you can use the createGraphQLResolverBuilder()
helper provided by this library. This helper returns a collection of functions that are used to create GraphQL resolver objects matching the schema. In the example above, the resolvers for the schema could be written like this:
import {createGraphQLResolverBuilder} from '@quilted/graphql/server';
import type {Schema} from './schema.graphql';
const {createQueryResolver, createMutationResolver} =
createGraphQLResolverBuilder<Schema>();
const Query = createQueryResolver({
me() {
return {name: 'Winston'};
},
});
const Mutation = createMutationResolver({
greet(_, {name}) {
return `Hello, ${name}!`;
},
});
Commonly, you will want to have all fields in your schema that return a particular type to return some base object. That base object is then used by the resolver for that type to construct the final return value. You can indicate these type mappings by providing a second type argument to createGraphQLResolverBuilder()
. For example, if we wanted all fields returning a Person
to return an object that the Person
resolver will use to construct its GraphQL fields, we could write the following:
import {createGraphQLResolverBuilder} from '@quilted/graphql/server';
import type {Schema} from './schema.graphql';
interface GraphQLResolverValues {
Person: {firstName: string; lastName?: string};
}
const {createResolver, createQueryResolver} = createGraphQLResolverBuilder<
Schema,
GraphQLResolverValues
>();
const Query = createQueryResolver({
me() {
return {firstName: 'Winston'};
},
});
const Person = createResolver('Person', {
name({firstName, lastName}) {
return lastName ? `${firstName} ${lastName}` : firstName;
},
});
GraphQL servers also commonly provide “context”, values shared throughout all resolvers in the schema. You can indicate the type of the context argument by providing a third type argument to createGraphQLResolverBuilder()
. For example, if we will provide a database
value through context, we could expose it to our resolvers like this:
import {createGraphQLResolverBuilder} from '@quilted/graphql/server';
import type {Schema} from './schema.graphql';
interface GraphQLContext {
database: Database;
}
const {createQueryResolver} = createGraphQLResolverBuilder<
Schema,
{},
GraphQLContext
>();
const Query = createQueryResolver({
// First argument is the "base" object, which is usually ignored for query fields
// Second argument are the variables, which we don’t have for this field
async me(_, __, {database}) {
const me = await database.user.findFirst();
return me;
},
});
To actually run a GraphQL query, you need to include the resolvers created with these helpers in a GraphQL server. Most GraphQL servers, including the reference JavaScript implementation and GraphQL Yoga, need a GraphQL schema to execute a query or mutation. For convenience, this library provides a createGraphQLSchema()
helper that can create a GraphQL schema object from your resolvers:
import {graphql} from 'graphql';
import {
createGraphQLSchema,
createGraphQLResolverBuilder,
} from '@quilted/graphql/server';
// Assumes we are using `@quilted/graphql-tools`, which gives us both
// the schema source and type definitions as exports from the schema file
import schemaSource, {type Schema} from './schema.graphql';
const {createQueryResolver, createMutationResolver} =
createGraphQLResolverBuilder<Schema>();
const Query = createQueryResolver({
me() {
return {name: 'Winston'};
},
});
const Mutation = createMutationResolver({
greet(_, {name}) {
return `Hello, ${name}!`;
},
});
const schema = createGraphQLSchema(schemaSource, {Query, Mutation});
const result = await graphql({
schema,
source: 'query { me { name } }',
});
Testing GraphQL-dependent code
During testing, it can be useful to have a GraphQL fetcher that always returns specific results. Having this tool at your disposal lets you simulate a GraphQL-dependent UI in various states. This library helps you implement this pattern, while taking advantage of the type safety of GraphQL to ensure test results are always valid.
There are two main parts to these GraphQL testing utilities: a GraphQL “controller”, which can fetch mock GraphQL results, and GraphQL “fillers”, which can provide type-safe mocked results for individual GraphQL queries and mutations.
To create a controller, use the GraphQLController
class. This function accepts one or more GraphQL “mocks”: objects that contain an operation
key, detailing the GraphQL operation this mock should be used for, and a result
key. The result can either be an object, or a function that returns an object, or a function that returns a promise for an object. This result
will be used to fulfill a GraphQL operation matching the operation
key.
To demonstrate, we’ll assume you have a GraphQL schema that looks like this:
type Person {
name: String!
age: Int!
}
type Query {
me: Person!
}
schema {
query: Query
}
We could create a GraphQL controller with a hand-written mock:
import {graphql, GraphQLController} from '@quilted/graphql/testing';
// `graphql` is optional, but it can provide better syntax
// highlighting in some editors.
const query = graphql`
query Me {
me {
name
age
}
}
`;
const controller = new GraphQLController();
controller.mock({
operation: query,
result() {
return {
me: {name: 'Winston', age: 9},
};
},
});
And you can then use this controller to fetch results by using its fetch()
method:
const result = await controller.fetch(query);
// {data: {me: {name: 'Winston', age: 9}}}
We have a controller, but we haven’t done anything particularly useful yet — we had to know the exact shape of our GraphQL queries, and mock all the fields manually. This is where GraphQL “fillers” come in: they let you create mocks for GraphQL queries that will automatically fill in the correct shape of the query.
To create GraphQL fillers, we need a GraphQL schema to describe the available types. If you use @quilted/graphql-tools
, you can import this schema’s TypeScript type and schema source, which can be used to create a GraphQL schema (using the createGraphQLSchema()
helper) and filler function (using the createGraphQLFiller()
helper):
import {
createGraphQLSchema,
createGraphQLFiller,
} from '@quilted/graphql/testing';
import schemaSource from './schema.graphql';
const schema = createGraphQLSchema(schemaSource);
const fillGraphQL = createGraphQLFiller(schema);
Now, when we create GraphQL controllers, we can use the fillGraphQL
function to create fillers for our queries and mutations. If we provide just a GraphQL operation to this function, it will create a GraphQL mock that fills in data for the query, respecting the nullability of your GraphQL schema and the types of your fields:
import {GraphQLController} from '@quilted/graphql/testing';
const controller = new GraphQLController();
const query = `
query Me {
me {
name
age
}
}
`;
controller.mock(fillGraphQL(query));
const result = await controller.fetch(query);
// {data: {me: {name: 'random string', age: 123}}}
When writing tests, it’s common to want to set a specific subset of fields, but allow other fields outside of the area under the test to be random values. You can do this by providing a subset of the GraphQL operation as the second argument to fillGraphQL()
:
import {GraphQLController} from '@quilted/graphql/testing';
const controller = new GraphQLController();
const query = `
query Me {
me {
name
age
}
}
`;
controller.mock(fillGraphQL(query, {me: {name: 'Winston'}}));
const result = await controller.fetch(query);
// {data: {me: {name: 'Winston', age: 123}}}
When you use @quilted/graphql-tools
to import GraphQL queries and mutations, TypeScript will ensure you only provide matching fields in your mock data:
import {GraphQLController} from '@quilted/graphql/testing';
import meQuery from './MeQuery.graphql';
const controller = new GraphQLController();
controller.mock(fillGraphQL(meQuery, {me: {age: '123'}}));
// Type error: `me.age` must be a number
The automatically filled data will match the shape of your operation, but otherwise will be randomly generated using the chance library. If you have specific types that always return data in a particular shape (such as custom scalars), you can provide default value creators for those types when calling createGraphQLFiller()
:
import {
createGraphQLSchema,
createGraphQLFiller,
} from '@quilted/graphql/testing';
import schemaSource from './schema.graphql';
const schema = createGraphQLSchema(schemaSource);
const fillGraphQL = createGraphQLFiller(schema, {
resolvers: {
// For convenience, the Chance object is provided for you to generate
// random values matching your custom data shape
Date: ({random}) => random.date().toISOString(),
// This overrides the default `ID` mock to provide ids in a consistent shape,
// here using a gid pattern.
ID: ({random, parent}) => `gid://my-app/${parent.name}/${random.integer()}`,
},
});