Front-End Web & Mobile

Building Scalable GraphQL APIs on AWS with CDK, TypeScript, AWS AppSync, Amazon DynamoDB, and AWS Lambda

October 16, 2023: This post has been updated to include the latest CDK AppSync Constructs.

September 14, 2021: Amazon Elasticsearch Service has been renamed to Amazon OpenSearch Service. See details.

AWS AppSync is a managed serverless GraphQL service that simplifies application development by letting you create a flexible API to securely access, manipulate, and combine data from one or more data sources with a single network call and API endpoint. With AppSync, developers can build scalable applications on a range of data sources, including Amazon DynamoDB NoSQL tables, Amazon Aurora Serverless relational databases, Amazon OpenSearch Service (successor to Amazon Elasticsearch Service) clusters, HTTP APIs, and serverless functions powered by AWS Lambda.

AppSync APIs can be deployed in a variety of different ways using various CloudFormation providers like the AWS Amplify CLI, AWS Serverless Application Model (AWS SAM), AWS Cloud Development Kit (AWS CDK), and the Serverless Framework (among others). In this post, we’ll be building an AWS AppSync API from scratch using CDK. The post will focus on how to use CDK to deploy AppSync APIs that leverage a variety of AWS services including Amazon DynamoDB, Amazon Cognito, Amazon Bedrock and AWS Lambda.

The API we will be deploying will be leveraging a DynamoDB database for creating, reading, updating, and deleting data. We’ll learn how to map GraphQL requests to Amazon Bedrock using Direct Lambda resolvers. We’ll also learn how to enable GraphQL subscriptions for real-time updates triggered from database events.

The final code for this project will available for the backend and the frontend.

CDK Overview

The AWS Cloud Development Kit (AWS CDK) is an open source software development framework to model and provision your cloud application resources using familiar programming languages. CDK can be written using a variety of programming languages like Python, Java, TypeScript, JavaScript, and C#. In this tutorial we will be using TypeScript

To work with CDK, you will need to install the CDK CLI. Once the CLI is installed, you will be able to do things like create new CDK projects, deploy services to AWS, deploy updates to existing services, and view changes made to your infrastructure during the development process.

In this post we’ll be using the CDK CLI to provision and deploy API updates.

CDK with AWS AppSync

An AWS AppSync API is usually composed of various AWS services. For example, if we are building an AWS AppSync API that interacts with a database and needs authorization, the API will depend on these resources being created.

With this in mind, we will not only be working with CDK modules for AWS AppSync but also various other AWS services. Part of building an AWS AppSync API is learning how to use all of these things and making them work well together, including managing IAM policies in certain circumstances in order to enable access between services or to configure certain types of data access patterns.

The AWS AppSync CDK constructs and classes take this into consideration and enable the configuration of various resources within an AWS AppSync API using AWS services also created as part of the CDK project.

Click here to view the CDK documentation. Click here to view the AWS AppSync documentation.

Getting Started

First, install the CDK CLI:

npm install -g aws-cdk

You must also provide your credentials and an AWS Region to use AWS CDK, if you have not already done so. The easiest way to satisfy this requirement is to install the AWS CLI and issue the following command:

aws configure

Next, create a directory called appsync-cdk-app and change into the new directory:

mkdir appsync-cdk-app

cd appsync-cdk-app

Next, we’ll create a new CDK project using the CDK CLI:

cdk init --language=typescript

The CDK project should now be initialized and you should see a few files in the directory, including a lib folder which is where the boilerplate for the root stack has been created.

Now that we’ve created the CDK project, let’s install the necessary dependencies we’ll need. Since we’ll be working with several different packages, we’ll need to go ahead and install them now:

npm install @aws-amplify/graphql-api-construct @aws-cdk/aws-cognito-identitypool-alpha

Running a build

Because the project is written in TypeScript, but will ultimately need to be deployed in JavaScript, you will need to create a build to convert the TypeScript into JavaScript before deploying.

There are two ways to do this, and the project is already set up with a couple of scripts to help with this.

Watch mode

