DEV Community

Cover image for GraphQL: understanding node interface.
Augusto Calaca
Augusto Calaca

Posted on

GraphQL: understanding node interface.

Overview

To provide options for GraphQL clients to elegantly handle for caching and data refetching GraphQL servers need to expose object identifiers in a standardized way. In the query, the schema should provide a standard mechanism for asking for an object by ID. We refer to objects with identifiers as nodes.

Reserved Types

To a GraphQL Server to be compatible, it must reserve certain types and type names to support the consistent object identification model. We should create the following types:

  • An interface named Node
  • The node field on the root query type

Node Interface

The server must provide an interface called Node. That interface must include exactly one field, called id that returns a non-null ID. This id should be a globally unique identifier for the object, and with just this id, the server should be able to refetch the object.

NOTE: If two objects appear in the query, both implementing the Node with identical ids, then the two objects must be equal.

Node root field

The server must provide a root field named node that returns the Node interface. This root field must take exactly one non-null ID argument called id.

When the value returned by the server in the Node's id field is passed as the id parameter to the node root field than this query should refetch the identical object that implements Node.

Now that we have some insight we must answer the following questions:

  • How and when does Node ID gets created?
  • When Node ID is used?
  • How are the nodeDefinitions methods used?

These questions were asked in this post Relay/GraphQL: De-mystifying Node Interface/ID. The present post also answers theses questions besides showing more examples, a generic and scalable form of registering types, how to test your nodeField and a generalization of the Node Interface pattern.

How and when the Node IDs gets created?

A Node ID is created for each GraphQL object declaring GraphQLObjectType for that object. The function globalIdField has the role of auto-generate the node ID when the GraphQL object is created.

import { globalIdField } from 'graphql-relay';
import { IUser } from './UserModel';
import { load } from './UserLoader';

const UserType = new GraphQLObjectType<IUser, GraphQLContext>({
  name: 'User',
  description: 'User data',
  fields: () => ({
    //  auto-generate node ID when the GraphQL object is created
    id: globalIdField('User'),
    ...
  }),
  ...
});

export const UserConnection = connectionDefinitions({
  name: 'User',
  nodeType: GraphQLNonNull(UserType),
});

export default registerTypeLoader(UserType, load);

Further, we will talk about registerTypeLoader.

When the Node ID is used?

Well, we already know that Node ID is used for refetch. But when exactly does a refetch occurs?
The First GraphQL request to fetch data is not a refetch and Node IDs have not been generated so far. The refetch occurs when on the client-side we already have GraphQL objects with Node IDs and, for instance:

  • we request more data for an existing GraphQL object, which can arise from another component relying on the same GraphQL object but requiring different pieces of data of that object
  • when the server-side object data has been changed
  • when to change the parameters used to fetch the original GraphQL object.

How are nodeDefinitions methods used?

For GraphQL types that you want to refetch you request to auto-generate a Node ID for the object during creation, as seen above, and you also need to tell GraphQL how to map globally the Node ID provided during refetch into actual data objects and their GraphQL types. You do this defining the interfaces field on GraphQLObjectType.

// UserType.ts
import { globalIdField } from 'graphql-relay';
import { IUser } from './UserModel';
import { load } from './UserLoader';
import { nodeInterface } from '../../interface/NodeDefinitions';

const UserType = new GraphQLObjectType<IUser, GraphQLContext>({
  name: 'User',
  description: 'User data',
  fields: () => ({
    id: globalIdField('User'),
    ...
  }),
  // use nodeInterface for re-fetching this GraphQL type by node ID
  interfaces: () => [nodeInterface],
});

export const UserConnection = connectionDefinitions({
  name: 'User',
  nodeType: GraphQLNonNull(UserType),
});

export default registerTypeLoader(UserType, load);

The nodeDefinitions has this format and export nodeField, nodeFields and nodeInterface.

// nodeDefinitions.ts
import { nodeDefinitions } from 'graphql-relay';
import { idFetcher, typeResolver } from './nodeRegistry';

export const { nodeField, nodesField, nodeInterface } = nodeDefinitions(
  idFetcher,
  typeResolver,
);

