Hitting the road with AppSync JavaScript resolvers

As someone who’s always curious about GraphQL, I wanted to see how AppSync’s novel JavaScript resolvers stack up against VTL, the old (and somewhat unpopular) kid on the block.

My go-to API for such experiments is part of a service I created a few years ago to learn React and explore AppSync. The service, a pure serverless solution1, watches your Twitter account and tells you about new or lost followers. Yes, it’s silly, but it’s also an excellent example of enjoying some recreational programming.

With that said, my goal was to migrate the following GraphQL queries2 from VTL to JS resolvers:

type Query {
  getUser(id: ID!): User
  getLatestFollowerEvents(userId: ID!): [FollowerEvent!]
  ping: String!
}

Let’s tackle the queries one by one, starting with the simplest.

ping

The ping query always returns the same response: the string “pong”. I originally included it for simple (and naive) API monitoring that does more than schema introspection and actually hits a resolver. It’s as basic as it gets – a good candidate to dive in and start migrating.

What follows are three different versions of the same resolver:

  1. The original VTL request and response mapping templates
  2. VTL converted to JavaScript
  3. JavaScript converted to TypeScript with static typing

I decided to show the final version first – a wall of VTL can be quite intimidating, whereas TS is more pleasant on the eyes – ignoring the deployment process for now. Switch tabs to study the differences.

  • import { NONERequest } from '@aws-appsync/utils'
    
    export function request(): NONERequest {
      return { payload: {} }
    }
    
    export function response(): string {
      return 'pong'
    }
    
  • export function request() {
      return {}
    }
    
    export function response() {
      return 'pong'
    }
    
  • ##Request
    {"version": "2018-05-29"}
    
    ##Response
    $util.toJson("pong")
    

The migration was smooth, almost boring. What’s truly exciting, however, is that the TS code gives a glimpse into the power of static typing. In this case, it allowed me to explicitly define the return types associated with the None data source and the GraphQL response itself. That alone is a massive advantage over VTL (and JS, for that matter).

getUser

Unsurprisingly, migrating getUser required more work. The resolver’s role is to:

  1. Authorize the request by making sure the sub claim from Auth0 (courtesy of AppSync’s OIDC provider) matches the user ID from the query
  2. Retrieve user information from DynamoDB
  3. Return the result with DynamoDB attribute names mapped to nicer GraphQL field names3

Here’s the code:

  • import { util, Context, AppSyncIdentityOIDC, DynamoDBQueryRequest } from '@aws-appsync/utils'
    import { User } from '../../app/src/gql/graphql'
    import { authorize } from './shared'
    
    export function request(ctx: Context<{ id: string }>): DynamoDBQueryRequest {
      const userId = authorize(ctx.args.id, ctx.identity as AppSyncIdentityOIDC)
    
      return {
        operation: 'Query',
        query: {
          expression: 'PK = :PK',
          expressionValues: util.dynamodb.toMapValues({
            ':PK': `USER#${userId}`,
          }),
        },
        index: 'UserIndex',
      }
    }
    
    export function response(ctx: Context): User {
      const { result, error } = ctx
    
      if (error) {
        util.error(error.message, error.type)
      }
      if (result.items.length === 0) {
        util.error('user not found')
      }
    
      const user = result.items[0]
    
      return {
        id: user.UserID,
        handle: user.Handle,
        name: user.Name,
        location: user.Location,
        bio: user.Bio,
        profileImageUrl: user.ProfileImageURL,
        slack: {
          enabled: user.Slack?.Enabled ?? false,
          webhookUrl: user.Slack?.WebhookURL,
          channel: user.Slack?.Channel,
        },
        ignoreFollowers: user.IgnoreFollowers,
        createdAt: user.CreatedAt,
        updatedAt: user.UpdatedAt,
        lastLogin: user.LastLogin,
      }
    }
    
  • import { util } from '@aws-appsync/utils'
    import { authorize } from './shared'
    
    export function request(ctx) {
      const userId = authorize(ctx.args.id, ctx.identity)
    
      return {
        operation: 'Query',
        query: {
          expression: 'PK = :PK',
          expressionValues: util.dynamodb.toMapValues({
            ':PK': `USER#${userId}`,
          }),
        },
        index: 'UserIndex',
      }
    }
    
    export function response(ctx) {
      const { result, error } = ctx
    
      if (error) {
        util.error(error.message, error.type)
      }
      if (result.items.length === 0) {
        util.error('user not found')
      }
    
      const user = result.items[0]
    
      return {
        id: user.UserID,
        handle: user.Handle,
        name: user.Name,
        location: user.Location,
        bio: user.Bio,
        profileImageUrl: user.ProfileImageURL,
        slack: {
          enabled: user.Slack?.Enabled ?? false,
          webhookUrl: user.Slack?.WebhookURL,
          channel: user.Slack?.Channel,
        },
        ignoreFollowers: user.IgnoreFollowers,
        createdAt: user.CreatedAt,
        updatedAt: user.UpdatedAt,
        lastLogin: user.LastLogin,
      }
    }
    
  • ##Request
    #if ($ctx.identity.sub && $ctx.identity.sub != $ctx.args.id)
      $util.unauthorized()
    #end
    
    #set($auth0ProviderPrefix = "twitter|")
    #set($userId = $util.str.toReplace($ctx.args.id, $auth0ProviderPrefix, ""))
    
    {
      "version": "2018-05-29",
      "operation": "Query",
      "query": {
        "expression": "PK = :PK",
        "expressionValues": {
          ":PK": $util.dynamodb.toDynamoDBJson("USER#${userId}")
        }
      },
      "index": "UserIndex"
    }
    
    ##Response
    #if ($ctx.error)
      $util.error($ctx.error.message, $ctx.error.type)
    #end
    
    #if ($ctx.result.items.size() == 0)
      $util.error("user not found")
    #end
    
    #set($user = $ctx.result.items[0])
    
    $util.toJson({
      "id": $user.UserID,
      "handle": $user.Handle,
      "name": $user.Name,
      "location": $user.Location,
      "bio": $user.Bio,
      "profileImageUrl": $user.ProfileImageURL,
      "slack": {
        "enabled": $util.defaultIfNull($user.Slack.Enabled, false),
        "webhookUrl": $user.Slack.WebhookURL,
        "channel": $user.Slack.Channel
      },
      "ignoreFollowers": $user.IgnoreFollowers,
      "createdAt": $user.CreatedAt,
      "updatedAt": $user.UpdatedAt,
      "lastLogin": $user.LastLogin
    })
    