You can run npm run watch to enable watch mode. The project will automatically compile to JS as soon as you make changes and save files and you will also be able to see any errors logged out to the terminal.

Manual build

You can also create a build at any time by running npm run build.

Creating the Cognito and API Stack

Now that the project is set up, we can start writing some code! Some boilerplate code for the stack has already been created for you. This code is located at appsync-cdk-app/lib/appsync-cdk-app-stack.ts. This is the root of the CDK app and where we will be writing the code for our app.

For the sake of simplicity, we’ll be adding everything to the appsync-cdk-app-stack.ts file. You can always split this into multiple stacks or files if needed in the future.

To get started, let’s first go ahead and import the CDK modules we’ll be needing:

// lib/appsync-cdk-app-stack.ts
import * as cdk from "aws-cdk-lib";
import * as path from "path";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as awsCognito from "aws-cdk-lib/aws-cognito";
import { Construct } from "constructs";
import { PolicyStatement } from "aws-cdk-lib/aws-iam";
import {
AmplifyGraphqlApi,
AmplifyGraphqlDefinition,
} from "@aws-amplify/graphql-api-construct";
import {
IdentityPool,
UserPoolAuthenticationProvider,
} from "@aws-cdk/aws-cognito-identitypool-alpha";

Cognito User Pools

Next we’ll use the awsCognito CDK module to create the user pool. This will be used to store our user information. Update the stack with following code inside the /lib/appsync-cdk-app-stack.ts file:

export class AppsyncCdkAppStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // UserPool
    const userPool = new awsCognito.UserPool(this, "MyUserPool", {
      userPoolName: "MyUserPool",
      selfSignUpEnabled: true,
      autoVerify: {
        email: true,
      },
      accountRecovery: awsCognito.AccountRecovery.EMAIL_ONLY,
      userVerification: {
        emailStyle: awsCognito.VerificationEmailStyle.CODE,
      },
      standardAttributes: {
        email: {
          required: true,
          mutable: true,
        },
      },
    });
 }
}

We’ve defined a basic Cognito user pool with the following configuration:

  • userPoolName: Defines the name of the Cognito user pool
  • selfSignUpEnabled: Allows users to sign up themselves.
  • autoVerify: Method how users will be able to recover their account.
  • userVerification: Configurations on how users will sign themselves up.
  • standardAttributes: A list of standard attributes that every user in the user pool are required to have.

Next, we need to define the user pool client. Inside the same appsync-cdk-app-stack.tsfile add the following section under the user pool.

   // user pool client
    const userPoolClient = new awsCognito.UserPoolClient(
      this,
      "MyUserPoolClient",
      { userPool }
    );

The user pool app client is a configuration within a user pool that interacts with your web or mobile application.

Identity Pool

For the next part, we’ll add in a identity pool.  Identity pools are a store of user identity data specific to your account and can be used to allow unauthenticated access. Add this snippet to the bottom of the appsync-cdk-app-stack.ts file below the user pool client.

    // Identity Pool
    const identityPool = new IdentityPool(this, "MyIdentityPool", {
      identityPoolName: "MyIdentityPool",
      allowUnauthenticatedIdentities: true,
      authenticationProviders: {
        userPools: [
          new UserPoolAuthenticationProvider({
            userPool: userPool,
            userPoolClient: userPoolClient,
          }),
        ],
      },
    });

The identity pool has these characteristics:

  • identityPoolName: Name of identity pool.
  • allowUnauthenticatedIdentities: This allows unauthenticated logins.
  • authenticationProviders: List of authentication providers for identity pool.

Lambda Function

Let’s provision a Lambda function that will be used as a resolver for our AppSync API. This Lambda function will hold our code to connect to Amazon Bedrock.

Add the following code after the Identity pool.

// Lambda
    const bedrockLambda = new lambda.Function(this, "bedrockLambda", {
      functionName: "MyBedrockLambda",
      code: lambda.Code.fromAsset(
        path.join(__dirname, "handlers/bedrocklambda")
      ),
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_18_X,
      timeout: cdk.Duration.seconds(300),
    });

