Using Relay with AWS AppSync

This post will walk you through a simple “todo list” application built with AWS AppSync, AWS Amplify, React, and Relay. The sample app will show how to achieve smooth integration of AppSync and Relay, supporting key features like automatic pagination, mutations with optimistic client-side updates, and real-time synchronization of data across clients with subscriptions.

If you want to jump right into the example app, you can see the code here. Otherwise, read ahead for a complete walkthrough.

Background

Relay is a popular client-side framework for interacting with GraphQL APIs from React-based applications. Like GraphQL and React, Relay is an open source project begun by Facebook. While Relay is old enough to predate GraphQL’s own open source debut, it was significantly revamped last year as Relay Modern, the version that will be used in this post.
 
 AWS AppSync is a fully-managed, serverless GraphQL service supporting real-time data queries, synchronization, and offline programming features. AppSync lets you define any GraphQL API you like and connect it easily to other AWS services like Lambda, ElasticSearch, or DynamoDB. Relay does have some particular requirements of the GraphQL server it connects to, but AppSync gives you the control over your schema to meet those needs.
 
 If you’re building a new app and choosing a frontend framework to use, the Apollo Client is another great option that integrates very will with AppSync and has fewer requirements about the structure of your schema. If you already have experience with Relay though, or if you prefer its features, app structure, and opinionated approach, then this post is for you.

AppSync Basics

Before addressing the specific requirements of Relay, it’s worth spending a moment on some core GraphQL and AppSync concepts. Here are a few that will be discussed throughout the post:

  • Schema: This is a key GraphQL concept. It is the primary “contract” between API consumers and the API maintainers.
  • Resolvers: Also a standard GraphQL concept, a resolver returns a value for a given field of a type in the schema. In general, resolvers may be called recursively.
  • Data Sources: In AppSync, a Data Source is a particular backend resource like a DynamoDB table or Lambda function that is used by resolvers to handle queries and mutations.
  • Resolver Mapping Templates: AppSync resolvers work by translating a request (a query or mutation coming from the client) into a JSON document that represents a data source invocation. Responses are then handled the same way, by translating the raw response from the data source into a GraphQL result. This translation is done with a resolver mapping template written in the Apache Velocity Template Language (VTL). (For detailed information, see the Template Reference).

With these terms established, we can dig into the sample application schema. If you want to follow along with a running AppSync environment, you can launch the sample app’s API backend with CloudFormation as documented in the project README. (You’ll need to create a Cognito User to authenticate as before running queries.)

The Relay Spec

In order to work with the Relay client library our spec must satisfy the GraphQL Server Specification. The spec consists mainly of:

  • Globally unique IDs: All types in your API must implement Relay’s Node interface, which consists of a single id field. This allows Relay to do things like automatically updating objects in its client-side store in response to mutations.
  • A particular connection convention: A one-to-many or many-to-many relationship between two types (e.g. User and Friends), often referred to as a “connection” in GraphQL parlance, must be structured a certain way, with intermediate “connection types” and “edge types”.
  • Specific mutation inputs/outputs: The inputs to mutations must be provided as “input types” (this is actually a first-class GraphQL concept), and if the client provides a clientMutationId with its input, the server must pass it back unchanged.

The unique ID requirement is very easy to achieve with AppSync, and corresponds very well to a persistence layer consisting of a single DynamoDB table, as is used in the sample app (just make sure all the types in the GraphQL API have a unique ID called “id” — this is a firm requirement of Relay).
 
 The other requirements for connections and mutations deserve a little more attention.

Connections

As discussed above, connections such as the listTodos field in our sample app must be structured a certain way in Relay. While this requires some up-front work on the backend, it lets us leverage Relay's built-in pagination support and avoid writing our own imperative pagination code. Let's look at a snippet from our GraphQL schema that involves this connection:

type Todo implements Node {
id: ID
userId: ID!
createdAt: String!
text: String!
complete: Boolean!
}
type TodoConnection {
edges: [TodoEdge!]
nextToken: String
pageInfo: PageInfo!
}
type TodoEdge {
node: Todo!
cursor: String
}
type Viewer implements Node {
id: ID
listTodos(after: String, first: Int): TodoConnection!
}
type Query {
viewer: Viewer!
}

This can be queried like:

query {
viewer {
listTodos {
edges {
node {
id
text
complete
}
}
}
}
}

A simple response mapping template for a DynamoDB query for Todos could look something like this:

$util.toJson($context.result.items)

However, that would just give us a simple “flat list” of results, which does not match the format required by Relay. Here is a mapping template that gives us what we need:

