Cal Perez

Senior Software Engineer. I also like to cook, paint, and play D&D and Warhammer RPG.

GraphQL and Schema Stitching

August 22, 2020

I'm having a great time building out the support app for Iris. I talked about deploying it on AWS in this post and now we're gonna talk about the backend. The stack for the support app is a React app that communicates with a Node backend requesting data from a GraphQL server. All of it is written in Typescript.

The Iris app was recently GraphQLified, meaning I used the GraphQL gem and Shopify's batching gem to expose a new /graphql endpoint on the Rails backend.

By the end of this post I'm going to get tired of typing GraphQL.

Since I had two separate schemas, I had to combine them so both were usable in one go from the /graphql endpoint in the Node app.

So how do we do this?

The code

I like to start with the full code then break it down. Obviously, this code is pared down to exclude Iris specific stuff.

import { buildSchema } from 'type-graphql';
import fetch from 'node-fetch';
import { print, DocumentNode, GraphQLResolveInfo } from 'graphql';
import {
  introspectSchema,
  mergeSchemas,
  wrapSchema,
  ExecutionResult,
} from 'graphql-tools';
import { UserResolver } from './resolvers/UserResolver';

type Executor = (operation: ExecutionParams) => Promise<ExecutionResult>;

type ExecutionParams = {
  document: DocumentNode;
  variables?: Object;
  context?: Object;
  info?: GraphQLResolveInfo;
};

const executor: Executor = async ({ document, variables }) => {
  const query = print(document);
  const fetchResult = await fetch(process.env.URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      query,
      variables,
    }),
  });
  return fetchResult.json();
};

export const getSchema = async () => {
  try {
    const localSchema = await buildSchema({
      resolvers: [UserResolver],
      emitSchemaFile: true,
      dateScalarMode: 'isoDate',
    });

    const remoteSchema = wrapSchema({
      schema: await introspectSchema(executor),
      executor,
    });

    return mergeSchemas({
      schemas: [localSchema, remoteSchema],
    });
  } catch (error) {
    console.error(error);
  }
};

The explanation

Let's go section by section.

import { buildSchema } from 'type-graphql';

We use TypeGraphQL to create the support app's schema and resolvers. I won't go into how it works, but you should definitely check it out.

import fetch from 'node-fetch';
import { print, DocumentNode, GraphQLResolveInfo } from 'graphql';
import {
  introspectSchema,
  mergeSchemas,
  wrapSchema,
  ExecutionResult,
} from 'graphql-tools';
import { UserResolver } from './resolvers/UserResolver';

The rest of the imports are fetch, the functions and types needed from the graphql and graphql-tools to merge the schemas, and our UserResolver that we made with TypeGraphQL.

type Executor = (operation: ExecutionParams) => Promise<ExecutionResult>;

type ExecutionParams = {
  document: DocumentNode;
  variables?: Object;
  context?: Object;
  info?: GraphQLResolveInfo;
};

const executor: Executor = async ({ document, variables }) => {
  const query = print(document);
  const fetchResult = await fetch(process.env.URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      query,
      variables,
    }),
  });
  return fetchResult.json();
};

Line 11 just defines the type for our executor() function parameters, which is why we imported DocumentNode, and GraphQLResolveInfo. Line 18 defines executor() function that actually fetches GraphQL results from the URL defined in our .env.

export const getSchema = async () => {
  try {
    const localSchema = await buildSchema({
      resolvers: [UserResolver],
      emitSchemaFile: true,
      dateScalarMode: 'isoDate',
    });

    const remoteSchema = wrapSchema({
      schema: await introspectSchema(executor),
      executor,
    });

    return mergeSchemas({
      schemas: [localSchema, remoteSchema],
    });
  } catch (error) {
    console.error(error);
  }
};

Line 38 builds the local schema with TypeGraphQL, while line 44 introspects the remote schema and uses the executor() function from above to parse the results. Finally, wrapSchema() is called to avoid type and naming collisions between the two schemas.

Line 49 combines both schemas, which makes it possible to send something like

query {
 users {
  id
  name
 }
 studios {
  name
  clients {
   email
  }
 }
}

to the one GraphQL endpoint using both executable schemas to get data back from both data sources. (users belongs to the local schema and studios belongs to the remote schema.)

It makes me feel super smart, but it's really just GraphQL doing all the heavy lifting.

Previous

Sobriety

Next

How to set up Guard in Docker