The Lambda function has the following configurations:

  • functionName: Name of Lambda function.
  • code: This will be the source code for your Lambda function.
  • handler: The name of the method within your code that Lambda calls to execute your function.
  • runtime: The runtime environment for the Lambda function that you are uploading.
  • timeout: The function execution time (in seconds) after which Lambda terminates the function.

We need to allow the Lambda function to talk to Amazon Bedrock. To begin you’ll need to make sure you have enabled Amazon Bedrock model access. Follow the instructions in the documentation and enable the models in the Amazon console.

Inside the appsync-cdk-app-stack.tsfile add another section under the Lambda section. This will grant the Lambda function access to Bedrock.

//IAM Policy
    bedrockLambda.grantPrincipal.addToPrincipalPolicy(
      new PolicyStatement({
        resources: [
          "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-v2",
        ],
        actions: ["bedrock:InvokeModel"],
      })
    );

The policy statement will allow the bedrockLambda invoke model access to anthropic claude-v2. This also assumes the model is in us-east-1 . This can be changed if needed.

AWS AppSync

Let’s add the AWS AppSync API.

Add the following code below the policy statement from earlier.

    // AWS AppSync
    new AmplifyGraphqlApi(this, "MyNewAPI", {
      apiName: "MyNewAPI",
      definition: AmplifyGraphqlDefinition.fromFiles(
        path.join(__dirname, "schema.graphql")
      ),
      authorizationModes: {
        defaultAuthorizationMode: "AMAZON_COGNITO_USER_POOLS",
        apiKeyConfig: {
          expires: cdk.Duration.days(30),
        },
        userPoolConfig: {
          userPool,
        },
      },
      functionNameMap: { noteSummary: bedrockLambda },
    });

This new AWS AppSync API will define a schema file, and setup authorization modes so users can access the API with a key or user pool. It also sets a function for our Lambda resolver that we’ll be using later. By convention, a DynamoDB database will also be created to store all the data.

Let’s take a look in more detail:

  • apiName: Name of API.
  • definition: This describes the transform definition of our GraphQL schema file.
  • authorizationModes: This lists the required auth modes for our API.
  • functionNameMap: This defines the Lambda function to be used derived by the @function name in the schema file.

Let’s make sure we output some values. We’ll need these later.

// outputs
    new cdk.CfnOutput(this, "UserPoolId", {
      value: userPool.userPoolId,
    });
    new cdk.CfnOutput(this, "UserPoolClientId", {
      value: userPoolClient.userPoolClientId,
    });
    new cdk.CfnOutput(this, "IdentityPoolId", {
      value: identityPool.identityPoolId,
    });

GraphQL Schema

Let’s define our GraphQL schema file! Create a new file named schema.graphql inside the same folder as the appsync-cdk-app-stack.ts file.

Add the following schema:

type Note
  @model
  @auth(rules: [{ allow: owner }, { allow: public, operations: [read] }]) {
  name: String!
  completed: Boolean!
  owner: String @auth(rules: [{ allow: owner, operations: [read, delete] }])
}

type Query {
  noteSummary(msg: String): String @function(name: "noteSummary") @auth(rules: [{ allow: private }])
}

This schema defines a notes app with basic auth rules that allow for public read access, and owner level access. Field-level access has been added to the owner field, which will only allow owners to read and delete, but not update this field. You may notice the directives @model and @auth. The @model directive will generate all the resolvers for CRUD (Create, Read, Update, Delete) operations for the entity. These resolvers connect to DynamoDB without any additional configuration needed. The @auth directive sets up authorization modes for your entities.

The noteSummary query will use a Lambda resolver denoted by the @function directive. The @functiondirective gives the ability to create custom business logic, so we can write our own resolvers. In this case this will connect to the Amazon Bedrock function that will summarize our notes.

Adding the Lambda function code

Finally, we need to add the function code that we will be using to summarize our notes using Amazon Bedrock with the noteSummary query.

Create a new folder lib/handlers/bedrocklambda. Change directories into this folder and initialize a new npm package.json.