Be aware that nodeDefinitions to tell GraphQL how performs the mapping the Node ID and has two methods:

  • the first receive the globalId, we map the globalId into its corresponding data object and with it can actually be able to read the GraphQL type and id of the object using fromGlobalId function. We will call it the idFetcher.
  • the second receive the result object and Relay uses that to map an object to its GraphQL data type. So if the object is an instance of User, it will return UserType and so on. We will call it the typeResolver.

See below how to implement the methods explained above:

// nodeRegistry.ts - that not scale well
import { fromGlobalId } from 'graphql-relay';
import User, * as UserLoader from '../modules/user/UserLoader';
import UserType from '../modules/user/UserType';
// many imports...

export const idFetcher = async (globalId: string, context: GraphQLContext) => {
  const { id, type } = fromGlobalId(globalId);

    if (type === 'User') {
      return UserLoader.load(context, id);
    }
    // many ifs...
    // all the loads of your project
    ...

    return null;
};

export const typeResolver = object => {
    if (obj instanceof User) {
      return UserType;
    }
    // many ifs...
    // all the types of your project
    ...

    return null;
}

Realize that this implementation can be improved.

After that, expose the node and your other queries on QueryType:

// QueryType.ts
import { GraphQLID, GraphQLString, GraphQLNonNull, GraphQLObjectType, GraphQLInputObjectType, GraphQLInterfaceType } from 'graphql';
import { connectionArgs, fromGlobalId, globalIdField } from 'graphql-relay';

import { nodeField, nodesField } from '../interface/NodeDefinitions';
import { GraphQLContext } from '../types';

import UserType, { UserConnection } from '../modules/user/UserType';
import { UserLoader } from '../loader';

export default new GraphQLObjectType<any, GraphQLContext>({
  name: 'Query',
  description: 'The root of all... queries',
  fields: () => ({
    id: globalIdField('Query'),
    node: nodeField,
    nodes: nodesField,
    users: {
      type: GraphQLNonNull(UserConnection.connectionType),
      args: {
        ...connectionArgs,
        filters: {
          type: new GraphQLInputObjectType({
            name: 'UserFilters',
            fields: () => ({
              search: {
                type: GraphQLString,
              },
            })
          }),
        }
      },
      resolve: (_, args, context: GraphQLContext) => UserLoader.loadAll(context, args),
    },
    ...
  }),
});

On playground take any id returned by UserList and pass it to node query, as this:

query Node {
    node(id: "VXNlcjo1ZTgyNGM4YTUwZjcwMzdhMjhmZDdmZWU=") {
    id
    __typename
  }
}

The result on __typename should be the User indicating the GraphQL type of the node.
Well, you just realized how both Node ID and nodeDefinitions are used \o/.

Now, we will go into a part about how you can improve its implementation.

Register Type Loader

To all GraphQL types that the server should be able to refetch you must register your type and load GraphQL.
This may involve many imports, many ifs and fast growth of nodeRegistry as it was shown previously. We want to prevent this situation because it does scale well.
In order to avoid the manual register on the nodeRegistry file, we use an object named typesLoaders that assigns a type and a load GraphQL through a function called registerTypeLoader. See again the snippet UserType.ts how simple it is to use it. So, the nodeRegistry just uses that object bringing it with the function getTypesLoaders. Check down its implementation:

// registerTypeLoader.ts
import { GraphQLObjectType } from 'graphql';
import { Load } from '../types';

type TypeLoaders = {
  [key: string]: {
    type: GraphQLObjectType,
    load: Load,
  };
};

const typesLoaders: TypeLoaders = {};
export const getTypesLoaders = () => typesLoaders;
export const registerTypeLoader = (type: GraphQLObjectType, load: Load) => {
  typesLoaders[type.name] = {
    type,
    load,
  };

  return type;
};

Use that function to type that you want that should be able to refetch, for instance: UserType.ts, PostType.ts, TransacationType.ts and so on.

// nodeRegistry.ts - clean implementation
import { fromGlobalId } from 'graphql-relay';
import { getTypesLoaders } from './registerTypeLoader';
import { GraphQLContext } from '../types';