As you can see, I could use proper types for almost everything, including a generated User type. I also extracted an authorize function to make it reusable – more on this shared code in a bit – without having to resort to hacks like assembling VTL files from multiple snippets.

Despite being limited in several ways, AppSync’s JS runtime supports many well-known language features such as optional chaining (?.) and the nullish coalescing (??) operator. I, for one, won’t miss $util.defaultIfNull and friends. :wave:

getLatestFollowerEvents

Last but not least, I rewrote getLatestFollowerEvents. The resolver has a lot in common with getUser, but this time the DynamoDB query returns a collection of items (follower events) transformed via JavaScript’s plain old map method.

  • import { util, Context, AppSyncIdentityOIDC, DynamoDBQueryRequest } from '@aws-appsync/utils'
    import { FollowerEvent } from '../../app/src/gql/graphql'
    import { authorize } from './shared'
    
    export function request(ctx: Context<{ userId: string }>): DynamoDBQueryRequest {
      const userId = authorize(ctx.args.userId, ctx.identity as AppSyncIdentityOIDC)
    
      return {
        operation: 'Query',
        query: {
          expression: 'PK = :PK and begins_with(SK, :SK)',
          expressionValues: util.dynamodb.toMapValues({
            ':PK': `USER#${userId}`,
            ':SK': 'EVENT#',
          }),
        },
        limit: 100,
        scanIndexForward: false,
      }
    }
    
    export function response(ctx: Context): FollowerEvent[] {
      const { result, error } = ctx
    
      if (error) {
        util.error(error.message, error.type)
      }
    
      return result.items.map((item: any) => ({
        id: item.EventID,
        totalFollowers: item.TotalFollowers,
        follower: {
          id: item.Follower.ID,
          handle: item.Follower.Handle,
          name: item.Follower.Name,
          location: item.Follower.Location,
          bio: item.Follower.Bio,
          profileImageUrl: item.Follower.ProfileImageURL,
          protected: item.Follower.Protected,
          totalFollowers: item.Follower.TotalFollowers,
        },
        followerState: item.FollowerState,
        followerStateReason: item.FollowerStateReason,
        createdAt: item.CreatedAt,
      }))
    }
    
  • import { util } from '@aws-appsync/utils'
    import { authorize } from './shared'
    
    export function request(ctx) {
      const userId = authorize(ctx.args.userId, ctx.identity)
    
      return {
        operation: 'Query',
        query: {
          expression: 'PK = :PK and begins_with(SK, :SK)',
          expressionValues: util.dynamodb.toMapValues({
            ':PK': `USER#${userId}`,
            ':SK': 'EVENT#',
          }),
        },
        limit: 100,
        scanIndexForward: false,
      }
    }
    
    export function response(ctx) {
      const { result, error } = ctx
    
      if (error) {
        util.error(error.message, error.type)
      }
    
      return result.items.map((item) => ({
        id: item.EventID,
        totalFollowers: item.TotalFollowers,
        follower: {
          id: item.Follower.ID,
          handle: item.Follower.Handle,
          name: item.Follower.Name,
          location: item.Follower.Location,
          bio: item.Follower.Bio,
          profileImageUrl: item.Follower.ProfileImageURL,
          protected: item.Follower.Protected,
          totalFollowers: item.Follower.TotalFollowers,
        },
        followerState: item.FollowerState,
        followerStateReason: item.FollowerStateReason,
        createdAt: item.CreatedAt,
      }))
    }
    
  • ##Request
    #if ($ctx.identity.sub && $ctx.identity.sub != $ctx.args.userId)
      $util.unauthorized()
    #end
    
    #set($auth0ProviderPrefix = "twitter|")
    #set($userId = $util.str.toReplace($ctx.args.userId, $auth0ProviderPrefix, ""))
    
    {
      "version": "2018-05-29",
      "operation": "Query",
      "query": {
        "expression": "PK = :PK and begins_with(SK, :SK)",
        "expressionValues": {
          ":PK": $util.dynamodb.toDynamoDBJson("USER#${userId}"),
          ":SK": $util.dynamodb.toDynamoDBJson("EVENT#")
        }
      },
      "limit": 100,
      "scanIndexForward": false
    }
    
    ##Response
    #if ($ctx.error)
      $util.error($ctx.error.message, $ctx.error.type)
    #end
    
    #set($events = [])
    
    #foreach ($item in $ctx.result.items)
      $util.qr(
        $events.add({
          "id": $item.EventID,
          "totalFollowers": $item.TotalFollowers,
          "follower": {
            "id": $item.Follower.ID,
            "handle": $item.Follower.Handle,
            "name": $item.Follower.Name,
            "location": $item.Follower.Location,
            "bio": $item.Follower.Bio,
            "profileImageUrl": $item.Follower.ProfileImageURL,
            "protected": $item.Follower.Protected,
            "totalFollowers": $item.Follower.TotalFollowers
          },
          "followerState": $item.FollowerState,
          "followerStateReason": $item.FollowerStateReason,
          "createdAt": $item.CreatedAt
        })
      )
    #end
    
    $util.toJson($events)
    

