DEV Community

loading...

Simplify using MirageJS with GraphQL

Chuck Carpenter
・3 min read

Originally published at shipshape.io

Using Mirage can be very handy to allow app developers to build features early and not be blocked by an API that doesn't exist yet or that is still in progress. This way you can have a proof of concept or can work on features that simply need to be put in place for persistence somewhere later on.

While this works across many different application stacks, it has traditionally been used expecting a REST style API and it's not completely turn-key for some technologies such as GraphQL. A number of folks, including the Mirage core team, have been working on the best workflows to make that an easier experience, but it is not yet robustly documented. While that is underway, this is how we decided to improve our workflow as the code base grows.

Recently, we needed to apply simulation for a Javascript application using GraphQL. As part of the implementation, we worked on some utilities to streamline the developer experience for updates and maintenance. The examples for dealing with this are for a very basic use case.

import { createServer } from 'miragejs';

const graphqlSchema = buildSchema(`
  type Query {
    movies: [Movie]
  }
  type Movie {
    id: ID!
    title: String!
  }
`);

export default function () {
  createServer({
    models: {
      movie: Model
    },

    seeds(server) {
      server.create('movie', { title: 'Interstellar' });
      server.create('movie', { title: 'Inception' });
      server.create('movie', { title: 'The Dark Knight' });
    },

    routes() {
      this.post('/graphql', (schema, request) => {
        const requestJson = JSON.parse(request.requestBody);
        const query = requestJson.query;
        const variables = requestJson.variables;

        const resolver = {
          movies() {
            return schema.db.movies;
          }
        };

        return graphql(graphqlSchema, query, resolver, null, variables);
      });
    }
  })
}

This is how the basic recommended configuration is when working with GraphQl. This can work early on, but become problematic to manage when you start to have a much larger schema and need to maintain way more models and relationships for your mocking. Given the following schema:

  type Query {
    movies: [Movie]
  }
  type Actor {
    id: ID!
    movies: [Movies]!
    name: String!
  }
  type Distributor {
    id: ID!
    movies: [Movie]!
    name: String!
  }
  type Movie {
    id: ID!
    actors: [Actor]!
    distributor: Distributor!
    title: String!
  }

The first thing we can do is automate adding models to our config at build time. This can be done by parsing our schema and some traversing of the parsed AST.

import { parse } from 'graphql';

const ast = parse(`
  // ...schema
`);

// get the object definitions and fields
const nodeTypes = ast.definitions
  .filter(def => {
    if (def.kind === "ObjectTypeDefinition") {
      const { value } = def.name;

      return !["Query"].includes(value);
    }

    return false;
  })
  .map(filteredDef => {
    return {
      model: filteredDef.name.value,
      fields: filteredDef.fields
    };
  });

// output an object with model mapping
const modelMaps = nodeTypes.reduce((modelAccumulator, node) => {
  modelAccumulator[node.model] = Model;

  return modelAccumulator;
}, {});

We can then add that to the configuration for Mirage as models: modelMaps and we'll get that automatically registered as we add to our schema. This does get more complicated though, when we start to add associations in our objects and need Mirage to see that as a relationship for queries that load all that data. Ideally, the graph can work for a query like so on the UI:

query ListAllMovies {
  movies {
    actors {
      name      
    }
    distributor {
      name
    }
    title
  }
}

We first want to identify all the model names (variable modelNames). Also, we'll want to reduce the fields we're checking against to only fields that are confirmed to be other object types (variable modelsReducedFields).

  const modelNames = nodeTypes.map(type => type.model);

  const modelsReducedFields = nodeTypes.map(node => {
    const nodeFields = node.fields || [];
    const fields = nodeFields.reduce((acc, field) => {
      const { type } = field;

      const isNestedType = node => !node.name && node.type;

      if (isNestedType(type)) {
        const rootField = _getRootType(field);
        const isListType = field.type.type.kind === "ListType";
        const model = rootField.name.value;
        if (modelNames.includes(model)) {
          acc.push({
            name: field.name.value,
            model,
            isListType
          });
        }
        return acc;
      }

      return acc;
    }, []);
  return { ...node, fields };
});

Now, what we're doing here with modelsReducedFields() is taking each node and reducing the fields down to other models and determining if they are a belongs-to or has-many kind of association. You might have noticed the call to _getRootType(), which is just a recursive function to go through nested objects in the AST and get the deepest node's name. I'm showing it independently in the following:

const _getRootType = field => (field.type ? _getRootType(field.type) : field);

We can now use this improved array for the modelMaps value to get models that have the associations automatically created.

Discussion (0)