airasoul.

Serverless GraphQL with AWS App Sync

Cover Image for Serverless GraphQL with AWS App Sync

The is the first in a 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
  • Opensearch

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.