handl

An isomorphic GraphQL client and server with a three-tier cache and persisted storage.

  • handl

Downloads in past

Stats

StarsIssuesVersionUpdatedCreatedSize
handl
0.1.226 years ago7 years agoMinified + gzip package size for handl in KB

Readme

handl
An isomorphic GraphQL client and server with a three-tier cache and persisted storage.
Build Status codecov Quality Gate License: MIT npm version dependencies Status devDependencies Status

Summary

  • Simple interface: Make queries, mutations and subscriptions using the same method.
  • Query filtering: Only request the data that handl does not already have in its cache.
  • Request batching: Automatically aggregate queries and mutations into fewer requests.
  • Three-tier cache: Reduce server requests with response, query path and object entity caching.
  • Cascading cache control: Have fine-grained control of what gets cached using http cache-control directives.
  • Cache pruning: Save storage by automatically removing expired and infrequently accessed cache entries.
  • Persisted storage: Store cache entries on browser using LocalStorage and IndexedDB and on server using Redis.
  • Cache sharing: Export cache entries in a serializable format to be imported by another handl.
  • Web worker interface: Free up the main thread by running handl in a web worker.

Installation

npm install handl --save

Compilation

The WEB_ENV environment variable must be set to 'true' when you compile your browser bundle in order to exclude node modules.

Web worker interface

You can run handl in a web worker by using WorkerHandl as the handl interface in your bundle on the main thread and { worker-handl.worker: ./node_modules/handl/lib/browser/worker.js } as the entry point for your bundle on the worker thread.

Documentation

Please read the full API documentation on the handl github pages.

Usage

Creating a client

Get started by creating an instance of ClientHandl and pass in whatever configuration options you require. The main options are detailed in the example below. For a full list, please read the API documentation.
// client-handl.js

import { ClientHandl } from 'handl';
import introspection from './introspection';

export default async function clientHandl() {
  try {
    return ClientHandl.create({
      // mandatory
      introspection,
      url: 'https://api.github.com/graphql',
      // optional
      batch: true,
      cachemapOptions: { use: { client: 'indexedDB', server: 'redis' } },
      fetchTimeout: 5000,
      headers: { Authorization: 'bearer 3cdbb1ec-2189-11e8-b467-0ed5f89f718b' },
      resourceKey: 'id',
      subscriptions: { address: 'ws://api.github.com/graphql' },
      typeCacheControls: { Query: 'max-age=60' },
    });
  } catch (error) {
    // Handle error...
  }
}

introspection is the output of an introspection query to the GraphQL server that handl needs to communicate with. There are several ways to generate this output. Handl uses the output to assist in parsing, filtering and caching.
url is the endpoint that handl will use to communicate with the GraphQL server for queries and mutations.
batch turns on batching of query and mutation requests. It is set to false by default because you need to use ServerHandl to receive these batched requests or write your own logic to unbatch the requests, execute them, and then re-batch the execution results to be sent back to ClientHandl.
cachemapOptions are passed through to the cachemap module, which is what handl uses for its persisted storage for each cache. The main properties you may want to change from their defaults are use, which allows you to specify the storage method, and redisOptions. which allows you to specify the database each cache should use.
fetchTimeout is the amount of time handl should wait for a response before rejecting a request. It is set to 5000 milliseconds by default.
headers are any additional headers you would like sent with every request.
resourceKey is the name of the property thats value is used as the unique identifier for each resource/entity in the GraphQL schema. It is set to 'id' by default.
subscriptions is the configuration object passed to handl's socket manager. address is the only mandatory property. If no configuration object is passed in, then subscriptions are not enabled.
typeCacheControls is an object of type names to cache control directives that enables type-level cache control.

Making queries, mutations or subscriptions

Handl lets you execute queries, mutations and subscriptions anywhere in your application, so you can use it in your service layer, Redux thunks, React higher-order components... whatever works for you. Just import the ClientHandl instance you created in the above example and pass the request and any options you require into the request method.

Query

// organization-query.js

export const organization = `
  query ($login: String!, $first: Int!) {
    organization(login: $login) {
      description
      login
      name
      repositories(first: $first) {
        edges {
          node {
            ...repositoryFields
          }
        }
      }
    }
  }
`;

// repository-fields-fragment.js

export const repositoryFields = `
  fragment repositoryFields on Repository {
    description
    name
  }
`;

// query.js

import clientHandl from './client-handl';
import { organization } from './organization-query';
import { repositoryFields } from './repository-fields-fragment';

(async function makeQuery() {
  try {
    const handl = await clientHandl();

    const { cacheMetadata, data, queryHash } = await handl.request(organization, {
      fragments: [repositoryFields],
      variables: { login: 'facebook', first: 20 },
    });

    // Do something with result...
  } catch (error) {
    // Handle error...
  }
}());