cd lib/handlers/bedrocklambda
npm init

You’ll then need to install the Amazon Bedrock and Lambda dependencies

npm i @aws-sdk/client-bedrock-runtime @types/aws-lambda aws-lambda

Create a new index.ts file in the folder and add this code:

import { AppSyncResolverEvent } from "aws-lambda";
import { BedrockRuntime } from "@aws-sdk/client-bedrock-runtime";

type AppSyncEvent = {
  msg: string;
};

export const handler = async (event: AppSyncResolverEvent<AppSyncEvent>) => {
  const prompt = `Take the following string and summarize it into one sentence, make sure to only return the summary only, no other text please. "${event.arguments.msg}"`;
  const claudePrompt = `\n\nHuman: ${prompt} \n\nAssistant:`;
  const config = {
    prompt: claudePrompt,
    max_tokens_to_sample: 2048,
    temperature: 0.5,
    top_k: 250,
    top_p: 1,
    stop_sequences: ["\n\nHuman:"],
  };
  const bedrock = new BedrockRuntime({ region: "us-east-1" });
  try {
    const response = await bedrock.invokeModel({
      body: JSON.stringify(config),
      modelId: "anthropic.claude-v2",
      accept: "application/json",
      contentType: "application/json",
    });
    const value = JSON.parse(response.body.transformToString());
    return value.completion;
  } catch (err) {
    console.log(err);
    return err;
  }
};

This uses the Claude 2 Anthropic Model to summarize text that is sent to it.  We then send that text back to the user. Next, we’ll build our CDK app.

Deploying and testing

Now we are ready to deploy. To do so, run the following command from your terminal:

npm run build && cdk deploy

If you haven’t already you may need to run the bootstrap command.

cdk bootstrap

Now that the updates have been deployed, let’s add a user! Visit the Cognito console and open up the new Cognito user pool. You may need to search for MyUserPool. Click on the Create user and fill out the information for your user. Make sure to check mark the email address as verified and set a password.

Next up visit the AppSync console and click on the API name to view the dashboard for your API.

Click on Queries in the left hand menu to view the query editor. Click the Login with User Pools button and login with the user you just created. It may ask you for a new password, just enter a new password in and continue on.

From here, we can test out the API by running the following queries and mutations:

mutation MyMutation {
  createNote(input: {completed: false, name: "my note"}) {
    id
  }
}
query MyQuery {
  listNotes {
    items {
      completed
      id
      name
    }
  }
}
query MyQuery {
  noteSummary(msg: "Today we will summarize a long note. This is a longer note.")
}

The last query will run agains the custom Lambda resolver to summarize the text.

Subscriptions

One of the most powerful components of a GraphQL API and one of the things that AWS AppSync makes really easy is enabling real-time updates via GraphQL subscriptions.

GraphQL subscriptions allow you to subscribe to mutations made agains a GraphQL API. In our case, we may want to subscribe to changes like when a new note is created, when a note is deleted, or when a note is updated.

To test out the changes, open another window and visit the AWS AppSync Query editor for the GraphQL API.

From here, you can test out the subscription by running a subscription in one editor and triggering a mutation in another:

subscription onCreate {
  onCreateNote {
    id	
    name
    completed
  }
}

Connecting a Client application

You can connect to an AWS AppSync API using the Amplify libraries for iOS, Android, or JavaScript.

In this example, we’ll walk through how you can make API calls from a JavaScript application using Next.js. We’ll assume you already have Next app created.

Client project setup

You first need to install the AWS Amplify libraries using either NPM or Yarn.

npm install aws-amplify

Though not required, you can also install the UI component library. This gives you a collection of accessible, themeable components that can help reduce the boiler plate needed in your project. This library is available for React, Vue, Angular, Swift, React Native, Flutter and Android.

If you are using a React application you’d can install the the UI library with npm i @aws-amplify/ui-react

Next, configure the Amplify app at the root of your project using the Project Region, API Key, Auth and GraphQL URL. This information is available both in the AWS AppSync dashboard for your API but also in the terminal after running cdk deploy. This configuration is usually done at the entry-point of your app:

  • Angular – main.ts
  • Vue – main.js 
  • React – index.js
  • Next.js – _app.js

