├── .nvmrc ├── src ├── globals.d.ts ├── Item.ts ├── ResponseError.ts ├── Response.ts ├── database.ts └── index.ts ├── .dockerignore ├── infrastructure ├── versions.tf ├── deploy-serverless.bash ├── install.bash ├── deploy-infrastructure.bash ├── codebuild-role-policy.tpl ├── main.tf ├── variables.tf └── README.md ├── .gitignore ├── Dockerfile ├── tslint.json ├── docker-compose.yml ├── LICENCE ├── webpack.config.ts ├── package.json ├── apiary.apib ├── README.md ├── tsconfig.json └── serverless.yml /.nvmrc: -------------------------------------------------------------------------------- 1 | 10 -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'serverless-webpack'; 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .dynamodb/ 3 | .history/ 4 | .webpack/ -------------------------------------------------------------------------------- /infrastructure/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 0.12" 3 | } 4 | -------------------------------------------------------------------------------- /src/Item.ts: -------------------------------------------------------------------------------- 1 | interface Item { 2 | id: string; 3 | userId: string; 4 | name: string; 5 | createdUtc: string; 6 | } 7 | 8 | export default Item; 9 | -------------------------------------------------------------------------------- /infrastructure/deploy-serverless.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | 3 | echo Running Serverless deploy... 4 | 5 | yarn run deploy 6 | 7 | echo Finished running Serverless deploy 8 | -------------------------------------------------------------------------------- /infrastructure/install.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | 3 | echo Installing dependencies... 4 | 5 | yarn install --ignore-engines --ignore-scripts 6 | 7 | echo Finished installing dependencies 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .serverless/ 2 | .webpack/ 3 | node_modules/ 4 | .DS_Store 5 | yarn-error.log 6 | .history/ 7 | .terraform/ 8 | *.tfplan* 9 | *.tfstate* 10 | .dynamodb/ 11 | .vscode/ 12 | dynamodb.tar.gz -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | WORKDIR /app 3 | 4 | COPY package.json yarn.lock serverless.yml ./ 5 | RUN yarn install --ignore-scripts 6 | 7 | COPY tsconfig.json tslint.json webpack.config.ts ./ 8 | COPY src src 9 | 10 | EXPOSE 3000/tcp 11 | 12 | ENTRYPOINT ["yarn", "run", "docker-dev"] 13 | -------------------------------------------------------------------------------- /infrastructure/deploy-infrastructure.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | 3 | echo Deploying infrastructure via Terraform... 4 | 5 | cd infrastructure 6 | terraform init \ 7 | -backend-config "bucket=${REMOTE_STATE_BUCKET}" \ 8 | -backend-config "key=${TF_VAR_name}" \ 9 | -backend-config "region=${TF_VAR_region}" \ 10 | -get=true \ 11 | -upgrade=true 12 | terraform plan -out main.tfplan 13 | terraform apply main.tfplan 14 | cd .. 15 | 16 | echo Finished deploying infrastructure 17 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint-config-airbnb"], 3 | "rules": { 4 | "variable-name": [true, "ban-keywords", "check-format", "allow-pascal-case"], 5 | "import-name": false, 6 | "object-shorthand-properties-first": false, 7 | "ordered-imports": [ 8 | true, 9 | { 10 | "import-sources-order": "lowercase-first", 11 | "named-imports-order": "lowercase-first" 12 | } 13 | ], 14 | "max-line-length": [ 15 | true, 16 | 150 17 | ] 18 | } 19 | } -------------------------------------------------------------------------------- /src/ResponseError.ts: -------------------------------------------------------------------------------- 1 | export interface ResponseErrorArgs { 2 | statusCode?: number; 3 | message?: string; 4 | } 5 | 6 | export const defaultResponseErrorArgs: ResponseErrorArgs = { 7 | statusCode: 500, 8 | message: 'Internal server error', 9 | }; 10 | 11 | export default class ResponseError extends Error { 12 | statusCode: number; 13 | message: string; 14 | 15 | constructor(args: ResponseErrorArgs = defaultResponseErrorArgs) { 16 | super(args.message ?? defaultResponseErrorArgs.message); 17 | this.statusCode = 18 | args.statusCode ?? (defaultResponseErrorArgs.statusCode as number); 19 | this.message = args.message ?? (defaultResponseErrorArgs.message as string); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | dynamodb: 4 | image: amazon/dynamodb-local 5 | restart: always 6 | healthcheck: 7 | test: ["CMD-SHELL", "curl -f http://localhost:8000/shell/ || exit 1"] 8 | interval: 1s 9 | timeout: 10s 10 | retries: 3 11 | ports: 12 | - "8000:8000" 13 | expose: 14 | - "8000" 15 | 16 | api: 17 | build: 18 | context: . 19 | depends_on: 20 | - dynamodb 21 | links: 22 | - dynamodb 23 | ports: 24 | - "3000:3000" 25 | restart: always 26 | healthcheck: 27 | test: "curl -f http://api/pingo" 28 | interval: 5s 29 | timeout: 3s 30 | retries: 5 31 | environment: 32 | AUTH0_CLIENT_SECRET: ${AUTH0_CLIENT_SECRET} 33 | DYNAMODB_HOST: dynamodb 34 | DYNAMODB_PORT: 8000 35 | DYNAMODB_NO_START: 'true' 36 | -------------------------------------------------------------------------------- /src/Response.ts: -------------------------------------------------------------------------------- 1 | export interface ResponseArgs { 2 | statusCode?: number; 3 | body?: any; 4 | headers?: { 5 | [name: string]: string; 6 | }; 7 | } 8 | 9 | export const defaultResponseArgs: ResponseArgs = { 10 | statusCode: 200, 11 | headers: { 12 | 'Access-Control-Allow-Origin': '*', // Required for CORS 13 | 'Access-Control-Allow-Credentials': 'true', 14 | }, 15 | }; 16 | 17 | export default class Response { 18 | statusCode: number; 19 | body?: string; 20 | headers: { 21 | [name: string]: string; 22 | }; 23 | 24 | constructor(args: ResponseArgs = defaultResponseArgs) { 25 | this.statusCode = 26 | args.statusCode ?? (defaultResponseArgs.statusCode as number); 27 | this.headers = { 28 | ...defaultResponseArgs.headers, 29 | ...(args.headers ?? {}), 30 | }; 31 | 32 | if (args.body !== undefined) { 33 | this.body = JSON.stringify(args.body); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /infrastructure/codebuild-role-policy.tpl: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Resource": [ 7 | "*" 8 | ], 9 | "Action": [ 10 | "logs:*", 11 | "s3:*", 12 | "codebuild:*", 13 | "codepipeline:*", 14 | "cloudwatch:*", 15 | "cloudfront:*", 16 | "route53:*", 17 | "iam:*", 18 | "apigateway:*", 19 | "cloudformation:*", 20 | "lambda:*", 21 | "ssm:DescribeParameters", 22 | "dynamodb:*", 23 | "application-autoscaling:*" 24 | ] 25 | }, 26 | { 27 | "Effect": "Allow", 28 | "Action": [ 29 | "kms:Decrypt" 30 | ], 31 | "Resource": ${kms_key_arns} 32 | }, 33 | { 34 | "Effect": "Allow", 35 | "Action": [ 36 | "ssm:GetParameters" 37 | ], 38 | "Resource": ${ssm_parameter_arns} 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Jordan Hornblow 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. -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin'; 2 | import path = require('path'); 3 | import slsw = require('serverless-webpack'); 4 | import nodeExternals = require('webpack-node-externals'); 5 | 6 | const srcPath = path.join(__dirname, 'src'); 7 | const nodeModulesPath = path.join(__dirname, 'node_modules'); 8 | 9 | module.exports = { 10 | mode: process.env.NODE_ENV === 'development' ? 'development' : 'production', 11 | devtool: 'cheap-module-source-map', 12 | entry: slsw.lib.entries, 13 | output: { 14 | libraryTarget: 'commonjs', 15 | path: path.join(__dirname, '.webpack'), 16 | filename: 'src/index.js', 17 | }, 18 | plugins: [ 19 | new ForkTsCheckerWebpackPlugin({ 20 | async: false, 21 | }), 22 | ], 23 | target: 'node', 24 | resolve: { 25 | extensions: ['.ts', '.js', '.json'], 26 | modules: [srcPath, nodeModulesPath], 27 | }, 28 | externals: ['aws-sdk', nodeExternals()], 29 | module: { 30 | rules: [ 31 | { 32 | test: /\.(ts)$/, 33 | include: srcPath, 34 | use: [ 35 | { 36 | loader: 'ts-loader', 37 | options: { 38 | transpileOnly: true, 39 | experimentalWatchApi: true, 40 | }, 41 | }, 42 | ], 43 | }, 44 | { 45 | test: /\.js$/, 46 | use: ['source-map-loader'], 47 | include: srcPath, 48 | enforce: 'pre', 49 | }, 50 | ], 51 | }, 52 | }; 53 | -------------------------------------------------------------------------------- /infrastructure/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "s3" { 3 | encrypt = "true" 4 | } 5 | } 6 | 7 | provider "aws" { 8 | region = var.region 9 | version = "~> 2.0" 10 | } 11 | 12 | resource "aws_iam_role" "codebuild_role" { 13 | name = "${var.name}-codebuild" 14 | 15 | assume_role_policy = <", 17 | "license": "MIT", 18 | "dependencies": { 19 | "aws-sdk": "^2.1593.0", 20 | "jsonwebtoken": "^9.0.2", 21 | "moment": "^2.30.1", 22 | "uuid": "^9.0.1" 23 | }, 24 | "devDependencies": { 25 | "@types/aws-lambda": "^8.10.136", 26 | "@types/jsonwebtoken": "^9.0.6", 27 | "@types/node": "^20.12.4", 28 | "@types/uuid": "^9.0.8", 29 | "@types/webpack": "^5.28.5", 30 | "@types/webpack-node-externals": "^3.0.4", 31 | "cross-env": "^7.0.3", 32 | "fork-ts-checker-webpack-plugin": "^9.0.2", 33 | "serverless": "^3.38.0", 34 | "serverless-domain-manager": "^7.3.8", 35 | "serverless-dynamodb": "^0.2.51", 36 | "serverless-dynamodb-autoscaling": "^0.6.2", 37 | "serverless-offline": "^13.3.3", 38 | "serverless-webpack": "^5.13.0", 39 | "source-map-loader": "^5.0.0", 40 | "ts-loader": "^9.5.1", 41 | "tslint": "^5.20.1", 42 | "tslint-config-airbnb": "^5.11.2", 43 | "tslint-loader": "^3.5.4", 44 | "tsutils": "^3.21.0", 45 | "typescript": "^5.4.4", 46 | "webpack": "^5.91.0", 47 | "webpack-node-externals": "^3.0.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /apiary.apib: -------------------------------------------------------------------------------- 1 | FORMAT: 1A 2 | HOST: https://62xhj71jke.execute-api.ap-southeast-2.amazonaws.com/prod 3 | 4 | # Serverless-node-dynamodb-api 5 | 6 | A simple API powered by Serverless, Node.js and DynamoDB, intended as a starting point for Serverless APIs. 7 | 8 | # Group Items 9 | Operations to retrieve/update Items. 10 | 11 | ## Items [/items] 12 | 13 | ### Get all Items [GET] 14 | 15 | + Request 16 | + Headers 17 | 18 | Authorization: Bearer JWT 19 | 20 | + Response 200 (application/json; charset=utf-8) 21 | 22 | + Attributes 23 | 24 | + items (array[Item]) 25 | 26 | + Response 401 (application/json; charset=utf-8) 27 | 28 | + Attributes (UnauthorizedResponse) 29 | 30 | ### Get a single Item [GET /items/{id}] 31 | 32 | + Parameters 33 | + id: `a34839f1-4896-4770-99fc-b9c8c2add08d` (string, required) 34 | 35 | + Request 36 | + Headers 37 | 38 | Authorization: Bearer JWT 39 | 40 | + Response 200 (application/json; charset=utf-8) 41 | 42 | + Attributes (Item) 43 | 44 | + Response 401 (application/json; charset=utf-8) 45 | 46 | + Attributes (UnauthorizedResponse) 47 | 48 | ### Create an Item [POST] 49 | 50 | + Request (application/json; charset=utf-8) 51 | + Headers 52 | 53 | Authorization: Bearer JWT 54 | 55 | + Body 56 | 57 | { 58 | "name" : "James Bond" 59 | } 60 | 61 | + Response 201 (application/json; charset=utf-8) 62 | 63 | + Attributes (Item) 64 | 65 | + Response 401 (application/json; charset=utf-8) 66 | 67 | + Attributes (UnauthorizedResponse) 68 | 69 | ### Edit an Item [PATCH /items/{id}] 70 | 71 | + Parameters 72 | + id: `a34839f1-4896-4770-99fc-b9c8c2add08d` (string, required) 73 | 74 | + Request (application/json; charset=utf-8) 75 | + Headers 76 | 77 | Authorization: Bearer JWT 78 | 79 | + Body 80 | 81 | { 82 | "name" : "Jim Bond" 83 | } 84 | 85 | + Response 200 86 | 87 | + Response 401 (application/json; charset=utf-8) 88 | 89 | + Attributes (UnauthorizedResponse) 90 | 91 | ### Delete an Item [DELETE /items/{id}] 92 | 93 | + Parameters 94 | + id: `a34839f1-4896-4770-99fc-b9c8c2add08d` (string, required) 95 | 96 | + Request 97 | + Headers 98 | 99 | Authorization: Bearer JWT 100 | 101 | + Response 200 102 | 103 | + Response 401 (application/json; charset=utf-8) 104 | 105 | + Attributes (UnauthorizedResponse) 106 | 107 | 108 | ## Data Structures 109 | 110 | ### Item (object) 111 | + id: `a34839f1-4896-4770-99fc-b9c8c2add08d` (string, required) 112 | + name: James Bond (string, required) 113 | + createdUtc: `2016-10-17T00:00:00.000Z` (string, required) 114 | 115 | ### UnauthorizedResponse (object) 116 | + statusCode: 401 (number, required) 117 | + error: Unauthorized (string, required) 118 | + message: Unauthorized (string, required) 119 | -------------------------------------------------------------------------------- /infrastructure/README.md: -------------------------------------------------------------------------------- 1 | # Deployment/Infrastructure 2 | 3 | This project is built, tested and deployed to AWS by CodeBuild. There are two components to deploy - the Serverless service and all supporting infrastructure which is defined with Terraform (CodeBuild, Route53, CloudFront etc.). 4 | 5 | I've created Docker-powered build/deployment environments for [Serverless projects](https://github.com/jch254/docker-node-serverless) and [Node projects](https://github.com/jch254/docker-node-terraform-aws) to use with AWS CodeBuild and Bitbucket Pipelines. 6 | 7 | ## Serverless Service 8 | 9 | To deploy/manage the Serverless service you will need to create an IAM user with the required permissions and set credentials for this user - see [here](https://github.com/serverless/serverless/blob/master/docs/providers/aws/guide/credentials.md) for further info. After you have done this, run the commands below to deploy the service: 10 | 11 | **AUTH0_CLIENT_SECRET environment variable must be set before `yarn run deploy` command below.** 12 | 13 | E.g. `AUTH0_CLIENT_SECRET=YOUR_SECRET yarn run deploy` 14 | 15 | ``` 16 | yarn install 17 | yarn run create-domain 18 | yarn run deploy 19 | ``` 20 | 21 | ## Supporting Infrastructure/Terraform 22 | 23 | **All commands below must be run in the /infrastructure directory.** 24 | 25 | To deploy to AWS, you must: 26 | 27 | 1. Install [Terraform](https://www.terraform.io/) and make sure it is in your PATH. 28 | 1. Set your AWS credentials using one of the following options: 29 | 1. Set your credentials as the environment variables `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. 30 | 1. Run `aws configure` and fill in the details it asks for. 31 | 1. Run on an EC2 instance with an IAM Role. 32 | 1. Run via CodeBuild or ECS Task with an IAM Role (see [buildspec-test.yml](../buildspec-test.yml) for workaround) 33 | 34 | #### Deploying infrastructure 35 | 36 | 1. Update and export all environment variables specified in the appropriate buildspec declaration (check all phases) and bash scripts 37 | 1. Initialise Terraform: 38 | ``` 39 | terraform init \ 40 | -backend-config 'bucket=YOUR_S3_BUCKET' \ 41 | -backend-config 'key=YOUR_S3_KEY' \ 42 | -backend-config 'region=YOUR_REGION' \ 43 | -get=true \ 44 | -upgrade=true 45 | ``` 46 | 1. `terraform plan -out main.tfplan` 47 | 1. `terraform apply main.tfplan` 48 | 49 | #### Updating infrastructure 50 | 51 | 1. Update and export all environment variables specified in the appropriate buildspec declaration (check all phases) and bash scripts 52 | 1. Make necessary infrastructure code changes. 53 | 1. Initialise Terraform: 54 | ``` 55 | terraform init \ 56 | -backend-config 'bucket=YOUR_S3_BUCKET' \ 57 | -backend-config 'key=YOUR_S3_KEY' \ 58 | -backend-config 'region=YOUR_REGION' \ 59 | -get=true \ 60 | -upgrade=true 61 | ``` 62 | 1. `terraform plan -out main.tfplan` 63 | 1. `terraform apply main.tfplan` 64 | 65 | #### Destroying infrastructure (use with care) 66 | 67 | 1. Update and export all environment variables specified in the appropriate buildspec declaration (check all phases) and bash scripts 68 | 1. Initialise Terraform: 69 | ``` 70 | terraform init \ 71 | -backend-config 'bucket=YOUR_S3_BUCKET' \ 72 | -backend-config 'key=YOUR_S3_KEY' \ 73 | -backend-config 'region=YOUR_REGION' \ 74 | -get=true \ 75 | -upgrade=true 76 | ``` 77 | 1. `terraform destroy` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Serverless-node-dynamodb-api](https://serverless-api.603.nz) 2 | 3 | ![Build Status](https://codebuild.ap-southeast-2.amazonaws.com/badges?uuid=eyJlbmNyeXB0ZWREYXRhIjoiRUR0VDBzZ0EvLzU5dktNNDJTVU0yaWFJVXBpUmNVdDliWVJrQzM0ZlEwWmJQNUVSd2IwSU1LanQ5ajRFMGVvT0lJQmtGdjR4NE5OdFdOMFp4Q1dzUGIwPSIsIml2UGFyYW1ldGVyU3BlYyI6InpsM1g0TE9nTFdyRDZJK0EiLCJtYXRlcmlhbFNldFNlcmlhbCI6MX0%3D&branch=master) 4 | 5 | API powered by Serverless, TypeScript, Webpack, Node.js and DynamoDB, intended as a starting point for Serverless APIs. I've also created a [React/Redux-powered UI](https://github.com/jch254/serverless-node-dynamodb-ui) to front this API. Auth0 handles authentication. You must signup/login to generate an auth token and gain access to the secured area. All endpoints in the API check validity of the auth token and return unauthorised if invalid, the UI then prompts you to log in again. The API also determines the identity of the user via the auth token. 6 | 7 | See [Apiary](http://docs.serverlessapi.apiary.io) for API structure - defined in [apiary.apib](./apiary.apib). 8 | 9 | ## Technologies Used 10 | 11 | - [Serverless](https://github.com/serverless/serverless) 12 | - [TypeScript](https://github.com/microsoft/typescript) 13 | - [Node.js](https://github.com/nodejs/node) 14 | - [Webpack](https://github.com/webpack/webpack) 15 | - [DynamoDB](https://aws.amazon.com/dynamodb) 16 | - [Serverless-offline](https://github.com/dherault/serverless-offline) 17 | - [Serverless-webpack](https://github.com/elastic-coders/serverless-webpack) 18 | - [Serverless-dynamodb](https://github.com/raisenational/serverless-dynamodb) 19 | - [Serverless-domain-manager](https://github.com/amplify-education/serverless-domain-manager) 20 | - [Jsonwebtoken](https://github.com/auth0/node-jsonwebtoken) 21 | - [Docker](https://www.docker.com) 22 | 23 | --- 24 | 25 | ## Running locally (with live-reloading and local DynamoDB server) 26 | 27 | To run locally you must run two servers - DB and API. 28 | 29 | Serverless-webpack, serverless-dynamodb-local and serverless-offline offer great tooling for local Serverless development. To start local servers that mimic AWS API Gateway and DyanamoDB, run the commands below. Both servers will fire up and code will be reloaded upon change so that every request to your API will serve the latest code. 30 | 31 | Serverless-dynamodb-local requires Java Runtime Engine (JRE) version 6.x or newer. 32 | 33 | **AUTH0_CLIENT_SECRET environment variable must be set before `yarn run dev` command below. Optional DYNAMODB_PORT and DYNAMODB_HOST environment variables may be set to override the defaults (localhost:8000).** 34 | 35 | E.g. `AUTH0_CLIENT_SECRET=YOUR_SECRET yarn run dev` 36 | 37 | ``` 38 | yarn install (serverless dynamodb install included as postinstall script) 39 | yarn run dev 40 | ``` 41 | 42 | Submit requests to http://localhost:3000. The DynamoDB shell console is available at http://localhost:8000/shell. 43 | 44 | ## Running locally with Docker 45 | 46 | Maintaining a Java installation for the sake of running DynamoDB locally is a pain, running in a Docker container is far easier. As above, to run locally you must run two servers - DB and API. 47 | 48 | To start the local servers that mimic AWS API Gateway and DyanamoDB using docker, run the commands below. 49 | 50 | **AUTH0_CLIENT_SECRET environment variable must be set before `docker-compose up --build` command below.** 51 | 52 | E.g. `AUTH0_CLIENT_SECRET=YOUR_SECRET docker-compose up --build` 53 | 54 | ``` 55 | docker-compose up --build 56 | ``` 57 | 58 | Submit requests to http://localhost:3000. The DynamoDB shell console is available at http://localhost:8000/shell. 59 | 60 | ## Testing 61 | 62 | TBC 63 | 64 | ### Deployment/Infrastructure 65 | 66 | Refer to the [/infrastructure](./infrastructure) directory. 67 | -------------------------------------------------------------------------------- /src/database.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDB } from 'aws-sdk'; 2 | import moment from 'moment'; 3 | import { v4 } from 'uuid'; 4 | import Item from './Item'; 5 | import ResponseError from './ResponseError'; 6 | 7 | const db = process.env.IS_OFFLINE 8 | ? new DynamoDB.DocumentClient({ 9 | region: 'localhost', 10 | accessKeyId: 'MOCK_ACCESS_KEY_ID', 11 | secretAccessKey: 'MOCK_SECRET_ACCESS_KEY', 12 | endpoint: `http://${process.env.DYNAMODB_HOST || 'localhost'}:${ 13 | process.env.DYNAMODB_PORT || 8000 14 | }`, 15 | }) 16 | : new DynamoDB.DocumentClient(); 17 | 18 | export async function getItems(userId: string): Promise { 19 | const params = { 20 | TableName: 'items', 21 | IndexName: 'userId-index', 22 | KeyConditionExpression: 'userId = :userId', 23 | ExpressionAttributeValues: { 24 | ':userId': userId, 25 | }, 26 | }; 27 | 28 | const data = await db.query(params).promise(); 29 | 30 | return data.Items as Item[]; 31 | } 32 | 33 | export async function getItemById( 34 | userId: string, 35 | itemId: string 36 | ): Promise { 37 | const params = { 38 | TableName: 'items', 39 | Key: { 40 | id: itemId, 41 | userId, 42 | }, 43 | }; 44 | 45 | const data = await db.get(params).promise(); 46 | 47 | if (data.Item === undefined) { 48 | throw new ResponseError({ 49 | statusCode: 404, 50 | message: `An item could not be found with id: ${itemId}`, 51 | }); 52 | } 53 | 54 | return data.Item as Item; 55 | } 56 | 57 | export async function createItem(userId: string, name: string): Promise { 58 | const params = { 59 | TableName: 'items', 60 | ConditionExpression: 61 | 'attribute_not_exists(id) AND attribute_not_exists(userId)', 62 | Item: { 63 | id: v4(), 64 | userId, 65 | name, 66 | createdUtc: moment().utc().toISOString(), 67 | }, 68 | }; 69 | 70 | await db.put(params).promise(); 71 | 72 | return params.Item; 73 | } 74 | 75 | export async function updateItem( 76 | userId: string, 77 | itemId: string, 78 | name: string 79 | ): Promise { 80 | try { 81 | const params = { 82 | TableName: 'items', 83 | ReturnValues: 'NONE', 84 | ConditionExpression: 'attribute_exists(id) AND attribute_exists(userId)', 85 | UpdateExpression: 'SET #name = :name', 86 | Key: { 87 | id: itemId, 88 | userId, 89 | }, 90 | ExpressionAttributeNames: { 91 | '#name': 'name', 92 | }, 93 | ExpressionAttributeValues: { 94 | ':name': name, 95 | }, 96 | }; 97 | 98 | await db.update(params).promise(); 99 | } catch (err: any) { 100 | if (err.code === 'ConditionalCheckFailedException') { 101 | throw new ResponseError({ 102 | statusCode: 404, 103 | message: `An item could not be found with id: ${itemId}`, 104 | }); 105 | } 106 | 107 | throw err; 108 | } 109 | } 110 | 111 | export async function deleteItem( 112 | userId: string, 113 | itemId: string 114 | ): Promise { 115 | try { 116 | const params = { 117 | TableName: 'items', 118 | ConditionExpression: 'attribute_exists(id) AND attribute_exists(userId)', 119 | Key: { 120 | id: itemId, 121 | userId, 122 | }, 123 | }; 124 | 125 | await db.delete(params).promise(); 126 | } catch (err: any) { 127 | if (err.code === 'ConditionalCheckFailedException') { 128 | throw new ResponseError({ 129 | statusCode: 404, 130 | message: `An item could not be found with id: ${itemId}`, 131 | }); 132 | } 133 | 134 | throw err; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "esModuleInterop": true, 5 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ 6 | "module": "commonjs", /* Specify module code generation: 'commonjs', 'amd', 'system', 'umd' or 'es2015'. */ 7 | "lib": [ /* Specify library files to be included in the compilation: */ 8 | "es5", 9 | "es2015", 10 | "es2016", 11 | "esnext" 12 | ], 13 | "allowJs": false, /* Allow javascript files to be compiled. */ 14 | "checkJs": false, /* Report errors in .js files. */ 15 | // "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 16 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 17 | "sourceMap": true, /* Generates corresponding '.map' file. */ 18 | // "outFile": "./", /* Concatenate and emit output to single file. */ 19 | "outDir": "./ts", /* Redirect output structure to the directory. */ 20 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 32 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 33 | 34 | /* Additional Checks */ 35 | "noUnusedLocals": true, /* Report errors on unused locals. */ 36 | // "noUnusedParameters": false, /* Report errors on unused parameters. */ 37 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 38 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 39 | 40 | /* Module Resolution Options */ 41 | // "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 42 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 43 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 44 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 45 | // "typeRoots": [], /* List of folders to include type definitions from. */ 46 | // "types": [], /* Type declaration files to be included in compilation. */ 47 | "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 48 | 49 | /* Source Map Options */ 50 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 51 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 52 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 53 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 54 | 55 | /* Experimental Options */ 56 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 57 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 58 | }, 59 | "exclude": [ 60 | "node_modules", 61 | ".serverless", 62 | ".webpack" 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AuthResponse, 3 | APIGatewayEvent, 4 | Callback, 5 | Context, 6 | CustomAuthorizerEvent, 7 | } from 'aws-lambda'; 8 | import * as jwt from 'jsonwebtoken'; 9 | import { 10 | createItem, 11 | deleteItem, 12 | getItems, 13 | getItemById, 14 | updateItem, 15 | } from './database'; 16 | import Response from './Response'; 17 | import ResponseError from './ResponseError'; 18 | 19 | export async function pingHandler(event: APIGatewayEvent, context: Context) { 20 | console.log('pingHandler'); 21 | console.log('event', JSON.stringify(event)); 22 | console.log('context', JSON.stringify(context)); 23 | 24 | try { 25 | return new Response({ statusCode: 200, body: { message: 'Chur' } }); 26 | } catch (err: any) { 27 | console.log(err); 28 | 29 | throw new ResponseError({ message: err.message }); 30 | } 31 | } 32 | 33 | export const authorizer = ( 34 | event: CustomAuthorizerEvent, 35 | context: Context, 36 | callback: Callback 37 | ) => { 38 | console.log('authorizer'); 39 | console.log('event', JSON.stringify(event)); 40 | console.log('context', JSON.stringify(context)); 41 | 42 | try { 43 | const authHeader = event.authorizationToken?.split(' ') || []; 44 | 45 | if (authHeader.length === 2 && authHeader[0].toLowerCase() === 'bearer') { 46 | const decoded = jwt.verify( 47 | authHeader[1], 48 | process.env.AUTH0_CLIENT_SECRET as string 49 | ) as { sub: string }; 50 | 51 | const authResponse: AuthResponse = { 52 | policyDocument: { 53 | Version: '2012-10-17', 54 | Statement: [ 55 | { 56 | Action: 'execute-api:Invoke', 57 | Resource: [event.methodArn], 58 | Effect: 'Allow', 59 | }, 60 | ], 61 | }, 62 | principalId: decoded.sub, 63 | }; 64 | 65 | callback(undefined, authResponse); 66 | } else { 67 | callback('Unauthorized', undefined); 68 | } 69 | } catch (err) { 70 | console.log(err); 71 | callback('Unauthorized', undefined); 72 | } 73 | }; 74 | 75 | // GET /items 76 | export async function getAllItemsHandler( 77 | event: APIGatewayEvent, 78 | context: Context 79 | ) { 80 | console.log('getAllItemsHandler'); 81 | console.log('event', JSON.stringify(event)); 82 | console.log('context', JSON.stringify(context)); 83 | 84 | try { 85 | const items = await getItems(event.requestContext.authorizer?.principalId); 86 | 87 | return new Response({ statusCode: 200, body: { items } }); 88 | } catch (err: any) { 89 | console.log(err); 90 | 91 | throw new ResponseError({ message: err.message }); 92 | } 93 | } 94 | 95 | // GET /items/{id} 96 | export async function getItemHandler(event: APIGatewayEvent, context: Context) { 97 | console.log('getItemHandler'); 98 | console.log('event', JSON.stringify(event)); 99 | console.log('context', JSON.stringify(context)); 100 | 101 | try { 102 | const item = await getItemById( 103 | event.requestContext.authorizer?.principalId, 104 | event.pathParameters?.id || '' 105 | ); 106 | 107 | return new Response({ statusCode: 200, body: item }); 108 | } catch (err: any) { 109 | console.log(err); 110 | 111 | throw err instanceof ResponseError 112 | ? err 113 | : new ResponseError({ message: err.message }); 114 | } 115 | } 116 | 117 | // POST /items 118 | export async function createItemHandler( 119 | event: APIGatewayEvent, 120 | context: Context 121 | ) { 122 | console.log('createItemHandler'); 123 | console.log('event', JSON.stringify(event)); 124 | console.log('context', JSON.stringify(context)); 125 | 126 | try { 127 | const item = await createItem( 128 | event.requestContext.authorizer?.principalId, 129 | JSON.parse(event.body as string).name 130 | ); 131 | 132 | return new Response({ statusCode: 201, body: item }); 133 | } catch (err: any) { 134 | console.log(err); 135 | 136 | throw new ResponseError({ message: err.message }); 137 | } 138 | } 139 | 140 | // PATCH /items/{id} 141 | export async function updateItemHandler( 142 | event: APIGatewayEvent, 143 | context: Context 144 | ) { 145 | console.log('updateItemHandler'); 146 | console.log('event', JSON.stringify(event)); 147 | console.log('context', JSON.stringify(context)); 148 | 149 | try { 150 | await updateItem( 151 | event.requestContext.authorizer?.principalId, 152 | event.pathParameters?.id || '', 153 | JSON.parse(event.body as string).name 154 | ); 155 | 156 | return new Response({ statusCode: 200 }); 157 | } catch (err: any) { 158 | console.log(err); 159 | 160 | throw err instanceof ResponseError 161 | ? err 162 | : new ResponseError({ message: err.message }); 163 | } 164 | } 165 | 166 | // DELETE /items/{id} 167 | export async function deleteItemHandler( 168 | event: APIGatewayEvent, 169 | context: Context 170 | ) { 171 | console.log('deleteItemHandler'); 172 | console.log('event', JSON.stringify(event)); 173 | console.log('context', JSON.stringify(context)); 174 | 175 | try { 176 | await deleteItem( 177 | event.requestContext.authorizer?.principalId, 178 | event.pathParameters?.id || '' 179 | ); 180 | 181 | return new Response({ statusCode: 200 }); 182 | } catch (err: any) { 183 | console.log(err); 184 | 185 | throw err instanceof ResponseError 186 | ? err 187 | : new ResponseError({ message: err.message }); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: serverless-node-dynamodb-api 2 | 3 | plugins: 4 | - serverless-webpack 5 | - serverless-dynamodb 6 | - serverless-offline 7 | - serverless-dynamodb-autoscaling 8 | - serverless-domain-manager 9 | 10 | provider: 11 | name: aws 12 | runtime: nodejs20.x 13 | memorySize: 1024 14 | stage: prod 15 | region: ap-southeast-2 16 | iamManagedPolicies: 17 | - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" 18 | iamRoleStatements: 19 | - Effect: Allow 20 | Action: 21 | - dynamodb:DescribeTable 22 | - dynamodb:Scan 23 | - dynamodb:PutItem 24 | - dynamodb:GetItem 25 | - dynamodb:DeleteItem 26 | - dynamodb:Query 27 | - dynamodb:UpdateItem 28 | Resource: 29 | - "Fn::Join": 30 | [ 31 | "", 32 | [ 33 | "arn:aws:dynamodb:", 34 | { "Ref": "AWS::Region" }, 35 | ":", 36 | { "Ref": "AWS::AccountId" }, 37 | ":table/items*", 38 | ], 39 | ] 40 | 41 | package: 42 | individually: true 43 | 44 | custom: 45 | customDomain: 46 | domainName: sls-api.603.nz 47 | stage: ${self:provider.stage} 48 | certificateArn: ${env:TF_VAR_acm_arn, ""} 49 | hostedZoneId: ${env:TF_VAR_route53_zone_id, ""} 50 | webpack: 51 | webpackConfig: ./webpack.config.ts 52 | packager: "yarn" 53 | includeModules: 54 | forceExclude: 55 | - aws-sdk 56 | serverless-dynamodb: 57 | start: 58 | port: ${env:DYNAMODB_PORT, 8000} 59 | host: ${env:DYNAMODB_HOST, "localhost"} 60 | migrate: true 61 | noStart: ${env:DYNAMODB_NO_START, false} 62 | stages: 63 | - ${self:provider.stage} 64 | capacities: 65 | - table: ItemsTable 66 | index: 67 | - userId-index 68 | read: 69 | minimum: 1 70 | maximum: 10 71 | usage: 0.1 72 | write: 73 | minimum: 1 74 | maximum: 10 75 | usage: 0.1 76 | 77 | functions: 78 | authorizer: 79 | handler: src/index.authorizer 80 | timeout: 30 81 | environment: 82 | AUTH0_CLIENT_SECRET: ${env:AUTH0_CLIENT_SECRET} 83 | 84 | pingo: 85 | handler: src/index.pingHandler 86 | timeout: 30 87 | events: 88 | - http: 89 | method: get 90 | path: pingo 91 | cors: true 92 | integration: lambda-proxy 93 | 94 | getAllItems: 95 | handler: src/index.getAllItemsHandler 96 | timeout: 30 97 | events: 98 | - http: 99 | method: get 100 | path: items 101 | cors: true 102 | integration: lambda-proxy 103 | authorizer: 104 | name: authorizer 105 | resultTtlInSeconds: 0 106 | identitySource: method.request.header.Authorization 107 | 108 | getItem: 109 | handler: src/index.getItemHandler 110 | timeout: 30 111 | events: 112 | - http: 113 | method: get 114 | path: items/{id} 115 | cors: true 116 | integration: lambda-proxy 117 | authorizer: 118 | name: authorizer 119 | resultTtlInSeconds: 0 120 | identitySource: method.request.header.Authorization 121 | 122 | createItem: 123 | handler: src/index.createItemHandler 124 | timeout: 30 125 | events: 126 | - http: 127 | method: post 128 | path: items 129 | cors: true 130 | integration: lambda-proxy 131 | authorizer: 132 | name: authorizer 133 | resultTtlInSeconds: 0 134 | identitySource: method.request.header.Authorization 135 | 136 | updateItem: 137 | handler: src/index.updateItemHandler 138 | timeout: 30 139 | events: 140 | - http: 141 | method: patch 142 | path: items/{id} 143 | cors: true 144 | integration: lambda-proxy 145 | authorizer: 146 | name: authorizer 147 | resultTtlInSeconds: 0 148 | identitySource: method.request.header.Authorization 149 | 150 | deleteItem: 151 | handler: src/index.deleteItemHandler 152 | timeout: 30 153 | events: 154 | - http: 155 | method: delete 156 | path: items/{id} 157 | cors: true 158 | integration: lambda-proxy 159 | authorizer: 160 | name: authorizer 161 | resultTtlInSeconds: 0 162 | identitySource: method.request.header.Authorization 163 | 164 | resources: 165 | Resources: 166 | ItemsTable: 167 | Type: "AWS::DynamoDB::Table" 168 | Properties: 169 | TableName: "items" 170 | AttributeDefinitions: 171 | - AttributeName: "id" 172 | AttributeType: "S" 173 | - AttributeName: "userId" 174 | AttributeType: "S" 175 | KeySchema: 176 | - AttributeName: "id" 177 | KeyType: "HASH" 178 | - AttributeName: "userId" 179 | KeyType: "RANGE" 180 | ProvisionedThroughput: 181 | ReadCapacityUnits: 1 182 | WriteCapacityUnits: 1 183 | GlobalSecondaryIndexes: 184 | - IndexName: "userId-index" 185 | KeySchema: 186 | - AttributeName: "userId" 187 | KeyType: "HASH" 188 | Projection: 189 | ProjectionType: "ALL" 190 | ProvisionedThroughput: 191 | ReadCapacityUnits: 1 192 | WriteCapacityUnits: 1 193 | GatewayResponseDefault4XX: 194 | Type: "AWS::ApiGateway::GatewayResponse" 195 | Properties: 196 | ResponseParameters: 197 | gatewayresponse.header.Access-Control-Allow-Origin: "'*'" 198 | gatewayresponse.header.Access-Control-Allow-Headers: "'*'" 199 | ResponseType: DEFAULT_4XX 200 | RestApiId: 201 | Ref: "ApiGatewayRestApi" 202 | GatewayResponseDefault5XX: 203 | Type: "AWS::ApiGateway::GatewayResponse" 204 | Properties: 205 | ResponseParameters: 206 | gatewayresponse.header.Access-Control-Allow-Origin: "'*'" 207 | gatewayresponse.header.Access-Control-Allow-Headers: "'*'" 208 | ResponseType: DEFAULT_5XX 209 | RestApiId: 210 | Ref: "ApiGatewayRestApi" 211 | --------------------------------------------------------------------------------