Mutation

// add-star-mutation.js

export const addStar = `
  mutation ($input: AddStarInput!) {
    addStar(input: $input) {
      clientMutationId
      starrable {
        id
        viewerHasStarred
      }
    }
  }
`;

// mutation.js

import clientHandl from './client-handl';
import { addStar } from './add-star-mutation';

(async function makeMutation() {
  try {
    const handl = await clientHandl();

    const { cacheMetadata, data } = await handl.request(addStar, {
      variables: { input: { clientMutationId: '1', starrableId: 'MDEwOlJlcG9zaXRvcnkzODMwNzQyOA==' } },
    });

    // Do something with result...
  } catch (error) {
    // Handle error...
  }
}());

Subscription

// favourite-added-subscription.js

export const favouriteAdded = `
  subscription {
    favouriteAdded {
      count
      products {
        displayName
        longDescription
        brand
      }
    }
  }
`;

// subscription.js

import { forAwaitEach } from 'iterall';
import clientHandl from './client-handl';
import { favouriteAdded } from './favourite-added-subscription';

(async function makeSubscription() {
  try {
    const handl = await clientHandl();
    const asyncIterator = await handl.request(favouriteAdded);

    forAwaitEach(asyncIterator, (result) => {
      const { cacheMetadata, data } = result;
      // Do something with result...
    });
  } catch (error) {
    // Handle error...
  }
}());

fragments are groups of fields that can be reused between queries, mutations and subscriptions. Handl will automatically insert these into a request. Read more information about fragments.
variables are arguments that can be passed to fields within a request. Handl will automatically insert these into a request. Read more information about arguments.
cacheMetadata is a map of query paths to their cache control directives. This metadata is used by a handl when receiving data from another handl to allow it to cache the data correctly.
data is the data requested in a query, mutation or subscription.
queryHash is a hash of the query that was requested.
asyncIterator is an asynchronous iterator that gets triggered each time a subscription result is returned. Read more about async iterators and the iterall utilities library.

Introspecting the schema

If the GraphQL server that handl needs to communicate with is a third-party such as Github, it is common practice to allow users to run an introspection query of the server's schema via a GET request (GraphQL requests are POST). If it is not a third-party, GraphQL provides an introspection query you can use on your schema. Either way, make an introspection query and write the response to a json file as part of a step in your build process.
Handl provides a command line interface for introspecting a schema and writing the response to a json file. Just create an npm script like either of the examples below and then run them in your terminal like so npm run introspect:url. All paths are relative to the project root. If you are writing in Typescript and want to introspect a schema without having to compile your code to javascript, the CLI accepts an additional argument of --tsconfig with the value being the path to your tsconfig.json file. You will need ts-node installed as a devDependency to make use of this feature.
"scripts": {
  "introspect:schema": "introspect --schema \"test/schema/index.ts\" --output \".out/introspection.json\"",
  "introspect:url": "introspect --url \"https://api.github.com/graphql\" --headers \"Authorization:bearer <TOKEN>\" --output \".out/introspection.json\"",
}

Creating a server

Get started by creating an instance of ServerHandl and pass in whatever configuration options you require. The main options are detailed in the example below. For a full list, please read the API documentation.
// server-handl.js

import { ServerHandl } from 'handl';
import schema from './graphql-schema';

export default async function serverHandl() {
  try {
    return ServerHandl.create({
      // mandatory
      schema,
      // optional
      cachemapOptions: { use: { server: 'redis' } },
      resourceKey: 'id',
    });
  } catch (error) {
    // handle error...
  }
}

schema is the GraphQL schema that you want to execute queries and mutations against. It must be an instance of GraphQLSchema. Read more about creating a GraphQL schema.

Routing queries, mutations and subscriptions

Query and mutation requests

Requests to a server for queries and mutations can be routed using a express-compatible middleware. Just import the ServerHandl instance you created in the above example and create a requestHandler using the request method, and mount the middleware function on the path to which your GraphQL requests will be sent. The middleware will execute the request and send the response.

Subscription messages

For websocket messages to a server for subscriptions, use the message method to create a messageHandler. This function returns an event handler for the websocket's message event. The handler will create the subscription and send the responses.
// app.js

import bodyParser from 'body-parser';
import cors from 'cors';
import express from 'express';
import http from 'http';
import serverHandl from './server-handl';

