Serverless GraphQL with AWS App Sync
The is the first in a two part series of posts where we implement a serverless graphQL service using the Serverless Framework and AppSync, we will be using (AWS Lambda resolvers). We will also be using Typescript and packaging with webpack, we are storing our data in DynamoDB.
We will be creating a music discography - discographql
:) a catalogue of sound recordings..
The stack includes the following:
- Serverless Framework
- Node.js
- Typescript
- Webpack
- AWS AppSync/GraphQL
- AWS Lambda
- DynamoDB
- Cognito
The source code can be found here: discographql, branch one
contains the code for this post.
Lets begin!
Create root folder
mkdir discographql
Initialise project
Run this command to generage a package.json
file
npm init -y
Install Serverless Framework
npm install -g serverless
Now change directory to the new discographql
folder.
Setup typescript
npm install typescript --save-dev
Run this command to generage a tsconfig.json
file
tsc --init
Install serverless framework plugins
Lets install the various serverless framework modules to support AppSync, bundling and offline/local development:
We have two packages that give us support for appsync serverless-appsync-plugin
, which gives us the ability to create appsync resources in AWS, and serverless-appsync-simulator
which gives us offline support via serverless-offline
.
Grab the contents of this package.json
file discographql/package.json and copy this into your file; now run:
npm i
Create folder structure
Create the following folder/file structure:
discographql
- dynamoDb
- src
- functions
- getRecording.ts
- createRecording.ts
- interfaces
- recording.ts
- services
- dynamoDb.ts
schema.graphql
- serverless.yml
- webpack.config.js
Webpack Bundling
This is our basic Webpack setup, we have included the CopyPlugin
module to copy schema.graphql
to the src
folder as part of our build.
const slsw = require('serverless-webpack')
const CopyPlugin = require('copy-webpack-plugin')
const nodeExternals = require('webpack-node-externals')
const isLocal = slsw.lib.webpack.isLocal
module.exports = {
mode: isLocal ? 'development' : 'production',
devtool: isLocal ? 'source-map' : false,
entry: slsw.lib.entries,
target: 'node',
resolve: {
extensions: ['.mjs', '.ts', '.js'],
},
externals: [nodeExternals()],
module: {
rules: [
{
test: /\.ts$/,
exclude: /node_modules/,
loader: 'ts-loader',
},
],
},
plugins: [
new CopyPlugin({
patterns: [
{
from: 'src/schema.graphql',
to: 'src/schema.graphql',
},
],
}),
],
}
GraphQL
Here is the graphQl we will implement; lets add this to src/schema.graphql
.
schema {
query: Query
mutation: Mutation
}
type Recording {
id: String
name: String
description: String
genre: String
artist: String
format: String
year: String
label: String
url: String
}
type Response {
message: String!
}
input RecordingCreateRequest {
name: String
description: String
genre: String
artist: String
format: String
year: String
label: String
url: String
}
input RecordingGetRequest {
recordingId: String
}
type Query {
getRecording(input: RecordingGetRequest): Recording
}
type Mutation {
createRecording(input: RecordingCreateRequest): Response
}
Typescript interfaces
Here are the Typescript interfaces to support our graphQL; lets add this to src/interfaces/recording.ts
.
export interface Recording {
id: string;
name: string;
description: string;
genre: string;
artist: string;
year: string;
format: string;
label: string;
url: string;
}
export interface RecordingStore {
id: string;
data: {
name: string;
description: string;
genre: string;
artist: string;
year: string;
format: string;
label: string;
url: string;
};
}
export interface Response {
message: string;
}
export interface RecordingCreateRequest {
input: {
name: string;
description: string;
genre: string;
artist: string;
year: string;
format: string;
label: string;
url: string;
};
}
export interface RecordingGetRequest {
input: {
recordingId: string;
};
}
DynamoDb Setup for local development
In order to use DynamoDb locally we will be using the official DynamoDb docker container. Serverless also supports dynamodb-local
we will not use this as it currently does not work on M1 Macs.
Here is how to set this up as well as a some commands to view and manage tables.
Start dynamoDb via docker
Pull the docker container for dynamodb
https://hub.docker.com/r/amazon/dynamodb-local
Run the container
docker run -p 8000:8000 amazon/dynamodb-local
Create table locally
Run this command to create this table, the file dynamodb/table.json
is only used for creating a table locally, the serverless framework will manage the creation of this on AWS.
aws dynamodb create-table --cli-input-json file://dynamodb/table.json --endpoint-url http://localhost:8000
List tables
You can check the table was created successfully with this command:
aws dynamodb list-tables --endpoint-url http://localhost:8000
DynamoDb client
In order to work with dynamoDb locally we use a different endpoint
, so we create this simple service to manage the DynamoDb client via IS_OFFLINE
. create this at src/services/dynamoDb
import AWS from "aws-sdk";
export const dynamoDb = process.env.IS_OFFLINE
? new AWS.DynamoDB.DocumentClient({ endpoint: "http://localhost:8000" })
: new AWS.DynamoDB.DocumentClient();
Serverless setup
We need a serverless framework file to create our AWS resources, the below will create our AppSync schema and Lambda resolvers, a DynamoDb table and has been configured to work offline. Our AppSync implementation will use API_KEY
authentication.
Now replace the serverless.yml
file with the following:
service: discographql
plugins:
- serverless-webpack
- serverless-appsync-plugin
- serverless-appsync-simulator
- serverless-offline
package:
individually: true
provider:
name: aws
runtime: nodejs14.x
stage: ${opt:stage, 'dev'}
region: eu-west-2
lambdaHashingVersion: 20201221
iam:
role:
statements:
- Effect: 'Allow'
Action:
- "dynamodb:PutItem"
- "dynamodb:UpdateItem"
- "dynamodb:DeleteItem"
- "dynamodb:GetItem"
- "dynamodb:Scan"
- "dynamodb:Query"
Resource: '*'
custom:
table: discographql-${self:provider.stage}
webpack:
includeModules: true
appsync-simulator:
dynamoDb:
endpoint: 'http://localhost:8000'
watch: false
serverless-offline:
port: 3005
httpPort: 9090
appSync:
name: discographql-${self:provider.stage}
authenticationType: API_KEY
schema:
- src/schema.graphql
xrayEnabled: true
mappingTemplates:
- type: Mutation
field: createRecording
dataSource: createRecordingFunction
request: false
response: false
- type: Query
field: getRecording
dataSource: getRecordingFunction
request: false
response: false
dataSources:
- type: AWS_LAMBDA
name: createRecordingFunction
config:
functionName: createRecording
- type: AWS_LAMBDA
name: getRecordingFunction
config:
functionName: getRecording
functions:
createRecording:
handler: src/functions/createRecording.handler
environment:
DISCOGRAPHQL_TABLE_NAME: ${self:custom.table}
getRecording:
handler: src/functions/getRecording.handler
environment:
DISCOGRAPHQL_TABLE_NAME: ${self:custom.table}
resources:
Resources:
Table:
Type: "AWS::DynamoDB::Table"
Properties:
BillingMode: PAY_PER_REQUEST
TableName: ${self:custom.table}
KeySchema:
- AttributeName: PK
KeyType: HASH
- AttributeName: SK
KeyType: RANGE
AttributeDefinitions:
- AttributeName: PK
AttributeType: S
- AttributeName: SK
AttributeType: S
StreamSpecification:
StreamViewType: NEW_IMAGE
Get a recording
The following handler retrieves a single recording from DynamoDb.
import { dynamoDb } from '../services/dynamoDb'
import { AppSyncResolverEvent } from 'aws-lambda'
import { Recording, RecordingGetRequest } from '../interfaces/recording'
export const handler = async (event: AppSyncResolverEvent<RecordingGetRequest>): Promise<Recording | null | Error> => {
try {
const recordingId = event.arguments.input.recordingId
const params = {
TableName: process.env.DISCOGRAPHQL_TABLE_NAME as string,
Key: {
PK: 'RECORDING',
SK: `RECORDING#${recordingId}`,
},
}
const { Item } = await dynamoDb.get(params).promise()
if (!Item) {
return null
}
return { ...Item.data, id: Item.id } as Recording
} catch (err) {
return new Error('Unable to get recording')
}
}
Create a recording
The following handler creates a single recording and stores this in DynamoDb.
import { dynamoDb } from '../services/dynamoDb'
import { v4 as uuidv4 } from 'uuid'
import { AppSyncResolverEvent } from 'aws-lambda'
import { RecordingCreateRequest, Response } from '../interfaces/recording'
export const handler = async (
event: AppSyncResolverEvent<RecordingCreateRequest>,
): Promise<Response | null | Error> => {
try {
const id = uuidv4()
const params = {
TableName: process.env.DISCOGRAPHQL_TABLE_NAME as string,
Item: {
PK: 'RECORDING',
SK: `RECORDING#${id}`,
id: id,
data: {
...event.arguments.input,
},
},
}
await dynamoDb.put(params).promise()
return { message: `Created recording ${id}` };
} catch (err) {
return new Error('Unable to save recording')
}
}
Run locally
In order to run the graphQL server locally:
sls offline start
Your terminal should print out the url for your graphQl server
AppSync Simulator: discographql-dev AppSync endpoint: http://***.***.*.**:20002/graphql
The server is configured to use API_KEY
level authentication for AppSync, by default the name of the header is x-api-key
and the value should be set to 0123456789
Using a graphQl client, (curl/postman/insomnia/etc), execute the following command to create a recording:
mutation createRecording {
createRecording(input: {
name: "Everybody Loves The Sunshine",
description: "In 1970 Ayers formed the Roy Ayers Ubiquity, which recorded several albums for Polydor and featured such players as Sonny Fortune, Billy Cobham, Omar Hakim, and Alphonse Mouzon.",
artist: "Roy Ayers Ubiquity",
url: "http://www.youtube.com/watch?v=nC9dQOnUyao",
label: "Polydor Records",
genre: "Jazz-Funk",
year: "1976",
format: "Albumn"
}) {
message
}
}
The message returned when successful will include the id
of the resource, so you can run the following to retrieve the resource, (replace the <ID>
).
query getRecording {
getRecording(input: { recordingId: "<ID>"}) {
id
name
description
label
genre
artist
year
format
}
}
Deploy to AWS
In order to deploy our server to AWS simply run:
sls deploy -s dev
This will generate all of our resources, a cloudformation stack discographql-dev
will be created. We are packaging our Lambda resolvers individually so each Lambda function gets its own bundle. This is useful and allows us some control over bundle size.
The terminal will print out some importnat information, the api key
and the endpoint
, we can use these to test the deployed server using the queries above we used to test locally.
...
appsync api keys:
da*-5**abk*we**m**fitgy**kk**y
endpoints:
appsync endpoints:
https://***fwrd***cmr***s7mci***um.appsync-api.eu-west-2.amazonaws.com/graphql
functions:
createRecording: discographql-dev-createRecording
getRecording: discographql-dev-getRecording
...
Thats all, next post we will add AWS cognito authentication to our server.