Write Reusable Code for AppSync JavaScript Resolvers

Write Reusable Code for AppSync JavaScript Resolvers

Learn how to share code between AppSync JS resolvers

Introduction

AWS AppSync is a fully managed service that allows developers to build scalable and performant GraphQL APIs. It is also serverless, meaning that you will only pay for what you use.

AWS recently introduced support for JavaScript resolvers. This was a long-awaited feature. Before that, developers had to learn and write resolvers in a language called VTL, which has a steep learning curve and is hard to debug.

Shortly after the announcement, I wrote an article explaining what JS resolvers are, and why they are beneficial, but also explaining the limitations they have. For example, you likely cannot use your favorite external libraries. For two main reasons:

  1. import (or require) is not allowed. All your code must be in the same file.

  2. The code must comply with all the strict rules that AppSync has, which is likely not the case for most packages on npm today.

But that does not mean you cannot write your own reusable code that can be shared between resolvers. As developers, we often try to be DRY. If some code has to be written more than once, then it should be encapsulated into a function.

Right after JavaScript resolvers were announced, I did a quick proof of concept that shows that it is possible; as long as you follow the rules.

In this article, I will show you how I achieved it with a simple practical example.

Pre-requisites and Assumptions

In order to keep this article focused on the subject and take it to the point, I will take a few shortcuts and assumptions.

  1. You are already familiar with JavaScript resolvers.

    I will not go into details about how JS resolvers work. If you are new to JS resolvers, please refer to this article and the documentation before you keep reading.

  2. Pure JavaScript.

    Even though we will use esbuild (more on that later), for the purpose of this article, I will not try to use TypeScript and stick to pure JavaScript. This will remove the extra complexity that TypeScript adds because of code transpiling. In a follow-up article, we will see how we can add TypeScript into the mix.

  3. Take IaC out of the equation.

    I will not show you how to couple this with any Infrastructure as Code (IaC), nor how to deploy the resulting resolvers either. At the time of writing this article, and as far as I know, no IaC solution out there today supports this out of the box. You will need to figure out that part yourself. However, I will show you how to test the result, so you can see if the code is valid and working as expected 🙂

Let's get started

Enough talk, let's write some code!

Imagine we want to add timestamp values (createdAt and updatedAt) for every item inserted into DynamoDB by all of our Mutations (e.g. createPost, createAuthor, createComment, etc.). Because we don't want to repeat that code in every single resolver, we might want to do something like this:

// createPost.js
import { util } from '@aws-appsync/utils';
import { createItem } from './helpers';

export function request(ctx) {

  // add timestamps
  const item = createItem(ctx.args.post);

  return {
    operation: 'PutItem',
    key: {
      id: util.dynamodb.toDynamoDB(util.autoId()),
    },
    attributeValues: util.dynamodb.toMapValues(item),
  };
}

export function response(ctx) {
  return ctx.result;
}
// helpers.js
export function createItem(item) {
  return {
    ...item,
    createdAt: util.time.nowISO8601(),
    updatedAt: util.time.nowISO8601(),
  };
}

As you can see, we created a helper function named createItem() in a new file (helpers.js) that adds the creation and update timestamps to the object that is passed as an argument and then returns it. We import that function into our resolver and use it. We can do so in every place where we need it (e.g. createAuthor.js, createComment.js, etc).

This is great, but it also breaks one of the AppSync JS rules: import is not allowed. (Note: with the exception of @aws-appsync/utils, which is a special package provided by AppSync).

However, what we can do is bundle our code into one single file before sending it to AppSync. To do so, we are going to leverage a widely used code bundler: esbuild.

There are a few things to keep in mind though:

  1. By default, esbuild converts ECMAScript module syntax to CommonJS.

    AppSync only supports ES modules, so we must ensure that esbuild outputs those instead. To solve that issue, we can use the format option with a value of esm.

  2. We don't want to bundle the AppSync utils library.

    AppSync provides the @aws-appsync/utils package by default. So, we don't want to have it bundled into our final code. The external option allows us to exclude it from the bundle.

Let's execute the following command:

esbuild createPost.js \
  --bundle \
  --outdir=build \
  --external:"@aws-appsync/utils" \
  --format=esm

If you look in the build directory, you should find a createPost.js file which contains the bundled code as follows:

// createPost.js
import { util as util2 } from "@aws-appsync/utils";

// helpers.js
import { util } from "@aws-appsync/utils";
function createItem(item) {
  return {
    ...item,
    createdAt: util.time.nowISO8601(),
    updatedAt: util.time.nowISO8601()
  };
}

// createPost.js
function request(ctx) {
  const item = createItem(ctx.args.post);
  return {
    operation: "PutItem",
    key: {
      id: util2.dynamodb.toDynamoDB(util2.autoId())
    },
    attributeValues: util2.dynamodb.toMapValues(item)
  };
}
function response(ctx) {
  return ctx.result;
}
export {
  request,
  response
};

We now have all the necessary code for our resolver in one single file that is compliant with AppSync 🎉

Testing

Great, now there is one more thing that we need to make sure of. Does our resolver work as expected? Does it contain any invalid code?

AWS provides a CLI that allows us to test JavaScript resolvers. It serves two purposes:

  • If the code contains any error or is invalid, the command will fail and let us know where the problem is. This is especially useful in this case because esbuild might easily break compatibility with AppSync when bundling the code.

  • We can inject mock data in order to check the result that would be sent to the data source. This allows us to check that our code logic works as expected.

Let's try it

aws appsync evaluate-code \
  --code file://build/createPost.js \
  --function request \
  --context file://context.json \
  --runtime name=APPSYNC_JS,runtimeVersion=1.0.0

with the following context.json file.

{
  "arguments": {
    "post": {
      "title": "Hello, world",
      "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
    }
  }
}

result:

{
  "evaluationResult": "{\"operation\":\"PutItem\",\"key\":{\"id\":{\"S\":\"927934a0-4fb4-4ee2-977a-4f4d458062fb\"}},\"attributeValues\":{\"createdAt\":{\"S\":\"2023-03-02T12:28:15.087Z\"},\"title\":{\"S\":\"Hello, world\"},\"content\":{\"S\":\"Lorem ipsum dolor sit amet, consectetur adipiscing elit.\"},\"updatedAt\":{\"S\":\"2023-03-02T12:28:15.087Z\"}}}",
  "logs": []
}

This looks a bit messy, but if you look closely, you will see that a DynamoDB PutItem request was generated. It contains our mocked data, which was enriched with the createdAt and udpatedAt fields from our helper function, as expected 🎉.

Before we call it a day, let me show you another way to test this.

GraphBolt is a tool for developers to build, test and debug AppSync APIs. You can also use it to easily test JS resolvers in a user-friendly way thanks to the Mapping Template Designer tool it provides.

Copy the same bundled resolver code and paste it into the tool. Select JavaScript as the runtime. Finally, enter the mock data in the top section (under args).

Hit the Eval button, and when prompted, chose request as the function to be evaluated.

And voilà!

On the right hand, you should see the result of the code evaluation, with the expected PutItem request.

Now that we confirmed that the code is valid, you can use it in your AppSync API using your favorite IaC.

💡 You can find the code for the example used in this tutorial, and more, on GitHub.

Conclusion

The introduction of AppSync JavaScript resolvers was a game-changer. They let developers write code quicker in a language they are more familiar with. They also open the possibility to write reusable code more easily. In this article, I showed you how to write simple helper functions that you can use in more than one resolver while still following AppSync's rules. Finally, I showed you two ways you can both validate the bundled code and test its logic.