#set($edges = [])
#foreach($item in $context.result.items)
$util.quiet($edges.add({"node": $item}))
#end
{
"edges": $util.toJson($edges),
}

This example omits some pagination-related fields for simplicity (see the source for a complete example), but the basic pattern of using VTL to transform the structure of the DynamoDB response into that required by Relay let’s us handle listTodos queries easily.

Mutations

Relay also has some specific requirements of the inputs and outputs of mutations (updates/deletes/etc). Take a look at this subset of our schema handling Todo updates in compliance with the spec (creates and deletes are handled similarly):

input UpdateTodoInput {
id: ID!
complete: Boolean
clientMutationId: ID
}
type UpdateTodoPayload {
node: Todo
clientMutationId: ID
userId: ID!
}
type Mutation {
updateTodo(input: UpdateTodoInput!): UpdateTodoPayload!
}

Note that an input type is used as required by the spec. Additionally, if a clientMutationId field is supplied in the input, it must be returned unchanged in the output for internal use by Relay (there is some discussion in the Relay project of removing this field, but for now it is required). This can be accomplished in the response mapping template like this:

{
"node": $util.toJson($context.result),
"clientMutationId": "$context.arguments.input.clientMutationId",
"userId": "$context.identity.sub"
}

To avoid adding unnecessary metadata to the core types in our schema, the Relay convention is to return a “payload” object from the mutation and add things like the clientMutationId there. In our app, which uses GraphQL subscriptions for real-time updates, this payload must also include a userId for authorizing and filtering subscriptions. Here is the relevant piece of the schema, which takes advantage of AppSync's native subscription support:

type Subscription {
updatedTodo(userId: ID!): UpdateTodoPayload
@aws_subscribe(mutations: ["updateTodo"])
}

See the AppSync documentation for a complete discussion of security, including granular authorization for subscriptions. You can also look at the sample app code for a working example.

The Frontend

Once the API backend is set up, all that’s left is to get the frontend to talk to it. Fortunately, the AWS Amplify client library makes this really easy. In fact, the entire integration between AppSync and Relay on the frontend is achieved with this one short source file:

import { API, graphqlOperation } from 'aws-amplify';
import { Environment, Network, RecordSource, Store } from 'relay-runtime';

function fetchQuery(operation, variables) {
return API.graphql(graphqlOperation(operation.text, variables));
}
function subscribe(operation, variables) {
return API.graphql(graphqlOperation(operation.text, variables))
.map(({value})=>value);
}
const environment = new Environment({
network: Network.create(fetchQuery, subscribe),
store: new Store(new RecordSource()),
});
export default environment;

This module sets up the “environment” in Relay terminology that is used by Relay to execute GraphQL queries. It simply maps the Relay fetchQuery and subscription functions to the corresponding Amplify API.graphql calls. Amplify returns a Promise in the case of a query or mutation and an Observable in the case of a subscription, which are exactly what Relay expects. (At the moment, Observables are still a proposal, but Amplify's and Relay's implementations are compatible.) The only small gotcha is that Amplify subscription events are wrapped in an object that includes a handle to the PubSub provider for that particular event, so it is necessary to “unwrap” the raw values with API.graphql(...).map(({value})=>value).
 
 Once this is set up, actually executing queries and mutations works the same as usual in Relay. See for example the docs on <QueryRenderer/>, Fragment Container, Mutations, and Subscriptions.
 
 All that's left is to tell Amplify how to connect and authenticate with the backend, which involves just a bit of configuration. Early in the app's main entrypoint, we just run something something like this:

Amplify.configure({
aws_appsync_graphqlEndpoint: AC.AppSyncEndpoint,
aws_appsync_region: AC.AppSyncRegion,
aws_appsync_authenticationType: 'AMAZON_COGNITO_USER_POOLS',
Auth: {
userPoolId: AC.UserPool,
userPoolWebClientId: AC.ClientId,
}
});

The global AC variable is injected by webpack with the DefinePlugin to make it easy to use different values in different development or production environments. Also note that we're using Cognito to handle authentication. Amplify even let's us provide a complete login experience for our app with a single invocation of withAuthenticator(App) where App is our app's root React component. (See the Amplify docs for more on the Higher Order Components available for React.)

Conclusion

Relay and React are mature, proven choices for a GraphQL-based frontend framework, and AWS AppSync is the best way to quickly deploy a scalable, serverless GraphQL backend. All of the methods, services, and libraries described in this post also work just fine for building mobile apps with React Native. You can build your next app by forking this sample or just using it as a reference if you get stuck on your own. Best of luck building your next real-time serverless app!