Front-End Web & Mobile

Evolving REST APIs with GraphQL using AWS AppSync Direct Lambda Resolvers

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:

  1. Create a GraphQL schema for our API.
  2. Update existing Lambda handler code.
  3. 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:

  1. Login to the AWS console and navigate to the AppSync service.
  2. Select the SampleAppSync API and drill to Queries.
  3. 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
      }
    }

    aws appsync subscribe via console

  4. 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}
 

aws appsync subscription results

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

Clay Brehm

Clay is a Senior Solutions Architect with AWS who focuses on enabling customers in the US Great Lakes area.