First experiences with the new AppSync Javascript resolver runtime

The new way of writing resolvers

Author's image
TamΓ‘s Sallai
9 mins
Photo by La Miko

AppSync Javascript resolver runtime

The new Javascript runtime is an alternative to the original VTL for writing resolvers. It is a new addition to the the platform and since in my views VTL was a mistake in the first place I was eager to try out the new language. In short: no new projects should use VTL.

But a short recap: what are resolvers?

Resolvers are the glue code between the GraphQL world and a second service called the data source. For example, fetching an item from a DynamoDB table requires 3 things:

  • the request function for the GraphQL field runs and returns an object about how to call DynamoDB
  • then AppSync goes to DynamoDB and fetches an item
  • then the response function gets the item and transforms it to GraphQL world

In code, the request function gets the ctx and returns a GetItem operation with some other fields:

export function request(ctx) {
	return {
		version: "2018-05-29",
		operation: "GetItem",
		key: {
			id: {S: ctx.args.id}
		},
		consistentRead: true
	};
}

The response function gets a ctx with a ctx.result and ctx.error that is the response from DynamoDB:

export function response(ctx) {
	if (ctx.error) {
		return util.error(ctx.error.message, ctx.error.type);
	}
	return ctx.result;
}

The above examples use the Javascript runtime. VTL was the only one before, but now you can choose between them.

My first experience with Javascript: it is lightyears ahead of VTL. But keep in mind that the bar was low as VTL is totally unsuited for this kind of job.

The Javascript resolver is still not a direct replacement for VTL as it misses some functions. Hopefully, they will be added in the future and we can forget VTL for good. Some tracking tickets for the missing things:

πŸ‘Ž Pipelines only

(Ticket)

One painful omission is that the JS runtime is only supported in pipeline functions and not in unit resolvers. This forced me to rewrite all single-step resolvers into pipelines with just a single function adding a ton of boilerplate.

Instead of a single resource that let's say fetches an item from DynamoDB:

resource "aws_appsync_resolver" "Query_groupById" {
	// ...
  code = <<EOF
import {util} from "@aws-appsync/utils";
export function request(ctx) {
	return {
		version : "2018-05-29",
		operation : "GetItem",
		key : {
			id : {S: ctx.args.id}
		},
		consistentRead : true
	}
}
export function response(ctx) {
	if (ctx.error) {
		return util.error(ctx.error.message, ctx.error.type);
	}
	return ctx.result;
}
EOF
}

Now it needs two:

resource "aws_appsync_resolver" "Query_groupById" {
	// ...
  code = <<EOF
export function request(ctx) {
	return {};
}
export function response(ctx) {
	return ctx.result;
}
EOF
  kind = "PIPELINE"
  pipeline_config {
    functions = [
      aws_appsync_function.Query_groupById_1.function_id,
    ]
  }
}
resource "aws_appsync_function" "Query_groupById_1" {
	# ...
}

πŸ‘Ž No early return

(Ticket)

VTL supports the #return function which skips the data source entirely. This is useful in only a handful of situations, but these are real-world use-cases for skipping.

The most common one I encountered is during access control. A resolver might run for multiple user types and they usually need different operations to check access. A good example is when an administrator can be of different levels: group or organization. To check whether an item in a given group can be accessed, an organization admin needs to perform an extra step: get the organization for the item's group.

Since there is no direct equivalent for skipping the data source, JS resolvers need some creative thinking to achieve a similar functionality. For example, to skip a DynamoDB GetItem operation, the resolver can try to fetch a non-existent item then discard the result:

import {util} from "@aws-appsync/utils";

// whether this pipeline function should be skipped
const shouldSkip = (ctx) => ctx.ars.userId === null;

export function request(ctx) {
  if (shouldSkip(ctx)) {
    return {
      version: "2018-05-29",
      operation: "GetItem",
      key: {
        id: {S: util.autoId()}
      }
    }
  }else {
    // ... handle normal call
  }
}

export function response(ctx) {
  if (shouldSkip(ctx)) {
    // if skipped, return immediately
    return null;
  }
  // ... handle normal call
}

Here, the shouldSkip function implements the decision to skip or not, and the util.autoId() generates an ID that does not exist.