(My apologies to GraphQL purists for the lack of pagination.)

Web standards?

To complete the picture, here’s the promised code from shared.ts used by both getUser and getLatestFollowerEvents for authorization:

import { util, AppSyncIdentityOIDC } from '@aws-appsync/utils'

// With String.prototype.replace(), the pattern can be a
// string or a regex. However, APPSYNC_JS only accepts a
// string that is treated as a regex!
const AUTH0_PROVIDER_PREFIX = '^twitter\\|'

export function authorize(userId: string, identity?: AppSyncIdentityOIDC): string {
  if (identity?.sub && identity.sub !== userId) {
    util.unauthorized()
  }
  return userId.replace(AUTH0_PROVIDER_PREFIX, '')
}

Notice the non-standard behavior of replace(). This threw me off and made it painfully clear that AppSync’s bespoke (closed-source) JS runtime could malfunction in subtle ways. AWS is aware of the problem.

Speaking of problems, I also encountered an issue when bundling the resolvers and transpiling them from TypeScript to JavaScript using esbuild. In short, a polyfill would cause the runtime to throw a ReferenceError. The fix was telling esbuild to target ES2020, thereby removing the converted code (which may or may not break something else, be careful).

For these reasons, I strongly encourage thorough testing to catch compatibility issues before they hit production. This ESLint plugin is a good start.

CDK deployment

At the end of the migration, I had some fun writing a custom CDK construct to automate the deployment of JS resolvers to AWS from either .js or .ts source files. Below is an example from my API stack that demonstrates JsResolver in action:

new JsResolver(this, 'GetUserResolver', {
  dataSource: tableDS,
  typeName: 'Query',
  fieldName: 'getUser',
  source: 'resolvers/getUser.ts',
})

The construct uses esbuild under the hood, as mentioned in the last section, and takes care of boilerplate tasks such as creating dummy pipeline resolvers. Don’t hesitate to reuse any or all of it.

Final thoughts

Given a choice between VTL and TypeScript, I’d pick the productivity of the familiar programming language with the excellent type system and comparable performance characteristics4 any day. While not powered by a “real” JS runtime that follows web standards, I still see AppSync JS resolvers as a noteworthy step forward.

Thanks to Benoît Bouré of GraphBolt for sharing valuable information and feedback on all things AppSync resolvers.

Update (2023-04-08): Improved the TypeScript code to take advantage of @aws-appsync/utils version 1.2.4, which provides a generic Context type and exposes AppSyncIdentityOIDC.

Update (2023-05-02): Tweaked the CDK construct to enable source maps now that AppSync supports them.

Update (2023-07-11): Added links to Listkeeper, which is now open source.


  1. Using AppSync, Lambda, DynamoDB, S3, EventBridge, and Amplify Hosting. 

  2. Mutations are resolved by a monolithic Lambda function written in Go. I’m not overly concerned about slowdowns from cold starts during write operations. Reads should be fast though, hence the switch from Lambda to VTL resolvers earlier on and now from VTL to JS resolvers for all queries. 

  3. I never bothered migrating the Go-based DynamoDB attribute names in order to skip the mapping – or to write a JS/TS helper function. 

  4. I ran a series of micro-benchmarks with hyperfine to compare the performance of VTL and JS resolvers. Both were on par.