Tutorial: Lambda resolvers - AWS AppSync

Tutorial: Lambda resolvers

You can use AWS Lambda with AWS AppSync to resolve any GraphQL field. For example, a GraphQL query might send a call to an Amazon Relational Database Service (Amazon RDS) instance, and a GraphQL mutation might write to an Amazon Kinesis stream. In this section, we'll show you how to write a Lambda function that performs business logic based on the invocation of a GraphQL field operation.

Create a Lambda function

The following example shows a Lambda function written in Node.js that performs different operations on blog posts as part of a blog post application.

exports.handler = (event, context, callback) => { console.log("Received event {}", JSON.stringify(event, 3)); var posts = { "1": {"id": "1", "title": "First book", "author": "Author1", "url": "https://amazon.com/", "content": "SAMPLE TEXT AUTHOR 1 SAMPLE TEXT AUTHOR 1 SAMPLE TEXT AUTHOR 1 SAMPLE TEXT AUTHOR 1 SAMPLE TEXT AUTHOR 1 SAMPLE TEXT AUTHOR 1", "ups": "100", "downs": "10"}, "2": {"id": "2", "title": "Second book", "author": "Author2", "url": "https://amazon.com", "content": "SAMPLE TEXT AUTHOR 2 SAMPLE TEXT AUTHOR 2 SAMPLE TEXT", "ups": "100", "downs": "10"}, "3": {"id": "3", "title": "Third book", "author": "Author3", "url": null, "content": null, "ups": null, "downs": null }, "4": {"id": "4", "title": "Fourth book", "author": "Author4", "url": "https://www.amazon.com/", "content": "SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4", "ups": "1000", "downs": "0"}, "5": {"id": "5", "title": "Fifth book", "author": "Author5", "url": "https://www.amazon.com/", "content": "SAMPLE TEXT AUTHOR 5 SAMPLE TEXT AUTHOR 5 SAMPLE TEXT AUTHOR 5 SAMPLE TEXT AUTHOR 5 SAMPLE TEXT", "ups": "50", "downs": "0"} }; var relatedPosts = { "1": [posts['4']], "2": [posts['3'], posts['5']], "3": [posts['2'], posts['1']], "4": [posts['2'], posts['1']], "5": [] }; console.log("Got an Invoke Request."); switch(event.field) { case "getPost": var id = event.arguments.id; callback(null, posts[id]); break; case "allPosts": var values = []; for(var d in posts){ values.push(posts[d]); } callback(null, values); break; case "addPost": // return the arguments back callback(null, event.arguments); break; case "addPostErrorWithData": var id = event.arguments.id; var result = posts[id]; // attached additional error information to the post result.errorMessage = 'Error with the mutation, data has changed'; result.errorType = 'MUTATION_ERROR'; callback(null, result); break; case "relatedPosts": var id = event.source.id; callback(null, relatedPosts[id]); break; default: callback("Unknown field, unable to resolve" + event.field, null); break; } };

This Lambda function retrieves a post by ID, adds a post, retrieves a list of posts, and fetches related posts for a given post.

Note: The Lambda function uses the switch statement on event.field to determine which field is currently being resolved.

Create this Lambda function using the AWS Management Console or an AWS CloudFormation stack. To create the function from a CloudFormation stack, you can use the following AWS Command Line Interface (AWS CLI) command:

aws cloudformation create-stack --stack-name AppSyncLambdaExample \ --template-url https://s3.us-west-2.amazonaws.com/awsappsync/resources/lambda/LambdaCFTemplate.yaml \ --capabilities CAPABILITY_NAMED_IAM

You can also launch the AWS CloudFormation stack in the US West (Oregon) AWS Region in your AWS account from here:

Configure a data source for Lambda

After you create the Lambda function, navigate to your GraphQL API in the AWS AppSync console, and then choose the Data Sources tab.

Choose Create data source, enter a friendly Data source name (for example, Lambda), and then for Data source type, choose AWS Lambda function. For Region, choose the same Region as your function. (If you created the function from the provided CloudFormation stack, the function is probably in US-WEST-2.) For Function ARN, choose the Amazon Resource Name (ARN) of your Lambda function.

After choosing your Lambda function, you can either create a new AWS Identity and Access Management (IAM) role (for which AWS AppSync assigns the appropriate permissions) or choose an existing role that has the following inline policy:

{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "lambda:InvokeFunction" ], "Resource": "arn:aws:lambda:REGION:ACCOUNTNUMBER:function/LAMBDA_FUNCTION" } ] }

