├── .eslintrc ├── .gitignore ├── .travis.yml ├── README.md ├── lib ├── data │ ├── resolvers │ │ ├── artist.js │ │ └── song.js │ └── types │ │ ├── artist.js │ │ └── song.js ├── dynamo │ ├── artists.js │ ├── dynamo.js │ └── songs.js └── handler.js ├── package.json ├── serverless.yml └── webpack.config.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "plugins": [ 4 | "import" 5 | ], 6 | "globals": { 7 | "expect": true, 8 | "test": true, 9 | "describe": true, 10 | "afterEach": true, 11 | "beforeEach": true 12 | }, 13 | "rules": { 14 | "no-underscore-dangle": off, 15 | "no-console": off 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .serverless 3 | __test__ 4 | coverage 5 | node_modules 6 | npm-debug.log 7 | yarn.lock 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '6.10' 4 | branches: 5 | only: 6 | - master 7 | cache: 8 | directories: 9 | - node_modules 10 | before_install: 11 | - npm i -g serverless 12 | script: 13 | - sls deploy 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![banner](https://user-images.githubusercontent.com/4102106/28962905-7e5aa6d0-7907-11e7-8b0e-022c0cd73a42.png) 2 | 3 | 4 | # Serverless GraphQL API using Lambda and DynamoDB 5 | [![serverless](http://public.serverless.com/badges/v3.svg)](http://www.serverless.com) 6 | [![Build Status](https://travis-ci.org/boazdejong/serverless-graphql-api.svg?branch=master)](https://travis-ci.org/boazdejong/serverless-graphql-api) 7 | 8 | GraphQL Lambda Server using [graphql-server-lambda](https://github.com/apollographql/graphql-server/tree/master/packages/graphql-server-lambda) from [Apollo](http://dev.apollodata.com/). 9 | 10 | [graphql-tools](https://github.com/apollographql/graphql-tools) and [merge-graphql-schemas](https://github.com/okgrow/merge-graphql-schemas) are used to generate the schema. 11 | 12 | [serverless-webpack](https://github.com/elastic-coders/serverless-webpack) is used to transform ES6 with [Babel](https://babeljs.io/) and build the lambda. 13 | 14 | 15 | ## Setup 16 | Clone the repository and install the packages. 17 | 18 | ``` 19 | git clone https://github.com/boazdejong/serverless-graphql-api 20 | cd serverless-graphql-api 21 | npm install 22 | ``` 23 | 24 | ## Deploy 25 | Run the `deploy` script to create the Lambda Function and API Gateway for GraphQL. This also creates two DynamoDB tables named `artists` and `songs` 26 | ``` 27 | npm run deploy 28 | ``` 29 | 30 | ## Queries and Mutations 31 | Query the GraphQL server using the [GraphiQL.app](https://github.com/skevy/graphiql-app). If you have Homebrew installed on OSX run 32 | ``` 33 | brew cask install graphiql 34 | ``` 35 | 36 | ### Mutations 37 | The following mutations are available in this example. 38 | 39 | #### createArtist() 40 | Create an artist providing the first and last name as arguments. The id will be a generated uuid. 41 | ```graphql 42 | mutation { 43 | createArtist(first_name: "Billy", last_name: "Crash") { 44 | id 45 | } 46 | } 47 | ``` 48 | 49 | #### createSong() 50 | Using the generated id from the artist you can create a song with the following mutation. Also provide a title and duration. 51 | ```graphql 52 | mutation { 53 | createSong(artist: "99a746e0-0734-11e7-b2fd-45ae0a3b9074", title: "Whatever", duration: 120) { 54 | id 55 | } 56 | } 57 | ``` 58 | 59 | #### updateArtist() 60 | ```graphql 61 | mutation { 62 | updateArtist(id: "99a746e0-0734-11e7-b2fd-45ae0a3b9074", first_name: "John", last_name: "Ruth") { 63 | id 64 | first_name 65 | last_name 66 | } 67 | } 68 | ``` 69 | 70 | #### updateSong() 71 | ```graphql 72 | mutation { 73 | updateSong(id: "a8a0a060-071b-11e7-bd09-8562f101f7c2", artist: "99a746e0-0734-11e7-b2fd-45ae0a3b9074", duration: 130, title: "A new title") { 74 | id 75 | } 76 | } 77 | ``` 78 | 79 | ### Queries 80 | #### Example query 81 | ```graphql 82 | { 83 | songs { 84 | id 85 | title 86 | duration 87 | artist { 88 | id 89 | first_name 90 | last_name 91 | } 92 | } 93 | } 94 | ``` 95 | 96 | This query will return a result similar to this 97 | ```json 98 | { 99 | "data": { 100 | "songs": [ 101 | { 102 | "id": "a8a0a060-071b-11e7-bd09-8562f101f7c2", 103 | "title": "Whatever", 104 | "duration": 120, 105 | "artist": { 106 | "id": "99a746e0-0734-11e7-b2fd-45ae0a3b9074", 107 | "first_name": "Billy", 108 | "last_name": "Crash" 109 | } 110 | } 111 | ] 112 | } 113 | } 114 | ``` 115 | 116 | ## DynamoDB Streams 117 | This project also includes an example of capturing table activity with DynamoDB Streams. 118 | The `record` lambda function is triggered by two stream events. One for each table. 119 | 120 | In `serverless.yml`: 121 | ``` 122 | record: 123 | handler: lib/handler.record 124 | events: 125 | - stream: 126 | type: dynamodb 127 | arn: 128 | Fn::GetAtt: 129 | - ArtistsDynamoDbTable 130 | - StreamArn 131 | batchSize: 1 132 | - stream: 133 | type: dynamodb 134 | arn: 135 | Fn::GetAtt: 136 | - SongsDynamoDbTable 137 | - StreamArn 138 | batchSize: 1 139 | ``` 140 | 141 | The stream is enabled when defining the DynamoDB table in the `serverless.yml` resources. 142 | ``` 143 | StreamSpecification: 144 | StreamViewType: NEW_AND_OLD_IMAGES 145 | ``` 146 | -------------------------------------------------------------------------------- /lib/data/resolvers/artist.js: -------------------------------------------------------------------------------- 1 | import * as dbArtists from '../../dynamo/artists'; 2 | import * as dbSongs from '../../dynamo/songs'; 3 | 4 | export default { 5 | Query: { 6 | artists: () => dbArtists.getArtists(), 7 | artist: (_, args) => dbArtists.getArtistById(args.id), 8 | }, 9 | Mutation: { 10 | createArtist: (_, args) => dbArtists.createArtist(args), 11 | updateArtist: (_, args) => dbArtists.updateArtist(args), 12 | deleteArtist: (_, args) => dbArtists.deleteArtist(args), 13 | }, 14 | Artist: { 15 | songs: artist => dbSongs.getSongsByArtist(artist.id), 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /lib/data/resolvers/song.js: -------------------------------------------------------------------------------- 1 | import * as dbArtists from '../../dynamo/artists'; 2 | import * as dbSongs from '../../dynamo/songs'; 3 | 4 | export default { 5 | Query: { 6 | songs: () => dbSongs.getSongs(), 7 | song: (_, args) => dbSongs.getSongById(args.id), 8 | }, 9 | Mutation: { 10 | createSong: (_, args) => dbSongs.createSong(args), 11 | updateSong: (_, args) => dbSongs.updateSong(args), 12 | deleteSong: (_, args) => dbSongs.deleteSong(args), 13 | }, 14 | Song: { 15 | artist: song => dbArtists.getArtistById(song.artist), 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /lib/data/types/artist.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | type Artist { 3 | id: ID! 4 | first_name: String 5 | last_name: String 6 | songs: [Song] 7 | } 8 | 9 | type Query { 10 | artists: [Artist] 11 | artist(id: ID!): Artist 12 | } 13 | 14 | type Mutation { 15 | createArtist( 16 | first_name: String! 17 | last_name: String! 18 | ): Artist 19 | updateArtist( 20 | id: ID! 21 | first_name: String! 22 | last_name: String! 23 | ): Artist 24 | deleteArtist( 25 | id: ID! 26 | ): Artist 27 | } 28 | `; 29 | -------------------------------------------------------------------------------- /lib/data/types/song.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | type Song { 3 | id: ID! 4 | title: String 5 | artist: Artist 6 | duration: Int 7 | } 8 | 9 | type Query { 10 | songs: [Song] 11 | song(id: ID!): Song 12 | } 13 | 14 | type Mutation { 15 | createSong( 16 | title: String! 17 | artist: String! 18 | duration: Int! 19 | ): Song 20 | updateSong( 21 | id: ID! 22 | title: String 23 | artist: String 24 | duration: Int 25 | ): Song 26 | deleteSong( 27 | id: ID! 28 | ): Song 29 | } 30 | `; 31 | -------------------------------------------------------------------------------- /lib/dynamo/artists.js: -------------------------------------------------------------------------------- 1 | import uuid from 'uuid/v1'; 2 | import * as db from './dynamo'; 3 | 4 | const TableName = 'artists'; 5 | 6 | export function getArtists() { 7 | const params = { 8 | TableName, 9 | AttributesToGet: [ 10 | 'id', 11 | 'first_name', 12 | 'last_name', 13 | ], 14 | }; 15 | 16 | return db.scan(params); 17 | } 18 | 19 | export function getArtistById(id) { 20 | const params = { 21 | TableName, 22 | Key: { 23 | id, 24 | }, 25 | }; 26 | 27 | return db.get(params); 28 | } 29 | 30 | export function createArtist(args) { 31 | const params = { 32 | TableName, 33 | Item: { 34 | id: uuid(), 35 | first_name: args.first_name, 36 | last_name: args.last_name, 37 | }, 38 | }; 39 | 40 | return db.createItem(params); 41 | } 42 | 43 | export function updateArtist(args) { 44 | const params = { 45 | TableName: 'artists', 46 | Key: { 47 | id: args.id, 48 | }, 49 | ExpressionAttributeValues: { 50 | ':first_name': args.first_name, 51 | ':last_name': args.last_name, 52 | }, 53 | UpdateExpression: 'SET first_name = :first_name, last_name = :last_name', 54 | ReturnValues: 'ALL_NEW', 55 | }; 56 | 57 | return db.updateItem(params, args); 58 | } 59 | 60 | export function deleteArtist(args) { 61 | const params = { 62 | TableName, 63 | Key: { 64 | id: args.id, 65 | }, 66 | }; 67 | 68 | return db.deleteItem(params, args); 69 | } 70 | -------------------------------------------------------------------------------- /lib/dynamo/dynamo.js: -------------------------------------------------------------------------------- 1 | import AWS from 'aws-sdk'; // eslint-disable-line import/no-extraneous-dependencies 2 | 3 | const dynamoDb = new AWS.DynamoDB.DocumentClient(); 4 | 5 | export function scan(params) { 6 | return new Promise((resolve, reject) => 7 | dynamoDb.scan(params).promise() 8 | .then(data => resolve(data.Items)) 9 | .catch(err => reject(err)), 10 | ); 11 | } 12 | 13 | export function get(params) { 14 | return new Promise((resolve, reject) => 15 | dynamoDb.get(params).promise() 16 | .then(data => resolve(data.Item)) 17 | .catch(err => reject(err)), 18 | ); 19 | } 20 | 21 | export function createItem(params) { 22 | return new Promise((resolve, reject) => 23 | dynamoDb.put(params).promise() 24 | .then(() => resolve(params.Item)) 25 | .catch(err => reject(err)), 26 | ); 27 | } 28 | 29 | export function updateItem(params, args) { 30 | return new Promise((resolve, reject) => 31 | dynamoDb.update(params).promise() 32 | .then(() => resolve(args)) 33 | .catch(err => reject(err)), 34 | ); 35 | } 36 | 37 | export function deleteItem(params, args) { 38 | return new Promise((resolve, reject) => 39 | dynamoDb.delete(params).promise() 40 | .then(() => resolve(args)) 41 | .catch(err => reject(err)), 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /lib/dynamo/songs.js: -------------------------------------------------------------------------------- 1 | import uuid from 'uuid/v1'; 2 | import * as db from './dynamo'; 3 | 4 | const TableName = 'songs'; 5 | 6 | export function getSongs() { 7 | const params = { 8 | TableName, 9 | AttributesToGet: [ 10 | 'id', 11 | 'title', 12 | 'artist', 13 | 'duration', 14 | ], 15 | }; 16 | 17 | return db.scan(params); 18 | } 19 | 20 | export function getSongById(id) { 21 | const params = { 22 | TableName, 23 | Key: { 24 | id, 25 | }, 26 | }; 27 | 28 | return db.get(params); 29 | } 30 | 31 | export function getSongsByArtist(artistId) { 32 | const params = { 33 | TableName, 34 | FilterExpression: 'artist = :artist_id', 35 | ExpressionAttributeValues: { ':artist_id': artistId }, 36 | }; 37 | 38 | return db.scan(params); 39 | } 40 | 41 | export function createSong(args) { 42 | const params = { 43 | TableName, 44 | Item: { 45 | id: uuid(), 46 | title: args.title, 47 | artist: args.artist, 48 | duration: args.duration, 49 | }, 50 | }; 51 | 52 | return db.createItem(params); 53 | } 54 | 55 | export function updateSong(args) { 56 | const params = { 57 | TableName: 'songs', 58 | Key: { 59 | id: args.id, 60 | }, 61 | ExpressionAttributeNames: { 62 | '#song_duration': 'duration', 63 | }, 64 | ExpressionAttributeValues: { 65 | ':title': args.title, 66 | ':artist': args.artist, 67 | ':duration': args.duration, 68 | }, 69 | UpdateExpression: 'SET title = :title, artist = :artist, #song_duration = :duration', 70 | ReturnValues: 'ALL_NEW', 71 | }; 72 | 73 | return db.updateItem(params, args); 74 | } 75 | 76 | export function deleteSong(args) { 77 | const params = { 78 | TableName, 79 | Key: { 80 | id: args.id, 81 | }, 82 | }; 83 | 84 | return db.deleteItem(params, args); 85 | } 86 | -------------------------------------------------------------------------------- /lib/handler.js: -------------------------------------------------------------------------------- 1 | import { graphqlLambda } from 'graphql-server-lambda'; 2 | import { makeExecutableSchema } from 'graphql-tools'; 3 | import { mergeResolvers, mergeTypes } from 'merge-graphql-schemas'; 4 | 5 | // Types 6 | import artistType from './data/types/artist'; 7 | import songType from './data/types/song'; 8 | 9 | // Resolvers 10 | import artistResolver from './data/resolvers/artist'; 11 | import songResolver from './data/resolvers/song'; 12 | 13 | const typeDefs = mergeTypes([artistType, songType]); 14 | const resolvers = mergeResolvers([artistResolver, songResolver]); 15 | 16 | const schema = makeExecutableSchema({ 17 | typeDefs, 18 | resolvers, 19 | }); 20 | 21 | exports.graphql = (event, context, callback) => { 22 | const callbackFilter = (error, output) => { 23 | const outputWithHeader = Object.assign({}, output, { 24 | headers: { 25 | 'Access-Control-Allow-Origin': '*', 26 | }, 27 | }); 28 | callback(error, outputWithHeader); 29 | }; 30 | 31 | graphqlLambda({ schema })(event, context, callbackFilter); 32 | }; 33 | 34 | exports.record = (event, context, callback) => { 35 | event.Records.forEach((record) => { 36 | console.log(record.eventID); 37 | console.log(record.eventName); 38 | console.log('DynamoDB Record: %j', record.dynamodb); 39 | }); 40 | callback(null, `Successfully processed ${event.Records.length} records.`); 41 | }; 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-api", 3 | "scripts": { 4 | "deploy": "npm-run-all lint:js sls", 5 | "jest": "jest --coverage", 6 | "jest:u": "jest -u --coverage", 7 | "lint:js": "eslint lib", 8 | "lint:js:fix": "eslint lib --fix", 9 | "sls": "sls deploy --verbose", 10 | "test": "npm-run-all lint:js copy babel jest", 11 | "test:u": "npm-run-all lint:js copy babel jest:u" 12 | }, 13 | "dependencies": { 14 | "aws-sdk": "^2.94.0", 15 | "babel-runtime": "^6.25.0", 16 | "graphql": "^0.10.5", 17 | "graphql-server-lambda": "^1.0.4", 18 | "graphql-tools": "^1.1.0", 19 | "merge-graphql-schemas": "^1.1.1", 20 | "uuid": "^3.1.0" 21 | }, 22 | "devDependencies": { 23 | "aws-sdk-mock": "^1.7.0", 24 | "babel-core": "^6.23.1", 25 | "babel-loader": "^7.1.1", 26 | "babel-preset-es2015": "^6.24.1", 27 | "eslint": "^4.3.0", 28 | "eslint-config-airbnb-base": "^11.1.0", 29 | "eslint-plugin-import": "^2.2.0", 30 | "imports-loader": "^0.7.1", 31 | "jest-cli": "^20.0.4", 32 | "lambda-tester": "^3.0.2", 33 | "npm-run-all": "^4.0.2", 34 | "serverless-webpack": "^2.2.0", 35 | "webpack": "^3.4.1", 36 | "webpack-node-externals": "^1.6.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: graphql-api 2 | 3 | plugins: 4 | - serverless-webpack 5 | 6 | custom: 7 | webpackIncludeModules: true 8 | 9 | provider: 10 | name: aws 11 | runtime: nodejs4.3 12 | iamRoleStatements: 13 | - Effect: Allow 14 | Action: 15 | - dynamodb:DescribeTable 16 | - dynamodb:Query 17 | - dynamodb:Scan 18 | - dynamodb:GetItem 19 | - dynamodb:PutItem 20 | - dynamodb:UpdateItem 21 | - dynamodb:DeleteItem 22 | - dynamodb:GetRecords 23 | - dynamodb:GetShardIterator 24 | - dynamodb:DescribeStream 25 | - dynamodb:ListStreams 26 | Resource: arn:aws:dynamodb:us-east-1:*:* 27 | 28 | functions: 29 | graphql: 30 | handler: lib/handler.graphql 31 | events: 32 | - http: 33 | path: graphql 34 | method: post 35 | cors: true 36 | record: 37 | handler: lib/handler.record 38 | events: 39 | - stream: 40 | type: dynamodb 41 | arn: 42 | Fn::GetAtt: 43 | - ArtistsDynamoDbTable 44 | - StreamArn 45 | batchSize: 1 46 | - stream: 47 | type: dynamodb 48 | arn: 49 | Fn::GetAtt: 50 | - SongsDynamoDbTable 51 | - StreamArn 52 | batchSize: 1 53 | 54 | resources: 55 | Resources: 56 | ArtistsDynamoDbTable: 57 | Type: AWS::DynamoDB::Table 58 | DeletionPolicy: Retain 59 | Properties: 60 | TableName: 'artists' 61 | AttributeDefinitions: 62 | - AttributeName: id 63 | AttributeType: S 64 | KeySchema: 65 | - AttributeName: id 66 | KeyType: HASH 67 | ProvisionedThroughput: 68 | ReadCapacityUnits: 1 69 | WriteCapacityUnits: 1 70 | StreamSpecification: 71 | StreamViewType: NEW_AND_OLD_IMAGES 72 | SongsDynamoDbTable: 73 | Type: AWS::DynamoDB::Table 74 | DeletionPolicy: Retain 75 | Properties: 76 | TableName: 'songs' 77 | AttributeDefinitions: 78 | - AttributeName: id 79 | AttributeType: S 80 | KeySchema: 81 | - AttributeName: id 82 | KeyType: HASH 83 | ProvisionedThroughput: 84 | ReadCapacityUnits: 1 85 | WriteCapacityUnits: 1 86 | StreamSpecification: 87 | StreamViewType: NEW_AND_OLD_IMAGES 88 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const nodeExternals = require('webpack-node-externals'); 3 | const slsw = require('serverless-webpack'); 4 | 5 | module.exports = { 6 | entry: slsw.lib.entries, 7 | target: 'node', 8 | externals: [nodeExternals()], 9 | module: { 10 | rules: [{ 11 | test: /\.js$/, 12 | use: [ 13 | 'imports-loader?graphql', 14 | { 15 | loader: 'babel-loader', 16 | options: { 17 | presets: ['es2015'], 18 | }, 19 | }, 20 | ], 21 | }], 22 | }, 23 | output: { 24 | libraryTarget: 'commonjs', 25 | path: path.join(__dirname, '.webpack'), 26 | filename: '[name].js', 27 | }, 28 | }; 29 | --------------------------------------------------------------------------------