Create a new aws-exports file in the src folder of your application. Add the following values:

const config = {
  aws_project_region: "<enter value here>",
  Auth: {
    region: "<enter value here>",
    userPoolId: "<enter value here>",
    userPoolWebClientId: "<enter value here>",
    identityPoolId: "<enter value here>",
  },
  aws_appsync_graphqlEndpoint:
    "<enter value here>",
  aws_appsync_region: "<enter value here>",
  aws_appsync_authenticationType: "<enter value here>",
};

export default config;

Inside your main application file, e.g. _app.tsx import the aws-exports file in and configure it as shown below:

import { Amplify } from "aws-amplify";
import awsExports from "../aws-exports";
import { withAuthenticator } from "@aws-amplify/ui-react";
import "@aws-amplify/ui-react/styles.css";
Amplify.configure(awsExports);
import type { AppProps } from "next/app";

function App({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />;
}

export default withAuthenticator(App, { loginMechanisms: ["email"] });

This will configure your application with the Authenticator from the UI library and setup the Amplify JS library. All users will need to login before they have access to create any values in the API.

Types, Queries, Mutations and Subscriptions

To access the AWS AppSync API you must write GraphQL queries. To make this process easier the Amplify CLI has a code generation tool that can automatically generate all your subscriptions, mutations, queries and types for you. Run the following command in your web app. Make sure to replace the regionand apiIdwith the correct values (you can find them after running the deploy).

npx @aws-amplify/backend-cli codegen add --region us-east-1 --apiId xxxxxx

This will generate a src/graphql folder and a API.ts file in the root of your project. Feel free to use these files to help write your queries.

For the sake of simplicity in the following sections I’ll show you how to run these queries manually, without importing them in from the code generated files. Remember, when running on the client you’ll need to be logged in before any of these queries will work. For this post, I’d recommend you follow the instructions earlier and surround your application by the Authenticator. That way users will have to sign up and login before they can add notes.

Fetching data (queries)

To query data, you can use the API category, passing in the query that you’d like to use. In our case, we’ll use the same query from above:

import { API } from 'aws-amplify'

const query = `
  query listNotes {
    listNotes {
      id name completed
    }
  }
`

async function fetchNotes(){
  const data = await API.graphql({ query })
  console.log('data from GraphQL:', data)
}

Updating data (mutations)

To create, update, or delete data, you can use the API category, passing in the mutation that you’d like to use along with any variables. In this example, we’ll look at how to create a new note:

import { API } from 'aws-amplify'

const mutation = `
  mutation createNote(note: NoteInput!) {
    createNote(note: $note) {
      name completed
    }
  }
`

async function createNote() {
  await API.graphql({
    query: mutation,
    variables: { note: { name: 'Note 1', completed: false } }
  })
  console.log('note successfully created!')
}

Real-time data (subscriptions)

To subscribe to real-time updates, we’ll use the API category and pass in the subscription we’d like to listen to. Any time a new mutation we are subscribed to happens, the data will be sent to the client application in real-time.

import { API } from 'aws-amplify'

const subscription = `
  subscription onCreateNote {
    onCreateNote {
      id name completed
    }
  }
`

function subscribe() {
  API.graphql({
    query: subscription
  })
  .subscribe({
    next: noteData => {
      console.log('noteData: ', noteData)
    }
  })
}

Summarize Data

To summarize data we can call our custom Lambda function noteSummary.

const summarizeIt = /* GraphQL */ `
      query NoteSummary($msg: String) {
        noteSummary(msg: $msg)
      }
    `;

    const data = await API.graphql({
      query: summarizeIt,
      variables: {
        msg: { "Long note that can be summarized" },
      },
    });

Conclusion

If you’d like to continue building this example out, you may look at implementing things like additional data sources, argument-based subscriptions, or creating and then querying against a DynamoDB Global Secondary Index.

For additional questions feel free to reach out and tweet me at ErikCH.