├── .dockerignore ├── .vscode ├── extensions.json └── launch.json ├── jsconfig.json ├── jest.config.js ├── .prettierrc ├── .gitignore ├── serverless.yml ├── Dockerfile ├── src ├── utils │ ├── request.util.js │ ├── response.util.js │ ├── request.util.spec.js │ └── response.util.spec.js ├── dynamodb.factory.js ├── handlers │ └── contacts │ │ ├── list.js │ │ ├── delete.js │ │ ├── get.js │ │ ├── add.js │ │ ├── contacts.serverless.yml │ │ ├── update.js │ │ └── contacts.spec.js ├── dynamodb.factory.spec.js └── repositories │ ├── contact.repository.js │ └── contact.repository.spec.js ├── index.d.ts ├── seed ├── contacts-test-data.json ├── runner.js └── contact.seeder.js ├── docker-compose.yml ├── package.json ├── LICENSE ├── README.MD └── contacts-api.postman.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | coverage/ 3 | node_modules/ 4 | 5 | .env 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "commonjs" 5 | } 6 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | // per issue: https://github.com/jsdom/jsdom/issues/2304 4 | testURL: 'http://localhost/' 5 | }; -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "singleQuote": true, 5 | "arrowParens": "always", 6 | "printWidth": 80 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules/ 3 | 4 | # Serverless directories 5 | .serverless/ 6 | .dynamodb 7 | 8 | # files 9 | .DS_Store 10 | .env 11 | 12 | # tests 13 | coverage/ -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: contacts-api 2 | 3 | provider: 4 | name: aws 5 | runtime: nodejs12.x 6 | stage: dev 7 | region: us-west-2 8 | 9 | functions: 10 | - '${file(src/handlers/contacts/contacts.serverless.yml)}' 11 | 12 | plugins: 13 | - serverless-offline 14 | 15 | custom: 16 | serverless-offline: 17 | host: 0.0.0.0 # for docker container hosting 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # use this dockerfile to build an image for this api 2 | 3 | FROM node:12.18.0 4 | 5 | WORKDIR /usr/src/app 6 | COPY package*.json ./ 7 | 8 | RUN npm install 9 | 10 | COPY . . 11 | 12 | EXPOSE 3000 13 | 14 | # ENV AWS_ENDPOINT='http://localhost:8000' 15 | # ENV AWS_REGION='us-west-1' 16 | # ENV AWS_ACCESS_KEY_ID='from-dockerfile-fake-access-key' 17 | # ENV AWS_SECRET_ACCESS_KEY='from-dockerfile-fake-secret-key' 18 | 19 | CMD [ "npm", "start" ] 20 | -------------------------------------------------------------------------------- /src/utils/request.util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses the body of a request using a parser 3 | * @param {(text: string) => any} parser The parser to use 4 | * @returns {(text: string) => any} The function that will use the parser 5 | */ 6 | const parseWith = (parser) => (text) => { 7 | if (!parser) { 8 | throw new Error('parser'); 9 | } 10 | 11 | if (!text) { 12 | throw new Error('text'); 13 | } 14 | 15 | return parser(text); 16 | }; 17 | 18 | module.exports = { 19 | parseWith 20 | }; -------------------------------------------------------------------------------- /src/dynamodb.factory.js: -------------------------------------------------------------------------------- 1 | const { DocumentClient } = require('aws-sdk/clients/dynamodb'); 2 | 3 | const withProcessEnv = ({ 4 | AWS_ENDPOINT, 5 | AWS_REGION, 6 | AWS_ACCESS_KEY_ID, 7 | AWS_SECRET_ACCESS_KEY, 8 | }) => () => { 9 | const options = { 10 | endpoint: AWS_ENDPOINT, 11 | region: AWS_REGION, 12 | accessKeyId: AWS_ACCESS_KEY_ID, 13 | secretAccessKey: AWS_SECRET_ACCESS_KEY, 14 | }; 15 | 16 | return new DocumentClient(options); 17 | }; 18 | 19 | module.exports = { 20 | withProcessEnv, 21 | }; 22 | -------------------------------------------------------------------------------- /src/handlers/contacts/list.js: -------------------------------------------------------------------------------- 1 | require('dotenv/config'); 2 | 3 | const { ContactRepository } = require('../../repositories/contact.repository'); 4 | const { withStatusCode } = require('../../utils/response.util'); 5 | const { withProcessEnv } = require('../../dynamodb.factory'); 6 | 7 | const docClient = withProcessEnv(process.env)(); 8 | const repository = new ContactRepository(docClient); 9 | const ok = withStatusCode(200, JSON.stringify); 10 | 11 | exports.handler = async (event) => { 12 | const contacts = await repository.list(); 13 | 14 | return ok(contacts); 15 | }; -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | namespace Models { 2 | interface Contact { 3 | id: string; 4 | firstName: string; 5 | lastName: string; 6 | phones: PhoneNumber[]; 7 | addresses: Address[]; 8 | } 9 | 10 | interface PhoneNumber { 11 | country: number; 12 | area: number; 13 | no: number; 14 | ext: number; 15 | } 16 | 17 | interface Address { 18 | address1: string; 19 | address2: string 20 | city: string; 21 | state: string; 22 | zip: number; 23 | } 24 | 25 | interface Response { 26 | statusCode: number; 27 | body?: string | any; 28 | } 29 | } -------------------------------------------------------------------------------- /src/handlers/contacts/delete.js: -------------------------------------------------------------------------------- 1 | require('dotenv/config'); 2 | 3 | const { ContactRepository } = require('../../repositories/contact.repository'); 4 | const { withStatusCode } = require('../../utils/response.util'); 5 | const { withProcessEnv } = require('../../dynamodb.factory'); 6 | 7 | const docClient = withProcessEnv(process.env)(); 8 | const repository = new ContactRepository(docClient); 9 | const noContent = withStatusCode(204); 10 | 11 | exports.handler = async (event) => { 12 | const { id } = event.pathParameters; 13 | 14 | await repository.delete(id); 15 | 16 | return noContent(); 17 | }; -------------------------------------------------------------------------------- /src/dynamodb.factory.spec.js: -------------------------------------------------------------------------------- 1 | describe('Dynamo DB factory', () => { 2 | const mockDocumentClient = jest.fn(); 3 | 4 | jest.mock('aws-sdk/clients/dynamodb', () => ({ DocumentClient: mockDocumentClient })); 5 | 6 | beforeEach(() => { 7 | jest.resetAllMocks(); 8 | }); 9 | 10 | it('should get an instance of a DocumentClient', () => { 11 | const { withProcessEnv } = require('./dynamodb.factory'); 12 | 13 | const documentClientCreator = withProcessEnv({}); 14 | const docClient = documentClientCreator(); 15 | 16 | expect(docClient).toBeDefined(); 17 | 18 | expect(mockDocumentClient).toHaveBeenCalled(); 19 | }); 20 | }); -------------------------------------------------------------------------------- /src/handlers/contacts/get.js: -------------------------------------------------------------------------------- 1 | 2 | require('dotenv/config'); 3 | 4 | const { ContactRepository } = require('../../repositories/contact.repository'); 5 | const { withStatusCode } = require('../../utils/response.util'); 6 | const { withProcessEnv } = require('../../dynamodb.factory'); 7 | 8 | const docClient = withProcessEnv(process.env)(); 9 | const repository = new ContactRepository(docClient); 10 | const ok = withStatusCode(200, JSON.stringify); 11 | const notFound = withStatusCode(404); 12 | 13 | exports.handler = async (event) => { 14 | const { id } = event.pathParameters; 15 | const contact = await repository.get(id); 16 | 17 | if (!contact){ 18 | return notFound(); 19 | } 20 | 21 | return ok(contact); 22 | }; -------------------------------------------------------------------------------- /src/handlers/contacts/add.js: -------------------------------------------------------------------------------- 1 | require('dotenv/config'); 2 | 3 | const { ContactRepository } = require('../../repositories/contact.repository'); 4 | const { withStatusCode } = require('../../utils/response.util'); 5 | const { parseWith } = require('../../utils/request.util'); 6 | const { withProcessEnv } = require('../../dynamodb.factory'); 7 | 8 | const docClient = withProcessEnv(process.env)(); 9 | const repository = new ContactRepository(docClient); 10 | const created = withStatusCode(201); 11 | const parseJson = parseWith(JSON.parse); 12 | 13 | exports.handler = async (event) => { 14 | const { body } = event; 15 | const contact = parseJson(body); 16 | 17 | await repository.put(contact); 18 | 19 | return created(); 20 | }; -------------------------------------------------------------------------------- /src/handlers/contacts/contacts.serverless.yml: -------------------------------------------------------------------------------- 1 | 2 | # handler paths are relative to the root serverless.yml file that this is being merged into 3 | # they are also compiled by babel into a 'dist' folder at the root 4 | list: 5 | handler: src/handlers/contacts/list.handler 6 | events: 7 | - http: get /contacts 8 | get: 9 | handler: src/handlers/contacts/get.handler 10 | events: 11 | - http: get /contact/{id} 12 | add: 13 | handler: src/handlers/contacts/add.handler 14 | events: 15 | - http: post /contact 16 | update: 17 | handler: src/handlers/contacts/update.handler 18 | events: 19 | - http: put /contact/{id} 20 | delete: 21 | handler: src/handlers/contacts/delete.handler 22 | events: 23 | - http: delete /contact/{id} -------------------------------------------------------------------------------- /seed/contacts-test-data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "firstName": "Luke", 5 | "lastName": "Skywalker", 6 | "phones": [ 7 | { 8 | "country": 1, 9 | "area": 425, 10 | "no": 5551234 11 | } 12 | ], 13 | "addresses": [ 14 | { 15 | "address1": "456 Some Other Street", 16 | "city": "Bellevue", 17 | "state": "WA", 18 | "zip": 98004 19 | } 20 | ] 21 | }, 22 | { 23 | "id": "2", 24 | "firstName": "Jin", 25 | "lastName": "Erso", 26 | "phones": [ 27 | { 28 | "country": 1, 29 | "area": 206, 30 | "no": 8675309, 31 | "ext": 123 32 | } 33 | ], 34 | "addresses": [ 35 | { 36 | "address1": "123 Some Street", 37 | "address2": "Apt 456", 38 | "city": "Seattle", 39 | "state": "WA", 40 | "zip": 98101 41 | } 42 | ] 43 | } 44 | ] -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Debug - Local", 11 | "program": "${workspaceFolder}/node_modules/.bin/sls", 12 | "args": [ 13 | "offline", 14 | "start" 15 | ] 16 | }, 17 | { 18 | "name": "Debug Jest - Current File", 19 | "type": "node", 20 | "request": "launch", 21 | "runtimeArgs": [ 22 | "--inspect-brk", 23 | "${workspaceRoot}/node_modules/.bin/jest", 24 | "--runInBand" 25 | ], 26 | "args": [ 27 | "${fileBasenameNoExtension}" 28 | ], 29 | "console": "integratedTerminal", 30 | "internalConsoleOptions": "neverOpen", 31 | "port": 9229 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | # builds the api container and sets up the localstack container to run 4 | 5 | services: 6 | api: 7 | build: . 8 | image: vanister/contacts_api 9 | depends_on: 10 | - localstack 11 | ports: 12 | - '3000:3000' 13 | container_name: contacts_api 14 | 15 | # these are the environment variables that are used in the api 16 | environment: 17 | AWS_ENDPOINT: 'http://dynamodb_localstack:8000' # localstack container host for dynamodb 18 | AWS_REGION: 'us-west-2' 19 | AWS_ACCESS_KEY_ID: 'fake-access-key-id' 20 | AWS_SECRET_ACCESS_KEY: 'fake-secret-key' 21 | 22 | localstack: 23 | image: localstack/localstack:latest 24 | ports: 25 | - '8000:8000' # using port 8000 to be consistent with dynamodb local jar 26 | - '4566:4566' # new dynamodb port 27 | - '8080:8080' # the localstack admin portal 28 | container_name: dynamodb_localstack 29 | environment: 30 | SERVICES: dynamodb:8000 31 | DATA_DIR: '/tmp/localstack/data' 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contacts-api", 3 | "version": "1.0.0", 4 | "description": "Contacts API", 5 | "private": true, 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/vanister/contacts_api" 9 | }, 10 | "author": "vanister", 11 | "license": "MIT", 12 | "keywords": [ 13 | "nodejs", 14 | "aws", 15 | "lamdas", 16 | "serverless", 17 | "dynamodb", 18 | "jest", 19 | "es6", 20 | "docker" 21 | ], 22 | "scripts": { 23 | "sls": "serverless", 24 | "seed": "node ./seed/runner.js", 25 | "start": "sls offline start", 26 | "test": "jest", 27 | "test:coverage": "jest --coverage", 28 | "test:v": "npm test -- --verbose", 29 | "deploy": "serverless deploy --aws-profile serverless", 30 | "package": "serverless package" 31 | }, 32 | "engines": { 33 | "node": "~12.16.0" 34 | }, 35 | "dependencies": { 36 | "dotenv": "^8.2.0" 37 | }, 38 | "devDependencies": { 39 | "aws-sdk": "^2.694.0", 40 | "jest": "^26.0.1", 41 | "serverless": "^1.72.0", 42 | "serverless-offline": "^6.4.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/response.util.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Creates a response with a given status code and a formatter 4 | * @param {number} statusCode The response status code 5 | * @param {(data: any) => string} formatter The data formatter function 6 | * @returns {(data?: any) => Models.Response} A function to create a response with a status code 7 | */ 8 | const withStatusCode = (statusCode, formatter = null) => { 9 | if (100 > statusCode || statusCode > 599) { 10 | throw new Error('status code out of range'); 11 | } 12 | 13 | const hasFormatter = typeof formatter === 'function'; 14 | // send whatever was passed in through if a formatter is not provided 15 | const format = hasFormatter ? formatter : _ => _; 16 | 17 | // return a function that will take some data and formats a response with a status code 18 | return (data = null) => { 19 | const response = { 20 | statusCode: statusCode 21 | }; 22 | 23 | // only send a body if there is data 24 | if (data) { 25 | response.body = format(data); 26 | } 27 | 28 | return response; 29 | } 30 | }; 31 | 32 | module.exports = { 33 | withStatusCode 34 | }; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 vanister 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils/request.util.spec.js: -------------------------------------------------------------------------------- 1 | const { parseWith } = require('./request.util'); 2 | 3 | const mockJSON = { 4 | parse: (text) => null 5 | }; 6 | 7 | describe('Request Utility', () => { 8 | beforeEach(() => jest.resetAllMocks()); 9 | 10 | it('should parse the body', () => { 11 | const body = JSON.stringify({ id: '1', name: 'Jin Erso' }); 12 | 13 | jest.spyOn(mockJSON, 'parse').mockReturnValue(JSON.parse(body)); 14 | 15 | const parseJson = parseWith(mockJSON.parse); 16 | 17 | const expected = { 18 | id: '1', 19 | name: 'Jin Erso' 20 | }; 21 | 22 | const parsed = parseJson(body); 23 | 24 | expect(parsed).toEqual(expected); 25 | expect(mockJSON.parse).toHaveBeenCalled(); 26 | }); 27 | 28 | it('should error if body cannot be parsed', () => { 29 | const body = `{"id": "1", "name": "Jin Erso"`; // bad json string 30 | const parseJson = parseWith(null); 31 | 32 | expect(() => parseJson(body)).toThrowError('parser'); 33 | }); 34 | 35 | it('should error if body is not defined', () => { 36 | const body = null; 37 | const parseJson = parseWith(mockJSON.parse); 38 | 39 | expect(() => parseJson(body)).toThrowError('text'); 40 | }); 41 | }); -------------------------------------------------------------------------------- /src/handlers/contacts/update.js: -------------------------------------------------------------------------------- 1 | require('dotenv/config'); 2 | 3 | const { ContactRepository } = require('../../repositories/contact.repository'); 4 | const { withStatusCode } = require('../../utils/response.util'); 5 | const { parseWith } = require('../../utils/request.util'); 6 | const { withProcessEnv } = require('../../dynamodb.factory'); 7 | 8 | const docClient = withProcessEnv(process.env)(); 9 | const repository = new ContactRepository(docClient); 10 | const ok = withStatusCode(200); 11 | const badRequest = withStatusCode(400); 12 | const notFound = withStatusCode(404); 13 | const parseJson = parseWith(JSON.parse); 14 | 15 | exports.handler = async (event) => { 16 | const { body, pathParameters } = event; 17 | const { id } = pathParameters; 18 | 19 | const existingContact = await repository.get(id); 20 | const contact = parseJson(body); 21 | 22 | if (!existingContact) { 23 | return notFound(); 24 | } 25 | 26 | if (existingContact.id !== contact.id) { 27 | return badRequest(); 28 | } 29 | 30 | // todo: merge or overwrite? 31 | const updatedContact = Object.assign({}, existingContact, contact); 32 | 33 | await repository.put(updatedContact); 34 | 35 | return ok(updatedContact); 36 | }; -------------------------------------------------------------------------------- /seed/runner.js: -------------------------------------------------------------------------------- 1 | require('dotenv/config'); 2 | 3 | const AWS = require('aws-sdk'); 4 | const { DynamoDB } = AWS; 5 | const { DocumentClient } = DynamoDB; 6 | 7 | const { ContactSeeder } = require('./contact.seeder'); 8 | const contactsData = require('./contacts-test-data.json'); 9 | 10 | const dynamo = new DynamoDB({ 11 | endpoint: process.env.AWS_ENDPOINT, 12 | region: process.env.AWS_REGION, 13 | accessKeyId: process.env.AWS_ACCESS_KEY_ID, 14 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, 15 | }); 16 | 17 | const doclient = new DocumentClient({ service: dynamo }); 18 | const contactSeeder = new ContactSeeder(dynamo, doclient); 19 | 20 | const log = (...mgs) => console.log('>>', ...mgs); 21 | 22 | const seedContacts = async () => { 23 | log(`Checking if 'contacts' table exists`); 24 | 25 | const exists = await contactSeeder.hasTable(); 26 | 27 | if (exists) { 28 | log(`Table 'contacts' exists, deleting`); 29 | await contactSeeder.deleteTable(); 30 | } 31 | 32 | log(`Creating 'contacts' table`); 33 | await contactSeeder.createTable(); 34 | 35 | log('Seeding data'); 36 | await contactSeeder.seed(contactsData); 37 | }; 38 | 39 | (async () => { 40 | try { 41 | await seedContacts(); 42 | } catch (error) { 43 | console.error(error); 44 | } 45 | })(); 46 | -------------------------------------------------------------------------------- /src/repositories/contact.repository.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The Contact Repository 3 | */ 4 | class ContactRepository { 5 | get _baseParams() { 6 | return { 7 | TableName: 'contacts' 8 | }; 9 | } 10 | 11 | /** 12 | * Contructs a new contact repository 13 | * @param {AWS.DynamoDB.DocumentClient} documentClient The Document Client 14 | */ 15 | constructor(documentClient) { 16 | this._documentClient = documentClient; 17 | } 18 | 19 | /** 20 | * Gets a list of contacts 21 | * @returns {Promise} A list of contacts 22 | */ 23 | async list() { 24 | const params = this._createParamObject(); 25 | const response = await this._documentClient.scan(params).promise(); 26 | 27 | return response.Items || []; 28 | } 29 | 30 | /** 31 | * Gets a contact by id 32 | * @param {string} id The contact id 33 | * @returns {Promise} The contact 34 | */ 35 | async get(id) { 36 | const params = this._createParamObject({ Key: { id } }); 37 | const response = await this._documentClient.get(params).promise(); 38 | 39 | return response.Item; 40 | } 41 | 42 | /** 43 | * Add or replace a contact 44 | * @param {Models.Contact} contact The contact 45 | * @returns {Promise} The contact 46 | */ 47 | async put(contact) { 48 | const params = this._createParamObject({ Item: contact }); 49 | await this._documentClient.put(params).promise(); 50 | 51 | return contact; 52 | } 53 | 54 | /** 55 | * Deletes a contact by id 56 | * @param {string} id The contact id 57 | * @return {Promise} The id of the deleted contact 58 | */ 59 | async delete(id) { 60 | const params = this._createParamObject({ Key: { id } }); 61 | await this._documentClient.delete(params).promise(); 62 | 63 | return id; 64 | } 65 | 66 | _createParamObject(additionalArgs = {}) { 67 | return Object.assign({}, this._baseParams, additionalArgs); 68 | } 69 | } 70 | 71 | exports.ContactRepository = ContactRepository; -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # Contacts API 2 | 3 | Source for the article: [Build a RESTful API using AWS Lambda, API Gateway, DynamoDB and the Serverless Framework](https://itnext.io/build-a-restful-api-using-aws-lambda-api-gateway-dynamodb-and-the-serverless-framework-30fc68e08a42) 4 | 5 | ## Setup 6 | 7 | NOTE: If you have Docker installed, instead of installing Java and downloading the DynamoDb-Local jar file to run, you can run `docker-compose up -d localstack` command against the `docker-compose.yml` file in this project to set up DynamoDb running in a LocalStack container with persisted data turned on. 8 | 9 | --- 10 | 11 | Download the DynamoDb-Local jar file for your system [here](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.html). 12 | 13 | - Extract the jar file to the root of this repository (the parent of this project folder) under the name: `dynamodb_local`. 14 | 15 | Create a `.env` file at the root of the project and add in your own values for these enviornment variables. 16 | 17 | - `AWS_ENDPOINT='http://localhost:8000/'` `# or http://localhost:4566` 18 | - `AWS_REGION='localhost'` 19 | - `AWS_ACCESS_KEY_ID='fake-access-key'` 20 | - `AWS_SECRET_ACCESS_KEY='fake-secret-key'` 21 | 22 | **IMPORTANT: DO NOT COMMIT THE `.env` FILE!!!** 23 | 24 | ## Running 25 | 26 | ### DynamoDB Local 27 | 28 | - Open a terminal at the the folder where you extracted the jar file (Setup section). 29 | - Run: `java -Djava.library.path=./DynamoDBLocal_lib -jar DynamoDBLocal.jar -sharedDb` to start it on the default port: `8000`. 30 | - Open a browser at: `http://localhost:8000/shell` to interact with DynamoDB through the interactive shell. 31 | 32 | ...Or with LocalStack Docker container 33 | - Run `docker-compose up -d localstack` 34 | - If you already run the `docker-compose.yml` file and didn't teardown the container, start it again with, `docker-compose start`. 35 | 36 | ### Lambda Functions 37 | 38 | - Run `npm run seed` to seed some test data. 39 | - Run `npm start` to start the functions locally. 40 | 41 | Test by trying to hit an Api endpoint. 42 | 43 | - `curl -i localhost:3000/contacts` 44 | -------------------------------------------------------------------------------- /src/utils/response.util.spec.js: -------------------------------------------------------------------------------- 1 | const { withStatusCode } = require('./response.util'); 2 | 3 | describe('Response Utilites', () => { 4 | const mockJSON = { 5 | stringify: (val) => 'turned into JSON' 6 | }; 7 | 8 | beforeEach(() => jest.resetAllMocks()); 9 | 10 | it('should be able to create a status code response', () => { 11 | const data = { 12 | id: '1', 13 | name: 'Jin Erso' 14 | }; 15 | 16 | jest.spyOn(mockJSON, 'stringify').mockReturnValue(JSON.stringify(data)); 17 | 18 | const success = withStatusCode(200, mockJSON.stringify); 19 | 20 | const expectedResponse = { 21 | statusCode: 200, 22 | body: JSON.stringify(data) 23 | }; 24 | 25 | const response = success(data); 26 | 27 | expect(response).toEqual(expectedResponse); 28 | expect(mockJSON.stringify).toHaveBeenCalled(); 29 | }); 30 | 31 | it('should fail when a status code is out of range', () => { 32 | const lowstatus = 90; 33 | const highstatus = 600; 34 | const expectedError = 'status code out of range'; 35 | 36 | expect(() => withStatusCode(lowstatus)).toThrowError(expectedError); 37 | expect(() => withStatusCode(highstatus)).toThrowError(expectedError); 38 | }); 39 | 40 | it('should not format body data when a formatter is not used', () => { 41 | jest.spyOn(mockJSON, 'stringify'); 42 | 43 | const success = withStatusCode(200); 44 | 45 | const data = { 46 | id: '2', 47 | name: 'Luke Skywalker' 48 | }; 49 | 50 | const expectedResponse = { 51 | statusCode: 200, 52 | body: data 53 | }; 54 | 55 | const response = success(data); 56 | 57 | expect(response).toEqual(expectedResponse); 58 | expect(mockJSON.stringify).not.toHaveBeenCalled(); 59 | }); 60 | 61 | it('should return a response with no body when data is used', () => { 62 | jest.spyOn(mockJSON, 'stringify'); 63 | 64 | const notfound = withStatusCode(404); 65 | 66 | const expectedResponse = { 67 | statusCode: 404 68 | }; 69 | 70 | const response = notfound(); 71 | 72 | expect(response).toEqual(expectedResponse); 73 | expect(mockJSON.stringify).not.toHaveBeenCalled(); 74 | }); 75 | }); -------------------------------------------------------------------------------- /seed/contact.seeder.js: -------------------------------------------------------------------------------- 1 | class ContactSeeder { 2 | /** 3 | * Constructs a new Contacts seeder 4 | * @param {AWS.DynamoDB} dynamodb The dynamo db instance 5 | * @param {AWS.DynamoDB.DocumentClient} docClient The dynamo db document client 6 | */ 7 | constructor(dynamodb, docClient) { 8 | this.dynamodb = dynamodb; 9 | this.docClient = docClient; 10 | 11 | this._tablename = 'contacts'; 12 | } 13 | 14 | async hasTable() { 15 | const tables = await this.dynamodb.listTables({ Limit: 5 }).promise(); 16 | 17 | return tables.TableNames && tables.TableNames.indexOf(this._tablename) >= 0; 18 | } 19 | 20 | async createTable() { 21 | const tableParams = { 22 | TableName: this._tablename, 23 | KeySchema: [ 24 | // The type of of schema. Must start with a HASH type, with an optional second RANGE. 25 | { 26 | // Required HASH type attribute 27 | AttributeName: 'id', 28 | KeyType: 'HASH', 29 | } 30 | ], 31 | AttributeDefinitions: [ 32 | // The names and types of all primary and index key attributes only 33 | { 34 | AttributeName: 'id', 35 | AttributeType: 'S', // (S | N | B) for string, number, binary 36 | } 37 | ], 38 | ProvisionedThroughput: { // required provisioned throughput for the table 39 | ReadCapacityUnits: 1, 40 | WriteCapacityUnits: 1, 41 | } 42 | }; 43 | 44 | const result = await this.dynamodb.createTable(tableParams).promise(); 45 | 46 | return !!result.$response.error; 47 | } 48 | 49 | async deleteTable() { 50 | const result = await this.dynamodb.deleteTable({ TableName: this._tablename }).promise(); 51 | 52 | return !!result.$response.err 53 | } 54 | 55 | /** 56 | * @param {AddressBook.Contact[]} contacts The seed data 57 | */ 58 | async seed(contacts = []) { 59 | // map the contact entries to a put request object 60 | const putRequests = contacts.map(c => ({ 61 | PutRequest: { 62 | Item: Object.assign({}, c) 63 | } 64 | })); 65 | 66 | // set the request items param with the put requests 67 | const params = { 68 | RequestItems: { 69 | [this._tablename]: putRequests 70 | } 71 | }; 72 | 73 | await this.docClient.batchWrite(params).promise(); 74 | } 75 | } 76 | 77 | exports.ContactSeeder = ContactSeeder; -------------------------------------------------------------------------------- /contacts-api.postman.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "35d33b95-e9cb-4319-b7b6-911d38e785c3", 4 | "name": "Contacts API", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "List contacts", 10 | "request": { 11 | "method": "GET", 12 | "header": [], 13 | "body": {}, 14 | "url": { 15 | "raw": "{{baseurl}}/contacts", 16 | "host": [ 17 | "{{baseurl}}" 18 | ], 19 | "path": [ 20 | "contacts" 21 | ] 22 | } 23 | }, 24 | "response": [] 25 | }, 26 | { 27 | "name": "Get contact by id", 28 | "request": { 29 | "method": "GET", 30 | "header": [], 31 | "body": {}, 32 | "url": { 33 | "raw": "{{baseurl}}/contact/1", 34 | "host": [ 35 | "{{baseurl}}" 36 | ], 37 | "path": [ 38 | "contact", 39 | "1" 40 | ] 41 | } 42 | }, 43 | "response": [] 44 | }, 45 | { 46 | "name": "Add contact (id: 3)", 47 | "request": { 48 | "method": "POST", 49 | "header": [ 50 | { 51 | "key": "Content-Type", 52 | "value": "application/json" 53 | } 54 | ], 55 | "body": { 56 | "mode": "raw", 57 | "raw": "{\n\t\"firstName\": \"Kylo\",\n\t\"lastName\": \"Ren\",\n\t\"phones\": [\n\t {\n\t \"area\": 425,\n\t \"country\": 1,\n\t \"no\": 5556789\n\t }\n\t],\n\t\"addresses\": [\n\t {\n\t \"zip\": 98004,\n\t \"state\": \"WA\",\n\t \"city\": \"Bellevue\",\n\t \"address1\": \"789 Some Other Street\"\n\t }\n\t],\n\t\"id\": \"3\"\n}" 58 | }, 59 | "url": { 60 | "raw": "{{baseurl}}/contact", 61 | "host": [ 62 | "{{baseurl}}" 63 | ], 64 | "path": [ 65 | "contact" 66 | ] 67 | } 68 | }, 69 | "response": [] 70 | }, 71 | { 72 | "name": "Update contact (id: 3)", 73 | "request": { 74 | "method": "PUT", 75 | "header": [ 76 | { 77 | "key": "Content-Type", 78 | "value": "application/json" 79 | } 80 | ], 81 | "body": { 82 | "mode": "raw", 83 | "raw": "{\n\t\"firstName\": \"Kylo\",\n\t\"lastName\": \"Ren Updated\",\n\t\"phones\": [\n\t {\n\t \"area\": 425,\n\t \"country\": 1,\n\t \"no\": 5556789\n\t }\n\t],\n\t\"addresses\": [\n\t {\n\t \"zip\": 98004,\n\t \"state\": \"WA\",\n\t \"city\": \"Bellevue\",\n\t \"address1\": \"789 Some Other Street\",\n\t \"address2\": \"Apt 10\"\n\t }\n\t],\n\t\"id\": \"3\"\n}" 84 | }, 85 | "url": { 86 | "raw": "{{baseurl}}/contact/3", 87 | "host": [ 88 | "{{baseurl}}" 89 | ], 90 | "path": [ 91 | "contact", 92 | "3" 93 | ] 94 | } 95 | }, 96 | "response": [] 97 | }, 98 | { 99 | "name": "Delete contact (id: 3)", 100 | "request": { 101 | "method": "DELETE", 102 | "header": [], 103 | "body": {}, 104 | "url": { 105 | "raw": "{{baseurl}}/contact/3", 106 | "host": [ 107 | "{{baseurl}}" 108 | ], 109 | "path": [ 110 | "contact", 111 | "3" 112 | ] 113 | } 114 | }, 115 | "response": [] 116 | } 117 | ] 118 | } -------------------------------------------------------------------------------- /src/repositories/contact.repository.spec.js: -------------------------------------------------------------------------------- 1 | const { ContactRepository } = require('./contact.repository'); 2 | 3 | describe('Contacts Repository', () => { 4 | /** @type {AWS.DynamoDB.DocumentClient} */ 5 | const mockDocClient = { 6 | scan: params => { }, 7 | // query: params => mockAwsRequest, 8 | get: params => { }, 9 | put: params => { }, 10 | delete: params => { }, 11 | // update: params => { } 12 | }; 13 | 14 | const mockContacts = [ 15 | { id: '1', name: 'Jin Erso' }, 16 | { id: '2', name: 'Luke Skywalker' }, 17 | { id: '3', name: 'Darth Vadar' } 18 | ]; 19 | 20 | const createAwsRequest = (data = null, resolveOrReject = true, errMsg = 'error') => { 21 | return { 22 | promise: () => resolveOrReject ? Promise.resolve(data) : Promise.reject(new Error('error')) 23 | }; 24 | }; 25 | 26 | /** @type {ContactsRepository} */ 27 | let respository; 28 | 29 | beforeEach(() => { 30 | respository = new ContactRepository(mockDocClient); 31 | }); 32 | 33 | it('should construct a new respository', () => { 34 | expect(respository).toBeDefined(); 35 | }); 36 | 37 | it('should list contacts', async () => { 38 | const expectedResult = { 39 | Items: mockContacts.slice() 40 | }; 41 | 42 | spyOn(mockDocClient, 'scan').and.returnValues(createAwsRequest(expectedResult), createAwsRequest({ Items: null })); 43 | 44 | const awsParams = { 45 | TableName: 'contacts' 46 | }; 47 | 48 | const results = await respository.list(); 49 | 50 | expect(results).toEqual(expectedResult.Items); 51 | expect(results.length).toBe(3); 52 | expect(mockDocClient.scan).toHaveBeenCalledWith(awsParams); 53 | 54 | const emptyResults = await respository.list(); 55 | 56 | expect(emptyResults).toEqual([]); 57 | }); 58 | 59 | it('should throw an error when listing fails', async () => { 60 | spyOn(mockDocClient, 'scan').and.returnValue(createAwsRequest(null, false)); 61 | 62 | try { 63 | await respository.list(); 64 | 65 | fail('listing should have failed with an error'); 66 | } catch (err) { 67 | expect(err).toBeDefined(); 68 | expect(err.message).toEqual('error'); 69 | } 70 | }); 71 | 72 | it('should get a contact by id', async () => { 73 | const expectedResult = { 74 | Item: Object.assign({}, mockContacts[0]) 75 | }; 76 | 77 | spyOn(mockDocClient, 'get').and.returnValue(createAwsRequest(expectedResult)); 78 | 79 | const id = '1'; 80 | const awsParams = { 81 | TableName: 'contacts', 82 | Key: { id } 83 | }; 84 | 85 | const contact = await respository.get(id); 86 | 87 | expect(contact).toBeDefined(); 88 | expect(contact).toEqual(expectedResult.Item); 89 | expect(mockDocClient.get).toHaveBeenCalledWith(awsParams); 90 | }); 91 | 92 | it('should put a new item in the db', async () => { 93 | spyOn(mockDocClient, 'put').and.returnValue(createAwsRequest()); 94 | 95 | const newContact = { 96 | id: '4', 97 | name: 'Han Solo' 98 | }; 99 | 100 | const awsParams = { 101 | TableName: 'contacts', 102 | Item: newContact 103 | }; 104 | 105 | const contact = await respository.put(newContact); 106 | 107 | expect(contact).toBeDefined(); 108 | expect(mockDocClient.put).toHaveBeenCalledWith(awsParams); 109 | }); 110 | 111 | it('should delete a contact, by id', async () => { 112 | spyOn(mockDocClient, 'delete').and.returnValue(createAwsRequest()); 113 | 114 | const id = '1'; 115 | const awsParams = { TableName: 'contacts', Key: { id } }; 116 | 117 | const deletedid = await respository.delete(id); 118 | 119 | expect(deletedid).toBe(id); 120 | expect(mockDocClient.delete).toHaveBeenCalledWith(awsParams); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /src/handlers/contacts/contacts.spec.js: -------------------------------------------------------------------------------- 1 | 2 | // TODO: consider breaking this out into individual suites 3 | 4 | describe('Contacts', () => { 5 | const mockContactRepository = { 6 | list: () => [], 7 | get: id => null, 8 | put: contact => null, 9 | delete: id => null 10 | }; 11 | 12 | const testcontacts = [ 13 | { id: '1', name: 'Jin Erso' }, 14 | { id: '2', name: 'Rey' }, 15 | { id: '3', name: 'Kylo Ren' } 16 | ]; 17 | 18 | const mockWithStatusCode = jest.fn(); 19 | const mockResponseUtil = { 20 | withStatusCode: (stat, fn) => mockWithStatusCode 21 | }; 22 | 23 | const mockParseWith = jest.fn(); 24 | const mockRequestUtil = { 25 | parseWith: (parser) => mockParseWith 26 | }; 27 | 28 | const mockDynamoDbFactory = { 29 | withProcessEnv: (env) => jest.fn() 30 | }; 31 | 32 | jest.mock('aws-sdk/clients/dynamodb', () => ({ DocumentClient: jest.fn() })); 33 | jest.mock('../../repositories/contact.repository', () => ({ ContactRepository: jest.fn(() => mockContactRepository) })); 34 | jest.mock('../../utils/response.util', () => mockResponseUtil); 35 | jest.mock('../../utils/request.util', () => mockRequestUtil); 36 | 37 | describe('list handler', () => { 38 | const { handler } = require('./list'); 39 | 40 | beforeEach(() => { 41 | jest.resetAllMocks(); 42 | mockWithStatusCode.mockImplementation((data) => ({ statusCode: 200, body: JSON.stringify(data) })); 43 | }); 44 | 45 | it('should return a list of contacts', async () => { 46 | jest.spyOn(mockContactRepository, 'list').mockResolvedValue(testcontacts); 47 | 48 | const expectedResponse = { 49 | statusCode: 200, 50 | body: JSON.stringify(testcontacts) 51 | }; 52 | 53 | const response = await handler({}); 54 | 55 | expect(response).toBeDefined(); 56 | expect(response).toEqual(expectedResponse); 57 | expect(mockContactRepository.list).toHaveBeenCalled(); 58 | expect(mockWithStatusCode).toHaveBeenCalled(); 59 | }); 60 | }); 61 | 62 | describe('get handler', () => { 63 | const { handler } = require('./get'); 64 | 65 | beforeEach(() => { 66 | jest.resetAllMocks(); 67 | mockWithStatusCode.mockImplementation((data) => ({ statusCode: 200, body: JSON.stringify(data) })); 68 | }); 69 | 70 | it('should get a contact by id', async () => { 71 | jest.spyOn(mockContactRepository, 'get').mockImplementation(id => Promise.resolve(testcontacts[id] || null)); 72 | 73 | const id = 1; 74 | const event = { 75 | pathParameters: { id } 76 | }; 77 | 78 | const expectedResponse = { 79 | statusCode: 200, 80 | body: JSON.stringify(testcontacts[id]) 81 | }; 82 | 83 | const response = await handler(event); 84 | 85 | expect(response).toEqual(expectedResponse); 86 | expect(mockContactRepository.get).toHaveBeenCalledWith(id); 87 | expect(mockWithStatusCode).toHaveBeenCalled(); 88 | }); 89 | 90 | it('should return a 404 not found if a contact does not exist', async () => { 91 | jest.spyOn(mockContactRepository, 'get').mockResolvedValue(null); 92 | 93 | mockWithStatusCode.mockClear(); 94 | mockWithStatusCode.mockImplementation(_ => ({ statusCode: 404 })); 95 | 96 | const id = 1000; 97 | const event = { 98 | pathParameters: { id } 99 | }; 100 | 101 | const expectedResponse = { 102 | statusCode: 404 103 | }; 104 | 105 | const response = await handler(event); 106 | 107 | expect(response).toEqual(expectedResponse); 108 | expect(mockContactRepository.get).toHaveBeenCalledWith(id); 109 | expect(mockWithStatusCode).toHaveBeenCalled(); 110 | }); 111 | }); 112 | 113 | describe('add handler', () => { 114 | const { handler } = require('./add'); 115 | 116 | beforeEach(() => { 117 | jest.resetAllMocks(); 118 | mockWithStatusCode.mockImplementation((data) => ({ statusCode: 201 })); 119 | mockParseWith.mockImplementation(text => JSON.parse(text)); 120 | 121 | }); 122 | 123 | it('should create a new contact', async () => { 124 | jest.spyOn(mockContactRepository, 'put').mockImplementation((data) => Promise.resolve(data)); 125 | 126 | const contact = { 127 | id: '4', 128 | name: 'Han Solo' 129 | }; 130 | 131 | const event = { 132 | body: JSON.stringify(contact) 133 | }; 134 | 135 | const expectedResponse = { 136 | statusCode: 201 137 | }; 138 | 139 | const response = await handler(event); 140 | 141 | expect(response).toEqual(expectedResponse); 142 | expect(mockContactRepository.put).toHaveBeenCalledWith(contact); 143 | }); 144 | }); 145 | 146 | describe('delete handler', () => { 147 | const { handler } = require('./delete'); 148 | 149 | beforeEach(() => { 150 | jest.resetAllMocks(); 151 | mockWithStatusCode.mockImplementation(() => ({ statusCode: 204 })); 152 | }); 153 | 154 | it('should delete a contact', async () => { 155 | jest.spyOn(mockContactRepository, 'delete').mockResolvedValue('1'); 156 | 157 | const id = '1'; 158 | 159 | const event = { 160 | pathParameters: { id } 161 | }; 162 | 163 | const expectedResponse = { 164 | statusCode: 204 165 | }; 166 | 167 | const response = await handler(event); 168 | 169 | expect(response).toEqual(expectedResponse); 170 | expect(mockContactRepository.delete).toHaveBeenCalledWith(id); 171 | }); 172 | }); 173 | 174 | describe('update handler', () => { 175 | const { handler } = require('./update'); 176 | 177 | beforeEach(() => { 178 | jest.resetAllMocks(); 179 | mockParseWith.mockImplementation(text => JSON.parse(text)); 180 | }); 181 | 182 | it('should create a new contact', async () => { 183 | jest.spyOn(mockContactRepository, 'put').mockImplementation((data) => Promise.resolve(data)); 184 | jest.spyOn(mockContactRepository, 'get').mockResolvedValue({ id: '3' }); 185 | 186 | mockWithStatusCode.mockImplementation((data) => ({ statusCode: 200, body: JSON.stringify(data) })); 187 | 188 | const contact = { 189 | id: '3', 190 | name: 'Darth Vader' 191 | }; 192 | 193 | const event = { 194 | pathParameters: { id: '3' }, 195 | body: JSON.stringify(contact) 196 | }; 197 | 198 | const expectedResponse = { 199 | statusCode: 200, 200 | body: JSON.stringify(contact) 201 | }; 202 | 203 | const response = await handler(event); 204 | 205 | expect(response).toEqual(expectedResponse); 206 | expect(mockContactRepository.put).toHaveBeenCalledWith(contact); 207 | expect(mockContactRepository.get).toHaveBeenCalledWith('3'); 208 | }); 209 | 210 | it('should return 404 not found if contact does not exist', async () => { 211 | jest.spyOn(mockContactRepository, 'put').mockRejectedValue('unexpected call to put'); 212 | jest.spyOn(mockContactRepository, 'get').mockResolvedValue(null); 213 | 214 | mockWithStatusCode.mockImplementation(() => ({ statusCode: 404 })); 215 | 216 | const contact = { 217 | id: '3', 218 | name: 'Darth Vader' 219 | }; 220 | 221 | const event = { 222 | pathParameters: { id: '3' }, 223 | body: JSON.stringify(contact) 224 | }; 225 | 226 | const expectedResponse = { 227 | statusCode: 404 228 | }; 229 | 230 | const response = await handler(event); 231 | 232 | expect(response).toEqual(expectedResponse); 233 | expect(mockContactRepository.get).toHaveBeenCalledWith('3'); 234 | 235 | expect(mockContactRepository.put).not.toHaveBeenCalledWith(contact); 236 | }); 237 | 238 | it('should return 400 bad request if contact id does not match', async () => { 239 | jest.spyOn(mockContactRepository, 'put').mockRejectedValue('unexpected call to put'); 240 | jest.spyOn(mockContactRepository, 'get').mockResolvedValue({ id: '1000' }); 241 | 242 | mockWithStatusCode.mockImplementation(() => ({ statusCode: 400 })); 243 | 244 | const contact = { 245 | id: '3', 246 | name: 'Darth Vader' 247 | }; 248 | 249 | const event = { 250 | pathParameters: { id: '3' }, 251 | body: JSON.stringify(contact) 252 | }; 253 | 254 | const expectedResponse = { 255 | statusCode: 400 256 | }; 257 | 258 | const response = await handler(event); 259 | 260 | expect(response).toEqual(expectedResponse); 261 | expect(mockContactRepository.get).toHaveBeenCalledWith('3'); 262 | 263 | expect(mockContactRepository.put).not.toHaveBeenCalledWith(contact); 264 | }); 265 | }); 266 | }); --------------------------------------------------------------------------------