const typesLoaders = getTypesLoaders();
// How to return an object given its globalId
// Is used to take the ID argument of the node field and use it to resolve an object
export const idFetcher = async (globalId: string, context: GraphQLContext) => {
  const { type, id } = fromGlobalId(globalId);
  const data = await typesLoaders[type].load(context, id);

  return data;
};

// How to return a GraphQL type given an object
// Allows your GraphQL server to work out which GraphQL type of object was returned
export const typeResolver = object => typesLoaders[object.constructor.name].type || null;

Much more structured and clean code, isn't it?

Test node field

To test if your node query works you can try a test by an inline fragment or spread fragment. Here we will show both.

// NodeDefinitions.spec.ts
import { graphql } from 'graphql';
import { toGlobalId } from 'graphql-relay';
import { schema } from '../../schema/schema';

it('should load User by inline fragment', async () => {
  const user = await createUser();

  const query = `
    query Node($id: ID!) {
      node(id: $id) {
        id
        ... on User {
          name
          username
          isActive
          __typename
        }
      }
    }
  `;

  const rootValue = {};
  const context = await getContext();
  const variables = {
    id: toGlobalId('User', user.id),
  };

  const result = await graphql(schema, query, rootValue, context, variables);
  expect(result.data!.node.id).toBe(variables.id);
  expect(result.data!.node.name).toBe(user.name);
  expect(result.data!.node.username).toBe(user.username);
  expect(result.data!.node.isActive).toBe(user.isActive);
  expect(result.data!.node.__typename).toBe('User');
});

Like we already registered the GraphQL type User and define the nodeField on Query we can test if the node brings the data correctly. First we create a user, after we define the query, a root value which can be an empty object, a context which must be similar to the passed on the your server, the variables that your query needs in that case the id. To execute the query we put that informations into graphql and finally we can to compare the returned data to verify if it is correct or not.

// NodeDefinitions.spec.ts
import { graphql } from 'graphql';
import { toGlobalId } from 'graphql-relay';
import { schema } from '../../schema/schema';

it('should load User by spread fragment', async () => {
  const user = await createUser();

  const query = `
    query Node($id: ID!) {
      node(id: $id) {
        id
        ...userFields
        __typename
      }
    }

    fragment userFields on User {
      name
      username
      isActive
    }
  `;

  const rootValue = {};
  const context = await getContext();
  const variables = {
    id: toGlobalId('User', user._id),
  };

  const result = await graphql(schema, query, rootValue, context, variables);
  expect(result.data!.node.id).toBe(variables.id);
  expect(result.data!.node.name).toBe(user.name);
  expect(result.data!.node.username).toBe(user.username);
  expect(result.data!.node.isActive).toBe(user.isActive);
  expect(result.data!.node.__typename).toBe('User');
});

This second test is just to check that our node also works with spread fragment and has the same behavior as the first. However, the test fulfills its role of checking if it works for both types of query.

Next

Recently the relay team made a commit named @refetchable support for @fetchable types in which they add the @fetchable directive. It has the role to define the schema to specify that a GraphQL type is refetchable and what field should be used to refetch it. Therefore, this is a generalization of the pattern explained in this post. Instead node on QueryType we must define foo root field could implement refetchable on top of our custom Foo Node Interface. It's basically the node interface with different field names.

Conclusion

  • Declare globalIdField on your GraphQL type to make GraphQL auto-generate a unique global Node ID for your GraphQL object during creation and ensure that it must be refetched.
  • Implement nodeDefinitions methods to map Node ID back to the corresponding server-side object, which you then can use to return other or updated attributes when a refetch provides the Node ID.
  • Use nodeInterface on your GraphQL type to instruct GraphQL to use the nodeDefinitions implementation to resolve between Node ID and actual server-side objects.
  • Use the registerTypeLoader to avoid the manual register on the nodeRegistry file and make the code more organized and scalable.
  • Use tests to avoid unwanted behaviors.
  • There was a generalization in the pattern.

Resources

Discussion (0)