├── .env.test ├── .gitignore ├── LICENSE.txt ├── README.md ├── codegen.yml ├── consts └── APIGatewayProxyEventSample.ts ├── docker-compose.yml ├── functions └── api │ └── rest │ ├── create │ ├── __snapshots__ │ │ └── controller.test.ts.snap │ ├── controller.test.ts │ ├── controller.ts │ ├── function.ts │ ├── function.yml │ ├── request_schema.d.ts │ ├── request_schema.json │ └── role.yml │ ├── delete │ ├── controller.ts │ ├── function.ts │ ├── function.yml │ └── role.yml │ └── get │ ├── controller.ts │ ├── function.ts │ ├── function.yml │ └── role.yml ├── graphql └── entities │ └── Thing.graphql ├── infra ├── dynamodb │ └── single-table-data-store.yml └── s3 │ └── dynamic-assets-bucket.yml ├── jest.config.js ├── models └── Thing.ts ├── package.json ├── scripts ├── graphql │ └── mergeTypes.js ├── jsonSchemasToInterfaces.js └── provisionTable.js ├── serverless.yml ├── services └── DynamoDB.ts ├── tsconfig.json ├── utils ├── consts.ts └── responses.ts ├── webpack.config.js └── yarn.lock /.env.test: -------------------------------------------------------------------------------- 1 | DATA_STORE_ARN=arn:aws:dynamodb:ddblocal:000000000000:table/datastore-dev -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | jspm_packages 4 | 5 | # Serverless directories 6 | .serverless 7 | 8 | # Webpack directories 9 | .webpack 10 | 11 | # GraphQL Generated Files 12 | schema.graphql 13 | graphql.schema.json 14 | graphql/entities/*.ts 15 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Copyright (c) 2020 Dynobase http://www.dynobase.dev 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Serverless DynamoDB API Boilerplate 2 | 3 | Kickstart your Cloud-native and Serverless project in minutes. **This is work in progress** 4 | 5 | *** 6 | 7 | [![serverless](http://public.serverless.com/badges/v3.svg)](http://www.serverless.com) 8 | 9 | ## About 10 | 11 | This repository is a result of many lessons from launching variety of Serverless powered applications to the production. It was created according to many best practices and aims to provide developers a rapid start while also being production-ready. 12 | 13 | ## Features 14 | 15 | - **Written in Typescript**, compiled by Webpack using [Serverless Webpack](https://github.com/serverless-heaven/serverless-webpack) plugin. Optimized for huge projects where Out of Memory errors are frequent 16 | - **Works fully offline** thanks to [Serverless Offline](https://github.com/dherault/serverless-offline) and DynamoDB Local ran using Docker Compose 17 | - **Easibly Testable** using Jest 18 | - **Interacts with DynamoDB** using [DocumentClient](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html) and [DynamoDB Toolbox](http://dynamodbtoolbox.com/) 19 | - **Works with both REST and GraphQL** 20 | - **Managable** - To avoid spaghetti code, `serverless.yml` is divided into logical parts. Functions' code is placed next to their definitions 21 | - **CORS by default** - allows preflight requests to the resource using the OPTIONS method 22 | - **HTTP API** - uses new version of API Gateway which provides costs savings up to 70% 23 | 24 | ## Quick start 25 | 26 | Prerequisites: 27 | - [Serverless Framework](https://serverless.com/) 28 | - Node.js 29 | - Git 30 | 31 | Start by pulling this repo using `git`: 32 | 33 | ``` 34 | git clone https://github.com/Dynobase/serverless-dynamodb-api-boilerplate 35 | ``` 36 | 37 | Or using Serverless Framework: 38 | 39 | ``` 40 | serverless create --template-url https://github.com/Dynobase/serverless-dynamodb-api-boilerplate --path myService 41 | ``` 42 | 43 | Once ready, install dependencies: 44 | 45 | ```sh 46 | yarn 47 | ``` 48 | 49 | After that, start the project locally: 50 | ```sh 51 | docker-compose up -d # To start DynamoDB local 52 | 53 | yarn provision-local-table # To provision DynamoDB table locally 54 | 55 | yarn dev # Run project locally 56 | 57 | # Add item to the table 58 | curl --location --request POST 'localhost:3000/dev' --data-raw '{ "name":"John Doe" }' 59 | ``` 60 | 61 | ## Deploying 62 | 63 | ```sh 64 | sls deploy 65 | ``` 66 | 67 | ### Adding new function 68 | 69 | 1. Create new folder in `functions` directory. It should contain: 70 | - `function.yml` - function definition in accordance to Serverless Framework contract 71 | - `function.ts` - actual implementation of Lambda function 72 | 73 | Optionally: 74 | - `role.yml` - in order to get better control over IAM Role tied to the function, referenced inside function's `role` property 75 | - `controller.ts` - the best practice is to keep functions as thin as possible. Controllers with injectable dependencies allow that 76 | - `request_schema.json` - request object definition in JSON Schema 77 | 78 | 2. Include newly created function in `serverless.yml` like so: 79 | ``` 80 | - ${file(./functions//function.yml)} 81 | ``` 82 | 83 | 3. Run `yarn generate-request-types` to generate Typescript types from request JSON Schema 84 | 85 | 4. Complete implementation of your function 86 | 87 | ## Viewing DynamoDB Local contents 88 | 89 | Apart from using DynamoDB Shell or CLI, you can use [Dynobase](https://dynobase.dev) to view contents of your local tables: 90 | 91 | ![Dynobase Interface](https://i.imgur.com/5iJYB9J.png "Dynobase showing contents of Local DynamoDB instance") 92 | 93 | ## Contributing 94 | 95 | Contributions are more than welcome. 96 | 97 | ## Licensing 98 | 99 | This project is licensed under the [MIT License](./LICENSE.txt). 100 | 101 | -------------------------------------------------------------------------------- /codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: "schema.graphql" 3 | documents: null 4 | generates: 5 | graphql/entities/types.ts: 6 | plugins: 7 | - "typescript-common" 8 | - "typescript-server" 9 | ./graphql.schema.json: 10 | plugins: 11 | - "introspection" -------------------------------------------------------------------------------- /consts/APIGatewayProxyEventSample.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEvent } from "aws-lambda"; 2 | 3 | export const APIGatewayProxyEventSample: APIGatewayProxyEvent = { 4 | body: ``, 5 | headers: {}, 6 | httpMethod: "POST", 7 | isBase64Encoded: false, 8 | path: "/", 9 | queryStringParameters: {}, 10 | pathParameters: {}, 11 | stageVariables: {}, 12 | multiValueHeaders: {}, 13 | requestContext: null, 14 | resource: "/", 15 | multiValueQueryStringParameters: {} 16 | }; 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | dynamodb-local: 4 | image: amazon/dynamodb-local 5 | ports: 6 | - "8000:8000" -------------------------------------------------------------------------------- /functions/api/rest/create/__snapshots__/controller.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`REST/Create returns created Thing 1`] = ` 4 | Object { 5 | "date_added": "1584184460879", 6 | "id": "f328ee8b-7e8a-4e8e-9525-fb1e42a11491", 7 | "name": "Testing", 8 | "status": "OK", 9 | } 10 | `; 11 | -------------------------------------------------------------------------------- /functions/api/rest/create/controller.test.ts: -------------------------------------------------------------------------------- 1 | import { createController } from "./controller"; 2 | import { documentClient } from "../../../../services/DynamoDB"; 3 | import { APIGatewayProxyEventSample } from "../../../../consts/APIGatewayProxyEventSample"; 4 | 5 | describe("REST/Create", () => { 6 | test("returns created Thing", async done => { 7 | const response = await createController( 8 | { 9 | ...APIGatewayProxyEventSample, 10 | body: `{"name":"Testing"}` 11 | }, 12 | documentClient // can be mocked to disable DB access 13 | ); 14 | 15 | expect(response).toMatchSnapshot(); 16 | done(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /functions/api/rest/create/controller.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEvent } from "aws-lambda"; 2 | import { DynamoDB } from "aws-sdk"; 3 | import { uuid } from "uuidv4"; 4 | import { ThingModel } from "../../../../models/Thing"; 5 | import { Thing } from "./request_schema"; 6 | 7 | // Separated business logic with injectable documentClient for testing purposes 8 | export const createController = async ( 9 | event: APIGatewayProxyEvent, 10 | documentClient: DynamoDB.DocumentClient 11 | ) => { 12 | // Parse string to verified request schema model 13 | const input: Thing = JSON.parse(event.body); 14 | const item = { 15 | id: uuid(), 16 | status: input.status || 'OK', 17 | date_added: (+new Date()).toString(), 18 | name: input.name, 19 | }; 20 | const params = ThingModel.put(item); 21 | 22 | await documentClient.put(params).promise(); 23 | 24 | return item; 25 | }; 26 | -------------------------------------------------------------------------------- /functions/api/rest/create/function.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyHandler } from "aws-lambda"; 2 | import "source-map-support/register"; 3 | 4 | import { httpResponse } from "../../../../utils/responses"; 5 | import { documentClient } from "../../../../services/DynamoDB"; 6 | import { createController } from "./controller"; 7 | 8 | export const handle: APIGatewayProxyHandler = async (event, _context) => { 9 | try { 10 | const result = await createController(event, documentClient); 11 | return httpResponse(result); 12 | } catch (error) { 13 | console.error(error); 14 | 15 | return httpResponse("Bad Request", 400); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /functions/api/rest/create/function.yml: -------------------------------------------------------------------------------- 1 | create: 2 | handler: functions/api/rest/create/function.handle 3 | role: RestAPICreateFunctionRole 4 | events: 5 | - httpApi: 6 | cors: true 7 | method: post 8 | path: / 9 | request: 10 | schema: 11 | application/json: ${file(./functions/api/rest/create/request_schema.json)} 12 | -------------------------------------------------------------------------------- /functions/api/rest/create/request_schema.d.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /** 3 | * This file was automatically generated by json-schema-to-typescript. 4 | * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, 5 | * and run json-schema-to-typescript to regenerate this file. 6 | */ 7 | 8 | export type NameOfTheThing = string; 9 | export type StatusOfTheThing = string; 10 | 11 | export interface Thing { 12 | name: NameOfTheThing; 13 | status?: StatusOfTheThing; 14 | [k: string]: any; 15 | } 16 | -------------------------------------------------------------------------------- /functions/api/rest/create/request_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "definitions": {}, 3 | "$schema": "http://json-schema.org/draft-04/schema#", 4 | "type": "object", 5 | "title": "Thing", 6 | "required": ["name"], 7 | "properties": { 8 | "name": { 9 | "type": "string", 10 | "title": "Name of the thing", 11 | "default": "", 12 | "pattern": "^[a-zA-Z0-9]+$" 13 | }, "status": { 14 | "type": "string", 15 | "title": "Status of the thing", 16 | "default": "OK", 17 | "pattern": "^[a-zA-Z0-9]+$" 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /functions/api/rest/create/role.yml: -------------------------------------------------------------------------------- 1 | Resources: 2 | RestAPICreateFunctionRole: 3 | Type: AWS::IAM::Role 4 | Properties: 5 | Path: / 6 | RoleName: ServerlessCRUDCreateFunctionRole-${self:custom.stage} 7 | AssumeRolePolicyDocument: 8 | Version: "2012-10-17" 9 | Statement: 10 | - Effect: Allow 11 | Principal: 12 | Service: 13 | - lambda.amazonaws.com 14 | Action: sts:AssumeRole 15 | Policies: 16 | - PolicyName: CRUDCreateFunctionRolePolicy-${self:custom.stage} 17 | PolicyDocument: 18 | Version: "2012-10-17" 19 | Statement: 20 | - Effect: Allow 21 | Action: 22 | - dynamodb:PutItem 23 | Resource: 24 | Ref: SingleTableDesignDynamoDBTable 25 | - Effect: Allow 26 | Action: 27 | - logs:CreateLogGroup 28 | - logs:CreateLogStream 29 | - logs:PutLogEvents 30 | Resource: "*" 31 | -------------------------------------------------------------------------------- /functions/api/rest/delete/controller.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEvent } from "aws-lambda"; 2 | import { DynamoDB } from "aws-sdk"; 3 | import { ThingModel } from "../../../../models/Thing"; 4 | 5 | // Separated business logic with injectable documentClient for testing purposes 6 | export const deleteController = async ( 7 | event: APIGatewayProxyEvent, 8 | documentClient: DynamoDB.DocumentClient 9 | ) => { 10 | let item = { 11 | id: event.queryStringParameters.id, 12 | status: event.queryStringParameters.status, 13 | date_added: event.queryStringParameters.date_added, 14 | } 15 | 16 | const params = ThingModel.delete(item) 17 | const response = await documentClient.delete(params).promise() 18 | 19 | return ThingModel.parse(response); 20 | }; 21 | -------------------------------------------------------------------------------- /functions/api/rest/delete/function.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyHandler } from "aws-lambda"; 2 | import "source-map-support/register"; 3 | 4 | import { httpResponse } from "../../../../utils/responses"; 5 | import { documentClient } from "../../../../services/DynamoDB"; 6 | import { deleteController } from "./controller"; 7 | 8 | export const handle: APIGatewayProxyHandler = async (event, _context) => { 9 | try { 10 | const result = await deleteController(event, documentClient); 11 | return httpResponse(result); 12 | } catch (error) { 13 | console.error(error); 14 | 15 | return httpResponse("Bad Request", 400); 16 | } 17 | }; -------------------------------------------------------------------------------- /functions/api/rest/delete/function.yml: -------------------------------------------------------------------------------- 1 | delete: 2 | handler: functions/api/rest/delete/function.handle 3 | role: RestAPIDeleteFunctionRole 4 | events: 5 | - httpApi: 6 | cors: true 7 | method: delete 8 | path: / 9 | parameters: 10 | querystrings: 11 | id: true 12 | status: true 13 | date_added: true 14 | -------------------------------------------------------------------------------- /functions/api/rest/delete/role.yml: -------------------------------------------------------------------------------- 1 | Resources: 2 | RestAPIDeleteFunctionRole: 3 | Type: AWS::IAM::Role 4 | Properties: 5 | Path: / 6 | RoleName: ServerlessCRUDDeleteFunctionRole-${self:custom.stage} 7 | AssumeRolePolicyDocument: 8 | Version: "2012-10-17" 9 | Statement: 10 | - Effect: Allow 11 | Principal: 12 | Service: 13 | - lambda.amazonaws.com 14 | Action: sts:AssumeRole 15 | Policies: 16 | - PolicyName: CRUDDeleteFunctionRolePolicy-${self:custom.stage} 17 | PolicyDocument: 18 | Version: "2012-10-17" 19 | Statement: 20 | - Effect: Allow 21 | Action: 22 | - dynamodb:PutItem 23 | Resource: 24 | Ref: SingleTableDesignDynamoDBTable 25 | - Effect: Allow 26 | Action: 27 | - logs:CreateLogGroup 28 | - logs:CreateLogStream 29 | - logs:PutLogEvents 30 | Resource: "*" 31 | -------------------------------------------------------------------------------- /functions/api/rest/get/controller.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEvent } from "aws-lambda"; 2 | import { DynamoDB } from "aws-sdk"; 3 | import { ThingModel } from "../../../../models/Thing"; 4 | 5 | // Separated business logic with injectable documentClient for testing purposes 6 | export const getController = async ( 7 | event: APIGatewayProxyEvent, 8 | documentClient: DynamoDB.DocumentClient 9 | ) => { 10 | let item = { 11 | id: event.queryStringParameters.id, 12 | status: event.queryStringParameters.status, 13 | date_added: event.queryStringParameters.date_added, 14 | } 15 | 16 | const params = ThingModel.get(item) 17 | const response = await documentClient.get(params).promise() 18 | 19 | return ThingModel.parse(response); 20 | }; 21 | -------------------------------------------------------------------------------- /functions/api/rest/get/function.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyHandler } from "aws-lambda"; 2 | import "source-map-support/register"; 3 | 4 | import { httpResponse } from "../../../../utils/responses"; 5 | import { documentClient } from "../../../../services/DynamoDB"; 6 | import { getController } from "./controller"; 7 | 8 | export const handle: APIGatewayProxyHandler = async (event, _context) => { 9 | try { 10 | const result = await getController(event, documentClient); 11 | return httpResponse(result); 12 | } catch (error) { 13 | console.error(error); 14 | 15 | return httpResponse("Bad Request", 400); 16 | } 17 | }; -------------------------------------------------------------------------------- /functions/api/rest/get/function.yml: -------------------------------------------------------------------------------- 1 | get: 2 | handler: functions/api/rest/get/function.handle 3 | role: RestAPIGetFunctionRole 4 | events: 5 | - httpApi: 6 | cors: true 7 | method: get 8 | path: / 9 | parameters: 10 | querystrings: 11 | id: true 12 | status: true 13 | date_added: true 14 | -------------------------------------------------------------------------------- /functions/api/rest/get/role.yml: -------------------------------------------------------------------------------- 1 | Resources: 2 | RestAPIGetFunctionRole: 3 | Type: AWS::IAM::Role 4 | Properties: 5 | Path: / 6 | RoleName: ServerlessCRUDGetFunctionRole-${self:custom.stage} 7 | AssumeRolePolicyDocument: 8 | Version: "2012-10-17" 9 | Statement: 10 | - Effect: Allow 11 | Principal: 12 | Service: 13 | - lambda.amazonaws.com 14 | Action: sts:AssumeRole 15 | Policies: 16 | - PolicyName: CRUDGetFunctionRolePolicy-${self:custom.stage} 17 | PolicyDocument: 18 | Version: "2012-10-17" 19 | Statement: 20 | - Effect: Allow 21 | Action: 22 | - dynamodb:GetItem 23 | Resource: 24 | Ref: SingleTableDesignDynamoDBTable 25 | - Effect: Allow 26 | Action: 27 | - logs:CreateLogGroup 28 | - logs:CreateLogStream 29 | - logs:PutLogEvents 30 | Resource: "*" 31 | -------------------------------------------------------------------------------- /graphql/entities/Thing.graphql: -------------------------------------------------------------------------------- 1 | type Thing { 2 | id: ID! 3 | name: String! 4 | } 5 | 6 | type ThingsResult { 7 | items: [Thing] 8 | nextPage: String 9 | } 10 | 11 | type Query { 12 | listThings: ThingsResult 13 | getThing(id: ID!): Thing 14 | } 15 | 16 | type Mutation { 17 | createThing(name: String!): Thing! 18 | } -------------------------------------------------------------------------------- /infra/dynamodb/single-table-data-store.yml: -------------------------------------------------------------------------------- 1 | Resources: 2 | SingleTableDesignDynamoDBTable: 3 | Type: AWS::DynamoDB::Table 4 | Properties: 5 | TableName: datastore-${self:provider.stage} 6 | AttributeDefinitions: 7 | - AttributeName: pk 8 | AttributeType: S 9 | - AttributeName: sk 10 | AttributeType: S 11 | - AttributeName: data 12 | AttributeType: S 13 | - AttributeName: model 14 | AttributeType: S 15 | - AttributeName: ref 16 | AttributeType: S 17 | BillingMode: PAY_PER_REQUEST 18 | KeySchema: 19 | - AttributeName: pk 20 | KeyType: HASH 21 | - AttributeName: sk 22 | KeyType: RANGE 23 | GlobalSecondaryIndexes: 24 | - IndexName: gsi_1 25 | KeySchema: 26 | - AttributeName: sk 27 | KeyType: HASH 28 | - AttributeName: data 29 | KeyType: RANGE 30 | Projection: 31 | ProjectionType: ALL 32 | - IndexName: gsi_2 33 | KeySchema: 34 | - AttributeName: model 35 | KeyType: HASH 36 | - AttributeName: sk 37 | KeyType: RANGE 38 | Projection: 39 | ProjectionType: ALL 40 | - IndexName: gsi_3 41 | KeySchema: 42 | - AttributeName: ref 43 | KeyType: HASH 44 | - AttributeName: data 45 | KeyType: RANGE 46 | Projection: 47 | ProjectionType: ALL 48 | -------------------------------------------------------------------------------- /infra/s3/dynamic-assets-bucket.yml: -------------------------------------------------------------------------------- 1 | Resources: 2 | DynamicAssetsBucket: 3 | Type: AWS::S3::Bucket 4 | Properties: 5 | BucketName: dynamic-assets-${self:provider.stage} 6 | AccessControl: PublicRead 7 | Tags: 8 | - Key: Environment 9 | Value: ${self:provider.stage} -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const dotenv = require('dotenv'); 2 | dotenv.config({ path: './.env.test' }); 3 | 4 | module.exports = { 5 | transform: { 6 | '^.+\\.ts$': 'ts-jest', 7 | }, 8 | testMatch: ['/**/*.test.ts'] 9 | } 10 | -------------------------------------------------------------------------------- /models/Thing.ts: -------------------------------------------------------------------------------- 1 | import { Model } from 'dynamodb-toolbox'; 2 | import getTableName from '../utils/consts'; 3 | 4 | export const ThingModel = new Model('Thing', { 5 | // Specify table name 6 | table: getTableName(), 7 | 8 | // Define partition and sort keys 9 | partitionKey: 'pk', 10 | sortKey: 'sk', 11 | 12 | timestamps: true, 13 | 14 | // Define schema 15 | schema: { 16 | pk: { type: 'string', alias: 'id' }, 17 | sk: { type: 'string', hidden: true }, 18 | data: { type: 'string', alias: 'name' }, 19 | status: ['sk', 0], // composite key mapping 20 | date_added: ['sk', 1] // composite key mapping 21 | } 22 | }); 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-dynamodb-api-boilerplate", 3 | "version": "0.1.0", 4 | "description": "Serverless webpack example using Typescript", 5 | "main": "handler.js", 6 | "scripts": { 7 | "deploy": "npm run make:graphql && NODE_OPTIONS=--max_old_space_size=8192 serverless deploy", 8 | "provision-local-table": "node scripts/provisionTable.js --table datastore-dev --region us-east-1 --endpoint http://localhost:8000", 9 | "generate-request-types": "node scripts/jsonSchemasToInterfaces.js", 10 | "dev": "npm run make:graphql && AWS_REGION=local NODE_OPTIONS=--max_old_space_size=8192 serverless offline", 11 | "test": "npm run make:graphql && jest", 12 | "make:graphql": "npm run make:schema && npm run make:types", 13 | "make:schema": "node scripts/graphql/mergeTypes.js", 14 | "make:types": "gql-gen --config codegen.yml" 15 | }, 16 | "dependencies": { 17 | "aws-lambda-graphql": "^1.0.0-alpha.4", 18 | "aws-xray-sdk": "^2.5.0", 19 | "dynamodb-toolbox": "^0.1.0", 20 | "graphql": "^14.6.0", 21 | "graphql-subscriptions": "^1.1.0", 22 | "json-schema-to-typescript": "^8.1.0", 23 | "serverless": "^1.66.0", 24 | "source-map-support": "^0.5.10", 25 | "uuidv4": "^6.0.6", 26 | "yarn": "^1.22.4" 27 | }, 28 | "devDependencies": { 29 | "@types/aws-lambda": "^8.10.17", 30 | "@types/jest": "^25.1.4", 31 | "@types/node": "^10.12.18", 32 | "glob": "^7.1.6", 33 | "graphql-code-generator": "^0.18.2", 34 | "graphql-codegen-introspection": "^0.18.2", 35 | "graphql-codegen-typescript-common": "^0.18.2", 36 | "graphql-codegen-typescript-server": "^0.18.2", 37 | "jest": "^25.1.0", 38 | "merge-graphql-schemas": "^1.7.6", 39 | "minimist": "^1.2.4", 40 | "serverless-offline": "6.1.2", 41 | "serverless-plugin-tracing": "^2.0.0", 42 | "serverless-webpack": "^5.2.0", 43 | "ts-jest": "^25.2.1", 44 | "ts-loader": "^5.3.3", 45 | "typescript": "^3.2.4", 46 | "webpack": "^4.29.0" 47 | }, 48 | "author": "Dynobase (https://github.com/dynobase)", 49 | "license": "MIT" 50 | } -------------------------------------------------------------------------------- /scripts/graphql/mergeTypes.js: -------------------------------------------------------------------------------- 1 | const { fileLoader, mergeTypes } = require('merge-graphql-schemas'); 2 | const { writeFileSync } = require('fs'); 3 | 4 | const typeDefs = mergeTypes(fileLoader(`${__dirname}/../../graphql/entities/**/*.graphql`), { 5 | all: true 6 | }); 7 | 8 | writeFileSync('schema.graphql', typeDefs); 9 | -------------------------------------------------------------------------------- /scripts/jsonSchemasToInterfaces.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const { compile, compileFromFile } = require("json-schema-to-typescript"); 3 | const glob = require("glob"); 4 | 5 | glob("./functions/api/rest/**/*.json", null, function(er, files) { 6 | return Promise.all( 7 | files.map(file => { 8 | return compileFromFile(file).then(ts => 9 | fs.writeFileSync(`${file.replace(".json", '')}.d.ts`, ts) 10 | ); 11 | }) 12 | ); 13 | }); 14 | 15 | -------------------------------------------------------------------------------- /scripts/provisionTable.js: -------------------------------------------------------------------------------- 1 | // Example: node scripts/provisionTable.js --table datastore-local --region local --endpoint http://localhost:8000 2 | 3 | const fs = require("fs"); 4 | const path = require("path"); 5 | const YAML = require("js-yaml"); 6 | const AWS = require("aws-sdk"); 7 | const argv = require('minimist')(process.argv.slice(2)); 8 | 9 | if (!argv.table || !argv.region) { 10 | throw new Error('Invalid arguments!'); 11 | } 12 | 13 | const ddb = new AWS.DynamoDB({ 14 | apiVersion: "2012-08-10", 15 | region: argv.region, 16 | endpoint: argv.endpoint ? argv.endpoint : undefined 17 | }); 18 | 19 | const tableParams = YAML.safeLoad( 20 | fs.readFileSync( 21 | path.join(__dirname, "..", "infra", "dynamodb", "single-table-data-store.yml"), 22 | "utf8" 23 | ) 24 | ); 25 | 26 | ddb 27 | .createTable( 28 | { 29 | ...tableParams.Resources.SingleTableDesignDynamoDBTable.Properties, 30 | TableName: argv.table 31 | }) 32 | .promise() 33 | .then(console.log) 34 | .catch(console.error); 35 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: 2 | name: serverless-dynamodb-api-boilerplate 3 | 4 | frameworkVersion: ">=1.50.0" 5 | 6 | custom: 7 | stage: ${opt:stage, 'local'} # Fallback to "local" if not set via --stage flag 8 | 9 | # As your solution will grow, you'll probably need to use Nested Stacks to avoid 200 resources limit of CloudFormation 10 | # splitStacks: 11 | # perFunction: true 12 | # perType: false 13 | # perGroupFunction: false 14 | # stackConcurrency: 5 15 | # resourceConcurrency: 10 16 | 17 | plugins: 18 | - serverless-webpack 19 | - serverless-plugin-tracing 20 | - serverless-offline 21 | 22 | provider: 23 | name: aws 24 | logs: 25 | restApi: true 26 | httpApi: true 27 | stackTags: 28 | Environment: ${self:custom.stage} 29 | runtime: nodejs10.x 30 | tracing: true # Enable X-Ray tracing 31 | environment: 32 | AWS_NODEJS_CONNECTION_REUSE_ENABLED: 1 # Make sure AWS-SDK reuses HTTP connections 33 | DATA_STORE_ARN: datastore-${self:provider.stage} 34 | 35 | functions: 36 | # REST API 37 | - ${file(./functions/api/rest/get/function.yml)} 38 | - ${file(./functions/api/rest/list/function.yml)} # TODO 39 | - ${file(./functions/api/rest/create/function.yml)} 40 | - ${file(./functions/api/rest/update/function.yml)} # TODO 41 | - ${file(./functions/api/rest/delete/function.yml)} 42 | 43 | # GraphQL API 44 | # To be added... 45 | 46 | resources: 47 | # DynamoDB 48 | - ${file(./infra/dynamodb/single-table-data-store.yml)} 49 | 50 | # S3 51 | - ${file(./infra/s3/dynamic-assets-bucket.yml)} 52 | 53 | # IAM 54 | - ${file(./functions/api/rest/create/role.yml)} 55 | - ${file(./functions/api/rest/get/role.yml)} 56 | -------------------------------------------------------------------------------- /services/DynamoDB.ts: -------------------------------------------------------------------------------- 1 | import * as AWSXRay from 'aws-xray-sdk'; 2 | import { DynamoDB } from 'aws-sdk'; 3 | import { getEndpoint, getRegion } from '../utils/consts'; 4 | 5 | export const documentClient = new DynamoDB.DocumentClient({ 6 | service: new DynamoDB({ 7 | endpoint: getEndpoint(), 8 | region: getRegion() 9 | }) 10 | }); 11 | 12 | // Capture X-Ray traces only on AWS Lambda 13 | if (process.env.AWS_EXECUTION_ENV) { 14 | AWSXRay.captureAWSClient((documentClient as any).service); 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2017"], 4 | "moduleResolution": "node", 5 | "noUnusedLocals": true, 6 | "noUnusedParameters": true, 7 | "sourceMap": true, 8 | "target": "es2017", 9 | "outDir": "lib" 10 | }, 11 | "exclude": ["node_modules"], 12 | "files": ["./node_modules/@types/jest/index.d.ts", "./node_modules/@types/node/index.d.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /utils/consts.ts: -------------------------------------------------------------------------------- 1 | export const getEndpoint = () => { 2 | if (process.env.AWS_EXECUTION_ENV) { 3 | return undefined; 4 | } else if (process.env.DYNAMODB_ENDPOINT) { 5 | return `http://${process.env.DYNAMODB_ENDPOINT}`; 6 | } else { 7 | return "http://localhost:8000"; 8 | } 9 | }; 10 | 11 | export const getRegion = () => { 12 | if (process.env.AWS_EXECUTION_ENV) { 13 | return undefined; 14 | } else if (process.env.AWS_REGION) { 15 | return process.env.AWS_REGION; 16 | } else if (process.env.AWS_DEFAULT_REGION) { 17 | return process.env.AWS_DEFAULT_REGION; 18 | } else { 19 | return "local"; 20 | } 21 | }; 22 | 23 | export default () => process.env.DATA_STORE_ARN.split("/").slice(-1)[0]; 24 | -------------------------------------------------------------------------------- /utils/responses.ts: -------------------------------------------------------------------------------- 1 | export const httpResponse = ( 2 | body: any, 3 | statusCode = 200, 4 | disableCors = false, 5 | customHeaders?: any 6 | ) => { 7 | const stringifiedBody = typeof body === "string" ? body : JSON.stringify(body, null, 2); 8 | const headers = { 9 | ...(customHeaders || {}) 10 | }; 11 | 12 | if (!disableCors) { 13 | headers['Access-Control-Allow-Origin'] = '*'; 14 | headers['Access-Control-Allow-Credentials'] = true; 15 | } 16 | 17 | return { 18 | body: stringifiedBody, 19 | statusCode, 20 | headers 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const slsw = require("serverless-webpack"); 3 | 4 | const entries = {}; 5 | 6 | Object.keys(slsw.lib.entries).forEach( 7 | key => (entries[key] = [ /* "./source-map-install.js", */ slsw.lib.entries[key]]) 8 | ); 9 | 10 | module.exports = { 11 | mode: slsw.lib.webpack.isLocal ? "development" : "production", 12 | entry: slsw.lib.entries, 13 | // { 14 | // ...entries, 15 | // schema: path.join(__dirname, "schema.graphql") 16 | // }, 17 | devtool: "source-map", 18 | resolve: { 19 | extensions: [".js", ".jsx", ".json", ".ts", ".tsx", ".mjs", ".graphql"] 20 | }, 21 | output: { 22 | libraryTarget: "commonjs", 23 | path: path.join(__dirname, ".webpack"), 24 | filename: "[name].js" 25 | }, 26 | target: "node", 27 | module: { 28 | rules: [ 29 | { 30 | test: /\.tsx?$/, 31 | loader: "ts-loader", 32 | options: { 33 | transpileOnly: true 34 | } 35 | }, 36 | { 37 | type: "javascript/auto", 38 | test: /\.mjs$/, 39 | use: [] 40 | }, 41 | { 42 | test: /\.(graphql)$/, 43 | use: [ 44 | { 45 | loader: "file-loader" 46 | } 47 | ] 48 | } 49 | ] 50 | } 51 | }; 52 | --------------------------------------------------------------------------------