You must also set up a trust relationship with AWS AppSync for the IAM role as follows:

{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "appsync.amazonaws.com" }, "Action": "sts:AssumeRole" } ] }

Create a GraphQL schema

Now that the data source is connected to your Lambda function, create a GraphQL schema.

From the schema editor in the AWS AppSync console, make sure that your schema matches the following schema:

schema { query: Query mutation: Mutation } type Query { getPost(id:ID!): Post allPosts: [Post] } type Mutation { addPost(id: ID!, author: String!, title: String, content: String, url: String): Post! } type Post { id: ID! author: String! title: String content: String url: String ups: Int downs: Int relatedPosts: [Post] }

Configure resolvers

Now that you've registered a Lambda data source and a valid GraphQL schema, you can connect your GraphQL fields to your Lambda data source using resolvers.

To create a resolver, you'll need mapping templates. To learn more about mapping templates, see Resolver Mapping Template Overview.

For more information about Lambda mapping templates, see Resolver mapping template reference for Lambda.

In this step, you attach a resolver to the Lambda function for the following fields: getPost(id:ID!): Post, allPosts: [Post], addPost(id: ID!, author: String!, title: String, content: String, url: String): Post!, and Post.relatedPosts: [Post].

From the schema editor in the AWS AppSync console, on the right side, choose Attach Resolver for getPost(id:ID!): Post.

Choose your Lambda data source. In the request mapping template section, choose Invoke And Forward Arguments.

Modify the payload object to add the field name. Your template should look like the following:

{ "version": "2017-02-28", "operation": "Invoke", "payload": { "field": "getPost", "arguments": $utils.toJson($context.arguments) } }

In the response mapping template section, choose Return Lambda Result.

In this case, use the base template as-is. It should look like the following:

$utils.toJson($context.result)

Choose Save. You have successfully attached your first resolver. Repeat this operation for the remaining fields as follows:

For addPost(id: ID!, author: String!, title: String, content: String, url: String): Post! request mapping template:

{ "version": "2017-02-28", "operation": "Invoke", "payload": { "field": "addPost", "arguments": $utils.toJson($context.arguments) } }

For addPost(id: ID!, author: String!, title: String, content: String, url: String): Post! response mapping template:

$utils.toJson($context.result)

For allPosts: [Post] request mapping template:

{ "version": "2017-02-28", "operation": "Invoke", "payload": { "field": "allPosts" } }

For allPosts: [Post] response mapping template:

$utils.toJson($context.result)

For Post.relatedPosts: [Post] request mapping template:

{ "version": "2017-02-28", "operation": "Invoke", "payload": { "field": "relatedPosts", "source": $utils.toJson($context.source) } }

For Post.relatedPosts: [Post] response mapping template:

$utils.toJson($context.result)

Test your GraphQL API

Now that your Lambda function is connected to GraphQL resolvers, you can run some mutations and queries using the console or a client application.

On the left side of the AWS AppSync console, choose Queries, and then paste in the following code:

addPost Mutation

mutation addPost { addPost( id: 6 author: "Author6" title: "Sixth book" url: "https://www.amazon.com/" content: "This is the book is a tutorial for using GraphQL with AWS AppSync." ) { id author title content url ups downs } }

getPost Query

query { getPost(id: "2") { id author title content url ups downs } }

allPosts Query

query { allPosts { id author title content url ups downs relatedPosts { id title } } }

Returning errors

Any given field resolution can result in an error. With AWS AppSync, you can raise errors from the following sources:

  • Request or response mapping template

  • Lambda function

From the mapping template

To raise intentional errors, you can use the $utils.error helper method from the Velocity Template Language (VTL) template. It takes as argument an errorMessage, an errorType, and an optional data value. The data is useful for returning extra data back to the client when an error occurs. The data object is added to the errors in the GraphQL final response.

The following example shows how to use it in the Post.relatedPosts: [Post] response mapping template:

$utils.error("Failed to fetch relatedPosts", "LambdaFailure", $context.result)

This yields a GraphQL response similar to the following:

{ "data": { "allPosts": [ { "id": "2", "title": "Second book", "relatedPosts": null }, ... ] }, "errors": [ { "path": [ "allPosts", 0, "relatedPosts" ], "errorType": "LambdaFailure", "locations": [ { "line": 5, "column": 5 } ], "message": "Failed to fetch relatedPosts", "data": [ { "id": "2", "title": "Second book" }, { "id": "1", "title": "First book" } ] } ] }

Where allPosts[0].relatedPosts is null because of the error and the errorMessage, errorType, and data are present in the data.errors[0] object.

From the Lambda function

AWS AppSync also understands errors that the Lambda function throws. The Lambda programming model lets you raise handled errors. If the Lambda function throws an error, AWS AppSync fails to resolve the current field. Only the error message returned from Lambda is set in the response. Currently, you can't pass any extraneous data back to the client by raising an error from the Lambda function.

Note: If your Lambda function raises an unhandled error, AWS AppSync uses the error message that Lambda set.

The following Lambda function raises an error:

exports.handler = (event, context, callback) => { console.log("Received event {}", JSON.stringify(event, 3)); callback("I fail. Always."); };

This returns a GraphQL response similar to the following:

{ "data": { "allPosts": [ { "id": "2", "title": "Second book", "relatedPosts": null }, ... ] }, "errors": [ { "path": [ "allPosts", 0, "relatedPosts" ], "errorType": "Lambda:Handled", "locations": [ { "line": 5, "column": 5 } ], "message": "I fail. Always." } ] }

Advanced use case: Batching

The Lambda function in this example has a relatedPosts field that returns a list of related posts for a given post. In the example queries, the allPosts field invocation from the Lambda function returns five posts. Because we specified that we also want to resolve relatedPosts for each returned post, the relatedPosts field operation is invoked five times.