(async function startServer() {
  const app = express();
  const handl = await serverHandl();
  const requestHandler = handl.request();
  const messageHandler = handl.message();

  app.use(cors())
    .use(bodyParser.urlencoded({ extended: true }))
    .use(bodyParser.json())
    .use('/graphql', requestHandler);

  const server = http.createServer(app);
  const wss = new WebSocket.Server({ path: '/graphql', server });

  wss.on('connection', (ws, req) => {
    ws.on('message', messageHandler(ws));
  });

  server.listen(3000);
}())

Caching


Handl has three levels of caching for data returned from requests, based on whether the request was a query, mutation or subscription. What gets cached and for how long is determined by http cache control directives. These directives can be set per GraphQL object type in handl, in the response headers to a handl client, or in the response data generated by a GraphQL schema.

Cascading cache control

Each entry in any of the three caches is assigned a cacheability through a mechanism of cascading cache control. If an entry has its own cache control directives, these are used to generate its cacheability, otherwise it inherits its directives from its parent. The root response data object inherits its directives from the response headers or the handl defaults.

Single source of truth

Cascading cache control works with the most common use-case for GraphQL as an aggregator of REST-based microservices. Each service will already be providing cache control directives in their responses, and this information can be mapped into the schema object types that represent the responses' data structures and passed on to handl. This keeps a microservice as the single source of truth for caching of its data.

The Metadata type

Handl provides a GraphQL object type to use for mapping cache control directives. Just create a _metadata field on the object type you want to associate cache control directives with and assign the field the type of MetadataType. In the object type's resolver, return _metadata as an object with a property of cacheControl and assign it the cache control directives.
// product-type.js

import { MetadataType } from 'handl';
import { GraphQLObjectType, GraphQLString } from 'graphql';

export default new GraphQLObjectType({
  fields: () => ({
    _metadata: { type: metadataType },
    brand: { type: GraphQLString },
    description: { type: GraphQLString },
    displayName: { type: GraphQLString },
  }),
  name: 'Product',
});

// query-type.js

import { GraphQLObjectType, GraphQLString } from 'graphql';
import ProductType from './product-type';

export default new GraphQLObjectType({
  fields: () => ({
    product: {
      args: { id: { type: GraphQLString } },
      resolve: async (obj, { id }) => {
        const fetchResult = await fetch(`https://product-endpoint/${id}`);
        const data = await fetchResult.json();
        const cacheControl = fetchResult.headers.get('cache-control');

        return {
          _metadata: { cacheControl },
          ...data,
        };
      },
      type: ProductType,
    },
  }),
  name: 'Query',
});

Type cache controls

You do not have to update your schema to get type-level cache control. Handl also offers the ability to assign cache control directives to each GraphQL object type in a schema by providing an object of type names to cache control directives to handl. This can be done when initializing handl or by using the setTypeCacheControls method.
// client-handl.js

import { ClientHandl } from 'handl';
import introspection from './introspection';

export default async function clientHandl() {
  try {
    return ClientHandl.create({
      introspection,
      url: 'https://api.github.com/graphql',
      typeCacheControls: { Organization: 'max-age=60' },
    });
  } catch (error) {
    // Handle error...
  }
}

// client.js

import clientHandl from './client-handl';

(async function setCacheControls() {
  try {
    const handl = await clientHandl();
    handl.setTypeCacheControls({ Organization: 'max-age=60' });

    // Do something else...
  } catch (error) {
    // Handle error...
  }
}());

Cache tiers

Responses
Each query's response data is cached against a hash of the query, unless it is instructed otherwise. So any time the same query is requested again, it will be served from the response cache, as long as the cache entry has not expired.
Query paths
As well as caching an entire query against its response data, handl also parses the query and breaks it down into its 'paths' and the data for each path is cached against a hash of each path. So, if handl does not find a match in the response cache, it could still return the requested data from cache by building up a response from the query path cache.
const parsedQuery = `
  query {
    organization(login: "facebook") {
      description
      login
      name
      repositories(first: 20) {
        edges {
          node {
            description
            name
            id
          }
        }
      }
      id
    }
  }
`;

const queryPaths = [
  'organization({login:"facebook"})',
  'organization({login:"facebook"}).repositories({first:20})',
  'organization({login:"facebook"}).repositories({first:20}).edges.node',
];

A query path is defined as an entity object (an object type with an ID) or a data type with arguments or directives. The query path is stored alongside its data except for when the data is part of a sub query path. So organization({login:"facebook"}) would include data for the fields description, login, name and id, but not for the field repositories.
Data entities
Data entities are object types with an ID. Handl will check query, mutation and subscription responses for data entities and each one it finds is cached, unless instructed otherwise, against a combination of the object type name and ID. i.e. typename:ID. This is equivalent to a normalized cache and allows handl to resolve a request from cache even if the requested entity was previously returned for a different request at a different level in the query path.

License

Handl is MIT Licensed.