AWS AppSync is a managed GraphQL service that makes it easy to connect disparate data sources into a single cohesive API. GraphQL APIs start with the definition of a schema that defines the data types and queries for accessing them. Data Sources are the backend services that the API will use to fulfill requests. Finally, resolvers connect the fields, queries, and mutations in a type’s schema to a given data source. AppSync resolvers use Apache Velocity Template Language (VTL) to translate the request and response to most data sources. Developers can also use AWS Lambda data sources to bypass VTL and directly invoke functions via a feature known as Direct Lambda Resolvers.
Developers use Lambda functions with Amazon API Gateway to build robust RESTful APIs. API Gateway enables builders to create, publish, maintain, monitor, and secure REST and HTTP APIs at any scale. Developers are also interested in building GraphQL APIs as an access method to their data. GraphQL APIs provide a single endpoint for clients to access data, a standard query language for them to request only the data they need, as well as the ability to subscribe to changes and have new data pushed to them.
In this post, we show you how to use resources from an existing REST API built with AWS Serverless Application Model SAM, AWS API Gateaway, and AWS Lambda to work as AWS AppSync Direct Lambda Resolvers. We will start with a web application template from AWS SAM, and show how the application codebase can quickly evolve to support GraphQL in tandem, using AWS AppSync and Direct Lambda Resolvers.
Prerequisites:
Initialize AWS Quick Start Web Application via the SAM CLI
To demonstrate the techniques, we’ll start with the AWS maintained Quick Start Web example template (GitHub repo).
sam init --name appsync-and-apigw --runtime nodejs14.x --dependency-manager npm --app-template quick-start-web
This template has a few artifacts that are typical in SAM projects. The artifacts we will modify to make this change are:
template.yaml
– AWS SAM resource template that defines the deployable components of the project.
src/handlers/
– This folder contains handler code for the AWS Lambda functions.
We will make the following changes to implement a new AppSync API:
- Create a GraphQL schema for our API.
- Update existing Lambda handler code.
- Add GraphQL resources to template.yaml.
Create GraphQL Schema
The RESTful API created by this template implements three different operations on a generic Item
table:
GET /
returns an array of all Items
GET /{id}
returns all information for a single Item
POST /
adds a new Item
We can implement the analogous GraphQL schema in just a few lines. Create a new file src/graphql.schema
with the following content:
type Item {
id: ID!
name: String
}
input ItemInput {
id: ID!
name: String!
}
type Query {
items: [Item]
getById(id:ID!): Item
}
type Mutation {
putItem(input:ItemInput!): Item
}
Update Lambda handler function
Open the file src/handlers/get-by-id.js
We will refactor the Amazon DynamoDB access logic from function exports.getByIdHandler
into an independent function to facilitate reuse. For clean separation, we will update the source module to contain a second distinct handler function for the GraphQL resolver.
Following the declaration of const docClient=...
, add the following function declaration:
async function getById(id) {
let params = {
TableName : tableName,
Key: { id: id },
};
const data = await docClient.get(params).promise();
return data.Item;
}
Update the existing function exports.getByIdHandler
to call this new method:
const id = event.pathParameters.id;
const item = await getById(id);
const response = {
statusCode: 200,
body: JSON.stringify(item)
};
Create a newly exported function to use as the GraphQL resolver:
//this handler function is used as an AppSync Direct Lambda resolver
exports.getByIdAppsyncResolver = async (context) => {
console.info('appsync resquest', context)
if (! (context.arguments && context.arguments.id)) {
throw new Error('missing "id" in arguments');
}
const id = context.arguments.id
const response = await getById(id);
console.info(`appsync handler response ${JSON.stringify(response)}`)
return response;
}
Note that it is not necessary to filter by selected fields in the lambda handler. AWS AppSync will handle this functionality for you. However, if you must optimize fetching, then the requested fields are available as info.selectionSetList
in the Context
object (see the AppSync Developers Guide for full details on the context).
The full code is listed as follows:
// Create clients and set shared const values outside of the handler.
// Get the DynamoDB table name from environment variables
const tableName = process.env.SAMPLE_TABLE;
// Create a DocumentClient that represents the query to add an item
const dynamodb = require('aws-sdk/clients/dynamodb');
const docClient = new dynamodb.DocumentClient();
async function getById(id) {
let params = {
TableName : tableName,
Key: { id: id },
};
const data = await docClient.get(params).promise();
return data.Item;
}
/**
* A simple example includes a HTTP get method to get one item by id from a DynamoDB table.
*/
exports.getByIdHandler = async (event) => {
if (event.httpMethod !== 'GET') {
throw new Error(`getMethod only accept GET method, you tried: ${event.httpMethod}`);
}
console.info('received:', event);
const id = event.pathParameters.id;
const item = await getById(id);
const response = {
statusCode: 200,
body: JSON.stringify(item)
};
console.info(`response from: ${event.path} statusCode: ${response.statusCode} body: ${response.body}`);
return response;
}
//this handler function is used as an AppSync Direct Lambda resolver
exports.getByIdAppsyncResolver = async (context) => {
console.info('appsync resquest', context)
if (! (context.arguments && context.arguments.id)) {
throw new Error('missing "id" in arguments');
}
const id = context.arguments.id
const response = await getById(id);
console.info(`appsync handler response ${JSON.stringify(response)}`)
return response;
}
Similar changes can be made to src/handlers/get-all-items.js
to implement the GraphQL query items():
// Create clients and set shared const values outside of the handler.
// Get the DynamoDB table name from environment variables
const tableName = process.env.SAMPLE_TABLE;
// Create a DocumentClient that represents the query to add an item
const dynamodb = require('aws-sdk/clients/dynamodb');
const docClient = new dynamodb.DocumentClient();
async function getAllItems() {
var params = {
TableName : tableName
};
const data = await docClient.scan(params).promise();
return data.Items;
}
/**
* A simple example includes a HTTP get method to get all items from a DynamoDB table.
*/
exports.getAllItemsHandler = async (event) => {
if (event.httpMethod !== 'GET') {
throw new Error(`getAllItems only accept GET method, you tried: ${event.httpMethod}`);
}
console.info('received:', event);
const items = await getAllItems();
const response = {
statusCode: 200,
body: JSON.stringify(items)
};
console.info(`response from: ${event.path} statusCode: ${response.statusCode} body: ${response.body}`);
return response;
}
exports.getAllItemsAppSyncHandler = async (context) => {
const response = await getAllItems();
return response;
}
Add AppSync API Resources to AWS SAM Template
AWS AppSync APIs can be fully managed using AWS CloudFormation, which is the foundation of SAM templating found in the template.yaml. To create the new API, we’ll add the following resources:
- AWS AppSync API using the GraphQLSchema file
src/graphql.schema
created above.
- AWS AppSync API Key for authorization (used for the following testing).
- AWS Lambda functions. Note that we’ve made a design choice to build a separate Lambda function to act as our direct Lambda resolver. This allows for cleaner code with minimal branching and more fine-grained authorization and monitoring control.
- AWS AppSync DataSources point to the new Lambda functions.
- AWS AppSync Resolvers link the items() and
getById
()Queries defined in our schema to the DataSource.
- Last but not least, an AWS IAM Role and Policy to enable AppSync to invoke the Lambda function.
Open template.yaml
in the project root and replace everything after line 109 with the following:
GetItemByIdAppSyncResolverFunction:
Type: AWS::Serverless::Function
Properties:
Handler: src/handlers/get-by-id.getByIdAppsyncResolver
Runtime: nodejs14.x
MemorySize: 128
Timeout: 100
Description: A simple example includes a HTTP post method to add one item to a DynamoDB table.
Policies:
# Give Create/Read/Update/Delete Permissions to the SampleTable
- DynamoDBCrudPolicy:
TableName: !Ref SampleTable
Environment:
Variables:
# Make table name accessible as environment variable from function code during execution
SAMPLE_TABLE: !Ref SampleTable
GetItemByIdAppSyncDataSource:
Type: AWS::AppSync::DataSource
Properties:
ApiId: !GetAtt AppSyncApi.ApiId
Description: Get Item By Id Direct Lambda
Name: Get_Item_By_Id
Type: AWS_LAMBDA
LambdaConfig:
LambdaFunctionArn: !GetAtt GetItemByIdAppSyncResolverFunction.Arn
ServiceRoleArn: !GetAtt AppSyncApiServiceRole.Arn
GetItemByIdResolver:
Type: AWS::AppSync::Resolver
Properties:
ApiId: !GetAtt AppSyncApi.ApiId
DataSourceName: !GetAtt GetItemByIdAppSyncDataSource.Name
FieldName: getById
TypeName: Query
Kind: UNIT
GetAllAppSyncResolverFunction:
Type: AWS::Serverless::Function
Properties:
Handler: src/handlers/get-all-items.getAllItemsAppSyncHandler
Runtime: nodejs14.x
MemorySize: 128
Timeout: 100
Description: Get All Items
Policies:
# Give Create/Read/Update/Delete Permissions to the SampleTable
- DynamoDBCrudPolicy:
TableName: !Ref SampleTable
Environment:
Variables:
# Make table name accessible as environment variable from function code during execution
SAMPLE_TABLE: !Ref SampleTable
GetAllAppSyncDataSource:
Type: AWS::AppSync::DataSource
Properties:
ApiId: !GetAtt AppSyncApi.ApiId
Description: Get All Items Item Direct Lambda
Name: Get_All_Items
Type: AWS_LAMBDA
LambdaConfig:
LambdaFunctionArn: !GetAtt GetAllAppSyncResolverFunction.Arn
ServiceRoleArn: !GetAtt AppSyncApiServiceRole.Arn
GetAllResolver:
Type: AWS::AppSync::Resolver
Properties:
ApiId: !GetAtt AppSyncApi.ApiId
DataSourceName: !GetAtt GetAllAppSyncDataSource.Name
FieldName: items
TypeName: Query
Kind: UNIT
AppSyncApi:
Type: AWS::AppSync::GraphQLApi
Properties:
AuthenticationType: API_KEY
Name: SampleAppSync
AppSyncSchema:
Type: AWS::AppSync::GraphQLSchema
Properties:
ApiId: !GetAtt AppSyncApi.ApiId
DefinitionS3Location: src/graphql.schema
AppSyncApiKey:
Type: AWS::AppSync::ApiKey
Properties:
ApiId: !GetAtt AppSyncApi.ApiId
Description: key for testing
AppSyncApiServiceRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service: appsync.amazonaws.com
Action: sts:AssumeRole
AppSyncApiServicePolicy:
Type: AWS::IAM::Policy
Properties:
PolicyName:
AppSyncLambdaInvokePolicy
Roles:
- !Ref AppSyncApiServiceRole
PolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Action: lambda:InvokeFunction
Resource:
- !GetAtt GetAllAppSyncResolverFunction.Arn
- !GetAtt GetItemByIdAppSyncResolverFunction.Arn
Outputs:
WebEndpoint:
Description: "API Gateway endpoint URL for Prod stage"
Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
AppSyncEndpoint:
Description: "AppSync Endpoint URL"
Value: !GetAtt AppSyncApi.GraphQLUrl
AppSyncApiKey:
Description: "APIKey for AppSync Testing"
Value: !GetAtt AppSyncApiKey.ApiKey
Note that the only resource modified was the original API Gateway Lambda handler function. This simple modification can be covered in unit test cases. The refactoring isn’t required, but it is done here in accordance with DRY (Don’t Repeat Yourself) programming principles.
Artifact Deployment
There are many ways to deploy AWS SAM applications, but, assuming that you’ve configured SAM previously, you can just use the CLI:
sam deploy --stack-name appsync-and-apigw --resolve-s3 --capabilities CAPABILITY_IAM
This command will run for a few minutes until the deployment is complete.
To complete our testing, we must retrieve the endpoints for both API Gateway and GraphQL, as well as the GraphQL API Key that we created. These will be displayed in the Outputs section. You’ll use them for testing.
Testing
We’ll use the curl command to validate both endpoints. We’ll first do this by adding a new item using the REST API, then by retrieving it using the GraphQL endpoint.
WebEndpoint=https://WebEndpoint.execute-api.us-east-1.amazonaws.com/Prod/; curl ${WebEndpoint} -d '{"id":"123","name":"item"}'
Now, invoke the GraphQL endpoint using curl. Copy and replace AppSyncApiKey and AppSyncEndpoint from the matching Outputs in the deploy command.
AppSyncApiKey=da2-lixcwxhox5axdbx6hxjsxapxrq
AppSyncEndpoint=https://f3w324vo5jge7hq6ev2ka7xsj6.appsync-api.region.amazonaws.com/graphql
curl \
-X POST \
-H "x-api-key: ${AppSyncApiKey}" \
-H "Content-Type: application/json" \
-d '{ "query": "query($id: ID!) { getById(id: $id) { id name } }",
"variables": { "id":"123" }
}' ${AppSyncEndpoint}
Queries in GraphQL
The GraphQL query language lets the client specify exactly which fields that they want to see in the response, a great feature for use cases that might have constrained bandwidth. By manipulating the GraphQL query, we can confirm that the AppSync service handles the filtering directly, so it doesn’t need to be built into the Lambda resolvers. We can return the full Item to AppSync and let the service filter to the client query specification.
In the following, we’ve removed the ID attribute from the requested response, so you’ll see that the output contains only the Item name. We use the items() query here so that the results will be in a JSON array:
curl \
-X POST \
-H "x-api-key: ${AppSyncApiKey}" \
-H "Content-Type: application/json" \
-d '{ "query": "query { items { name } }"}' ${AppSyncEndpoint}
Adding Subscriptions
AppSync subscriptions let real-time updates be pushed to clients who register. They are triggered in response to mutations, which are GraphQL requests that modify data. To show how we can quickly extend our API to subscriptions, we’ll implement the mutation putItem that we defined in the GraphQL schema, and then add subscription capabilities.
GraphQL mutations must be able to return a full data set rather than just changed fields. Therefore, we must enhance the put-item.js
function to return the full output. We optimistically merge changed data with the old data upon successful DynamoDB put() call, which avoids a separate read().
src/handlers/put-item.js:
// Create clients and set shared const values outside of the handler.
// Create a DocumentClient that represents the query to add an item
const dynamodb = require('aws-sdk/clients/dynamodb');
const docClient = new dynamodb.DocumentClient();
// Get the DynamoDB table name from environment variables
const tableName = process.env.SAMPLE_TABLE;
async function putItem(item) {
var params = {
TableName : tableName,
Item: item,
ReturnValues: "ALL_OLD"
};
return await docClient.put(params).promise();
}
/**
* A simple example includes a HTTP post method to add one item to a DynamoDB table.
*/
exports.putItemHandler = async (event) => {
if (event.httpMethod !== 'POST') {
throw new Error(`postMethod only accepts POST method, you tried: ${event.httpMethod} method.`);
}
// All log statements are written to CloudWatch
console.info('received:', event);
// Get id and name from the body of the request
const body = JSON.parse(event.body)
const id = body.id;
const name = body.name;
// Creates a new item, or replaces an old item with a new item
// https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html#put-property
const result = await putItem({ id : id, name: name });
const response = {
statusCode: 200,
body: JSON.stringify(body)
};
// All log statements are written to CloudWatch
console.info(`response from: ${event.path} statusCode: ${response.statusCode} body: ${response.body}`);
return response;
}
/* AppSync Handler */
exports.putItemAppsyncResolver = async (context) => {
console.info("request", context);
if (! (context.arguments && context.arguments.input)) {
throw new Error("no arguments found");
}
const result = await putItem(context.arguments.input);
console.info("result", result);
//merge old and new values for return
let newItem = Object.assign({}, result.Attributes, context.arguments.input);
return newItem;
}
Next, add the following resource definitions to the Resources
section of template.yaml
:
PutItemAppSyncResolverFunction:
Type: AWS::Serverless::Function
Properties:
Handler: src/handlers/put-item.putItemAppsyncHandler
Runtime: nodejs14.x
MemorySize: 128
Timeout: 100
Description: A simple example includes a HTTP post method to add one item to a DynamoDB table.
Policies:
# Give Create/Read/Update/Delete Permissions to the SampleTable
- DynamoDBCrudPolicy:
TableName: !Ref SampleTable
Environment:
Variables:
# Make table name accessible as environment variable from function code during execution
SAMPLE_TABLE: !Ref SampleTable
PutItemAppSyncDataSource:
Type: AWS::AppSync::DataSource
Properties:
ApiId: !GetAtt AppSyncApi.ApiId
Description: Put Item Direct Lambda
Name: Put_Item
Type: AWS_LAMBDA
LambdaConfig:
LambdaFunctionArn: !GetAtt PutItemAppSyncResolverFunction.Arn
ServiceRoleArn: !GetAtt AppSyncApiServiceRole.Arn
PutItemResolver:
Type: AWS::AppSync::Resolver
Properties:
ApiId: !GetAtt AppSyncApi.ApiId
DataSourceName: !GetAtt PutItemAppSyncDataSource.Name
FieldName: putItem
TypeName: Mutation
Kind: UNIT
PutItemAppSyncServicePolicy:
Type: AWS::IAM::Policy
Properties:
PolicyName:
AppSyncLambdaMutationPolicy
Roles:
- !Ref AppSyncApiServiceRole
PolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Action: lambda:InvokeFunction
Resource:
- !GetAtt PutItemAppSyncResolverFunction.Arn
The AppSync engine handles all the heavy lifting of managing subscriptions, so we don’t need any additional coding beyond the following four lines appended to the GraphQL schema. See the AppSync developer guide for more information on real-time data.
type Subscription {
subscribeToItem:Item
@aws_subscribe(mutations: ["putItem"])
}
Testing Real-Time Subscriptions
We can use the the AWS AppSync Queries tool to test the AppSync subscriptions. To do this:
- Login to the AWS console and navigate to the AppSync service.
- Select the SampleAppSync API and drill to Queries.
- Enter the following in the middle box – you should see an indication that you are subscribed towards the top right.
subscription MySubscription {
subscribeToItem {
id
name
}
}
- Submit a GraphQL Mutation request to your AWS AppSync endpoint using the following command, and you should see the results reflected in the query tool.
curl \
-X POST \
-H "x-api-key: ${AppSyncApiKey}" \
-H "Content-Type: application/json" \
-d '{ "query": "mutation($input: ItemInput!) { putItem(input: $input) { id name } }",
"variables": { "input": {"id":"345","name":"new345"}}}' ${AppSyncEndpoint}
Conclusion
AppSync Direct Lambda Resolvers let customers use their Lambda functions without writing any VTL. In this post, we showed how you can add a GraphQL interface to existing API Gateway deployments by reusing existing business logic with minimal refactoring. Customers looking to realize the benefits of offering a GraphQL alternative to sit along traditional REST APIs can start to use these techniques today. Once you’ve deployed your initial GraphQL API, you can evolve your design with AppSync features like configurable batching, fine-grained caching with entry eviction, and custom domains for your API endpoints. You can also find more patterns leveraging AWS SAM to build AppSync solutions at serverlessland.
About the author