Speed Up Your Local Development Cycle with the AWS AppSync Simulator

I recently started a project that involves building a few GraphQL APIs, backend services, and React components that will be used by other applications. The services and components are relatively simple. That meant it was a good opportunity to try out tools that could reduce the amount of boilerplate we needed to get the services up and running. Enter AWS AppSync.

Per AWS,

AWS AppSync is a fully managed service that makes it easy to develop GraphQL APIs by handling the heavy lifting of securely connecting to data sources like AWS DynamoDB, Lambda, and more. Adding caches to improve performance, subscriptions to support real-time updates, and client-side data stores that keep off-line clients in sync are just as easy. Once deployed, AWS AppSync automatically scales your GraphQL API execution engine up and down to meet API request volumes.

What I Worry About

When we use tools like AWS AppSync, we give up some control in exchange for the hope of increased value. But there are specific things I’m very hesitant to give up. Unfortunately, hosted services like AppSync often force my hand in those exact areas. Specifically, I care about keeping a fast and valuable feedback cycle during development. We achieve that through:

  • A fast automated test suite that can run locally and on CI
  • The ability to run the application locally for manual testing
  • Minimizing differences in application behavior when run locally vs. deployed

In AppSync’s case, I worried that it could be difficult to run locally in a meaningful way. Another concern was that, once we did get it running, we would see behavior differences that decreased the value of doing so. AppSync handles authentication, mapping resolvers to data sources, and transforming requests and responses. That means there is a significant chance of some aspects of it going poorly.

The fallback of deploying an application to do any manual testing can be painful. Locally, a cycle can take seconds between making a change and seeing it reflected in the running application. Deploying an application to AWS, in the best case, is tens of seconds. And, that’s only possible if the changes are limited to a Lambda’s code or static assets for a front-end application. Pushing up changes to resolver mappings and authentication, which are now part of the AWS AppSync configuration, can take minutes via the AWS CDK (or CloudFormation). My team would not feel thrilled about that development experience.

Now, the good news: the AppSync simulator has addressed most of my concerns. We have a good local development experience and reasonable feedback cycles. I’ll share specifics of our setup with the AppSync Simulator along with observations and issues to watch out for.

What the Simulator Gives us

The AppSync simulator gives us a way to run an AppSync GraphQL API locally without complicated infrastructure. We’ve found the behavior is similar enough to the deployed service for our purposes. Some specific high points that we’ve appreciated include:

The resolver invocations get the same data via the simulator vs. deployed

It supports GraphQL subscriptions and websockets

We can use authentication modes we care about in realistic ways:

  • API_KEY — just pass in the key in an `x-api-key` request header, and you’re off to the races.
  • AMAZON_COGNITO_USER_POOLS — send a JWT token in the `authorization` request header and the contents of the decoded token will be placed on the `identity` property that gets passed into the Lambda. The simulator doesn’t care how it’s signed.

It has a built-in GraphQL API Explorer for manual API invocations

There’s a code example below that shows how to start it.

The Rough Edges

As good as it is, the simulator is not perfect. Here are some of the rough edges we’ve run into.

The simulator does not validate the schemas/queries in the same way as the real AppSync service. For example, it will let you start a subscription with an invalid query AND give you data. The deployed service will not. At one point I forgot to deal with a GraphQL union type in a subscription query. The simulator didn’t throw any errors, and that subscription didn’t work at all once deployed.

Another rough edge is that we’ve had to duplicate some of our configuration code. It’s split across our CDK code and our code for running the simulator. We’ve built up ways to minimize drift (iterating over shared resolver configuration in both places), though. I don’t expect it to be a significant problem for us.

AppSync’s support for GraphQL subscriptions requires you to make mutation calls that return the same data that the subscription needs to push the data to the client. It’s not the end of the world. Background processes that would otherwise create data directly in the database are creating entities via GraphQL mutations, and we’re all generally happy. Other parts of the product ecosystem will probably need to do that anyway. And, having everything go through the same path ensures our subscriptions remain happy.

Finally, there’s a risk that we could run into N+1 query problems if our GraphQL API becomes more complex in the future. We lose some degree of control over the data loaders, caching, and batching by using AppSync. Others have reported problems with this. At this point, we don’t expect that risk to manifest. Plus, there have been recent improvements to the resolver batching that sound promising.

Simulator Setup

We didn’t find many (any?) examples of other people directly using the AppSync simulator. Most people use it in the context of an Amplify project or via the Serverless Framework. However, both of those tools take over the management of your entire project, and we didn’t want to go down that road for various reasons.

Digging into the Serverless Framework’s codebase gave us good insights into how to run the simulator ourselves. (Score one for open source!) It turned out to be fairly straightforward. Here’s a code snippet showing how to start the simulator.

import {
  AmplifyAppSyncSimulator,
  AmplifyAppSyncSimulatorAuthenticationType,
  AmplifyAppSyncSimulatorConfig,
  RESOLVER_KIND,
} from "amplify-appsync-simulator";

// Templates that are equivalent to the direct Lambda resolver behavior,
// based on what we've seen with deployed direct Lambda resolvers.
const directLambdaRequestTemplate = `## Direct lambda request
{
    "version": "2018-05-29",
    "operation": "Invoke",
    "payload": $utils.toJson($context)
}`;
const directLambdaResponseTemplate = `## Direct lambda response
#if($ctx.error)
    $util.error($ctx.error.message, $ctx.error.type, $ctx.result)
#end
$util.toJson($ctx.result)`;

// Replace with your GraphQL schema
const schemaContent = `
  type Story {
    id: String
    name: String
  }
  type Query {
    stories: [Story]
  }`;

const baseConfig: AmplifyAppSyncSimulatorConfig = {
  appSync: {
    defaultAuthenticationType: {
      authenticationType: AmplifyAppSyncSimulatorAuthenticationType.API_KEY,
    },
    name: "test",
    apiKey: "fake-api-key",
    additionalAuthenticationProviders: [],
  },
  schema: { content: schemaContent },
  dataSources: [
    {
      type: "AWS_LAMBDA",
      name: "testLambda",
      invoke: async (context: ResolverFunctionContext) => {
        return [{ id: "noop", chapters: [{ id: "ch1" }] }];
      },
    },
  ],
  resolvers: [
	  // Add your own resolver mappings here
    {
      kind: RESOLVER_KIND.UNIT,
      typeName: "Query",
      fieldName: "stories",
      dataSourceName: "testLambda",
      requestMappingTemplate: directLambdaRequestTemplate,
      responseMappingTemplate: directLambdaResponseTemplate,
    },
  ],
};

const graphQLApiSimulator = new AmplifyAppSyncSimulator({
  port: 3000,
  wsPort: 3001,
});
await graphQLApiSimulator.start();
await graphQLApiSimulator.init(baseConfig);

Further Reading & References