Another example: how to skip deleting a user from Cognito? Issue the AdminGetUser operation instead of the AdminDeleteUser one:

export function request(ctx) {
  return {
    // ...
    params: {
      // ...
      headers: {
        // ...
        "X-Amz-Target": shouldSkip(ctx) ?
          "AWSCognitoIdentityProviderService.AdminGetUser" :
          "AWSCognitoIdentityProviderService.AdminDeleteUser"
      },
    },
  }
}

Note that these are not direct replacements to VTL's #return. The DynamoDB GetItem still sends a request to the table and that incurs some costs. And the AdminGetUser sends a request to Cognito and also requires the permission to get a user.

πŸ‘ Deploy-time checks

AppSync runs a TypeScript checker during upload and that can catch a lot of typos. This is a welcome change as previously VTL ingested everything and errors only surfaced at runtime.

For example, there is a typo here:

export function request(ctx) {
	return {
		version : "2018-05-29",
		operation : "GetItem",
		key : {
			// notice the typo here
			id : {S: cx.args.id}
		},
		consistentRead : true
	}
}

And AppSync catches it correctly:

 Error: updating AppSync Function (ors7xqwhsfbndl2j2c2e4acmk4-rp6sj6p6erbfhgucrulvb4m6iu): BadRequestException: The code contains one or more errors.
{
 RespMetadata: {
	 StatusCode: 400,
	 RequestID: "fa32d255-0195-460d-86aa-cf264e17f6b0"
 },
 Detail: {
	 CodeErrors: [{
			 ErrorType: "PARSE_ERROR",
			 Location: {
				 Column: 13,
				 Line: 7,
				 Span: 2
			 },
			 Value: "code.js(7,13): error TS2304: Cannot find name 'cx'.\n"
		 }]
 },
 Message_: "The code contains one or more errors.",
 Reason: "CODE_ERROR"
}

 with aws_appsync_function.Query_groupById_1,
 on resolvers.tf line 27, in resource "aws_appsync_function" "Query_groupById_1":
 27: resource "aws_appsync_function" "Query_groupById_1" {

It does not catch everything, but it helped me detect problems early in countless cases.

πŸ‘ Easy logging

Logging is straightforward in the new runtime: console.log writes directly to the CloudWatch Log Group for the function, and is also prepended with the request ID.

While VTL has something similar, I find console.log more natural.

export function request(ctx) {
  console.log("About to search for " + ctx.args.text);
  // ...
}

export function response(ctx) {
  // ...
  console.log(JSON.stringify(ctx.result.items, undefined, 4));
  // ...
}
Console.log in the logs

πŸ‘ Modern list procesing

VTL only supports the #foreach construct to process lists. This opened the way for filtering and transforming, but with a horrible syntax:

#set($results = [])
#foreach($res in $ctx.result.items)
	#if($res.level == "PUBLIC" || $ctx.stash.user.permissions.contains("secret_documents"))
		$util.qr($results.add($res))
	#end
#end
$util.toJson($results)

The JS runtime supports a lot of Array functions, such as .filter and .map. The previous example with the new runtime:

return ctx.result.items
	.filter((res) =>
		res.level === "PUBLIC" ||
		ctx.stash.user.permissions.includes("secret_documents")
	);

Similarly, it's easy to construct lists:

export function request(ctx) {
  return {
    version : "2018-05-29",
    operation : "TransactGetItems",
    transactItems: ctx.source.friends.map((friend) => {
      return {
        table: "${aws_dynamodb_table.users.name}",
        key : {
          username: {S: friend}
        }
      }
    })
  }
}

πŸ‘ Reusable functions

With Javascript, it's easier to reuse functions between resolvers. For examle, I needed a rather long resolver to call a GraphQL mutation in several places. With extracting the common part into a Javascript function to a different file, I only needed to include that file and then call the function:

resource "aws_appsync_function" "Mutation_addTodo_4" {
	# ...
  code = <<EOF
import {util} from "@aws-appsync/utils";
${file("${path.module}/notifyTodo.js")}

export function request(ctx) {
	return notifyTodo({
		userId: ctx.prev.result.userid,
		groupId: ctx.stash.user.groupid,
		severity: ctx.prev.result.severity,
		id: ctx.prev.result.id
	});
}
export function response(ctx) {
	// ...
}
EOF
}

The notifyTodo.js defines a function:

const notifyTodo = ({userId, groupId, severity, id}) => {
	return {
		version: "2018-05-29",
		method: "POST",
		params: {
			query: {},
			headers: {
				"Content-Type" : "application/json"
			},
			body: JSON.stringify({
				// ...
				}`,
				operationName: "notifyTodo",
				variables: {
					userId,
					groupId,
					severity,
					id,
				}
			})
		},
		resourcePath: "/graphql"
	};
};

Reusing parts like this makes it easier to reason about what each part does and feels more natural to have utility functions available that the resolver can call.

πŸ‘ Filter object structure

AppSync has a $util.transform.toSubscriptionFilter function that makes it possible to assemble a filter object for enhanced subscriptions. This was a necessary addition as VTL is not suitable to implement conditionality for object keys.

But the toSubscriptionFilter's syntax is weird to say the least and it led to monstrosities like this one:

#set($nonDefinedFilters = [])
#if($util.isNull($ctx.args.userId))
	$util.qr($nonDefinedFilters.add("userId"))
#end
#if($util.isNull($ctx.args.groupId))
	$util.qr($nonDefinedFilters.add("groupId"))
#end
#if($util.isNull($ctx.args.minSeverity))
	$util.qr($nonDefinedFilters.add("severity"))
#end

$extensions.setSubscriptionFilter($util.transform.toSubscriptionFilter({
	"userId": {"eq": $ctx.args.userId},
	"groupId": {"eq": $ctx.args.groupId},
	"severity": {"in": $severityLevels}
},
$nonDefinedFilters
))

It is not intuitive what is the result here.

But with a Javascript resolver, the same structure is more readable:

const filterJson = {
  filterGroup: [{
    filters: [
      ...(ctx.args.minSeverity !== null ? [{
        fieldName: "severity",
        operator: "in",
        value: [
          "HIGH",
          ...(ctx.args.minSeverity === "MEDIUM" ||
            ctx.args.minSeverity === "LOW" ? ["MEDIUM"] : []),
          ...(ctx.args.minSeverity === "LOW" ? ["LOW"] : []),
        ],
      }] : []),
      ...(ctx.args.name !== null ? [{
        fieldName: "name",
        operator: "eq",
        value: ctx.args.name,
      }]: [])
    ]
  }]
};

The ternary combined with the object spread operator (...(<condition> ? {<result>} : {})) makes it easy to implement conditionality.

πŸ‘ No need to escape results

A huge problem with VTL is that everything that goes into the output needs to be escaped properly. This relies heavily on the $util.toJson utility function:

$util.toJson($ctx.result)

With JS resolvers just return the value and it will be escaped properly:

return ctx.result;

πŸ‘ Variables work just normal

On a similar topic, VTL has a hard time distinguishing between things that are meant for the output and things that are not. This makes it necessary to silence the output in some cases:

#set($nonDefinedFilters = [])
$util.qr($nonDefinedFilters.add("userId"))
$util.qr($ctx.stash.put("test", "data"))

With JS, variables works just as they should:

const nonDefinedFilters = [];
nonDefinedFilters.push("userId");
ctx.stash.test = "data";

πŸ‘Ž No environment variables

(Ticket)

When a JS code needs some info from the environment, it is usually communicated via environment variables. But unfortunately, JS resolvers don't support this.

For example, the TransactWriteItems needs the name of the DynamoDB table, and the best we can do is string concatenation:

resource "aws_appsync_function" "Mutation_pairUsers_1" {
	# ...
  code = <<EOF
import {util} from "@aws-appsync/utils";
export function request(ctx) {
	return {
		version: "2018-05-29",
		operation: "TransactWriteItems",
		transactItems: [
			{
				// needs the table name here
				table: "${aws_dynamodb_table.user.name}",
				operation: "UpdateItem",
				key: {
					id : {S: ctx.args.user1}
				},
				// ...
			},
			{
				// ...
			}
		]
	}
}
// ...
EOF
}

Conclusion

While the Javascript runtime has some shortcomings and there are a few annoying missing features, it can replace VTL in almost all use-cases. I hope that eventually those gaps will be closed and VTL deprecated for good, but even in the meantime it should be the primary way for writing resolvers.

March 7, 2023