query { allPosts { // 1 Lambda invocation - yields 5 Posts id author title content url ups downs relatedPosts { // 5 Lambda invocations - each yields 5 posts id title } } }

While this might not sound substantial in this specific example, this compounded over-fetching can quickly undermine the application.

If you were to fetch relatedPosts again on the returned related Posts in the same query, the number of invocations would increase dramatically.

query { allPosts { // 1 Lambda invocation - yields 5 Posts id author title content url ups downs relatedPosts { // 5 Lambda invocations - each yield 5 posts = 5 x 5 Posts id title relatedPosts { // 5 x 5 Lambda invocations - each yield 5 posts = 25 x 5 Posts id title author } } } }

In this relatively simple query, AWS AppSync would invoke the Lambda function 1 + 5 + 25 = 31 times.

This is a fairly common challenge and is often called the N+1 problem (in this case, N = 5), and it can incur increased latency and cost to the application.

One approach to solving this issue is to batch similar field resolver requests together. In this example, instead of having the Lambda function resolve a list of related posts for a single given post, it could instead resolve a list of related posts for a given batch of posts.

To demonstrate this, let's switch the Post.relatedPosts: [Post] resolver to a batch-enabled resolver.

On the right side of the AWS AppSync console, choose the existing Post.relatedPosts: [Post] resolver. Change the request mapping template to the following:

{ "version": "2017-02-28", "operation": "BatchInvoke", "payload": { "field": "relatedPosts", "source": $utils.toJson($context.source) } }

Only the operation field has changed from Invoke to BatchInvoke. The payload field now becomes an array of whatever is specified in the template. In this example, the Lambda function receives the following as input:

[ { "field": "relatedPosts", "source": { "id": 1 } }, { "field": "relatedPosts", "source": { "id": 2 } }, ... ]

When BatchInvoke is specified in the request mapping template, the Lambda function receives a list of requests and returns a list of results.

Specifically, the list of results must match the size and order of the request payload entries so that AWS AppSync can match the results accordingly.

In this batching example, the Lambda function returns a batch of results as follows:

[ [{"id":"2","title":"Second book"}, {"id":"3","title":"Third book"}], // relatedPosts for id=1 [{"id":"3","title":"Third book"}] // relatedPosts for id=2 ]

The following Lambda function in Node.js demonstrates this batching functionality for the Post.relatedPosts field as follows:

exports.handler = (event, context, callback) => { console.log("Received event {}", JSON.stringify(event, 3)); var posts = { "1": {"id": "1", "title": "First book", "author": "Author1", "url": "https://amazon.com/", "content": "SAMPLE TEXT AUTHOR 1 SAMPLE TEXT AUTHOR 1 SAMPLE TEXT AUTHOR 1 SAMPLE TEXT AUTHOR 1 SAMPLE TEXT AUTHOR 1 SAMPLE TEXT AUTHOR 1", "ups": "100", "downs": "10"}, "2": {"id": "2", "title": "Second book", "author": "Author2", "url": "https://amazon.com", "content": "SAMPLE TEXT AUTHOR 2 SAMPLE TEXT AUTHOR 2 SAMPLE TEXT", "ups": "100", "downs": "10"}, "3": {"id": "3", "title": "Third book", "author": "Author3", "url": null, "content": null, "ups": null, "downs": null }, "4": {"id": "4", "title": "Fourth book", "author": "Author4", "url": "https://www.amazon.com/", "content": "SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4", "ups": "1000", "downs": "0"}, "5": {"id": "5", "title": "Fifth book", "author": "Author5", "url": "https://www.amazon.com/", "content": "SAMPLE TEXT AUTHOR 5 SAMPLE TEXT AUTHOR 5 SAMPLE TEXT AUTHOR 5 SAMPLE TEXT AUTHOR 5 SAMPLE TEXT", "ups": "50", "downs": "0"} }; var relatedPosts = { "1": [posts['4']], "2": [posts['3'], posts['5']], "3": [posts['2'], posts['1']], "4": [posts['2'], posts['1']], "5": [] }; console.log("Got a BatchInvoke Request. The payload has %d items to resolve.", event.length); // event is now an array var field = event[0].field; switch(field) { case "relatedPosts": var results = []; // the response MUST contain the same number // of entries as the payload array for (var i=0; i< event.length; i++) { console.log("post {}", JSON.stringify(event[i].source)); results.push(relatedPosts[event[i].source.id]); } console.log("results {}", JSON.stringify(results)); callback(null, results); break; default: callback("Unknown field, unable to resolve" + field, null); break; } };

Returning individual errors

The previous examples show that it's possible to return a single error from the Lambda function or raise an error from the mapping templates. For batched invocations, raising an error from the Lambda function flags an entire batch as failed. This might be acceptable for specific scenarios where an irrecoverable error occurs, such as a failed connection to a data store. However, in cases where some items in the batch succeed and others fail, it's possible to return both errors and valid data. Because AWS AppSync requires the batch response to list elements matching the original size of the batch, you must define a data structure that can differentiate valid data from an error.

For example, if the Lambda function is expected to return a batch of related posts, you could choose to return a list of Response objects where each object has optional data, errorMessage, and errorType fields. If the errorMessage field is present, it means that an error occurred.

The following code shows how you could update the Lambda function:

exports.handler = (event, context, callback) => { console.log("Received event {}", JSON.stringify(event, 3)); var posts = { "1": {"id": "1", "title": "First book", "author": "Author1", "url": "https://amazon.com/", "content": "SAMPLE TEXT AUTHOR 1 SAMPLE TEXT AUTHOR 1 SAMPLE TEXT AUTHOR 1 SAMPLE TEXT AUTHOR 1 SAMPLE TEXT AUTHOR 1 SAMPLE TEXT AUTHOR 1", "ups": "100", "downs": "10"}, "2": {"id": "2", "title": "Second book", "author": "Author2", "url": "https://amazon.com", "content": "SAMPLE TEXT AUTHOR 2 SAMPLE TEXT AUTHOR 2 SAMPLE TEXT", "ups": "100", "downs": "10"}, "3": {"id": "3", "title": "Third book", "author": "Author3", "url": null, "content": null, "ups": null, "downs": null }, "4": {"id": "4", "title": "Fourth book", "author": "Author4", "url": "https://www.amazon.com/", "content": "SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4", "ups": "1000", "downs": "0"}, "5": {"id": "5", "title": "Fifth book", "author": "Author5", "url": "https://www.amazon.com/", "content": "SAMPLE TEXT AUTHOR 5 SAMPLE TEXT AUTHOR 5 SAMPLE TEXT AUTHOR 5 SAMPLE TEXT AUTHOR 5 SAMPLE TEXT", "ups": "50", "downs": "0"} }; var relatedPosts = { "1": [posts['4']], "2": [posts['3'], posts['5']], "3": [posts['2'], posts['1']], "4": [posts['2'], posts['1']], "5": [] }; console.log("Got a BatchInvoke Request. The payload has %d items to resolve.", event.length); // event is now an array var field = event[0].field; switch(field) { case "relatedPosts": var results = []; results.push({ 'data': relatedPosts['1'] }); results.push({ 'data': relatedPosts['2'] }); results.push({ 'data': null, 'errorMessage': 'Error Happened', 'errorType': 'ERROR' }); results.push(null); results.push({ 'data': relatedPosts['3'], 'errorMessage': 'Error Happened with last result', 'errorType': 'ERROR' }); callback(null, results); break; default: callback("Unknown field, unable to resolve" + field, null); break; } };

For this example, the following response mapping template parses each item of the Lambda function and raises any errors that occur:

#if( $context.result && $context.result.errorMessage ) $utils.error($context.result.errorMessage, $context.result.errorType, $context.result.data) #else $utils.toJson($context.result.data) #end

This example returns a GraphQL response similar to the following:

{ "data": { "allPosts": [ { "id": "1", "relatedPostsPartialErrors": [ { "id": "4", "title": "Fourth book" } ] }, { "id": "2", "relatedPostsPartialErrors": [ { "id": "3", "title": "Third book" }, { "id": "5", "title": "Fifth book" } ] }, { "id": "3", "relatedPostsPartialErrors": null }, { "id": "4", "relatedPostsPartialErrors": null }, { "id": "5", "relatedPostsPartialErrors": null } ] }, "errors": [ { "path": [ "allPosts", 2, "relatedPostsPartialErrors" ], "errorType": "ERROR", "locations": [ { "line": 4, "column": 9 } ], "message": "Error Happened" }, { "path": [ "allPosts", 4, "relatedPostsPartialErrors" ], "data": [ { "id": "2", "title": "Second book" }, { "id": "1", "title": "First book" } ], "errorType": "ERROR", "locations": [ { "line": 4, "column": 9 } ], "message": "Error Happened with last result" } ] }

Configuring the maximum batching size

By default, when using BatchInvoke, AWS AppSync sends requests to your Lambda function in batches of up to five items. You can configure the maximum batch size of your Lambda resolvers.

To configure the maximum batching size on a resolver, use the following command in the AWS Command Line Interface (AWS CLI):

$ aws appsync create-resolver --api-id <api-id> --type-name Query --field-name relatedPosts \ --request-mapping-template "<template>" --response-mapping-template "<template>" --data-source-name "<lambda-datasource>" \ --max-batch-size X
Note

When providing a request mapping template, you must use the BatchInvoke operation to use batching.

You can also use the following command to enable and configure batching on Direct Lambda Resolvers:

$ aws appsync create-resolver --api-id <api-id> --type-name Query --field-name relatedPosts \ --data-source-name "<lambda-datasource>" \ --max-batch-size X

Maximum batching size configuration with VTL templates

For Lambda Resolvers that have VTL in-request templates, the maximum batch size will have no effect unless they have directly specified it as a BatchInvoke operation in VTL. Similarly, if you are performing a top-level mutation, batching is not conducted for mutations because the GraphQL specification requires parallel mutations to be executed sequentially.

For example, take the following mutations:

type Mutation { putItem(input: Item): Item putItems(inputs: [Item]): [Item] }

Using the first mutation, we can create 10 Items as shown in the snippet below:

mutation MyMutation { v1: putItem($someItem1) { id, name } v2: putItem($someItem2) { id, name } v3: putItem($someItem3) { id, name } v4: putItem($someItem4) { id, name } v5: putItem($someItem5) { id, name } v6: putItem($someItem6) { id, name } v7: putItem($someItem7) { id, name } v8: putItem($someItem8) { id, name } v9: putItem($someItem9) { id, name } v10: putItem($someItem10) { id, name } }

In this example, the Items will not be batched in a group of 10 even if the maximum batch size is set to 10 in the Lambda Resolver. Instead, they will execute sequentially according to the GraphQL specification.

To perform an actual batch mutation, you may follow the example below using the second mutation:

mutation MyMutation { putItems([$someItem1, $someItem2, $someItem3,$someItem4, $someItem5, $someItem6, $someItem7, $someItem8, $someItem9, $someItem10]) { id, name } }

For more information about using batching with Direct Lambda Resolvers, see Direct Lambda Resolvers.